Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 91 additions & 44 deletions docs/dev/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Scanner>` where the surface is a single character cell
in a text row rather than a standalone waiting affordance, and over
Expand All @@ -110,40 +91,106 @@ 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. (`<SleepSpinner>` 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 `<SleepSpinner>` - 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
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.

## `<SleepSpinner>`

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 `<AsciiSpinner>`. 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 `<AsciiSpinner>` 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
`<AsciiSpinner>` 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.** `<sub>`/`<sup>` 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
`<AsciiSpinner>` 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).

## `<ModelCombobox>`

File: `src/components/ModelCombobox.svelte`.
Expand Down
5 changes: 4 additions & 1 deletion docs/dev/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<AsciiSpinner>`
in-flight step row both render `<SleepSpinner>` - three Z's with a
brightness wave travelling up them, where the wiki strip uses the
plain `<AsciiSpinner>` 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
Expand Down
4 changes: 3 additions & 1 deletion docs/dev/wiki.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,9 @@ UI:
in progress" low-fidelity spinner when a run THIS strip didn't start
is active - an `<AsciiSpinner>`, 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 `<SleepSpinner>` 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).
Expand Down
16 changes: 10 additions & 6 deletions docs/qa/use-cases/memory-librarians.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions docs/user/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 16 additions & 26 deletions src/components/AsciiSpinner.svelte
Original file line number Diff line number Diff line change
@@ -1,38 +1,28 @@
<!--
Text spinner for in-flight rows in the manual librarian-run strips
(`src/screens/Memories.svelte`, `src/screens/Wiki.svelte`). Cycles a
short frame sequence on a timer; `variant` picks which one - the
terminal bar `- \ | /`, or the memory librarian's drowsing `zzz`.
(`src/screens/Memories.svelte`). Cycles the classic terminal bar
frames `- \ | /` on a timer.

Why a JS timer instead of the CSS `transform: rotate()` trick the
chat tool rows use on their U+21BB glyph (`.tool-status.status-pending`
in styles.css): a spinning single glyph at this size reads as a
faint shimmer, and the strip's previous pulsing ellipsis was even
quieter - users could not tell the run was alive. Swapping glyphs
changes the SHAPE every frame, which the eye catches at any size.
That is also why the slow `sleep` cadence is safe: what failed
before was opacity-only motion, not slowness.
changes the shape every 100ms, which the eye catches at any size.

Callers must keep this inside an `aria-hidden` container. Every
strip call site sits in an `aria-live="polite"` region, and a live
region announces each mutation inside it - an unhidden spinner would
read its frame sequence aloud several times a second.

The span reserves the widest frame's width and left-aligns, so a
growing sequence never nudges the label beside it.
Callers must keep this inside an `aria-hidden` container. Both strip
call sites sit in an `aria-live="polite"` region, and a live region
announces every mutation inside it - an unhidden spinner would read
the frame sequence aloud ten times a second.
-->
<script lang="ts">
import { onMount } from 'svelte';
import {
SPINNER_FRAME_MS,
SPINNER_STATIC_FRAME,
spinnerFrame,
spinnerFrameMs,
spinnerStaticFrame,
spinnerWidthCh,
type SpinnerVariant,
} from '$lib/ui/ascii-spinner';

let { variant = 'bar' as SpinnerVariant }: { variant?: SpinnerVariant } = $props();

let tick = $state(0);
let animate = $state(false);

Expand All @@ -44,23 +34,23 @@
animate = true;
const id = setInterval(() => {
tick += 1;
}, spinnerFrameMs(variant));
}, SPINNER_FRAME_MS);
return () => clearInterval(id);
});
</script>

<span class="ascii-spinner" style="width: {spinnerWidthCh(variant)}ch"
>{animate ? spinnerFrame(tick, variant) : spinnerStaticFrame(variant)}</span
<span class="ascii-spinner"
>{animate ? spinnerFrame(tick) : SPINNER_STATIC_FRAME}</span
>

<style>
.ascii-spinner {
/* Pinned to the mono stack even though the app body already uses
it - the reserved width is in `ch`, so it only holds the column
if the glyphs actually advance one cell each. */
it - the frames only hold their column if every glyph advances
the same width, so this must not depend on an ancestor's font. */
font-family: var(--font-mono);
display: inline-block;
text-align: left;
white-space: pre;
width: 1ch;
text-align: center;
}
</style>
Loading