Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -389,3 +389,10 @@
display: none;
}
}

/* Done-dot crossfade: when a running row transitions to done, the 7px ink
circle fades in over 150ms instead of appearing instantly. Softens the
running→done handoff so the eye reads continuity, not a hard swap. */
[data-slot="thought-rail-dot"][data-state="done"] {
transition: opacity 150ms ease-out;
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ describe("bottom-anchored harmonic dot", () => {
})
})

describe("done-dot crossfade", () => {
test("done-dot has an opacity transition (fade-in on completion)", () => {
// The done-dot should fade in smoothly rather than appearing instantly.
// Look for a CSS rule with transition containing "opacity" on the done state.
expect(indexCss).toMatch(/thought-rail-dot[^}]*done[^}]*transition[^}]*opacity/)
})
})

describe("timeline entrance animation", () => {
test("timeline-enter keyframe uses blur + rise + opacity", () => {
expect(polishCss).toMatch(/timeline-enter[\s\S]*opacity:\s*0/)
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/amicode/harmonic-dot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ import {
INNER_R,
CIRCLE_DONUT_PATH,
SMIL,
smilBeginOffset,
} from "./harmonic-geometry"

export function HarmonicDot(props: {
class?: string
style?: ComponentProps<"svg">["style"]
}) {
// Phase-lock: compute once at creation so SMIL picks up the global morph
// phase instead of restarting from the ring on every remount.
const begin = smilBeginOffset()
return (
<svg
data-component="harmonic-dot"
Expand All @@ -57,6 +61,7 @@ export function HarmonicDot(props: {
values={SMIL.innerRadius}
keyTimes={SMIL.keyTimes}
dur={SMIL.dur}
begin={begin}
repeatCount="indefinite"
calcMode="linear"
/>
Expand All @@ -73,6 +78,7 @@ export function HarmonicDot(props: {
values={SMIL.values}
keyTimes={SMIL.keyTimes}
dur={SMIL.dur}
begin={begin}
repeatCount="indefinite"
calcMode="linear"
/>
Expand Down
34 changes: 34 additions & 0 deletions packages/ui/src/amicode/harmonic-geometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
harmonicRadius,
harmonicPath,
buildSmil,
smilBeginOffset,
} from "./harmonic-geometry"

describe("constants", () => {
Expand Down Expand Up @@ -240,3 +241,36 @@ describe("SMIL keyframes", () => {
}
})
})

describe("smilBeginOffset (phase-lock)", () => {
test("returns a negative ms string matching -(now % CYCLE_MS)", () => {
const before = Date.now()
const result = smilBeginOffset()
const after = Date.now()
// Must be a string of the form "-<digits>ms"
expect(result).toMatch(/^-\d+ms$/)
// The numeric value should be within [before % CYCLE_MS, after % CYCLE_MS] ± 50ms
const offsetMs = parseInt(result.slice(1, -2), 10) // strip leading "-" and trailing "ms"
const expectedLow = before % CYCLE_MS
const expectedHigh = after % CYCLE_MS
// Handle the wraparound case where the modulus crosses the cycle boundary
if (expectedHigh >= expectedLow) {
expect(offsetMs).toBeGreaterThanOrEqual(expectedLow - 50)
expect(offsetMs).toBeLessThanOrEqual(expectedHigh + 50)
} else {
// Wraparound: either offsetMs is near the end of the cycle or near the start
const inRange =
(offsetMs >= expectedLow - 50) || (offsetMs <= expectedHigh + 50)
expect(inRange).toBe(true)
}
})

test("offset is always less than CYCLE_MS", () => {
for (let i = 0; i < 10; i++) {
const result = smilBeginOffset()
const offsetMs = parseInt(result.slice(1, -2), 10)
expect(offsetMs).toBeLessThan(CYCLE_MS)
expect(offsetMs).toBeGreaterThanOrEqual(0)
}
})
})
12 changes: 12 additions & 0 deletions packages/ui/src/amicode/harmonic-geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,15 @@ export const MORPH_CADENCE_MS = CYCLE_MS
export function smilKeyTimes(): string {
return SMIL.keyTimes
}

/**
* Compute a SMIL `begin` offset that phase-locks the animation to a global
* modular clock. Every mount of HarmonicDot calls this once; the returned
* string (e.g. "-4200ms") makes the browser start the SMIL timeline as if
* it had been running since wall-clock t=0 mod CYCLE_MS. Two dots mounting
* at different times agree on the current shape because they share the same
* epoch.
*/
export function smilBeginOffset(): string {
return `-${Date.now() % CYCLE_MS}ms`
}
Loading