From 89b1148f1a9a93c6ef0588d88f420cbf7a409886 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 08:44:37 -0400 Subject: [PATCH] fix(app): phase-lock harmonic dot SMIL + crossfade done-dot transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-lock: set SMIL begin to -(Date.now() % CYCLE_MS)ms so every mount picks up the morph cycle at the globally correct phase instead of restarting from the ring. Crossfade: 150ms opacity ease-out on the done-dot so the running→done handoff reads as continuity, not a hard swap. Closes #273 --- packages/app/src/index.css | 7 ++++ .../session/timeline/travelling-dot.test.ts | 8 +++++ packages/ui/src/amicode/harmonic-dot.tsx | 6 ++++ .../ui/src/amicode/harmonic-geometry.test.ts | 34 +++++++++++++++++++ packages/ui/src/amicode/harmonic-geometry.ts | 12 +++++++ 5 files changed, 67 insertions(+) diff --git a/packages/app/src/index.css b/packages/app/src/index.css index a9b3b592a..2f1aa408c 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -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; +} diff --git a/packages/app/src/pages/session/timeline/travelling-dot.test.ts b/packages/app/src/pages/session/timeline/travelling-dot.test.ts index 00df88075..80f857fde 100644 --- a/packages/app/src/pages/session/timeline/travelling-dot.test.ts +++ b/packages/app/src/pages/session/timeline/travelling-dot.test.ts @@ -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/) diff --git a/packages/ui/src/amicode/harmonic-dot.tsx b/packages/ui/src/amicode/harmonic-dot.tsx index 89db5a565..268d34f0e 100644 --- a/packages/ui/src/amicode/harmonic-dot.tsx +++ b/packages/ui/src/amicode/harmonic-dot.tsx @@ -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 ( @@ -73,6 +78,7 @@ export function HarmonicDot(props: { values={SMIL.values} keyTimes={SMIL.keyTimes} dur={SMIL.dur} + begin={begin} repeatCount="indefinite" calcMode="linear" /> diff --git a/packages/ui/src/amicode/harmonic-geometry.test.ts b/packages/ui/src/amicode/harmonic-geometry.test.ts index 5e58756f1..27424a997 100644 --- a/packages/ui/src/amicode/harmonic-geometry.test.ts +++ b/packages/ui/src/amicode/harmonic-geometry.test.ts @@ -22,6 +22,7 @@ import { harmonicRadius, harmonicPath, buildSmil, + smilBeginOffset, } from "./harmonic-geometry" describe("constants", () => { @@ -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 "-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) + } + }) +}) diff --git a/packages/ui/src/amicode/harmonic-geometry.ts b/packages/ui/src/amicode/harmonic-geometry.ts index 24abf7696..13c463600 100644 --- a/packages/ui/src/amicode/harmonic-geometry.ts +++ b/packages/ui/src/amicode/harmonic-geometry.ts @@ -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` +}