diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css
index 08baf080b..e74ec5936 100644
--- a/packages/app/src/design-polish.css
+++ b/packages/app/src/design-polish.css
@@ -209,23 +209,7 @@
[data-part-enter] {
animation: timeline-enter var(--motion-enter-duration) var(--motion-enter-ease) backwards;
}
-/* Prose-fragment cards get a snappier, blur-free entrance (#265): 150ms
- ease-out, 10px rise, no blur. Faster than the general timeline-enter because
- these settle during active streaming — the eye is already watching the bottom
- edge, so the entrance can be crisp without startling. */
-[data-prose-fragment][data-part-enter] {
- animation: prose-fragment-enter 150ms ease-out backwards;
-}
-@keyframes prose-fragment-enter {
- from {
- opacity: 0;
- transform: translateY(10px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
+
/* The open cascade holds at frame 0 (opacity 0, risen, blurred) until the
timeline has settled at the bottom — released by removing
[data-entrance-pending] (message-timeline.tsx entranceReady). Also keeps
@@ -279,9 +263,6 @@
--motion-enter-rise: 0px;
--motion-enter-blur: 0px;
}
- [data-prose-fragment][data-part-enter] {
- animation: none;
- }
}
/* ── inline code: readable in both schemes ── */
diff --git a/packages/app/src/index.css b/packages/app/src/index.css
index 4b23fe85c..a9b3b592a 100644
--- a/packages/app/src/index.css
+++ b/packages/app/src/index.css
@@ -378,44 +378,14 @@
/* The thought rail's live node: a spherical-harmonic morphing dot that pulses
through Y_l^m silhouettes via SMIL path animation (sphere → shape → sphere).
- The grow animation fires on mount (7→13px). Honours reduced motion. */
+ display:block kills the inline SVG baseline gap (~2-3px descender space)
+ that otherwise pushes the dot above its intended centre. */
.thought-rail-dot--harmonic {
- animation: thought-rail-grow 150ms ease-out both;
-}
-/* Travelling dot (#265): after the first measurement settles, the dot
- transitions smoothly to track the latest prose-fragment card. The settled
- class gates the transition so the initial mount uses the grow animation
- alone — no slide from top:0 to the first measured position. */
-.thought-rail-dot--settled {
- transition: top 150ms ease-out;
-}
-/* The rail line extends in sync with the dot — same timing so the spine
- grows as the dot descends. */
-[data-slot="thought-rail-line"] {
- transition: height 150ms ease-out;
-}
-@keyframes thought-rail-grow {
- from {
- transform: scale(0.538); /* 7/13 — starts at done-dot size */
- opacity: 0.7;
- }
- to {
- transform: scale(1);
- opacity: 1;
- }
+ display: block;
}
@media (prefers-reduced-motion: reduce) {
- .thought-rail-dot--harmonic {
- animation: none;
- }
.thought-rail-dot--harmonic .harmonic-dot-shape animate {
/* SMIL respects this; browsers also pause SMIL under reduced-motion */
display: none;
}
- .thought-rail-dot--settled {
- transition: none;
- }
- [data-slot="thought-rail-line"] {
- transition: none;
- }
}
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx
index 4c7b8c095..5913c7a4d 100644
--- a/packages/app/src/pages/session/timeline/message-timeline.tsx
+++ b/packages/app/src/pages/session/timeline/message-timeline.tsx
@@ -19,7 +19,7 @@ import { useMutation } from "@tanstack/solid-query"
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { Accordion } from "@opencode-ai/ui/accordion"
import { AmicodeEntityRail } from "@opencode-ai/ui/amicode-entity-rail"
-import { DEFAULT_DOT_CENTRE, ThoughtRail, ThoughtRailLabel, THOUGHT_RAIL_INSET, shouldRenderRail } from "./thought-rail"
+import { DEFAULT_DOT_CENTRE, ThoughtRail, ThoughtRailLabel, THOUGHT_RAIL_INSET, shouldRenderRail, dotCentreForGroup } from "./thought-rail"
import { formatElapsed, formatTokens, turnTokens } from "@opencode-ai/ui/amicode-thinking"
import {
AmicodeEntityView,
@@ -98,6 +98,7 @@ import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { sessionTitle } from "@/utils/session-title"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
+import { smoothScrollInterpolate, SMOOTH_SCROLL_DURATION } from "./smooth-scroll"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
@@ -702,6 +703,64 @@ export function MessageTimeline(props: {
)
},
})
+
+ // ── Smooth scroll (#631) ───────────────────────────────────────────────
+ // Custom RAF loop for bottom-follow scrolls (180ms ease-out cubic). Instant
+ // scrollToEnd is used for mount, reveal, and prepend-anchor; smooth only for
+ // the "new content arrived while anchored" path. Cancels on user gesture.
+ let smoothFrame: number | undefined
+ let smoothStartY = 0
+ let smoothStartTime = 0
+ const prefersReducedMotion = typeof window !== "undefined"
+ ? window.matchMedia("(prefers-reduced-motion: reduce)")
+ : undefined
+
+ const cancelSmoothScroll = () => {
+ if (smoothFrame !== undefined) {
+ cancelAnimationFrame(smoothFrame)
+ smoothFrame = undefined
+ }
+ }
+
+ const smoothScrollToEnd = () => {
+ const el = listRoot()
+ if (!el) { virtualizer.scrollToEnd(); return }
+ // Reduced motion: instant
+ if (prefersReducedMotion?.matches) { virtualizer.scrollToEnd(); return }
+ // Compute target (max scroll position = scrollHeight - clientHeight)
+ const target = el.scrollHeight - el.clientHeight
+ const current = el.scrollTop
+ if (Math.abs(target - current) < 2) return // already there
+ // If an animation is in flight, restart from current position
+ cancelSmoothScroll()
+ smoothStartY = current
+ smoothStartTime = performance.now()
+ const tick = (now: number) => {
+ const elapsed = now - smoothStartTime
+ const y = smoothScrollInterpolate(smoothStartY, target, elapsed, SMOOTH_SCROLL_DURATION)
+ el.scrollTop = y
+ if (elapsed < SMOOTH_SCROLL_DURATION) smoothFrame = requestAnimationFrame(tick)
+ else smoothFrame = undefined
+ }
+ smoothFrame = requestAnimationFrame(tick)
+ }
+
+ // Cancel smooth scroll on user gesture (wheel, touch, pointer)
+ onMount(() => {
+ const el = listRoot()
+ if (!el) return
+ const cancel = () => cancelSmoothScroll()
+ el.addEventListener("wheel", cancel, { passive: true })
+ el.addEventListener("touchstart", cancel, { passive: true })
+ el.addEventListener("pointerdown", cancel)
+ onCleanup(() => {
+ el.removeEventListener("wheel", cancel)
+ el.removeEventListener("touchstart", cancel)
+ el.removeEventListener("pointerdown", cancel)
+ cancelSmoothScroll()
+ })
+ })
+
const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
const anchorResizedBottom = () => {
@@ -710,7 +769,7 @@ export function MessageTimeline(props: {
queueMicrotask(() => {
resizeAnchorScheduled = false
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
- virtualizer.scrollToEnd()
+ smoothScrollToEnd()
})
}
virtualizer.resizeItem = (index, size) => {
@@ -819,7 +878,7 @@ export function MessageTimeline(props: {
if (index === undefined) return
virtualizer.scrollToIndex(index, { align: "center" })
})
- props.setScrollToEnd?.(() => virtualizer.scrollToEnd())
+ props.setScrollToEnd?.(() => smoothScrollToEnd())
props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
})
@@ -843,7 +902,7 @@ export function MessageTimeline(props: {
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
- virtualizer.scrollToEnd()
+ smoothScrollToEnd()
}
let measuredSessionKey = sessionKey()
@@ -1479,38 +1538,38 @@ export function MessageTimeline(props: {
if (row._tag === "Thinking") {
const hasOutput = hasAssistantParts(row.userMessageID)
// Dot stays on Thinking only while no output exists
- return { first: true, last: !hasOutput, running: row.turnRunning && !hasOutput }
+ return { first: true, last: !hasOutput, running: row.turnRunning && !hasOutput, prose: false }
}
if (row._tag !== "AssistantPart") return undefined
if (!shouldRenderRail(row)) return undefined
- // Last AssistantPart gets the dot when the turn is still running
- return { first: false, last: row.lastAssistantPart, running: row.turnRunning && row.lastAssistantPart }
+ // Last AssistantPart gets the dot when the turn is still running.
+ // prose = text content that grows → dot bottom-anchored.
+ // !prose = tool/status row → dot at dotCentre.
+ // A "part" group can be either text or a single tool — check the actual part.
+ let isProse = false
+ if (row.group.type === "part") {
+ const part = getMsgPart(row.group.ref.messageID, row.group.ref.partID)
+ isProse = part?.type === "text"
+ }
+ return { first: false, last: row.lastAssistantPart, running: row.turnRunning && row.lastAssistantPart, prose: isProse }
}
- // The dot sits on the row's FIRST TEXT LINE, wherever the content puts it
- // (Kate 2026-08-24: dots must line up with the text they coincide with).
- // Prose and rail-label rows put it at the default 11px; rows that open
- // with a card (a tool chip, a group header, a widget preview) start their
- // first line lower by that card's own padding — measured, not tabulated,
- // because the set of card species is open-ended. The ResizeObserver
- // re-measures when async card content mounts (deferToolContent) or
- // streaming reflows the row; observers exist only on rendered rows, so
- // the count is bounded by the virtualizer's window.
+ // The dot aligns with the vertical centre of the row's first text line.
+ // Initialized from dotCentreForGroup (deterministic per row type) so the
+ // dot starts in the right place without waiting for DOM measurement.
+ // ResizeObserver refines the value once the content has rendered.
let turnEl: HTMLDivElement | undefined
- const [dotCentre, setDotCentre] = createSignal(DEFAULT_DOT_CENTRE)
- const [dotSettled, setDotSettled] = createSignal(false)
+ const initialDotCentre = () => {
+ const row = input.row()
+ if (row._tag === "Thinking") return dotCentreForGroup("thinking")
+ if (row._tag === "AssistantPart") return dotCentreForGroup(row.group.type)
+ return DEFAULT_DOT_CENTRE
+ }
+ const [dotCentre, setDotCentre] = createSignal(initialDotCentre())
const measureDotCentre = () => {
if (!turnEl || !rail()) return
const hostTop = turnEl.getBoundingClientRect().top
- // Travelling dot (#265): ONLY the running dot tracks the last
- // prose-fragment card. The done-dot stays at the first text line
- // (top of the row) so the rail reads as a sequence of origin marks.
- const r = rail()
- const isRunning = r && r.last && r.running
- const fragments = isRunning ? turnEl.querySelectorAll("[data-prose-fragment]") : undefined
- const lastFragment = fragments && fragments.length > 0 ? (fragments[fragments.length - 1] as HTMLElement) : null
- const target = lastFragment ?? turnEl
- const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT)
+ const walker = document.createTreeWalker(turnEl, NodeFilter.SHOW_TEXT)
let node: Node | null
while ((node = walker.nextNode())) {
if (!node.textContent?.trim()) continue
@@ -1519,13 +1578,10 @@ export function MessageTimeline(props: {
const rect = range.getClientRects()[0]
if (!rect || rect.height === 0) continue
const centre = rect.top + rect.height / 2 - hostTop
- // When targeting a fragment, the dot can be anywhere down the row
- // (no ceiling). For non-fragment rows the 80px ceiling guards against
- // mid-virtualisation nonsense measurements.
- const maxCentre = lastFragment ? Infinity : 80
- if (centre > 0 && centre < maxCentre) setDotCentre(Math.max(DEFAULT_DOT_CENTRE, Math.round(centre * 2) / 2))
- if (!dotSettled()) setDotSettled(true)
- return
+ if (centre > 0 && centre < 80) {
+ setDotCentre(Math.max(DEFAULT_DOT_CENTRE, Math.round(centre * 2) / 2))
+ return
+ }
}
}
onMount(() => {
@@ -1546,6 +1602,7 @@ export function MessageTimeline(props: {
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
"md:mx-auto": props.centered,
"pt-3": previousAssistantPart(),
+ "md:pl-3": assistantPart(),
}}
>
diff --git a/packages/app/src/pages/session/timeline/model-pure.ts b/packages/app/src/pages/session/timeline/model-pure.ts
new file mode 100644
index 000000000..5edbf8b9b
--- /dev/null
+++ b/packages/app/src/pages/session/timeline/model-pure.ts
@@ -0,0 +1,35 @@
+import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
+import type { Accessor } from "solid-js"
+
+export function selectUserMessages(messages: Message[]) {
+ return messages.filter((message): message is UserMessage => message.role === "user")
+}
+
+export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
+ return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
+}
+
+export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
+ if (!revertMessageID) return messages
+ return messages.filter((message) => message.id < revertMessageID)
+}
+
+export async function loadOlderTimeline(input: {
+ sessionID: Accessor
+ more: Accessor
+ loading: Accessor
+ loadMore: (sessionID: string) => Promise
+ before?: () => void
+ after?: (done: boolean) => void
+}) {
+ const id = input.sessionID()
+ if (!id || !input.more() || input.loading()) return
+
+ input.before?.()
+ await input.loadMore(id).catch((error) => {
+ if (input.sessionID() === id) input.after?.(true)
+ throw error
+ })
+ if (input.sessionID() !== id) return
+ input.after?.(true)
+}
diff --git a/packages/app/src/pages/session/timeline/model.test.ts b/packages/app/src/pages/session/timeline/model.test.ts
index 24612072c..b19a393e5 100644
--- a/packages/app/src/pages/session/timeline/model.test.ts
+++ b/packages/app/src/pages/session/timeline/model.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
-import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
+import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model-pure"
const user = (id: string) => ({ id, role: "user" }) as UserMessage
const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts
index 7eebee608..b254f0692 100644
--- a/packages/app/src/pages/session/timeline/model.ts
+++ b/packages/app/src/pages/session/timeline/model.ts
@@ -3,6 +3,8 @@ import { createMemo, createResource, onCleanup, untrack, type Accessor } from "s
import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
import { same } from "@/utils/same"
+import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model-pure"
+export { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model-pure"
const emptyUserMessages: UserMessage[] = []
const sessionFreshness = 15_000
@@ -93,36 +95,3 @@ export function createTimelineModel(input: {
refreshTimer = undefined
}
}
-
-export function selectUserMessages(messages: Message[]) {
- return messages.filter((message): message is UserMessage => message.role === "user")
-}
-
-export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
- return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
-}
-
-export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
- if (!revertMessageID) return messages
- return messages.filter((message) => message.id < revertMessageID)
-}
-
-export async function loadOlderTimeline(input: {
- sessionID: Accessor
- more: Accessor
- loading: Accessor
- loadMore: (sessionID: string) => Promise
- before?: () => void
- after?: (done: boolean) => void
-}) {
- const id = input.sessionID()
- if (!id || !input.more() || input.loading()) return
-
- input.before?.()
- await input.loadMore(id).catch((error) => {
- if (input.sessionID() === id) input.after?.(true)
- throw error
- })
- if (input.sessionID() !== id) return
- input.after?.(true)
-}
diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts
index 0dc5f86f3..202c3e42c 100644
--- a/packages/app/src/pages/session/timeline/rows.ts
+++ b/packages/app/src/pages/session/timeline/rows.ts
@@ -241,6 +241,17 @@ export namespace Timeline {
assistantItems.forEach((item, itemIndex) => {
if (item.type === "interrupted") {
+ // ThinkingMeta attaches to the last output BEFORE the interruption —
+ // it summarises the work that was interrupted, not the post-interrupt tail.
+ if (assistantPartRefs.length > 0 && !turnIsRunning) {
+ rows.push(
+ new TimelineRow.ThinkingMeta({
+ userMessageID: userMessage.id,
+ turnRunning: false,
+ turnDurationMs: computeTurnDuration(userMessage, assistantMessages),
+ }),
+ )
+ }
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
@@ -266,8 +277,8 @@ export namespace Timeline {
// ThinkingMeta row renders LAST — duration + tokens as a historical record.
// Hidden while streaming (the harmonic dot signals "working"); appears only
- // after the turn completes.
- if (assistantPartRefs.length > 0 && !turnIsRunning) {
+ // after the turn completes. Skipped when interrupted — already emitted above.
+ if (assistantPartRefs.length > 0 && !turnIsRunning && !interrupted) {
rows.push(
new TimelineRow.ThinkingMeta({
userMessageID: userMessage.id,
diff --git a/packages/app/src/pages/session/timeline/smooth-scroll.test.ts b/packages/app/src/pages/session/timeline/smooth-scroll.test.ts
new file mode 100644
index 000000000..f59abc126
--- /dev/null
+++ b/packages/app/src/pages/session/timeline/smooth-scroll.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from "bun:test"
+import { smoothScrollInterpolate } from "./smooth-scroll"
+
+describe("smoothScrollInterpolate", () => {
+ test("returns startY at elapsed=0", () => {
+ expect(smoothScrollInterpolate(100, 500, 0, 180)).toBe(100)
+ })
+
+ test("returns targetY when elapsed >= duration", () => {
+ expect(smoothScrollInterpolate(100, 500, 180, 180)).toBe(500)
+ expect(smoothScrollInterpolate(100, 500, 300, 180)).toBe(500)
+ })
+
+ test("returns intermediate value at 50% elapsed", () => {
+ const mid = smoothScrollInterpolate(0, 400, 90, 180)
+ // Ease-out cubic at t=0.5: 1 - (1 - 0.5)^3 = 1 - 0.125 = 0.875
+ expect(mid).toBe(400 * 0.875)
+ })
+
+ test("is monotonically increasing for scrolling down", () => {
+ let prev = 0
+ for (let t = 0; t <= 180; t += 10) {
+ const current = smoothScrollInterpolate(0, 1000, t, 180)
+ expect(current).toBeGreaterThanOrEqual(prev)
+ prev = current
+ }
+ })
+
+ test("handles scrolling up (target < start)", () => {
+ const result = smoothScrollInterpolate(500, 100, 180, 180)
+ expect(result).toBe(100)
+ const mid = smoothScrollInterpolate(500, 100, 90, 180)
+ expect(mid).toBeLessThan(500)
+ expect(mid).toBeGreaterThan(100)
+ })
+
+ test("clamps to targetY for very large elapsed values", () => {
+ expect(smoothScrollInterpolate(0, 1000, 99999, 180)).toBe(1000)
+ })
+})
diff --git a/packages/app/src/pages/session/timeline/smooth-scroll.ts b/packages/app/src/pages/session/timeline/smooth-scroll.ts
new file mode 100644
index 000000000..5a8301370
--- /dev/null
+++ b/packages/app/src/pages/session/timeline/smooth-scroll.ts
@@ -0,0 +1,15 @@
+// Smooth scroll helper for the timeline (#631).
+// Pure easing function — testable without DOM. The RAF loop that drives it
+// lives in message-timeline.tsx; this module is the math.
+
+/** Ease-out cubic: fast start, gentle settle. `1 - (1 - t)^3` */
+export function smoothScrollInterpolate(startY: number, targetY: number, elapsed: number, duration: number): number {
+ if (elapsed >= duration) return targetY
+ const t = Math.min(elapsed / duration, 1)
+ const eased = 1 - (1 - t) ** 3
+ return startY + (targetY - startY) * eased
+}
+
+/** Duration for smooth-follow scrolls (ms). Kept short so the timeline feels
+ * responsive — native smooth-behavior is 500ms+ and browser-dependent. */
+export const SMOOTH_SCROLL_DURATION = 180
diff --git a/packages/app/src/pages/session/timeline/thought-rail-pure.ts b/packages/app/src/pages/session/timeline/thought-rail-pure.ts
new file mode 100644
index 000000000..b9d5e3894
--- /dev/null
+++ b/packages/app/src/pages/session/timeline/thought-rail-pure.ts
@@ -0,0 +1,37 @@
+// Pure (no DOM / no Kobalte) exports from the thought-rail module.
+// Extracted so tests can import these without triggering the SSR error that
+// Kobalte's client-only tooltip causes in a test environment.
+
+/** Where a row's dot centre sits when nothing measures it: 11px — the centre
+ * of a 22px first text line starting at the row's top, which is what prose
+ * and rail-label rows produce. */
+export const DEFAULT_DOT_CENTRE = 11
+
+/** Deterministic dot centre per group type — the vertical centre of the first
+ * text line for each content species, measured once and tabulated. Used as the
+ * initial value for dotCentre (the ResizeObserver measurement refines it once
+ * the DOM settles, but the initial value prevents a frame of misalignment). */
+export function dotCentreForGroup(groupType: string): number {
+ if (groupType === "prose" || groupType === "part") return DEFAULT_DOT_CENTRE
+ if (groupType === "single_tool" || groupType === "shell" || groupType === "edit" || groupType === "context") return DEFAULT_DOT_CENTRE
+ if (groupType === "tool_group") return DEFAULT_DOT_CENTRE
+ if (groupType === "thinking") return DEFAULT_DOT_CENTRE
+ return DEFAULT_DOT_CENTRE
+}
+
+/**
+ * Rule 6 — lone COMPLETED steps render nothing (one dot is decoration). A
+ * RUNNING turn rails from its very first step, though — the live dot is the
+ * timeline's only "working" mark. The lone live dot draws no line — every line
+ * must end at a dot at both ends, so a single dot has no dangling half — and a
+ * one-step completion still reads as "dot fills".
+ */
+export function shouldRenderRail(_input: {
+ previousAssistantPart: boolean
+ lastAssistantPart: boolean
+ turnRunning: boolean
+}) {
+ // The Thinking row is always the first rail node, so every AssistantPart
+ // always has at least one other node above it — the rail always renders.
+ return true
+}
diff --git a/packages/app/src/pages/session/timeline/thought-rail.test.ts b/packages/app/src/pages/session/timeline/thought-rail.test.ts
index 19c784904..fd7ac9299 100644
--- a/packages/app/src/pages/session/timeline/thought-rail.test.ts
+++ b/packages/app/src/pages/session/timeline/thought-rail.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { shouldRenderRail } from "./thought-rail"
+import { shouldRenderRail, dotCentreForGroup } from "./thought-rail-pure"
// The rail's grammar, stated as tests. A step is "running" only when it is the
// TAIL of a turn that is still working; everything above it has by definition
@@ -63,3 +63,23 @@ describe("thought rail", () => {
expect(steps.map((s) => s.last)).toEqual([false, false, true])
})
})
+
+describe("dotCentreForGroup", () => {
+ test("returns DEFAULT_DOT_CENTRE for all group types (measurement is source of truth)", () => {
+ // All types return DEFAULT_DOT_CENTRE as the initial value;
+ // the ResizeObserver measurement refines it once DOM settles.
+ expect(dotCentreForGroup("prose")).toBe(11)
+ expect(dotCentreForGroup("tool_group")).toBe(11)
+ expect(dotCentreForGroup("single_tool")).toBe(11)
+ expect(dotCentreForGroup("thinking")).toBe(11)
+ expect(dotCentreForGroup("part")).toBe(11)
+ expect(dotCentreForGroup("shell")).toBe(11)
+ expect(dotCentreForGroup("edit")).toBe(11)
+ expect(dotCentreForGroup("context")).toBe(11)
+ })
+
+ test("returns default for unknown group types", () => {
+ expect(dotCentreForGroup("unknown")).toBe(11)
+ expect(dotCentreForGroup("")).toBe(11)
+ })
+})
diff --git a/packages/app/src/pages/session/timeline/thought-rail.tsx b/packages/app/src/pages/session/timeline/thought-rail.tsx
index 7213789d7..b3521472c 100644
--- a/packages/app/src/pages/session/timeline/thought-rail.tsx
+++ b/packages/app/src/pages/session/timeline/thought-rail.tsx
@@ -84,22 +84,23 @@
// // tail: { top: first ? "0px" : NEG, height: first ? "0px" : `calc(${STEP_GAP} + ${dotCentre}px)` }
-import { createSignal, onCleanup, Show } from "solid-js"
+import { createSignal, onCleanup } from "solid-js"
import { HarmonicDot, HARMONIC_SIZE } from "@opencode-ai/ui/amicode-harmonic-dot"
import { formatElapsed, formatTokens } from "@opencode-ai/ui/amicode-thinking"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+export { DEFAULT_DOT_CENTRE, dotCentreForGroup, shouldRenderRail } from "./thought-rail-pure"
const NODE = 7 // dot diameter, px — matches the site's Step
-/** Where a row's dot centre sits when nothing measures it: 11px — the centre
- * of a 22px first text line starting at the row's top, which is what prose
- * and rail-label rows produce. Rows whose content opens with a CARD (a tool
- * chip, a group header, a widget preview) start their first text line lower
- * — measured 16px for a chip row, 26.5px for a widget preview — so
- * TimelineRowFrame measures the actual first line and passes `dotCentre`
- * (Kate 2026-08-24: dots must line up with the text they coincide with).
- * Segment caps derive from the same value, so alignment can never detach
- * the spine from its dots. */
-export const DEFAULT_DOT_CENTRE = 11
+// The bottom-anchored dot on prose rows sits with its centre at the last
+// text line's vertical centre — approximately: card bottom-padding (10px) +
+// half body line-height (~11px) = 21px from the row's bottom edge. This is
+// a fixed offset, so the dot never jumps — it just rides down as the row grows.
+const PROSE_DOT_BOTTOM_INSET = 21
+
+// DEFAULT_DOT_CENTRE, dotCentreForGroup, shouldRenderRail are re-exported
+// from ./thought-rail-pure (extracted for test isolation from Kobalte SSR).
+import { DEFAULT_DOT_CENTRE } from "./thought-rail-pure"
// The rail sits in a gutter carved out of the row's own left inset, NOT flush
// against the row edge. Flush was wrong: below the md breakpoint the message
@@ -127,12 +128,12 @@ export function ThoughtRail(props: {
last: boolean
/** the turn is still working, so this tail step is in flight */
running: boolean
+ /** true for prose/text rows (growing content) — dot bottom-anchors.
+ * false for Thinking/tool/shell rows (status cards) — dot at dotCentre. */
+ prose: boolean
/** measured centre of the row's first text line (px from the row's top);
- * defaults to DEFAULT_DOT_CENTRE for unmeasured/prose rows */
+ * used for done-dot alignment and status-row running dot */
dotCentre?: number
- /** true once the first measurement has landed — gates the CSS transition
- * so the initial mount uses the grow animation alone (#265) */
- settled?: boolean
/** epoch-ms when the user message was created — anchors the tooltip timer */
turnStartedAt?: number
/** streamed token count for this turn — shown in the tooltip */
@@ -152,8 +153,7 @@ export function ThoughtRail(props: {
return (
<>
{/* Rail line BEHIND the dot — the SVG's internal background circle
- masks the line within the ring's interior. The line reaches dotCentre
- geometrically to connect with harmonic shapes that extend inward. */}
+ masks the line within the ring's interior. */}
{isRunning() ? (
- // RUNNING: spherical-harmonic morphing dot — 13px SVG centred on LINE_X.
- // The grow animation (7→13px) is a CSS @keyframes on mount; the morph
- // cycles Y_l^m silhouettes via SMIL; slow rotation via CSS on the .
- // The settled class gates the top transition (#265): after the first
- // measurement, subsequent dotCentre changes slide smoothly.
+ // RUNNING: spherical-harmonic morphing dot.
+ // - Prose rows (growing text content): bottom-anchored. As content
+ // grows above it, the row's height increases and the dot rides down
+ // passively — no transitions, no re-renders.
+ // - Status rows (Thinking, Shell, Edit, etc.): centred on dotCentre
+ // so the dot aligns with the label text.
// Tooltip on hover shows elapsed time + token count (#625).
@@ -216,56 +223,70 @@ export function ThoughtRail(props: {
}
/** Running dot with a hover tooltip showing elapsed time + tokens (#625).
- * The tooltip only renders while hovered to keep DOM cost near zero. The
- * timer ticks from `turnStartedAt` (the user message's `time.created`), so
- * it survives component remount across session switches. */
+ * Two positioning modes:
+ * - bottomAnchored=true: sits at the bottom edge of the row, rides down as
+ * content grows above it. Used for AssistantPart content rows.
+ * - bottomAnchored=false: sits at dotCentre (first-line aligned). Used for
+ * the Thinking row and status rows where the dot signals "working" beside
+ * a fixed label, not below growing content.
+ * Uses TooltipV2 (placement="bottom") so the popup renders below the dot and
+ * never occludes the rail or chat content above. The timer ticks from
+ * `turnStartedAt` (the user message's `time.created`), so it survives
+ * component remount across session switches. */
function DotWithTooltip(props: {
+ bottomAnchored: boolean
dotCentre: number
- settled?: boolean
turnStartedAt?: number
tokens?: number
}) {
- const [hovered, setHovered] = createSignal(false)
- const [elapsedMs, setElapsedMs] = createSignal(0)
+ // Tick elapsed time every second while this component is mounted.
+ // Only one running dot exists at a time, so the cost is trivial.
+ const [elapsedMs, setElapsedMs] = createSignal(
+ props.turnStartedAt != null ? Date.now() - props.turnStartedAt : 0,
+ )
+ const clock = setInterval(() => {
+ if (props.turnStartedAt != null) setElapsedMs(Date.now() - props.turnStartedAt)
+ }, 1000)
+ onCleanup(() => clearInterval(clock))
- // Tick the timer only while hovered — no cost when tooltip is hidden
- let clock: ReturnType | undefined
- const startTicking = () => {
- if (props.turnStartedAt == null) return
- setElapsedMs(Date.now() - props.turnStartedAt)
- clock = setInterval(() => setElapsedMs(Date.now() - props.turnStartedAt!), 1000)
- }
- const stopTicking = () => {
- if (clock != null) clearInterval(clock)
- clock = undefined
+ const tooltipValue = () => {
+ if (props.turnStartedAt == null) return undefined
+ return (
+
+ {formatElapsed(elapsedMs())}
+ {props.tokens != null && (
+ <>
+ {"\u00B7"}
+ {"\u2191"} {formatTokens(props.tokens!)} tokens
+ >
+ )}
+
+ )
}
- onCleanup(stopTicking)
return (
{ setHovered(true); startTicking() }}
- onMouseLeave={() => { setHovered(false); stopTicking() }}
>
-
-
-
- {formatElapsed(elapsedMs())}
-
- ·
- {formatTokens(props.tokens!)} tokens
-
-
-
+
+
+
)
}
@@ -292,22 +313,3 @@ export function ThoughtRailLabel(props: { label: string }) {
* 15px; pl-6 (24px) leaves the same ~9px dot-to-content breath the website's
* Step has — pl-4 left a 1px gap and labels read as glued to their dots. (Rule 5) */
export const THOUGHT_RAIL_INSET = "pl-6"
-
-/**
- * Rule 6 — lone COMPLETED steps render nothing (one dot is decoration). A
- * RUNNING turn rails from its very first step, though — the live dot is the
- * timeline's only "working" mark (the card's pulsing sig and Livedot are
- * gone), and a turn's first step is often its longest, so waiting for step
- * two meant the whole opening had no status signal. The lone live dot draws
- * no line — every line must end at a dot at both ends, so a single dot has
- * no dangling half — and a one-step completion still reads as "dot fills".
- */
-export function shouldRenderRail(_input: {
- previousAssistantPart: boolean
- lastAssistantPart: boolean
- turnRunning: boolean
-}) {
- // The Thinking row is always the first rail node, so every AssistantPart
- // always has at least one other node above it — the rail always renders.
- return true
-}
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 dd62fb86b..00df88075 100644
--- a/packages/app/src/pages/session/timeline/travelling-dot.test.ts
+++ b/packages/app/src/pages/session/timeline/travelling-dot.test.ts
@@ -2,51 +2,40 @@ import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
-// Regression guard for the travelling dot + bottom-up card animation (#265).
-// These CSS declarations are load-bearing: removing them silently breaks the
-// dot's smooth travel and the card entry motion.
+// Regression guard for the bottom-anchored harmonic dot (replaces travelling
+// dot #265). The running dot sits at the bottom of the last row — no position
+// transitions needed, content grows above it.
const indexCss = readFileSync(resolve(__dirname, "../../../index.css"), "utf8")
const polishCss = readFileSync(resolve(__dirname, "../../../design-polish.css"), "utf8")
-describe("travelling dot transition (#265)", () => {
- test("settled dot has top transition", () => {
- expect(indexCss).toContain("thought-rail-dot--settled")
- expect(indexCss).toMatch(/thought-rail-dot--settled[^}]*transition[^}]*top/)
+describe("bottom-anchored harmonic dot", () => {
+ test("harmonic dot class has display:block (kills SVG baseline gap)", () => {
+ expect(indexCss).toContain("thought-rail-dot--harmonic")
+ expect(indexCss).toMatch(/thought-rail-dot--harmonic[^}]*display:\s*block/)
})
- test("rail line has height transition", () => {
- expect(indexCss).toMatch(/thought-rail-line[^}]*transition[^}]*height/)
+ test("no position transition on the dot (bottom-anchored, passive)", () => {
+ expect(indexCss).not.toContain("thought-rail-dot--settled")
})
- test("reduced motion disables dot transition", () => {
- // Inside a prefers-reduced-motion block, the settled class gets transition: none
- expect(indexCss).toMatch(/prefers-reduced-motion[\s\S]*thought-rail-dot--settled[\s\S]*transition:\s*none/)
+ test("no height transition on rail line (no travelling)", () => {
+ expect(indexCss).not.toMatch(/thought-rail-line[^}]*transition[^}]*height/)
})
- test("reduced motion disables rail line transition", () => {
- expect(indexCss).toMatch(/prefers-reduced-motion[\s\S]*thought-rail-line[\s\S]*transition:\s*none/)
+ test("reduced motion disables SMIL animation", () => {
+ expect(indexCss).toMatch(/prefers-reduced-motion[\s\S]*harmonic-dot-shape[\s\S]*display:\s*none/)
})
})
-describe("prose fragment entry animation (#265)", () => {
- test("prose-fragment-enter keyframe exists", () => {
- expect(polishCss).toContain("prose-fragment-enter")
+describe("timeline entrance animation", () => {
+ test("timeline-enter keyframe uses blur + rise + opacity", () => {
+ expect(polishCss).toMatch(/timeline-enter[\s\S]*opacity:\s*0/)
+ expect(polishCss).toMatch(/timeline-enter[\s\S]*translateY/)
+ expect(polishCss).toMatch(/timeline-enter[\s\S]*blur/)
})
- test("prose-fragment-enter uses translateY", () => {
- expect(polishCss).toMatch(/prose-fragment-enter[\s\S]*translateY\(10px\)/)
- })
-
- test("prose-fragment cards use prose-fragment-enter animation", () => {
- expect(polishCss).toMatch(/data-prose-fragment.*data-part-enter[\s\S]*prose-fragment-enter/)
- })
-
- test("prose-fragment-enter has 150ms duration", () => {
- expect(polishCss).toMatch(/data-prose-fragment.*data-part-enter[\s\S]*150ms/)
- })
-
- test("reduced motion disables prose-fragment entrance", () => {
- expect(polishCss).toMatch(/prefers-reduced-motion[\s\S]*data-prose-fragment.*data-part-enter[\s\S]*animation:\s*none/)
+ test("[data-part-enter] uses timeline-enter (unified entrance)", () => {
+ expect(polishCss).toMatch(/data-part-enter[\s\S]*timeline-enter/)
})
})
diff --git a/packages/session-ui/src/components/message-part-text.ts b/packages/session-ui/src/components/message-part-text.ts
index e2a52a366..72101d39f 100644
--- a/packages/session-ui/src/components/message-part-text.ts
+++ b/packages/session-ui/src/components/message-part-text.ts
@@ -2,54 +2,38 @@ export function readPartText(accum: Record | undefined, part: {
return (accum?.[part.id] ?? part.text ?? "").trim()
}
-/* Streaming prose lands in whole CHUNKS (Kate 2026-08-25: no typing reveal,
- but no waiting for the entire reply either). A chunk boundary is a blank
- line that is
- · OUTSIDE a code fence — splitting inside one corrupts the markdown, and
- · not immediately before a list item, blockquote, or indented line —
- splitting those restarts ordered-list numbering and swells the gap
- between fragments that render as separate lists.
- Text past the last boundary is still being composed and stays withheld
- until the next boundary or completion. */
+/* Streaming prose lands in whole SECTIONS (heading-anchored segmentation).
+ A chunk boundary fires at every markdown heading (`# `, `## `, `### `, etc.)
+ that is OUTSIDE a code fence. Each section = heading + everything until the
+ next heading of any rank. The intro (text before the first heading) is its
+ own section. Text past the last boundary is still being composed and stays
+ withheld until the next heading appears or the message completes.
+
+ This replaces the old blank-line splitting which produced orphan heading
+ cards, empty cards from `---`, and isolated math blocks. */
const FENCE = /^\s{0,3}(```|~~~)/
-const CONTINUATION = /^\s{0,3}([-*+]\s|\d{1,3}[.)]\s|>)|^\s{4,}\S/
+const HEADING = /^#{1,6}\s/
/** Every settled chunk boundary of a streaming text, in order. Each value is
- * the char index where the NEXT chunk begins. Monotonic as the text grows.
- * A boundary is invalid when the next line CONTINUES a construct the
- * previous line already started (list item after list item, quote after
- * quote): splitting there restarts ordered-list numbering across fragments.
- * A construct's FIRST line after a paragraph is a fine place to split. */
+ * the char index where the heading line begins. A boundary fires at each
+ * heading outside a code fence, EXCEPT the first heading when it's at the
+ * very start of the text (that heading opens the first section, not a split
+ * point). The last section is always the tail (still composing). */
function chunkBoundaries(text: string): number[] {
const lines = text.split("\n")
const boundaries: number[] = []
let inFence = false
let offset = 0
- let prevNonBlank: string | undefined
+ let foundFirstContent = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (FENCE.test(line)) inFence = !inFence
- offset += line.length + 1
- if (line.trim() !== "") prevNonBlank = line
- if (inFence || line.trim() !== "") continue
- // blank line: the boundary candidate sits at the next non-blank line
- let j = i + 1
- let candidate = offset
- while (j < lines.length && lines[j].trim() === "") {
- candidate += lines[j].length + 1
- j++
+ if (!inFence && HEADING.test(line) && foundFirstContent) {
+ boundaries.push(offset)
}
- if (j >= lines.length) break // trailing blanks — the tail is still composing
- if (
- CONTINUATION.test(lines[j]) &&
- prevNonBlank !== undefined &&
- CONTINUATION.test(prevNonBlank)
- )
- continue
- boundaries.push(candidate)
- offset = candidate
- i = j - 1
+ if (line.trim() !== "") foundFirstContent = true
+ offset += line.length + 1
}
return boundaries
}
diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css
index be84cbd6a..10e069a70 100644
--- a/packages/session-ui/src/components/message-part.css
+++ b/packages/session-ui/src/components/message-part.css
@@ -246,6 +246,7 @@
[data-component="text-part"] {
width: 100%;
+ margin-top: 12px;
[data-slot="text-part-body"] {
margin-top: 0;
diff --git a/packages/session-ui/src/components/message-part.test.ts b/packages/session-ui/src/components/message-part.test.ts
index 651d860ae..96f2cf79c 100644
--- a/packages/session-ui/src/components/message-part.test.ts
+++ b/packages/session-ui/src/components/message-part.test.ts
@@ -27,44 +27,99 @@ describe("readPartText", () => {
})
})
-describe("splitSettledChunks", () => {
+describe("splitSettledChunks — heading-anchored segmentation", () => {
- test("no boundary while a single paragraph streams", () => {
- const text = "The first paragraph is still being"
- expect(settledChunkBoundary(text)).toBe(0)
- expect(splitSettledChunks(text)).toEqual({ chunks: [], tail: text })
+ test("no headings → single chunk (whole text) on completion", () => {
+ const text = "Just a paragraph.\n\nAnother paragraph.\n\nNo headings here."
+ const { chunks, tail } = splitSettledChunks(text)
+ // No heading means no boundary — everything stays in the tail while streaming
+ expect(chunks).toEqual([])
+ expect(tail).toBe(text)
})
- test("a paragraph settles once the next one has started", () => {
- const text = "First paragraph.\n\nSecond is being writ"
+ test("heading splits text into sections", () => {
+ const text = "Intro paragraph.\n\n## Section One\n\nBody of section one.\n\n## Section Two\n\nBody of two."
const { chunks, tail } = splitSettledChunks(text)
- expect(chunks).toEqual(["First paragraph.\n\n"])
- expect(tail).toBe("Second is being writ")
+ expect(chunks).toEqual([
+ "Intro paragraph.\n\n",
+ "## Section One\n\nBody of section one.\n\n",
+ ])
+ expect(tail).toBe("## Section Two\n\nBody of two.")
})
- test("multiple settled paragraphs split at every boundary", () => {
- const text = "One.\n\nTwo.\n\nThree is being writ"
+ test("heading at the very start creates no empty intro chunk", () => {
+ const text = "## First\n\nBody one.\n\n## Second\n\nBody two."
const { chunks, tail } = splitSettledChunks(text)
- expect(chunks).toEqual(["One.\n\n", "Two.\n\n"])
- expect(tail).toBe("Three is being writ")
+ expect(chunks).toEqual(["## First\n\nBody one.\n\n"])
+ expect(tail).toBe("## Second\n\nBody two.")
})
- test("a blank line inside a code fence is not a boundary", () => {
- const text = "Intro.\n\n```py\na = 1\n\nb = 2\n```\n\nAfter the fence starts"
+ test("### sub-headings are also boundaries", () => {
+ const text = "## Main\n\nIntro.\n\n### Sub\n\nDetails.\n\n### Another Sub\n\nMore."
const { chunks, tail } = splitSettledChunks(text)
- expect(chunks).toEqual(["Intro.\n\n", "```py\na = 1\n\nb = 2\n```\n\n"])
- expect(tail).toBe("After the fence starts")
+ expect(chunks).toEqual([
+ "## Main\n\nIntro.\n\n",
+ "### Sub\n\nDetails.\n\n",
+ ])
+ expect(tail).toBe("### Another Sub\n\nMore.")
})
- test("splits before a list's first item, never between its items", () => {
- const text = "Steps:\n\n1. one\n\n2. two\n\n> quoted\n\nNext paragraph beg"
+ test("heading inside a code fence is not a boundary", () => {
+ const text = "## Real Section\n\n```md\n## Not a heading\n\nJust code\n```\n\n## Next Section\n\nBody."
const { chunks, tail } = splitSettledChunks(text)
- expect(chunks).toEqual(["Steps:\n\n", "1. one\n\n2. two\n\n> quoted\n\n"])
- expect(tail).toBe("Next paragraph beg")
+ expect(chunks).toEqual([
+ "## Real Section\n\n```md\n## Not a heading\n\nJust code\n```\n\n",
+ ])
+ expect(tail).toBe("## Next Section\n\nBody.")
})
- test("trailing blank lines do not settle the tail", () => {
- const text = "Done paragraph.\n\n"
- expect(settledChunkBoundary(text)).toBe(0)
+ test("--- horizontal rules do not create boundaries or empty cards", () => {
+ const text = "## Section\n\nParagraph one.\n\n---\n\nParagraph two.\n\n## Next\n\nBody."
+ const { chunks, tail } = splitSettledChunks(text)
+ expect(chunks).toEqual([
+ "## Section\n\nParagraph one.\n\n---\n\nParagraph two.\n\n",
+ ])
+ expect(tail).toBe("## Next\n\nBody.")
+ })
+
+ test("display math stays with its section", () => {
+ const text = "## Math Section\n\nThe fidelity is:\n\n$$F = |\\langle\\psi|\\phi\\rangle|^2$$\n\nWhich means...\n\n## Conclusion\n\nDone."
+ const { chunks, tail } = splitSettledChunks(text)
+ expect(chunks).toEqual([
+ "## Math Section\n\nThe fidelity is:\n\n$$F = |\\langle\\psi|\\phi\\rangle|^2$$\n\nWhich means...\n\n",
+ ])
+ expect(tail).toBe("## Conclusion\n\nDone.")
+ })
+
+ test("trailing content with no following heading stays as tail (streaming)", () => {
+ const text = "## Section\n\nThis is still being written"
+ const { chunks, tail } = splitSettledChunks(text)
+ expect(chunks).toEqual([])
+ expect(tail).toBe("## Section\n\nThis is still being written")
+ })
+
+ test("settledChunkBoundary returns 0 when no heading boundary exists", () => {
+ expect(settledChunkBoundary("Just text, no headings")).toBe(0)
+ expect(settledChunkBoundary("Still streaming...")).toBe(0)
+ })
+
+ test("settledChunkBoundary returns offset of last boundary", () => {
+ const text = "Intro.\n\n## One\n\nBody.\n\n## Two\n\nTail."
+ const boundary = settledChunkBoundary(text)
+ // Should point to where "## Two" starts
+ expect(text.slice(boundary)).toBe("## Two\n\nTail.")
+ })
+
+ test("whitespace-only chunks are never emitted", () => {
+ const text = "## A\n\nContent.\n\n\n\n## B\n\nMore."
+ const { chunks } = splitSettledChunks(text)
+ expect(chunks.every(c => c.trim() !== "")).toBe(true)
+ })
+
+ test("single # heading is a boundary", () => {
+ const text = "# Title\n\nIntro.\n\n## Section\n\nBody."
+ const { chunks, tail } = splitSettledChunks(text)
+ expect(chunks).toEqual(["# Title\n\nIntro.\n\n"])
+ expect(tail).toBe("## Section\n\nBody.")
})
})
diff --git a/packages/ui/src/amicode/harmonic-dot.tsx b/packages/ui/src/amicode/harmonic-dot.tsx
index ce5161fda..89db5a565 100644
--- a/packages/ui/src/amicode/harmonic-dot.tsx
+++ b/packages/ui/src/amicode/harmonic-dot.tsx
@@ -14,25 +14,23 @@
// show through the evenodd hole. The circle matches INNER_R so it exactly fills
// the ring's interior during the sphere state and shrinks with it during morphs.
//
-// RANDOMIZED: each mount picks fresh random rotation angles.
-// Under prefers-reduced-motion the SMIL animates are hidden — static ring.
+// DETERMINISTIC: the sequence is level-ordered (l=1→l=4, pill first within each
+// level) with fixed rotation angles — no randomization. Every mount plays the
+// same animation. Under prefers-reduced-motion the SMIL animates are hidden —
+// static ring.
import { type ComponentProps } from "solid-js"
import {
HARMONIC_SIZE,
INNER_R,
CIRCLE_DONUT_PATH,
- randomPulseSequence,
- buildSmil,
+ SMIL,
} from "./harmonic-geometry"
export function HarmonicDot(props: {
class?: string
style?: ComponentProps<"svg">["style"]
}) {
- const sequence = randomPulseSequence()
- const smil = buildSmil(sequence)
-
return (