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`
+}