Skip to content

Commit 52d592b

Browse files
feat(app): travelling harmonic dot + bottom-up card entry animation
The harmonic dot now tracks the LAST prose-fragment card within a running text-part row rather than staying pinned to the first text line. As new paragraph cards settle during streaming, the dot slides smoothly down the rail (CSS transition, 150ms ease-out), keeping the loading indicator visible near the newest content. Changes: - measureDotCentre targets last [data-prose-fragment] instead of first text node when fragments are present (works for both running and done states) - ThoughtRail accepts a settled prop; dot gets .thought-rail-dot--settled class after first measurement to gate the transition (prevents slide on initial mount) - Rail line gets matching height transition so spine extends in sync - Prose-fragment cards get a dedicated entry animation: 150ms ease-out, translateY(10px) + opacity fade, no blur (snappier than the general timeline-enter) - All transitions/animations honour prefers-reduced-motion: reduce - Sanity ceiling (80px) removed for fragment-targeting measurements since the dot can travel far down a long response Closes #265
1 parent e0913dd commit 52d592b

5 files changed

Lines changed: 112 additions & 7 deletions

File tree

‎packages/app/src/design-polish.css‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,23 @@
209209
[data-part-enter] {
210210
animation: timeline-enter var(--motion-enter-duration) var(--motion-enter-ease) backwards;
211211
}
212+
/* Prose-fragment cards get a snappier, blur-free entrance (#265): 150ms
213+
ease-out, 10px rise, no blur. Faster than the general timeline-enter because
214+
these settle during active streaming — the eye is already watching the bottom
215+
edge, so the entrance can be crisp without startling. */
216+
[data-prose-fragment][data-part-enter] {
217+
animation: prose-fragment-enter 150ms ease-out backwards;
218+
}
219+
@keyframes prose-fragment-enter {
220+
from {
221+
opacity: 0;
222+
transform: translateY(10px);
223+
}
224+
to {
225+
opacity: 1;
226+
transform: translateY(0);
227+
}
228+
}
212229
/* The open cascade holds at frame 0 (opacity 0, risen, blurred) until the
213230
timeline has settled at the bottom — released by removing
214231
[data-entrance-pending] (message-timeline.tsx entranceReady). Also keeps
@@ -262,6 +279,9 @@
262279
--motion-enter-rise: 0px;
263280
--motion-enter-blur: 0px;
264281
}
282+
[data-prose-fragment][data-part-enter] {
283+
animation: none;
284+
}
265285
}
266286

267287
/* ── inline code: readable in both schemes ── */

‎packages/app/src/index.css‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,18 @@
382382
.thought-rail-dot--harmonic {
383383
animation: thought-rail-grow 150ms ease-out both;
384384
}
385+
/* Travelling dot (#265): after the first measurement settles, the dot
386+
transitions smoothly to track the latest prose-fragment card. The settled
387+
class gates the transition so the initial mount uses the grow animation
388+
alone — no slide from top:0 to the first measured position. */
389+
.thought-rail-dot--settled {
390+
transition: top 150ms ease-out;
391+
}
392+
/* The rail line extends in sync with the dot — same timing so the spine
393+
grows as the dot descends. */
394+
[data-slot="thought-rail-line"] {
395+
transition: height 150ms ease-out;
396+
}
385397
@keyframes thought-rail-grow {
386398
from {
387399
transform: scale(0.538); /* 7/13 — starts at done-dot size */
@@ -400,4 +412,10 @@
400412
/* SMIL respects this; browsers also pause SMIL under reduced-motion */
401413
display: none;
402414
}
415+
.thought-rail-dot--settled {
416+
transition: none;
417+
}
418+
[data-slot="thought-rail-line"] {
419+
transition: none;
420+
}
403421
}

‎packages/app/src/pages/session/timeline/message-timeline.tsx‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,10 +1465,18 @@ export function MessageTimeline(props: {
14651465
// the count is bounded by the virtualizer's window.
14661466
let turnEl: HTMLDivElement | undefined
14671467
const [dotCentre, setDotCentre] = createSignal(DEFAULT_DOT_CENTRE)
1468+
const [dotSettled, setDotSettled] = createSignal(false)
14681469
const measureDotCentre = () => {
14691470
if (!turnEl || !rail()) return
14701471
const hostTop = turnEl.getBoundingClientRect().top
1471-
const walker = document.createTreeWalker(turnEl, NodeFilter.SHOW_TEXT)
1472+
// Travelling dot (#265): when the row has prose-fragment cards, target
1473+
// the LAST one so the dot (running or done) aligns with the newest
1474+
// settled chunk. This applies in both running and done states — the
1475+
// done-dot should stay at the last card, not jump back to the first.
1476+
const fragments = turnEl.querySelectorAll("[data-prose-fragment]")
1477+
const lastFragment = fragments.length > 0 ? (fragments[fragments.length - 1] as HTMLElement) : null
1478+
const target = lastFragment ?? turnEl
1479+
const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT)
14721480
let node: Node | null
14731481
while ((node = walker.nextNode())) {
14741482
if (!node.textContent?.trim()) continue
@@ -1477,10 +1485,12 @@ export function MessageTimeline(props: {
14771485
const rect = range.getClientRects()[0]
14781486
if (!rect || rect.height === 0) continue
14791487
const centre = rect.top + rect.height / 2 - hostTop
1480-
// Half-px grid; never above the default (a dot poking into the
1481-
// inter-row gap would detach from its own tail cap), and a sanity
1482-
// ceiling against mid-virtualisation nonsense measurements.
1483-
if (centre > 0 && centre < 80) setDotCentre(Math.max(DEFAULT_DOT_CENTRE, Math.round(centre * 2) / 2))
1488+
// When targeting a fragment, the dot can be anywhere down the row
1489+
// (no ceiling). For non-fragment rows the 80px ceiling guards against
1490+
// mid-virtualisation nonsense measurements.
1491+
const maxCentre = lastFragment ? Infinity : 80
1492+
if (centre > 0 && centre < maxCentre) setDotCentre(Math.max(DEFAULT_DOT_CENTRE, Math.round(centre * 2) / 2))
1493+
if (!dotSettled()) setDotSettled(true)
14841494
return
14851495
}
14861496
}
@@ -1512,7 +1522,7 @@ export function MessageTimeline(props: {
15121522
>
15131523
<Show when={rail()}>
15141524
{(r) => (
1515-
<ThoughtRail first={r().first} last={r().last} running={r().running} dotCentre={dotCentre()} />
1525+
<ThoughtRail first={r().first} last={r().last} running={r().running} dotCentre={dotCentre()} settled={dotSettled()} />
15161526
)}
15171527
</Show>
15181528
{/* The gutter is reserved for EVERY assistant part, not only the ones

‎packages/app/src/pages/session/timeline/thought-rail.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ export function ThoughtRail(props: {
128128
/** measured centre of the row's first text line (px from the row's top);
129129
* defaults to DEFAULT_DOT_CENTRE for unmeasured/prose rows */
130130
dotCentre?: number
131+
/** true once the first measurement has landed — gates the CSS transition
132+
* so the initial mount uses the grow animation alone (#265) */
133+
settled?: boolean
131134
}) {
132135
// Only the tail of a still-running turn is hollow. Everything above it has,
133136
// by definition, been succeeded. (Rule 4 — adjacency.)
@@ -172,8 +175,10 @@ export function ThoughtRail(props: {
172175
// RUNNING: spherical-harmonic morphing dot — 13px SVG centred on LINE_X.
173176
// The grow animation (7→13px) is a CSS @keyframes on mount; the morph
174177
// cycles Y_l^m silhouettes via SMIL; slow rotation via CSS on the <g>.
178+
// The settled class gates the top transition (#265): after the first
179+
// measurement, subsequent dotCentre changes slide smoothly.
175180
<HarmonicDot
176-
class="pointer-events-none absolute thought-rail-dot--harmonic"
181+
class={`pointer-events-none absolute thought-rail-dot--harmonic${props.settled ? " thought-rail-dot--settled" : ""}`}
177182
style={{
178183
top: `${dotCentre() - HARMONIC_SIZE / 2}px`,
179184
left: `${LINE_X - HARMONIC_SIZE / 2}px`,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { readFileSync } from "node:fs"
3+
import { resolve } from "node:path"
4+
5+
// Regression guard for the travelling dot + bottom-up card animation (#265).
6+
// These CSS declarations are load-bearing: removing them silently breaks the
7+
// dot's smooth travel and the card entry motion.
8+
9+
const indexCss = readFileSync(resolve(__dirname, "../../../index.css"), "utf8")
10+
const polishCss = readFileSync(resolve(__dirname, "../../../design-polish.css"), "utf8")
11+
12+
describe("travelling dot transition (#265)", () => {
13+
test("settled dot has top transition", () => {
14+
expect(indexCss).toContain("thought-rail-dot--settled")
15+
expect(indexCss).toMatch(/thought-rail-dot--settled[^}]*transition[^}]*top/)
16+
})
17+
18+
test("rail line has height transition", () => {
19+
expect(indexCss).toMatch(/thought-rail-line[^}]*transition[^}]*height/)
20+
})
21+
22+
test("reduced motion disables dot transition", () => {
23+
// Inside a prefers-reduced-motion block, the settled class gets transition: none
24+
expect(indexCss).toMatch(/prefers-reduced-motion[\s\S]*thought-rail-dot--settled[\s\S]*transition:\s*none/)
25+
})
26+
27+
test("reduced motion disables rail line transition", () => {
28+
expect(indexCss).toMatch(/prefers-reduced-motion[\s\S]*thought-rail-line[\s\S]*transition:\s*none/)
29+
})
30+
})
31+
32+
describe("prose fragment entry animation (#265)", () => {
33+
test("prose-fragment-enter keyframe exists", () => {
34+
expect(polishCss).toContain("prose-fragment-enter")
35+
})
36+
37+
test("prose-fragment-enter uses translateY", () => {
38+
expect(polishCss).toMatch(/prose-fragment-enter[\s\S]*translateY\(10px\)/)
39+
})
40+
41+
test("prose-fragment cards use prose-fragment-enter animation", () => {
42+
expect(polishCss).toMatch(/data-prose-fragment.*data-part-enter[\s\S]*prose-fragment-enter/)
43+
})
44+
45+
test("prose-fragment-enter has 150ms duration", () => {
46+
expect(polishCss).toMatch(/data-prose-fragment.*data-part-enter[\s\S]*150ms/)
47+
})
48+
49+
test("reduced motion disables prose-fragment entrance", () => {
50+
expect(polishCss).toMatch(/prefers-reduced-motion[\s\S]*data-prose-fragment.*data-part-enter[\s\S]*animation:\s*none/)
51+
})
52+
})

0 commit comments

Comments
 (0)