From 4f765097e797630a9bc7989f483ae5835763d92a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 19:56:20 +0000 Subject: [PATCH] Give the memory librarian three rising Z's with a brightness wave The growing zzz worked but read as a text spinner. Three Z's climbing a staircase, with peak brightness travelling up them on a ~1s cycle, reads as the comic-book shorthand for sleeping instead. This is a chase, not the pulse the strip removed earlier. That one modulated a single glyph's opacity uniformly, so nothing moved and the eye read it as static. Here the bright spot changes POSITION, which carries motion the way marquee bulbs do even though no glyph ever changes shape. Uniform fading is what failed; travelling brightness is a different mechanism. Its own component rather than another AsciiSpinner variant. The two now share no implementation at all - CSS keyframes over three spans against a JS interval over one text node - so a variant prop would have been two disjoint render paths behind one name. AsciiSpinner and its frame module revert to the bar-only shape they had before the variant table, and the wiki strip is untouched: the contrast is how you tell which librarian a strip belongs to. Three details that will look arbitrary later, so they carry comments. The staircase is `transform: translateY()` and not sub/sup markup, which would shrink the font, change the glyph advance width, and grow the line box - breaking the ch grid the column depends on. The animation delays are negative and in an order that looks wrong (0, -2D/3, -D/3), because an element started x seconds in next peaks at duration - x, and a positive delay would park each Z at its base opacity until it elapsed. And the wave is opacity against the inherited colour rather than literal greys, so it respects dark mode and the accent and danger tints the settled rows use. The empty script block is required: svelte-check cannot synthesise a component declaration for a script-less .svelte file, and importers fail with an implicit-any error without it. --- docs/dev/components.md | 135 +++++++++++++++-------- docs/dev/memory.md | 5 +- docs/dev/wiki.md | 4 +- docs/qa/use-cases/memory-librarians.md | 16 +-- docs/user/memory.md | 10 +- src/components/AsciiSpinner.svelte | 42 +++----- src/components/SleepSpinner.svelte | 141 +++++++++++++++++++++++++ src/lib/ui/ascii-spinner.ts | 115 ++++++-------------- src/lib/ui/memory-librarian.ts | 2 +- src/screens/Memories.svelte | 24 +++-- tests/ascii-spinner.test.ts | 73 ++----------- tests/sleep-spinner.test.ts | 51 +++++++++ vite.config.ts | 1 + 13 files changed, 377 insertions(+), 242 deletions(-) create mode 100644 src/components/SleepSpinner.svelte create mode 100644 tests/sleep-spinner.test.ts diff --git a/docs/dev/components.md b/docs/dev/components.md index b27bb7d5..ed2dc5af 100644 --- a/docs/dev/components.md +++ b/docs/dev/components.md @@ -77,29 +77,10 @@ transitions), archive loading sentinel in the drawer. File: `src/components/AsciiSpinner.svelte`. -Cycles a short frame sequence on a `setInterval`. One optional prop, -`variant`: - -```ts -interface Props { variant?: 'bar' | 'sleep'; } -``` - -- **`bar`** (default) - the classic terminal `- \ | /` at 100ms. -- **`sleep`** - a drowsing `z` / `zZ` / `zZZ` at 320ms, for the memory - librarian, whose two passes are both named after sleep stages - (deep-sleep for slow-wave, rem for REM). The wiki librarian has no - such conceit and stays on `bar`. - -The sequences, their cadences, the reduced-motion stand-in, and the -cell width each needs live in a variant table in -`src/lib/ui/ascii-spinner.ts`; the component is the timer plus a -`$state` counter. `spinnerWidthCh` is derived from the widest frame -rather than written down separately, so editing a sequence cannot -leave a stale width behind. - -The slow `sleep` cadence is safe because what makes these readable is -the SHAPE changing between frames, not the rate. The pulsing ellipsis -this replaced failed on opacity-only motion, not on slowness. +Classic terminal bar spinner - cycles `- \ | /` on a 100ms +`setInterval`. Takes no props. Frame sequence, cadence, and the +reduced-motion fallback glyph live in `src/lib/ui/ascii-spinner.ts`; +the component is the timer plus a `$state` counter. Picked over `` where the surface is a single character cell in a text row rather than a standalone waiting affordance, and over @@ -110,33 +91,32 @@ outright changes the shape, which the eye catches at any size. Two contracts callers must honour: -- **Keep it inside an `aria-hidden` container.** Every current call - site is in an `aria-live="polite"` region; an unhidden spinner would - announce its frame sequence several times a second. -- **Give it a cell at least `spinnerWidthCh(variant)` wide** if a - label sits beside it. The component reserves that width itself and - left-aligns, so a growing sequence stays put - but the surrounding - column has to be sized for it too, or the widest frame overflows. - `Memories.svelte` sizes `.librarian-step-icon` to `3ch` for exactly - this reason, which also keeps the settled check/cross rows sharing - one gutter. +- **Keep it inside an `aria-hidden` container.** Every call site is in + an `aria-live="polite"` region; an unhidden spinner would announce its + frame sequence ten times a second. (`` needs no such + wrapper - CSS keyframes mutate no DOM. Do not generalise from it.) +- **Give it a fixed-width cell** if a label sits beside it. The + component pins `--font-mono` and `width: 1ch` so the frames hold + their column, but a caller that lets the cell size to content will + still shift on frames with different side bearings in a + non-Lekton fallback font. Under `prefers-reduced-motion: reduce` (sampled once at mount) it -renders that variant's static frame instead of starting the timer - -an ellipsis for `bar`, a full `zZZ` for `sleep`. +renders a static ellipsis instead of starting the timer. -Consumers: `Memories.svelte` and `Wiki.svelte` - in each, three -places: the pending row of the librarian run's step list, the -trailing "Working" tail row (`src/lib/ui/librarian-run-tail.ts` -decides when that shows), and the "running in the background" notice -displayed when the in-flight lease is held by a run this strip didn't -start. See [`./memory.md`](./memory.md) and [`./wiki.md`](./wiki.md). +Consumers: `Wiki.svelte`, in three places: the pending row of the +librarian run's step list, the trailing "Working" tail row +(`src/lib/ui/librarian-run-tail.ts` decides when that shows), and the +"running in the background" notice displayed when the in-flight lease +is held by a run this strip didn't start. See +[`./wiki.md`](./wiki.md). `Memories.svelte` mirrors those three sites +with `` - see [`./memory.md`](./memory.md). `showsRunTail` guarantees the two spinners inside a step list are mutually exclusive - the tail appears only when the bottom row isn't -already pending. Worth preserving: two of these one above the other -mount at different times, so their frames run out of phase and the -pair reads as a rendering bug. +already pending. Worth preserving for BOTH spinner species: two of +either one above the other mount at different times, so they run out of +phase and the pair reads as a rendering bug. Deliberately NOT applied to the chat tool-call rows (`.tool-status.status-pending`), which keep their rotating glyph. That @@ -144,6 +124,73 @@ indicator appears on every tool call in every conversation, so changing it is a change to the app's overall texture rather than a fix to one strip - it wants its own decision. +## `` + +File: `src/components/SleepSpinner.svelte`. + +Three **Z's climbing a staircase** with a **brightness wave travelling +up** them - the memory librarian's in-flight cue. Takes no props. On +the nose for a subsystem whose two passes are named after sleep stages +(deep-sleep for slow-wave, rem for REM); the wiki librarian shares the +strip layout but not the conceit and keeps ``. That +contrast is load-bearing - the glyph is how you tell at a glance which +librarian a strip belongs to. + +Legible where the strip's old **pulsing ellipsis** was not, and the +distinction is worth keeping straight: the ellipsis modulated one +glyph's opacity uniformly, so nothing moved and the eye read it as +static. Here the bright spot changes **position**, which reads as +motion the way marquee bulbs do - even though no glyph ever changes +shape. "Fading is what we removed" is therefore NOT an argument +against this; uniform fading is. + +**CSS keyframes, not the JS interval `` runs on.** A +three-phase chase is what staggered `animation-delay` is for, and it +buys two things a timer cannot: + +- **No DOM mutation**, so the surrounding `aria-live="polite"` region + has nothing to announce. The failure mode that forces every + `` call site into an `aria-hidden` wrapper cannot arise + here. The component still marks itself `aria-hidden` - decorative + noise beside the row's own label - so callers need no wrapper at all. +- **Reduced motion is a live media query** rather than a value sampled + once at mount. + +Three things in the stylesheet that look arbitrary and are not: + +- **`transform: translateY()` for the staircase, never sub/sup + markup.** ``/`` shrink the font (~0.83em) and use + `vertical-align`, which changes glyph advance width and grows the line + box - breaking the `ch` grid and making the step rows taller. + Transforms do not participate in layout, so the glyphs keep their + `1ch` advance and the row keeps its height. +- **Negative `animation-delay`s, in an order that looks wrong.** Peak + brightness must travel upward, so peaks land at 0, D/3, 2D/3. An + element started `x` seconds in next peaks at `duration - x`, so the + offsets are 0, **-2D/3**, **-D/3** - which is why mid's delay looks + like it belongs to high. They are negative because a positive delay + parks the element at its base opacity until it elapses, showing as a + visible settling-in on the first cycle. +- **Opacity against the inherited colour, never literal greys.** The + strip hands the spinner full text contrast on a pending row, and the + same cascade tints settled rows with the accent or danger colour. + Hardcoded greys would ignore the active theme and flatten dark mode. + +Uniform `font-size` across the three glyphs is also deliberate: sizing +them into a growing ramp would make each `1ch` a different width (`ch` +resolves against the element's own font-size) and the trio would no +longer total the 3ch the parent column reserves. + +The base opacities double as the **reduced-motion state** - brightest +at the bottom, fading as it rises, which reads as dissipating smoke +while standing still. + +Consumers: `Memories.svelte`, in three places mirroring the +`` call sites in `Wiki.svelte` - the pending row of the +step list, the trailing "Working" tail row, and the "running in the +background" notice. `.librarian-step-icon` is sized to `3ch` for this; +see [`./memory.md`](./memory.md). + ## `` File: `src/components/ModelCombobox.svelte`. diff --git a/docs/dev/memory.md b/docs/dev/memory.md index a3474ced..85c1c2d0 100644 --- a/docs/dev/memory.md +++ b/docs/dev/memory.md @@ -396,7 +396,10 @@ in `docs/user/memory.md`. The dev side has five moving parts: strip), and the lease instead disables the confirm strip's **Run** submit and renders a "running in the background" spinner when a pass is in flight elsewhere. That spinner and the progress strip's - in-flight step row both render `` + in-flight step row both render `` - three Z's with a + brightness wave travelling up them, where the wiki strip uses the + plain `` bar; the contrast is how you tell the two + librarians' strips apart at a glance (`docs/dev/components.md`); settled step rows render `stepIcon`'s check/cross, which is why that helper's domain is the two settled statuses only. A third spinner sits at the BOTTOM of the step list diff --git a/docs/dev/wiki.md b/docs/dev/wiki.md index da85bb56..fdcc1dcb 100644 --- a/docs/dev/wiki.md +++ b/docs/dev/wiki.md @@ -466,7 +466,9 @@ UI: in progress" low-fidelity spinner when a run THIS strip didn't start is active - an ``, the same cue the step list's in-flight row and its trailing "Working" tail row carry; see - `docs/dev/components.md`. The tail row shows whenever the run is + `docs/dev/components.md`. The wiki strip keeps the bar deliberately: + the memory strip swapped to `` because its passes are + named after sleep stages, and wiki articles have no such conceit. The tail row shows whenever the run is live and the bottom row has already settled - `showsRunTail` in `src/lib/ui/librarian-run-tail.ts`, shared with the memory strip - so the list never goes still while more steps are coming). diff --git a/docs/qa/use-cases/memory-librarians.md b/docs/qa/use-cases/memory-librarians.md index 51b3621c..0eb7fac7 100644 --- a/docs/qa/use-cases/memory-librarians.md +++ b/docs/qa/use-cases/memory-librarians.md @@ -81,12 +81,16 @@ the ONE shared in-flight guard across all four paths gap before the first streamed step (the strip is visible from the synchronous instant the run starts, not only once a step arrives). Live narrated steps in the strip (manual runs carry the activity - param). The step in flight is marked by an animating `z` / `zZ` / - `zZZ` spinner - visibly cycling, not a static or merely pulsing - glyph - and settles to a check or cross as the next step opens. The - glyph column does NOT jump as the zzz grows: the check and cross - rows share the same gutter. (The wiki librarian's strip keeps the - `- \ | /` bar; only the memory passes drowse.) A spinning + param). The step in flight is marked by three rising Z's whose + brightness wave travels UPWARD (bottom Z peaks, then middle, then + top, on a ~1s cycle) - visibly moving, not a uniform pulse of the + whole group - and settles to a check or cross as the next step opens. + The Z's must not shift the layout: the step rows keep the same height + as the settled rows, the labels keep one straight left edge, and the + translated Z's do not collide with the rows above or below. (The wiki + librarian's strip keeps the `- \ | /` bar; only the memory passes + drowse.) Under an OS reduced-motion setting the Z's hold still, + brightest at the bottom, and nothing animates. A spinning *Working* row holds the bottom of the list for the whole run, including the gap before the first step arrives and the gap after a tool row settles; exactly one spinner is ever visible in the list diff --git a/docs/user/memory.md b/docs/user/memory.md index 4439fddc..e4c6a2f1 100644 --- a/docs/user/memory.md +++ b/docs/user/memory.md @@ -389,11 +389,11 @@ next scheduled cycle. On a narrow screen the pair collapses into a single overflow (**...**) menu to keep the bar uncluttered. The panel shows a live step-by-step progress strip while the run executes and a one-sentence summary of what the agent did when it -finishes. The step currently in flight is marked with a drowsing -`zzz` that grows and restarts - the two passes are named after sleep -stages, so the librarian snoozes while it works; finished steps get a -check, failed ones a cross. A ticking **Working** row sits at the -bottom of the list for as long +finishes. The step currently in flight is marked with three sleeping +Z's, drifting upward as they brighten and dim in turn - the two passes +are named after sleep stages, so the librarian snoozes while it works; +finished steps get a check, failed ones a cross. A ticking **Working** +row sits at the bottom of the list for as long as the run is going, so you can always tell the difference between "more steps are coming" and "it stopped here". A cross partway down the list is usually not the end of the run - the librarian often diff --git a/src/components/AsciiSpinner.svelte b/src/components/AsciiSpinner.svelte index e70e9c37..81cc7197 100644 --- a/src/components/AsciiSpinner.svelte +++ b/src/components/AsciiSpinner.svelte @@ -1,38 +1,28 @@ -{animate ? spinnerFrame(tick, variant) : spinnerStaticFrame(variant)}{animate ? spinnerFrame(tick) : SPINNER_STATIC_FRAME} diff --git a/src/components/SleepSpinner.svelte b/src/components/SleepSpinner.svelte new file mode 100644 index 00000000..9ac44b0c --- /dev/null +++ b/src/components/SleepSpinner.svelte @@ -0,0 +1,141 @@ + + + + + + + diff --git a/src/lib/ui/ascii-spinner.ts b/src/lib/ui/ascii-spinner.ts index cb4e077c..a01f4178 100644 --- a/src/lib/ui/ascii-spinner.ts +++ b/src/lib/ui/ascii-spinner.ts @@ -1,96 +1,49 @@ /** * Frame data for the text spinner that marks an in-flight row in the - * manual librarian-run strips (`src/components/AsciiSpinner.svelte`, - * rendered by `src/screens/Memories.svelte` and - * `src/screens/Wiki.svelte`). The sequences, their cadences, and the - * cell width they need live here so they can be tested without - * mounting a component or running timers. + * wiki librarian-run strip (`src/components/AsciiSpinner.svelte`, + * rendered by `src/screens/Wiki.svelte`). The sequence and the wrap + * arithmetic live here so they can be tested without mounting a + * component or running timers. + * + * The memory strip does NOT use this - it runs `SleepSpinner.svelte`, + * whose frames are CSS keyframes rather than data, because its passes + * are named after sleep stages. */ /** - * Which sequence a spinner cycles. + * The classic terminal bar spinner. Order matters - the bar sweeps + * through a half turn per cycle, so a shuffled sequence reads as + * jitter rather than rotation. * - * `sleep` exists for the memory librarian, whose two passes are both - * named after sleep stages - deep-sleep for slow-wave, rem for REM - - * so a drowsing "zzz" says which subsystem is working as well as that - * it is working. The wiki librarian has no such conceit and stays on - * `bar`. + * Equal-width frames are a hard requirement: an uneven advance would + * shove the label beside the spinner left and right four times a + * second. The app body font is Lekton (`--font-mono`, styles.css), so + * all four frames occupy one character cell. */ -export type SpinnerVariant = 'bar' | 'sleep'; - -interface SpinnerSpec { - /** - * Equal-meaning frames cycled in order. They need NOT be equal - * length - the caller reserves spinnerWidthCh() and left-aligns, so - * a growing sequence stays put instead of nudging its label. - */ - readonly frames: readonly string[]; - /** Milliseconds per frame. */ - readonly frameMs: number; - /** - * Shown instead of the animation under prefers-reduced-motion. An - * honest in-flight cue for a row that is not allowed to move. - */ - readonly staticFrame: string; -} - -const SPECS: Record = { - /** - * The classic terminal bar. Order matters - the bar sweeps through a - * half turn per cycle, so a shuffled sequence reads as jitter rather - * than rotation. 100ms is the conventional terminal cadence: fast - * enough to read as motion, slow enough that each frame is a - * distinct glyph rather than a blur. - */ - bar: { - frames: ['-', '\\', '|', '/'], - frameMs: 100, - staticFrame: '…', - }, - /** - * A drowsing "zzz" that grows and restarts. Deliberately far slower - * than the bar: at the bar's 100ms this reads as frantic, which is - * the opposite of what a sleep pass should look like. Slow is safe - * here because the SHAPE changes between frames - the visibility - * problem that killed the old pulsing ellipsis was opacity-only - * motion, not slowness. - */ - sleep: { - frames: ['z', 'zZ', 'zZZ'], - frameMs: 320, - staticFrame: 'zZZ', - }, -}; +const SPINNER_FRAMES = ['-', '\\', '|', '/'] as const; /** - * Frame for a monotonically increasing tick counter. Wraps. The double - * modulo keeps a negative tick in range so the function is total - - * callers never have to reason about the counter's origin to avoid an - * `undefined` frame. + * Milliseconds per frame. 100ms is the conventional terminal-spinner + * cadence - fast enough to read unambiguously as motion, slow enough + * that each frame is a distinct glyph rather than a blur. */ -export function spinnerFrame(tick: number, variant: SpinnerVariant = 'bar'): string { - const { frames } = SPECS[variant]; - const n = frames.length; - return frames[((Math.trunc(tick) % n) + n) % n]; -} +export const SPINNER_FRAME_MS = 100; -/** Milliseconds each frame of this variant holds for. */ -export function spinnerFrameMs(variant: SpinnerVariant = 'bar'): number { - return SPECS[variant].frameMs; -} - -/** Reduced-motion stand-in for this variant. */ -export function spinnerStaticFrame(variant: SpinnerVariant = 'bar'): string { - return SPECS[variant].staticFrame; -} +/** + * Shown instead of the animation when the user has asked for reduced + * motion. An ellipsis is the honest in-flight cue for a row that is + * not allowed to move: it says "still working" without implying the + * frozen bar glyph is a rendering bug. + */ +export const SPINNER_STATIC_FRAME = '…'; /** - * Character cells the caller must reserve so the sequence never - * reflows its neighbour. Derived from the widest frame rather than - * written down separately, so editing a sequence cannot leave a stale - * width behind. + * Frame for a monotonically increasing tick counter. Wraps at the end + * of the sequence. The double modulo keeps a negative tick in range so + * the function is total - callers never have to reason about the + * counter's origin to avoid an `undefined` frame. */ -export function spinnerWidthCh(variant: SpinnerVariant = 'bar'): number { - const { frames, staticFrame } = SPECS[variant]; - return Math.max(...frames.map((f) => f.length), staticFrame.length); +export function spinnerFrame(tick: number): string { + const n = SPINNER_FRAMES.length; + return SPINNER_FRAMES[((Math.trunc(tick) % n) + n) % n]; } diff --git a/src/lib/ui/memory-librarian.ts b/src/lib/ui/memory-librarian.ts index db2209e6..5b4878b4 100644 --- a/src/lib/ui/memory-librarian.ts +++ b/src/lib/ui/memory-librarian.ts @@ -97,7 +97,7 @@ export function librarianProgressAriaLabel( } /** Status glyph for a SETTLED step row. Pending rows have no glyph - - * the strip renders an animated `AsciiSpinner` in the icon cell + * the strip renders an animated `SleepSpinner` in the icon cell * instead, so the argument is narrowed to the two settled statuses * rather than carrying a third arm the markup can never reach. */ export function stepIcon(status: 'ok' | 'error'): string { diff --git a/src/screens/Memories.svelte b/src/screens/Memories.svelte index 2f04dcb5..86caa46b 100644 --- a/src/screens/Memories.svelte +++ b/src/screens/Memories.svelte @@ -73,7 +73,7 @@ } from '$lib/ui/memories'; import { SEARCH_DEBOUNCE_MS } from '$lib/ui/memories-list'; import type { Memory, MemoryRelation, SimilarMemory } from '$lib/supabase'; - import AsciiSpinner from '../components/AsciiSpinner.svelte'; + import SleepSpinner from '../components/SleepSpinner.svelte'; import Markdown from '../components/Markdown.svelte'; import MemoryChangelogPanel from '../components/MemoryChangelogPanel.svelte'; import { librarianRun } from '$lib/agents/memory-librarian-run.svelte'; @@ -899,7 +899,7 @@

{librarianConfirmInfo.description}

{#if runInFlightElsewhere}

- + A memory-librarian pass is running in the background…

{/if} @@ -959,7 +959,7 @@ announced ten times a second. -->