# UI guidelines

Canonical page: https://zoomies.sh/ui-guidelines/

The Zoomies web UI is meant to be left open on a second monitor all day. That is
the bar every decision below is measured against: it should be calm when nothing
is happening, unmistakable when something is wrong, and never *need* the
operator to click refresh.

This document is the contract. If you are adding a component, take the tokens
from here rather than inventing values.

---

## 1. Design tokens

### Visual style and colour mode

![Mission Control visual-style selection](https://zoomies.sh/screenshots/mission-control/appearance-dark.webp)

Settings, Appearance offers **Classic** and **Mission Control** (the default).
Classic retains the compact neutral palette. Mission Control uses navy surfaces
in dark mode, cool light surfaces in light mode, and slightly larger type.
Runner Blue remains the interactive accent; cyan still means running work.
Both styles follow the independent System, Light or Dark colour-mode choice.
The phone's More menu offers the same two style choices.

Visual style stays in this browser under `zoomies.ui-style` and is applied before
first paint, including sign-in and the first-account form. Missing or unknown
values get Mission Control; only a stored `classic` opts a browser out, so
choosing Mission Control again simply clears the key.
A browser that stored Light, Dark or Mission Control under an earlier release
keeps that choice. System and Classic were stored as the absence of a key, so a
browser that had chosen either, or never chose, now opens in Mission Control and
the dark theme like a new one; choosing them again in Settings, Appearance is
remembered from then on.
The separate public `/status` document retains Classic. It is dark unless its
reader stored Light or System, and the dark is written on its `<html>` element,
because its policy allows no inline script of its own to apply it.

Selecting a style updates root tokens without replacing the route or its state.
It preserves the original Zoomies artwork and logo treatment, Eli, dog/paw
animations, Off/Cute/Standard vocabulary, reduced motion, navigation and saved
phone-table layouts. Log viewers refresh their canvas palette and type without
clearing output. No fleet setting or API call is needed to change style.

Both styles use the Calm operations Overview: first-run guidance, attention
summaries and four compact live metrics, then pools and active jobs beside a
three-entry recent-events preview. One scope selector controls the page. Sticky
jump links assist navigation; phones put supplementary destinations under More.
History, host capacity and startup/registration timings are expandable sections
with live summaries and browser-local expansion preferences. History remains
this fleet's activity, with its own labelled range, even when other runners are
included in job counts. A fragment link opens its destination before scrolling.
The full events feed expands in place, retaining the controller's reason strings.
On a Mission Control phone, Jobs puts search, status
and records before supplementary analytics; Filters and Fleet context expand
without clearing the URL filters.

A grid with no saved phone-layout choice starts as Cards in Mission Control
and Rows in Classic. A saved per-grid view wins over a saved global view, and
both win over the style default. Existing saved Rows values are preserved,
including values older versions may have written automatically. Changing a
logo or vocabulary does not record an unchosen global table default. Saved
column order and widths take precedence over the new Jobs default order.

The Eli repairs pages use the same grammar: a sticky **On this page** navigation
with counts, native disclosures whose closed summaries stay useful, and the
supporting detail (History, Automatic repairs, the attempt's case and technical
details) closed until somebody asks for it. A section that holds live work opens
by itself; a quiet History does too, because then it is the content. A browser's
explicit open or closed choice is remembered, and the phone keeps the last two
destinations behind **More**.

Mission Control colour values live in the `--z-mission-*` tokens. Preview cards
use those same tokens, so their light/dark previews agree with the selected
style. Normal and secondary text, accent text and filled-button text meet
4.5:1 across its surfaces; meaningful control edges meet 3:1. The contrast tests
cover both palettes, including hovered surfaces and accent-subtle backgrounds.

The style is checked in the states a healthy fleet never shows, not only the
populated happy path. Each is a capture from the real binary with the seeded
fleet's responses stubbed just enough to reach it, in Mission Control with
Classic beside it:

- [First run](screenshots/mission-control/overview-first-run-dark.webp) keeps the
  setup checklist first.
- [Loading](screenshots/mission-control/overview-loading-dark.webp) puts
  skeletons where the figures have not arrived.
- [A failed fetch](screenshots/mission-control/overview-error-dark.webp) says the
  fleet could not be read and offers a retry, rather than showing a stale figure
  as though it were current.
- [An empty pool list](screenshots/mission-control/pools-empty-dark.webp) and
  [a filter that matches nothing](screenshots/mission-control/jobs-no-results-dark.webp)
  are separate states, each with its own next step.

A headless browser cannot raise a soft keyboard, so the assistant sheet's
keyboard behaviour is covered by the browser suite rather than photographed.

Primitives are reviewed in the development-only component gallery
(`web/gallery.html`, served by `make gallery`), which renders the real
components in every state above, in four style and mode combinations. That is
not a product page: it is never built into the binary and never linked from
the navigation. Its captures live under `design/theme-review/gallery` and are
refreshed with `make screenshots-gallery`; `web/tests/gallery.spec.ts` checks
all four combinations, the refusal reasons, focus rings, live status contrast
and the phone width.

Logo backdrops are chosen in Settings, Appearance and saved in this browser.
Orbital haze is the default for new, missing or unrecognised preferences.
Starlight, Pearl veil, White square and White circle are the other choices. Every
main logo uses the same choice, including the unauthenticated login screen.
The original black artwork remains unchanged in both themes. The `--z-logo-*`
tokens select the full atmospheric backdrop assets and define inset spacing.
Decoration is static and never carries status meaning. The main logo uses the
same detailed artwork on desktop and portrait phones. Paw icons remain unchanged:
black in light mode, white in dark mode, with no decorative backdrop.
Backdrops are lossless WebP files; the original black logo remains a separate SVG.

Runner, workflow and queue status share `ZoomiesStatusIcon`. Settings →
Appearance → **Zoomies vocabulary** offers three choices:

| Choice | Status words | Illustration |
| --- | --- | --- |
| Off (default) | Plain state names | Standard Lucide status icon |
| Cute | Zoomies dog-park vocabulary | Original animated avatar |
| Standard | Zoomies dog-park vocabulary | The dog icon and paw prints, with a gesture and a persistent cue glyph per state |

The choice stays in this browser. A browser that has never chosen (a new
install, or one whose saved preferences were cleared) starts on Off. Existing
users keep Cute when their old vocabulary switch was on, and Off when it was
off. The validated `prefs.statusStyle` is the saved choice; `prefs.quirkyStatus`
is its derived boolean for state maps, filters and activity text. Those maps
still accept a `quirky` argument so they remain importable as plain TypeScript.

Off draws each state with a Lucide icon from the state map in
`web/src/lib/status.ts`. Runner, workflow and queue icons sit in a compact
24px tile, with an 18px glyph, a slightly firmer stroke and the state's subtle
background and border. This keeps their weight and alignment consistent while
labels and tooltips carry the meaning. A state still in progress moves a
little, chosen by glyph so the same glyph moves the same way on a runner, a
job or a machine: being set up turns, work executing sends one pulse along its
trace from left to right, being accepted breathes, and finishing turns its
hourglass over now and then. The three CPU states each move the way the CPU
went: extra CPU gives its trend line two quick hops, maximum CPU shakes its
bolt harder as it fills with charge and then flashes, and CPU being held back
tugs its line down and drags it slowly back up. Everything waiting or finished
holds still, so a page of idle runners is a calm one and a failure never draws
the eye by moving, and reduced motion stops all of it. A glyph the motion map
does not know holds still too, unless the state's tone is the busy one, in
which case it falls back to the quiet breathe: a new in-progress glyph nobody
mapped yet must not look finished. **An icon names a state, never an action**: the
Queue's Run now, Pause, Resume and Delete carry Zap, Pause, Play and the bin,
Re-run a turning arrow, and the Runners page's Drain the slashed circle, so a
status drawn with one of them beside those buttons reads as something to press.
One glyph also means one thing wherever it appears:

| Icon | Means | Used for |
| --- | --- | --- |
| `LoaderCircle` | being set up | provisioning runner; machine creating, starting, bootstrapping |
| `Handshake` | being accepted | registering runner; enrolling machine |
| `CircleDot` | ready, waiting for work | idle runner; ready machine |
| `Activity` | work executing | busy runner; running job or step; machine with runners |
| `Hourglass` | finishing, then leaving | draining runner |
| `CircleX` | failed | failed runner; failed job |
| `CircleMinus` | gone | removed runner; job removed from the queue |
| `Clock` | queued | queued or waiting job; a Ready queue row |
| `ChevronsUp` | prioritised | a job somebody pressed Run now on |
| `OctagonPause` | held by an operator | a paused job |
| `CircleSlash` | stopped taking work | cancelling job or run; cordoned host; disabled pool |
| `TimerOff` | ran out of time | timed-out job |
| `IterationCcw` | another attempt asked for | a re-run requested on a job's timeline |
| `Zap`, `TrendingUp`, `TrendingDown` | CPU lent at maximum, lent, taken back | elastic CPU on a busy or idle runner |

Standard is the Lucide dog face, two `PawPrint` marks under it and the state's
cue glyph in the corner, in one SVG per runner, moved by CSS alone: each
state's gesture is a set of keyframes for those marks, so there is no drawing
to keep beyond the face's own eye and mouth variants. Busy runners trot while
the paw prints step underneath them, extra zoomies dash from side to side with
speed lines and dust, and maximum zoomies dash faster with dashed streaks and a
charge flash, both leaving trail prints. Idle dogs sit, blink and tap a paw,
provisioning dogs dip down to sniff the paw prints as a scent rises,
registering dogs wave a paw print under a ripple ring, throttled dogs strain
against a taut leash, and draining dogs sink and settle as their paws fade.
Failed dogs hold their heads low with downcast eyes and one slow sigh, removed
dogs fade as dashed ghosts, and unknown dogs rock with glancing eyes; these
three move slowly and sadly rather than freezing, so a stalled runner never
reads as a broken image. The glyph in the corner is the cue, and it is the icon
Off draws for the same state (`Activity`, `CircleDot`, `Hourglass`, `CircleX`
and so on, with `TrendingUp` and `Zap` for the two boost levels), so one glyph
keeps one meaning across the styles; throttled, which Off has no glyph for,
wears a `Turtle` rather than a pause, since pause is a button on the Queue.
Labels, colours and tooltips keep their existing status meaning. Below 28px the
accents and mouths step aside and pose, eyes and the state's colour carry the
state.

Each runner ID seeds its timing so rows and workflow packs do not move together.
Reduced motion disables every animation while retaining the expression and cue.
The existing reserved avatar size and workflow pack widths are preserved.
Cute keeps its palette and gains a pose per state: posture, ears, eyes, mouth
and brows are chosen per state, the collar tag and props borrow the status
tone, and below 32px the props and fine detail step aside so posture, ears and
tone carry the state. Off keeps plain status icons.

All tokens live in exactly one place: `web/src/lib/styles/tokens.css`, declared
as CSS custom properties on `:root` and overridden under `[data-theme="dark"]`.
Tailwind v4 consumes them through `@theme` so utility classes and hand-written
CSS resolve to the same values: colours, sizes, radii, shadows, weights,
tracking and the easing curve are mapped by name, and the whole spacing scale
is derived from the same 4px step by arithmetic rather than by a mapping that
could drift.

**Never write a raw colour, and never write a raw value that already has a
token.** Colour is absolute: a hex or `rgb()` in a component is a bug, because a
colour written by hand is a colour that only works in one theme. Everything
else is a rule about repetition; a value that appears twice is a decision being
made twice, and it belongs here where it can be made once. A hairline border,
the tracking on an uppercase label, the width of a dialog and the height of a
switch thumb were all raw in several dozen files before they were tokens.

Three things are still raw, and each is raw for a reason worth knowing:

* **Media query widths.** `@media (max-width: var(--z-bp-md))` is not valid CSS
  (a media query is evaluated before custom properties exist) so the two
  thresholds are written out. `--z-bp-md` and `--z-bp-lg` are declared anyway,
  so the number has one home and JavaScript can read it rather than repeat it.
* **A one-off content measure.** The width a filter row stops growing at, the
  column count a card grid wraps at: these are that component's own layout,
  answer to nothing else, and naming them would only add a lookup. The moment a
  second component wants the same measure it becomes a token.
* **One hex, in the log viewer.** xterm.js takes its theme as JavaScript values
  and insists on `#rrggbb`, so the bridge in `LogViewer.svelte` resolves every
  token through a probe element and converts the result. The single literal
  there is the fallback for a colour that failed to parse, which is a
  last-resort value rather than a design decision.

### 1.1 Colour

The palette is the brand palette: a cool near-black spine with Runner Blue as
the interactive accent. See [brand.md](https://zoomies.sh/brand/) for the identity itself and
the two decisions -- darkening Runner Blue for text, and spending Fast Cyan on
the busy state -- that turn it into a usable UI palette.

Every pairing below has been measured; the numbers are real, not aspirational.

#### Brand constants

Fixed by the identity, available as `--z-brand-*` and unchanged between themes.

| Token | Value |
| --- | --- |
| `--z-brand-black` | `#080808` |
| `--z-brand-white` | `#FFFFFF` |
| `--z-brand-cool-grey` | `#B9BCC2` |
| `--z-brand-mid-grey` | `#666A73` |
| `--z-brand-runner-blue` | `#2F80ED` |
| `--z-brand-fast-cyan` | `#22D3EE` |
| `--z-brand-paw-black` | `#000000` |

Two of these are the record rather than a value a rule reads. Mid Grey is the
brand's light-theme secondary text, but `--z-text-muted` darkens it to `#5B6069`
for the same reason the accent is darkened below: at 13px on white the brand
value is 5.4:1, and text an operator reads all day should be past 7:1. Paw Black
is the favicon artwork, which is a PNG rather than a colour any rule sets.

#### Neutrals

| Token | Light | Dark | Use |
| --- | --- | --- | --- |
| `--z-bg` | `#F6F7F9` | `#0A0B0D` | Page background |
| `--z-surface` | `#FFFFFF` | `#131519` | Cards, panels, table body |
| `--z-surface-raised` | `#FFFFFF` | `#1B1E23` | Popovers, dialogs, dropdowns |
| `--z-surface-sunken` | `#EEF0F3` | `#050607` | Wells, code blocks, table headers |
| `--z-border` | `#E2E5EA` | `#262A31` | Default hairlines |
| `--z-border-strong` | `#C7CBD3` | `#363B44` | Inputs, focused containers |
| `--z-text` | `#0B0C0E` | `#F4F5F7` | Primary text |
| `--z-text-muted` | `#5B6069` | `#B9BCC2` | Secondary text, labels |
| `--z-text-subtle` | `#666B74` | `#868B94` | Timestamps, placeholders |

Primary text is 18:1 in both themes. Muted is 5.9:1 light and 10.3:1 dark, on the
page ground. Subtle -- the weakest text in the product -- is 5.0:1 light and
5.75:1 dark on the page ground, and clears AA on every surface it sits on: the
lowest is a sunken well at 4.7:1 in the light theme and a raised surface at
4.9:1 in the dark. It is measured on all of them, not only on white, because a
breadcrumb sits straight on the page ground; `web/unit/text-contrast.test.ts`
holds it there.

#### Brand accent

| Token | Light | Dark | Use |
| --- | --- | --- | --- |
| `--z-accent` | `#1A63D8` | `#78B4FF` | Primary buttons, links, active nav, focus |
| `--z-accent-hover` | `#154FB0` | `#9BC9FF` | Hover |
| `--z-accent-subtle` | `#E8F0FD` | `#10203A` | Selected rows, badge backgrounds |
| `--z-accent-contrast` | `#FFFFFF` | `#08131F` | Text on an accent fill |

5.13:1 and 9.16:1 against their backgrounds; the text on an accent fill is
5.5:1 light and 8.7:1 dark. Full-strength Runner Blue (`--z-brand-runner-blue`) is
available for illustrative fills, where the bar is 3:1.

#### Status

Runner and job states get their own hue. The mapping is fixed -- do not use
these colours for anything else, because operators learn them.

| State | Token | Light | Dark |
| --- | --- | --- | --- |
| idle / healthy / success | `--z-idle` | `#0F7A3D` | `#5FD68C` |
| **busy / running** | `--z-busy` | `#00757F` | `#3BD6E8` |
| provisioning / registering / pending / throttled / warning | `--z-pending` | `#9A6100` | `#F2B246` |
| draining / cordoned / paused / waiting on a step an operator asked for | `--z-draining` | `#4D3F5C` | `#D0BEE0` |
| failed / error / destructive | `--z-danger` | `#BD2018` | `#FF8D84` |
| removed / neutral | `--z-neutral` | `#686D76` | `#7F858E` |

Two meanings that are not states of the fleet are written into the table
rather than left to be inferred, because each had quietly grown a colour of its
own. **Warning** is `--z-pending`: a finding or setting the operator should look
at, drawn the way a state that will resolve itself is. **Waiting on a step an
operator asked for** -- a setting saved but not applied until Zoomies restarts, a
restore staged for the next start -- is `--z-draining`, because it is held on
purpose and will not move until somebody acts, like a cordon.

**Information is not a status.** A note, an explanation or an `info` severity
carries `--z-accent`, never one of the six: cyan says a runner is executing a
job right now, and plum says something was held back, so neither can also mean
"for your information". A box that only explains itself takes `--z-accent-subtle`
and `--z-accent-border`.

The fleet status page at `/status` has three states of its own, and they sit on
this mapping rather than beside it: **blocked** is `--z-danger` with the
triangle, **degraded** is `--z-pending` with the dashed ring, and **healthy**
is `--z-idle` with the hollow dot. A reader with no account sees the same
colour and shape for "something is stopping jobs" that an operator sees for a
failed runner, and the state is always written out as a word beside them.

On a host, the heartbeat reads **Connected** or **Unreachable**, and "healthy"
is kept for the host's OS report, so the two pills on one card never use a word
for two different things. The mapping above does not move: only the label did.

Busy is the Fast Cyan family: the brand asks for the accent to be used
sparingly, and *this runner is executing a job right now* is the single most
valuable "look here" signal on the page.

**The six are separated in lightness and chroma, not only in hue**, because a
legend is the one place they are read side by side. An earlier palette set all
six within a few points of L\* 45 and let hue do the whole job, which worked for
green against red and failed everywhere else: draining and neutral were two
greys at the same lightness, six points of CIEDE2000 apart, where a pair has
to be around twenty before a legend dot tells them apart at a glance, and
busy, accent and the greys sat inside twenty points of each other. Two of them are now pulled off
that plane. Draining carries a plum cast and the far end of the lightness range
(dark on light, light on dark: it is a state an operator asked for, and should
be findable), and neutral is the only flat grey left, sitting quietly at its
contrast floor.

Measured across all six plus `--z-accent`, the closest pair is now 19.3
(CIEDE2000, light) and 21.7 (dark), against 6.1 and 10.2 before; no pair that
can appear in one legend is under 19, and most are past 25. When a chart puts
two of them within about twenty points (the executing and allocated lines in
the usage chart, queued against cancelled work) the nearer of the two is drawn
dashed, so the legend swatch carries a stroke style and not only a colour.

Each status foreground clears 4.9:1 on its own theme's surface and 4.5:1 on its
own `-subtle` ground, which is what a badge is.

Each has a `-subtle` companion for badge and chart-fill backgrounds, and
`--z-danger` has a `-contrast` for text on a destructive fill. Every status is
**also** carried by a shape: a filled dot for busy, a hollow dot for idle, a
dashed ring for provisioning and for a throttled host, a slash for draining, a
triangle for failed. A throttled host is pending rather than draining on
purpose: the fleet stepped it down and the fleet will step it back up, so it is
a state that resolves itself, like provisioning, and not one an operator asked
for, like a cordon.
Colour alone never encodes state -- that is both an accessibility requirement
and a practical one for an operator glancing at a sparkline from across the
room.

#### Charts

`--z-chart-1` … `--z-chart-6` are a categorical ramp derived from the status
hues, ordered for maximum adjacent separation. Sparklines use `--z-busy` for
running work and `--z-pending` for queue depth, because those are the two
quantities an operator is actually watching.

#### The mark

`--z-mark-chip` is the Zoomies Black ground the monochrome mark sits on. The
mark is white line art drawn for a dark surface, so the UI puts it on a small
black chip rather than inverting it -- one asset, correct in both themes,
legible at 24px. See [brand.md](https://zoomies.sh/brand/), which lists every place the signed-in
product carries the mark, the name or the descriptor.

#### The panel

A panel is a ground that is Zoomies Black in both themes (the sign-in page's
logo stage, the Appearance preview's), so what is drawn on it cannot follow
the theme: the light theme's muted grey is 3.2:1 on black. These are declared
once, never overridden per theme, and read on a panel alone. Measured on
`#080808`:

| Token | Value | Use | Contrast |
| --- | --- | --- | --- |
| `--z-panel-bg` | `--z-brand-black` | The panel | - |
| `--z-panel-text` | `--z-brand-white` | The tagline, fact titles, the credit's name | 20.0:1 |
| `--z-panel-text-muted` | `--z-brand-cool-grey` | The lede and the links | 10.5:1 |
| `--z-panel-text-subtle` | `#868B94` | The facts' detail, "Developed by" | 5.9:1 |
| `--z-panel-border` | `#262A31` | The fact icons' edge | decorative |
| `--z-panel-raised` | `#121418` | The fact icons' ground | decorative |
| `--z-panel-ring` | `rgb(255 255 255 / .07)` | The motion-path rings | decorative |

The subtle and border values are the dark theme's `--z-text-subtle` and
`--z-border`, repeated because the light theme has no name for them. The one
arc of `--z-brand-runner-blue` in the rings is 5.2:1, an illustrative stroke
rather than anything to read. The focus ring stays `--z-accent`, which is 3.6:1
on the panel in the light theme and 9.3:1 in the dark, both past the 3:1 a
control's indicator needs.

### 1.2 Type

Two faces, both self-hosted as woff2 so an air-gapped install has no network
dependency and no third-party font request:

* **Inter Variable**: UI. `--z-font-sans`. Geist and `system-ui` are the
  brand's sanctioned alternatives and sit in the fallback stack.
* **JetBrains Mono**: logs, IDs, labels, durations, anything monospaced.
  `--z-font-mono`.

The wordmark in the logo artwork is custom-rendered and is not Inter. Do not
try to set it in type.

Both declare a full system fallback stack so the first paint is never blank.

| Token | Size / line-height | Weight | Use |
| --- | --- | --- | --- |
| `--z-text-2xs` | 11px / 16px | `--z-weight-medium` | Table micro-labels, badge text |
| `--z-text-xs` | 12px / 18px | `--z-weight-normal` | Metadata, timestamps |
| `--z-text-sm` | 13px / 20px | `--z-weight-normal` | **Default table and body text** |
| `--z-text-base` | 14px / 22px | `--z-weight-normal` | Form controls, prose |
| `--z-text-lg` | 16px / 24px | `--z-weight-semibold` | Card titles |
| `--z-text-xl` | 20px / 28px | `--z-weight-semibold` | Page titles |
| `--z-text-2xl` | 28px / 34px | `--z-weight-bold` | Overview metrics; at `--z-weight-semibold`, the sign-in page's heading and tagline |
| `--z-text-3xl` | 36px / 42px | `--z-weight-bold` | Hero numbers |

Four weights exist and the table names them rather than numbers, because the
numbers are Inter Variable's axis positions and are not round: `--z-weight-normal`
450, `--z-weight-medium` 500, `--z-weight-semibold` 550, `--z-weight-bold` 640.

Tracking is set for uppercase and for display sizes, and left alone in between:
`--z-tracking-wide` (0.04em) for the uppercase micro-labels above table headers
and drawer sections, `--z-tracking-wider` (0.08em) for the widely spaced brand
lockups in the footer, nav and About panel, and `--z-tracking-tight` (-0.01em)
for headings at `--z-text-2xl` and above, which set too loose at their default.
A quiet link's hover underline sits `--z-underline-offset` (0.15em) below the
text, clear of the descenders.

The sign-in page is the one place a title is set at `--z-text-2xl`. Everywhere
else a page title is a label over the content that follows; there, the form is
the whole page and the title and the brand panel's tagline are its display
text.

The base size is 13px, not 16px. This is a dense operational tool; 13px Inter at
450 weight on the warm background stays comfortably legible while fitting a
useful number of runners on screen. Prose in docs and empty states steps up to
14px.

Numerals in tables use `font-variant-numeric: tabular-nums` so columns align.
IDs use `--z-font-mono` at `--z-text-xs`.

**A value that is not there is written `--`, everywhere.** Every formatter in
`lib/format.ts` answers with it for a null, and a page with no formatter to call
(a tile whose figure is not yet known, a cell with nothing behind it) writes the
`NO_VALUE` constant from the same file rather than its own dash. An em dash or an
en dash in that place is how a table cell and the tile above it came to disagree
about what empty looks like. Ranges (`1–10 of 24`) and sentences keep their dashes.

**Form controls are the one exception, and only on a phone.** Below 768px,
`Input`, `Textarea` and `Select` step up to `--z-text-lg` (16px). Mobile Safari
zooms the whole viewport whenever a focused control is under 16px, and the
viewport meta deliberately sets no `maximum-scale`, so without this every field
tap on the first-run screens jumped a 360px page to roughly 410px of effective
width and ran the card off both edges, once per field. Control height comes
from the space scale, so nothing reflows; only the glyphs grow.

**A validation error takes the hint's place; it never appears beside it.**
`Field` renders one message row, because an error rendered *in addition* to the
hint adds a line and moves everything below it, including the submit button,
out from under a pointer already on its way down, so the click lands on whatever
takes its place and the operator experiences a button that does nothing. A field
on a form worth clicking therefore carries a hint, so the row is occupied before
an error needs it. Anything a field must say *alongside* an error goes in
`notice`, which is rendered independently, is `aria-live="polite"`, and is what
the caps-lock warning uses.

### 1.3 Spacing

A 4px base with a restricted scale. Only these values exist:

`--z-space-1` 4 · `-2` 8 · `-3` 12 · `-4` 16 · `-5` 20 · `-6` 24 · `-8` 32 ·
`-10` 40 · `-12` 48 · `-16` 64

Page gutters are `--z-space-6`, card padding `--z-space-5`, table cell padding
`--z-space-3` vertical / `--z-space-4` horizontal, form field gap
`--z-space-4`.

### 1.4 Radii, borders and geometry

`--z-radius-sm` 4 (badges, inputs) · `--z-radius-md` 8 (buttons, cards) ·
`--z-radius-lg` 12 (dialogs, panels) · `--z-radius-full` 9999 (dots, pills).

Borders come in three weights and the choice between them is meaning, not
taste. `--z-border-width` (1px) is the hairline that separates things belonging
to the same list. `--z-border-width-thick` (2px) marks the one that is selected,
focused or wrong. `--z-border-width-rail` (3px) is the flag down the left edge
of a problem, a disabled pool or a failed step, thicker because it is a signal
rather than a boundary.

Below the 4px spacing grid there are three optical nudges, `--z-nudge-1` to
`--z-nudge-3`. They exist because a checkbox aligned to the grid sits visibly
low against a 13px line. Anything that *can* sit on the spacing scale does.

The drawn controls have their own sizes, for the same reason: `--z-control-box`
(15px, the checkbox and radio box), `--z-control-thumb` (14px, the switch), and
`--z-control-icon` (14px, the icon inside a button). Field and button *heights*
are on the spacing scale and stay there: `sm` is `--z-space-6`, `md` is
`--z-space-8`, and `lg` (which only the sign-in form uses, because it is the one
form that is the whole page) is `--z-space-10`. The one exception is `--z-control-touch`
(44px) is the height a control read by a finger takes under
`@media (pointer: coarse)` (the segmented choices, the chips and legend
switches every trend carries, a slider's thumb, a column heading's reposition
grip and resize edge, and the sign-in form's `lg` fields and button) because a 24px row
a mouse is fine with is two rows under one fingertip. It applies only where the pointer is coarse,
so the desktop keeps its density.

Dialogs and drawers are cut to their content's comfortable measure rather than
to the viewport, so each has three widths and they are the three shapes we
actually put in one; a confirmation, a form, a table:
`--z-width-dialog-sm|md|lg` (400/560/820) and `--z-width-drawer-sm|md|lg`
(360/520/760).

### 1.5 Elevation

Shadows are cool-tinted, to match the near-black spine, and very restrained; in
dark mode elevation is carried mostly by surface colour, not shadow.

| Token | Light | Dark |
| --- | --- | --- |
| `--z-shadow-sm` | `0 1px 2px rgb(8 12 20 / .06)` | `0 1px 2px rgb(0 0 0 / .5)` |
| `--z-shadow-md` | `0 4px 12px -2px rgb(8 12 20 / .10)` | `0 4px 12px -2px rgb(0 0 0 / .6)` |
| `--z-shadow-lg` | `0 16px 40px -8px rgb(8 12 20 / .16)` | `0 16px 40px -8px rgb(0 0 0 / .7)` |

Every modal sits on the same veil, `--z-scrim` with `--z-scrim-blur`; the page's
own ground at 72% and a 3px blur, so it dims in both themes without a hard-coded
black. Dialog, Drawer, the command palette and the phone's navigation sheet all
take it from the token, so a dialog opened over a drawer dims the page by one
amount.

### 1.6 Focus

One look, two mechanisms. `app.css` draws the global ring with `outline`, from
`--z-focus-width`, `--z-focus-colour` and `--z-focus-offset`; that is what
almost everything gets, on `:focus-visible` only, so a mouse click draws no ring
and a keyboard tab does. `--z-focus-ring` is the same ring as a `box-shadow`,
for the controls whose outline a scrolling or clipping parent would cut off. A
consumer on a raised surface overrides `--z-focus-gap` so the ring's inner gap
matches what it is drawn on. A parent that clips for a different reason makes
room instead: a grid cell truncates its content, and pushes that clip out by an
offset and a width so the ring of whatever is focused inside it still has
somewhere to be drawn.

The ring is never removed. Removing it and replacing it with a colour change
does not count: a border that goes from grey to blue is invisible to anyone who
cannot see the difference, and is exactly the change a focused *and* invalid
field cannot make.

### 1.7 Motion

| Token | Value | Use |
| --- | --- | --- |
| `--z-motion-fast` | 120ms | Hover, focus, colour change |
| `--z-motion-base` | 200ms | Panels, dropdowns, row insert |
| `--z-motion-slow` | 320ms | Dialogs, drawer, route transition |
| `--z-ease` | `cubic-bezier(.2,.8,.2,1)` | Everything |

State changes animate the *colour and the dot*, never layout; a table that
reflows while you are reading it is worse than one that does not animate at all.
Everything inside `@media (prefers-reduced-motion: reduce)` collapses to `1ms`.

### 1.8 Theme

Three states: `light`, `dark`, `system`. `dark` is the default. `system` sets no
attribute, so `prefers-color-scheme` decides. Every choice persists to
`localStorage` under `zoomies.theme`, and all but `system` write `data-theme` on
`<html>`; a browser with nothing stored gets `dark`.
The choice is applied by a tiny inline script before first paint so there is no
flash.

### 1.9 Scrollbars

The scrollbar is the one control the browser draws for us, and the one that is
easiest to leave too faint to find. It is styled so that it belongs to the theme
and, more to the point, so that an operator can see it and take hold of it.

| Token | Light | Dark | Use |
| --- | --- | --- | --- |
| `--z-scrollbar-size` | `14px` | `14px` | The column, which is the whole hit target |
| `--z-scrollbar-inset` | `3px` | `3px` | Gap between thumb and column edge, each side |
| `--z-scrollbar-thumb` | `--z-text-subtle` | `--z-text-subtle` | Resting |
| `--z-scrollbar-thumb-hover` | `--z-text-muted` | `--z-text-muted` | Under the pointer |
| `--z-scrollbar-thumb-active` | `--z-text` | `--z-text` | While it is held |
| `--z-scrollbar-track` | `rgb(8 12 20 / .06)` | `rgb(255 255 255 / .07)` | Behind the thumb |

The thumb borrows the weakest text colour, so its ratios are the ones already
measured above: against every surface it can sit on it is never below 4.1:1 in
light or 4.8:1 in dark, past the 3:1 a control needs. The track is a
tint rather than a colour so that it reads on a white card and on a sunken well
alike. The visible thumb is the column less the inset on each side -- 8px -- and
the whole 14px column responds to the pointer.

Two rules keep it working, and both are written into `app.css` beside the
rules themselves:

* **Pointer only.** The styling sits inside `@media (pointer: fine)`. On a
  touchscreen a finger scrolls the content, never the bar, and the browser's
  overlay scrollbar -- which takes no space and fades when idle -- is the right
  control there. Styling it would pin a permanent column down the edge of a
  phone.
* **One mechanism per browser.** Chromium and WebKit draw from the
  `::-webkit-scrollbar` pseudo-elements, which are the only way to get a thumb
  that is inset and rounded. The standard `scrollbar-width` and
  `scrollbar-color` properties are set only where those pseudo-elements are
  unknown (`@supports not selector(::-webkit-scrollbar)`, which today means
  Firefox), because a browser that knows both ignores the pseudo-elements the
  moment the standard properties are set and draws its own thin bar instead.
  Setting `scrollbar-width: thin` "as well" is exactly how the thumb became a
  sliver nobody could find.

The log viewer's terminal draws its own scrollbar rather than the browser's, so
it is handed the same three thumb tokens through its theme.

---

## 2. Information architecture

Persistent left navigation, collapsible to icons only (persisted). Order is
fixed, because muscle memory is the point:

1. **Overview**: fleet health at a glance
2. **Pools**: what runners to make
3. **Runners**: what runners exist right now
4. **Queue**: what is waiting for a runner, and why
5. **Workflows**: what has run, one row per workflow run, each opening to the
   jobs inside it. The job-level list is one step down, at `/jobs`: it is not
   in the navigation, but every link that names a job (the problems drawer's,
   the Overview's) lands there, and the two pages share their filters.
6. **Usage**: runner-hours and job activity by pool, repository or workflow
7. **Hosts**: where runners can go
8. **Providers**: the infrastructure that machines are rented from
9. **Installations**: GitHub App connections
10. **Eli repairs**: review failed PR offers, follow what is running and what
    happened, and set up the accounts and repositories behind them. It is one
    entry for four pages under `/repairs`: the hub, a failure at `/repairs/new`,
    one repair at `/repairs/<id>` and setup at `/repairs/setup`. The entry is lit
    on all four.
11. **Migrate repositories**: move repositories off GitHub's runners onto this
    fleet. The navigation shortens it to *Migrate*, because a nav label has to
    fit beside an icon in a 232px column; every other place that names the page
    (its heading, the palette, the shortcut sheet, the browser title) uses the
    full name.
12. **Kennel Club**: which repositories could hurt the fleet or stop its CI
    ([Kennel Club](https://zoomies.sh/kennel-club/)). It is a section of pages, like Settings:
    *Standards* (the Overview and the list of Repositories, and each
    repository's own page) and, under *For assistants*, **AI Context**,
    preparing repositories for AI coding assistants, where explicit source access
    is separate from fleet permissions. AI Context has no section of its own, and
    `/ai-context` still reaches it.
13. **Audit**: who did what
14. **Settings**: a section of pages rather than one page: your account and
    appearance; users and API tokens; the configuration, backups, updates and about.
    Each has an address of its own (`/settings/users`), the section's own rail
    lists them beside the page, and `/settings` alone goes to the first of
    them, except on a phone, where it is the list.

The sidebar reads the same order under four headings (*Fleet*, *Infrastructure*,
*GitHub* and *Administration*, with the Overview standing alone at the top and
Administration pinned to the foot) so an entry is found by its neighbourhood
rather than read for from the top. Collapsed, the headings become hairlines
and nothing moves. The headings change nothing about the order or the `g`
chords, which is what lets them be added without anyone relearning anything.

Every page is shown, in both themes, in [The UI](https://zoomies.sh/ui/).

The navigation is headed by the mark, the wordmark and the descriptor, and every
page ends in a hairline footer carrying the mark, the name, the running version,
a link to the docs and the credit *Developed by EyUp.io*, so a signed-in
screenshot says which product and which build it came from, and who makes it,
without anyone having to open Settings. On a phone the navigation moves to the
bottom edge and loses its masthead, so the mark appears in the top bar instead,
and again at the head of the sheet the More button opens, which is the one
place on a phone with room to say the name.

The account menu in the top bar is about the person rather than the fleet: who
is signed in and as what, the theme as a choice with the one in force marked,
their own settings pages, the keyboard shortcuts, the documentation and the way
out. On a phone the same things live at the top of the More sheet.

**A link that leaves the product opens in a new tab; a link within it does
not.** An operator watching a fleet should not lose the page they were on to go
and read what a runner group is. Both footer links leave, so both take a new
tab, as does every link to GitHub or to the documentation from a dialog,
`Button` has a `newTab` prop for the ones that are buttons. Each of them adds
"(opens in a new tab)" to its accessible name, because a tab appearing under
somebody who cannot see it happen is disorienting rather than helpful.

A **command palette** on `Cmd/Ctrl+K` jumps to any page, any pool, any runner by
ID or name, and runs quick actions (drain a runner, cordon a host, create a
pool). It is the fastest path to everything and is discoverable from a hint in
the top bar.

### Overview

The one page that has to earn the second monitor.

* **The activity matrix**, first: today by the hour, and up to a year of the
  fleet's days as one band of squares, a column per week and a row per
  weekday, coloured by what finished,
  greener as more jobs finish, red the moment any fail, darker red the
  larger the share. Colour never carries it alone: a failing square has a hole
  in it, a square with work waiting and nothing finished is hollow, and every
  square's accessible name is the whole sentence its tooltip shows. The grid
  is one tab stop walked with the arrow keys; selecting a square opens the
  day's hours and its links beneath the grid rather than in an overlay. Quick
  ranges cut the window (a day and a week are drawn by the hour, a month and a
  quarter as the calendar with each day cut into four- or eight-hour squares
  side by side, and the year by the day) and the choice, the day it starts on
  aside, is a per-operator preference. Slicing the days is what gives the
  shorter calendars about as many columns as the year, so every range fills
  the band the same way. Every range is drawn whole, never cut to the width or
  scrolled: its square shrinks until the window fits, so the newest week is
  always on screen and a year is twelve months on a phone, and the month
  names shorten to initials where the columns are too close for them. It is
  compact on purpose: every line it spends is a line the
  numbers under it lose. The band spends its width as well as its height: a
  grid with at least as many columns as rows grows its square until it fills
  the room beside the figures, one too narrow for that keeps its size and
  hands the width over, and the figures (what the squares on screen come to,
  so the window reads without hovering every square) take whatever is left.
  On a phone the band cannot hold both, so the one that would use the width
  gets it: a week of hours or a month of sliced days grows across the whole
  band with the figures under it, a few weeks of whole days keeps them beside
  it instead of leaving that width white. Which of the two it is comes out of
  the measurement, not a second breakpoint.
  The key runs under both, where it explains the squares rather than floating
  at the right with the white space in front of it. The component lives in
  `lib/insights/` because the Usage page draws its range with the same one.
* **Four metric tiles**: queued jobs, running jobs, live runners, median queue
  wait. Each carries a sparkline of the last hour.
* **Per-pool utilisation bars**: busy / live, with the pool's min and max marked
  so an operator can see a pool pinned at its ceiling.
* **Recent events**, a reverse-chronological list of what has happened to the
  fleet, whatever kind of thing it was: the scheduler's decisions in its own
  words (*"scaled `linux-x64` 2 → 4: 3 jobs queued > 30s"*), how each job
  ended, a runner that failed and a runner that came up ready, a runner lent
  spare CPU or slowed by the host it is on (*"Squirrel spotted, maximum
  zoomies"*, in the same words the runner's own page uses), a host that went
  quiet, was throttled or is holding new runners off under pressure, a machine
  a provider is renting, a pool somebody changed, the GitHub connection,
  problems as they are raised, and the audit trail. **What goes right is a
  category as much as what goes wrong**: a panel that only ever reported
  failures would teach an operator that silence is the good state, which is
  the same thing as teaching them not to read it. Every line is
  the same four things (a mark in one of the six status tones, what happened,
  what it happened to, and when) with the controller's own sentence under it
  where there is one, printed verbatim. A job's line names the step it stopped
  at, how long it took and where it came from, and a job whose runner stopped
  under it says so: that failure is the fleet's, not the workflow's, and this
  page is where the two are told apart. The hour's failures and how many of
  them were the fleet's own are a badge in the panel's header, because nothing
  else answers that, GitHub records both as *failure*.

  **Which kinds it carries is the operator's choice**, one switch per category
  on *Settings → Events*, grouped under four headings and kept in the browser
  like the theme. Two rules make
  that safe: the panel says how many kinds are switched off rather than
  quietly omitting them, and a category never reports a frame that carries no
  news, so nothing floods the panel with heartbeats. A category with no
  history to load (a host, a machine, a pool) says so on the settings page,
  because one that is on and empty otherwise looks exactly like one that is
  broken.

  On a desktop the pools and the running jobs share the left-hand column and
  this feed takes the right, cut to their height and scrolling inside itself.
  It is the one panel whose length says nothing about the fleet, so it never
  decides the height of the page: a fleet with one pool does not get a screen
  of blank space under it because the scheduler has been busy.
* **Active jobs**, under the pools: what is running this moment, newest
  first. How the last jobs ended was a second panel across the bottom of the
  page and is now part of the feed: two reverse-chronological lists on one
  page, each with its own idea of what belonged on it, was one list too many.
* **Problems summary**: one line saying how many things need a person, worst
  severity first, with a *Review* button that opens the problems drawer.
  **When everything is fine it is a single quiet line, not an empty box and not
  a green celebration.** It is a line rather than a list on purpose: the panels
  above are what the page is for, and a configuration warning somebody chose
  deliberately must not push the pools and the running jobs below the fold
  every day.
* **Recent failures**: unresolved failures nobody has acknowledged, newest first, with the
  count in the panel's header and each row naming the repository, the workflow,
  the job and the step or fault it stopped at. A row opens the job's own drawer,
  and its **Mark as known** button takes the failure off the list, with the
  toast carrying the undo and the drawer able to put it back. It is a panel
  rather than a line like the problems summary, because each entry is a
  separate job with a separate thing to do about it and each leaves when
  somebody acknowledges it or a newer run replaces it. Background GitHub checks
  also remove fixed, superseded and closed PR failures; cancelled or skipped
  results are not proof of a fix. An unavailable GitHub read never resolves a
  failure. Opening a stale repair link explains what changed and offers the
  appropriate next step instead of sending the reader into setup. It shows a short glance of the newest
  few with the whole count in the header, not the whole list, so it too leaves
  the pools and the running jobs above the fold of a laptop screen. When
  nothing is owed it drops to one quiet line with a link to the Failed view:
  an empty card would spend the same height saying nothing.

### The problems drawer

Reachable from the count in the top bar on every page, from the Overview's
*Review*, and from the command palette. It holds the list itself (unhealthy
hosts, failed registrations, webhook delivery failures, unmatched queued jobs,
jobs whose runner stopped under them in the last hour, and every dangerous
configuration setting the validator flagged) worst first,
each entry saying what is true, why it matters and what to change, with a link
to the pool, host, runner or installation it is about.

Every entry can be **dismissed** or **snoozed** for 15 minutes, an hour, four
hours or a day, for that one problem, or for every problem of its kind. A
decision is the operator's own, kept on their account in the database
(`problem_dismissals`, via `GET`/`PATCH /api/v1/auth/problem-dismissals`) so it
holds on another browser, another device and a second tab, and is never fleet
state: `GET /api/v1/problems`, `zoomies status` and any alerting rule still see
everything. An identity with no account behind it, such as an auth-disabled
instance, keeps them in `localStorage` instead. Three rules stop a dismissal
from hiding a real fault:

* a plain dismissal is forgotten the moment the controller stops reporting that
  problem, so the same fault happening again is news again;
* a snooze is not: it ends when its own clock does, so a problem that clears for
  one pass and comes back inside the window stays put away; and
* it only covers the severity it was made at, so a warning that becomes an
  error comes back.

Dismissed entries stay listed at the bottom of the drawer, dated, and can be
restored one at a time or all at once.

---

## 3. Component inventory

The components below live in `web/src/lib/components/` and take their values
from tokens. Everything else lives in a folder named for the thing it serves --
`lib/pools/`, `lib/jobs/`, `lib/shell/` -- so a file's path says who owns it,
and a component earns its way into `components/` when a second feature needs it
rather than on the day it is written. Svelte 5 runes (`$state`, `$derived`,
`$props`, `$effect`) throughout; no stores except for genuinely global state.

### Primitives

| Component | Notes |
| --- | --- |
| `Button` | variants: `primary`, `secondary`, `ghost`, `danger`; sizes `sm`, `md`, and `lg` for the sign-in form alone; `loading` swaps the label for a spinner without changing width. `help` is a sentence in the shared tooltip; `reason`, with `disabled`, says why it cannot be used and keeps it reachable (see *Help*) |
| `IconButton` | square, always has an `aria-label`, which is also the heading of its tooltip. `help`, `keys` and `reason` add a sentence, the keys that do the same and why it is unavailable |
| `Input`, `Textarea`, `Select`, `Switch`, `Checkbox`, `RadioGroup` | all support `error` and `hint`; the error is announced with `aria-describedby`. `Input` has the same three sizes as `Button` |
| `Field` | label + control + hint + error; the only way form controls are laid out |
| `Badge` | status pill: colour **and** shape from the state map |
| `StatusDot` | the shape half of the state encoding, reusable inline |
| `Tooltip` | the one hover and focus help, on one bubble (`TooltipBubble`) and one set of timing rules (`lib/tooltip.ts`); never the only place information lives. Plain help is a `title` heading, a `text` sentence, a `reason` and the `keys` that do the same thing; `text` is also what assistive technology gets, linked to the control with `aria-describedby`. An optional `content` snippet draws a richer card for sighted readers, with `text` as its one-sentence copy. What it wraps has to be something a keyboard reaches, or focus opens nothing; a card that carries figures also opens on a tap, which has no hover. See *Help* |
| `Segmented` | one choice among a few as one control, `aria-pressed` on the one in force: the activity matrix's ranges, the fleet trend's windows, the usage chart's two measures |
| `Dialog` | focus trap, restores focus on close unless something else has taken focus meanwhile, `Esc` closes, backdrop click closes only non-destructive dialogs, and never one that is showing a secret exactly once. A dialog with text fields is a `<form>` whose footer button is `type="submit" form="…"`, so `Enter` submits it |
| `Drawer` | right-hand detail panel; same focus rules. On a phone it runs the full height but not the full width: a strip of the page stays visible down its left, under the scrim, so it reads as a panel over the page rather than a new page, and a tap on the strip closes it |
| `NavMenu` | the phone's menu: a sheet that rises from the bottom edge, where the thumb that pressed More is, carrying every section named and in order, plus the account, the theme and sign out; same focus rules, closes when a section is chosen |
| `DropdownMenu` | roving tabindex, type-ahead; the list opens in the browser's top layer, placed against its trigger and flipped above where there is no room below, so a menu on a grid's last row is not cut off by the frame that scrolls. It closes when its trigger scrolls out of that frame. A `header` snippet names who the menu is about, and items sharing a `choice` are one segmented row of radio items with the one in force checked |
| `Tabs` | `aria-selected` and arrow-key navigation; `aria-controls` only when the component renders the panel, because a caller that draws what the tabs switch between elsewhere leaves it none to point at, and an `aria-controls` naming an id that is not on the page is an error |
| `Toast` | bottom-right, `aria-live="polite"` (`assertive` for errors), auto-dismiss except on error |
| `Skeleton` | **the only loading affordance for content.** Spinners are for in-flight *actions* only |
| `EmptyState` | icon + one sentence of what this is + the action that fills it; a `visual` snippet replaces the icon disc where artwork is wanted, as the not-found page does with the mark |
| `CopyButton` | wraps any ID; announces "copied" to screen readers |
| `RelativeTime` | "4m ago", the exact local time and the ISO timestamp in the tooltip, updates itself |
| `Duration` | humanised, tabular numerals |
| `Sparkline` | inline SVG, no chart library, `role="img"` with a text summary |
| `Slider` | the browser's own range control between notches, with the recommended notch ringed and words at the ones worth naming; a second row when two would collide. `aria-valuetext` says the value in the same words the readout shows |
| `QuantityField` | **every size and CPU figure**; a runner's limits, a host's reserve, a machine's shape, and every numeric or duration setting on the Configuration page. A `Slider` for choosing beside a field that reads what people write (`4gb`, `4096mb`, `4g`, `4 GiB`, `1.5`, `1500m`) and, on Enter or blur, writes it back in the largest unit that says it exactly: `4096mb` becomes “4 GB”, `1536` becomes “1.5 GB”, and `3000 MB` stays as it is rather than turning into a rounded, different limit. A gigabyte is 1024 MB, as it is everywhere else on the page. A length of time reads Go's spelling and the one people say (`168h`, `7d`, `2 weeks`, `1h30m`, `90 seconds`) is shown as “7d” or “1h 30m”, and is sent to the controller in Go's spelling, which has no day. What cannot be read is said beside the field and changes nothing. Never a bare `type="number"` box with the unit in its label |
| `UtilisationBar` | busy/live with min and max ticks |
| `ConfirmDialog` | destructive confirmation that **names the thing** ("Delete pool `linux-x64`? 3 runners will be drained.") and requires typing the name for anything irreversible; `Enter` in that field confirms only once the name matches |

### Composites

| Component | Notes |
| --- | --- |
| `DataGrid` | TanStack Table core + our own markup. Server-side pagination, sorting and filtering; column show/hide, width and order persisted per grid; a column's `help` makes its heading a dotted-underline button that opens a sentence; sticky header; row selection with a bulk action bar; full keyboard navigation (`↑ ↓` rows, `Enter` opens, `Space` selects, `Shift+↑/↓` range). Defaults fit the frame; an explicitly widened layout scrolls inside its own frame, see *Tables use the space they need* |
| `RowActions` | a row's actions as buttons rather than a menu, as an ARIA toolbar: one tab stop per row, `← →` along it, and every button's name carries what it acts on |
| `FilterBar` | chips for active filters, each individually removable, plus a clear-all |
| `PageHeader` | title, subtitle, breadcrumb, primary action, and the refresh button where the page passes `onrefresh` |
| `RefreshButton` | the one refresh: registers the page's handler so the button, `R` and the palette all run it; turning icon while it works |
| `MetricTile` | number, label, delta, sparkline |
| `LogViewer` | `lib/logs/`. xterm.js with search, follow/pause, wrap toggle, download, and a line counter |
| `MemoryBadgePill`, `MemoryBadgeCard` | `lib/runners/`. A small pill and the card it opens, for what the memory valve has done for a runner and which of its folders are kept in memory. The model is `memory-badges.ts`, free of components so a unit test reads it: a badge names a tone (`accent` for something to notice, `pending` for something to look at, `neutral` for a fact, `danger` for a refusal) and an icon by meaning, and the components resolve both from tokens. A pill is as wide as its words -- a short column wraps its pills, it never squeezes one to a glyph -- a dashed one is something that has not happened, and a dot on one means the runner wants more and cannot have it. The card is a `Tooltip` `content` with the `wide` size, and the same markup is drawn open in the runner page's panel, so what an operator reads in the tooltip is what the page says. A pill is for reading, never for navigating: its click stops at it |
| `HostMemoryPool` | `lib/hosts/`. The memory a host may lend, as a figure and a bar drawn against the whole machine, so segments compare between hosts of different sizes; the ledger it was worked out from, line by line, is the card behind it. The figure and bar are one button, so the ledger opens on focus and a tap as well as on hover |
| `HostCard` | `lib/hosts/`. One host, closed unless asked. It opens on what the Hosts page exists to answer: the host's state, what kind of host it is, why it is not taking work if it is not, the slot bar and three utilisation bars, then a **Details** disclosure that opens the rest beneath it. The rule it keeps is that a fold may hide detail and never a reason: every sentence that explains why a host takes no work, and the Backends block for a host with nothing to run a job in, stay on the closed card. The page that holds the cards owns which are open, because a filter that takes a card off the screen must not close it; the choice is remembered per browser, written when it is made and never on sight. The open part is not mounted while closed. A card is a subgrid of the page's grid and its sections are rows of it, so the cards in a row are one height and the same section sits at the same height in each (a notice on one card leaves the space for it on the others, and opening one makes the whole row as tall as that card needs), with the page's row gap at zero so that it does not also open up the rows inside every card |
| `HostVitals` | `lib/hosts/`. CPU, memory and disk as a share of the whole machine: three rows of one grid, so the bars align across a card and, by the columns' least widths, across a row of cards, each with the figures it is a share of. The arithmetic is the capacity map's (`hostSeries.ts`) and the lines are the product's own, one for each: amber from the scheduler's 85% for CPU, counted without the CPU it has lent to runners; at or below the host reserve for memory, where the scheduler holds new starts; and by the doctor's low-disk rule for the disk, with a warning triangle and a sentence in the row's label so colour is never the only cue. A figure that is not measured is the absent-value mark, and a host that has stopped sending heartbeats shows none as current |
| `MemoryBar` | `lib/runners/`. A runner's memory as one bar: guaranteed, lent, swap (hatched, so it cannot be mistaken for the solid fill where colour is lost) and a tick at the ceiling. `aria-hidden`: the figures beside it carry the meaning |
| `RunnerTimeline` | `lib/runners/`. One row per state with how long the runner stayed there, reconstructed from the four timestamps a runner row carries -- and it says so, rather than letting an operator read it as an audit trail |
| `Wizard` | a flow of steps that is genuinely a sequence; the AI Context setup, the provider form, the migration; on a phone its footer stays pinned above the bottom bar. The provider form also sets `jumpable`, so each stage in the list but the current one is a button that goes straight to it; the current stage is plain text with `aria-current` on its list item, because a button that did nothing would be a dead tab stop. A pool is not one: see [the pool editor](#the-pool-editor) |

### The log viewer

Runner details open on **Build log**, the workflow's console output after the
GitHub runner has masked secrets. **Runner diagnostics** keeps startup and worker
messages separate. Switching tabs reuses the stream and preserves both buffers.
The step summary reflects GitHub's latest report; it does not claim to update
after every step. Save log exports the build output received in this view,
and the GitHub link opens the complete archived job log.

Live build capture requires the updated stock runner entrypoint. It extracts
the runner's masked `JobServerQueue` console trace before GitHub truncates long
lines; other verbose traces are discarded before entering the relay. Older,
native and custom runners without this capture show an explicit waiting message
and the GitHub link. This live feed is best effort, not an archive: relay limits
still apply, and output is unavailable after the container is removed.

100k lines without jank is a hard requirement. xterm.js with the canvas/WebGL
addon handles the rendering; the constraints on our side are:

* a bounded scrollback (`scrollback: 100000`): beyond that the oldest lines go;
* writes are **batched on `requestAnimationFrame`**, never once per SSE frame;
* `fit` is debounced and only runs on real size changes;
* search uses the search addon, not a DOM scan;
* "follow" is a mode, not a scroll position: leaving the bottom turns it off,
  and a floating "jump to latest" button turns it back on.

---

## 4. Interaction patterns

### Real time

A single SSE connection to `/api/v1/events` feeds a client-side cache; each
page subscribes to the event kinds it cares about. On reconnect the client
sends `Last-Event-ID` and the server replays what it buffered, then the page
does one reconciling fetch; frames that land while that fetch is in flight are
held and applied on top of its result, because they are newer than it. The
connection state is visible in the top bar: a quiet dot when live, an explicit
"reconnecting…" when not, never a silent stall.

A grid that fetches its rows from the server refetches on the cache's *shape*
(a runner appearing, changing state, pool, host or job) not on every frame,
so a heartbeat that only moves a runner's CPU and memory costs no round trip;
and it refreshes at most about once a second however fast the shape moves, so
a fleet in trouble is still readable while it is in trouble.

The one page that asks rather than listens is *Add a host*: while it waits for
the new machine it fetches its own join token every few seconds, because
credentials are deliberately not on the stream. Even there the stream is the
fast path (a host's first frame is the cue to ask at once) and the page says
in words that it is waiting and how.

### Refresh

Refreshing is never how the screen keeps up, the stream is, but it is how an
operator settles the question of whether it has. Some things genuinely do not
arrive over the stream: join tokens, users, API tokens, and the configuration.

So the control is one control, in one place, everywhere it means anything:

* `PageHeader` renders it, first in the actions row, ahead of the page's own
  actions. It is a `secondary` button, never the primary one; the primary
  action changes the fleet, not the view of it.
* A page declares what refreshing means for it by passing `onrefresh`, and
  `RefreshButton` registers that with `lib/state/refresh.svelte.ts`, which is
  also what the `R` shortcut and the palette entry run. The button and the key
  cannot drift apart, because there is only one handler.
* A page with nothing to fetch (a wizard mid-flight, the not-found page)
  passes nothing and shows no button. A control that succeeds at nothing is
  worse than no control.
* One at a time: a second press joins the refresh in flight rather than starting
  another, so a page that fans out to five requests cannot be made to send
  fifteen.
* The icon turns while the fetch runs and for a beat after it, because a refresh
  answered out of a warm cache in twenty milliseconds otherwise looks like
  nothing happened. The label does not move; `aria-busy` and one polite
  announcement say the same thing without the animation.

### Optimistic updates

User actions apply locally first and roll back on failure with a toast that says
what failed and why. Draining a runner flips its badge immediately; if the API
returns 409 the badge flips back and the toast explains. Background outcomes
(the runner actually reaching `removed`) arrive over SSE and need no toast.

The same holds for an answer a person is waiting on after they asked for it: a
refresh, or a check they asked a host to run, shows its result by the page
changing (a fresher time, a finding that cleared) and is announced once in a
polite live region for people who cannot see the change. A toast is for the
failure of the request itself, which the person's own press caused, never for
what arrived afterwards.

The controller's own words are what a failure says; a 403 names the role
required, and paraphrasing it would lose that. They are shown as they are, but
run through `sentence()` in `lib/errors.ts` first, in the failure toast and the
error panel: the server writes for a log line (lowercase, no full stop), and
under a sentence-case title that reads as raw output. That changes only the
first letter and the last mark, and leaves alone a message that opens with an
identifier such as a setting key. An error a form or dialog shows inline is not
yet passed through it.

### Forms

Validation rules come from the same source as the API's, generated into
`web/src/lib/api/schema.d.ts` from the OpenAPI document. Errors appear inline on
blur and again on submit; the first invalid field receives focus. Defaults are
filled in from the host's detected capabilities, so a first pool is a name and
a press of *Create pool*.

A form that is a document of settings rather than a procedure is one page of
sections, not a wizard. A wizard asks the questions in an order; a pool's
settings have no order the operator has to follow, only a short list that most
pools need and a long one that few do, and a step list that cannot be pressed
is a form's table of contents that does not work. The pool editor below is the
pattern.

### The pool editor

Creating a pool and editing one are the same page, made of sections that are
each a *decision* (who the pool is for, which hosts, what a runner is, how big,
how many, what makes it faster) rather than a screen of the code that handles
it.

* **Every section is a row that says its answer.** A heading holding a
  disclosure button, with the current answer in a line beneath it (`One share
  of each host · elastic CPU observing`), so the whole pool reads down the page
  without opening anything. The whole row is the target, and the summary is the
  button's description rather than its name. A closed section is not mounted:
  the draft is one object, so nothing typed is lost by closing it.
* **A new pool opens on one section** and leaves the rest at their defaults,
  under *Fine-tune (optional)*. **An existing pool opens on none**, because an
  operator who came to change one setting is better served by six lines they
  can read than by a form to scroll. A link to a section opens it: `#size`.
* **The rows are the navigation.** From `--z-bp-lg` up a rail beside the page
  lists the same sections, marks the ones that are edited or have something to
  fix, and jumps, opening, scrolling to and focusing the section, and writing
  `#size` into the address as a replacement rather than an entry. Below it there
  is no rail: a second copy of the list above the form is one more thing to
  scroll past on a phone.
* **One sticky bar, and the button is never disabled for validation.** It says
  what the controller makes of the pool so far, and on a phone says nothing
  unless there is something to act on. Pressing *Create pool* while something is
  wrong opens the first section with a problem and puts the cursor in it; a
  disabled button cannot explain itself. It is disabled only for an edit that
  has changed nothing.
* **The exception is behind a row of its own.** A section is the answer to one
  question, and the controls that refine it are in a native disclosure with a
  line saying whether any are in use. One or two to a section, and open already
  when one is, never a setting in use hidden behind a closed row.
* **The form vocabulary is shared.** `.group`, `.hint`, `.echo`, `.pair`,
  `.proposal` and `.callout` are laid out once, by the section that holds them, so
  six bodies do not carry six copies of the same eight rules; a `.pair` stacks
  at `--z-bp-md`, and anything that can be one long word (an image reference, a
  path, a selector) wraps rather than widening the page. The phone suite checks
  every section, and the states that widen one, at 360px.

### Destructive actions

Named, counted, and consequential. "Delete" says what will be destroyed and how
many things it affects. Anything that removes runners from GitHub says so
explicitly. Irreversible actions require typing the resource name.

### Empty states

Never a blank table. Each says what the thing is and what to do next, with the
action inline:

* Pools: *"No pools yet. A pool decides what labels your runners answer to and
  how many of them exist."* + **Create a pool**
* Runners: *"No runners right now. That is normal when nothing is queued,
  runners are created on demand."*
* Jobs: *"Nothing is running right now. No runner here is working on a job at
  this moment, which on a quiet fleet is the ordinary state."* + **Show every
  status**; the page opens on what is running, so that is the empty grid an
  operator meets first

**An empty grid that is empty because of a filter says so instead**, naming the
filter rather than the noun: "No pools match those filters", "No runners match
these filters". The two are different facts and an operator acts on them
differently, one is a fleet with nothing in it, the other is a search with
nothing in it. The Jobs page carries this furthest, because an empty grid there
means seven different things, and the page says which: with a search or a
facet in force it is "no jobs match these filters" (the Workflows page says
"no runs") offering **Clear filters**, and that outranks every status
sentence below, because none of them is true of a search that matched nothing
(the dates are not a facet here; an empty window keeps its own advice to widen
it); under the unmatched filter
it is "no unmatched jobs", which is good news and says so; under the failed one
it is "no failed jobs"; under the attention view it is "no failure is waiting
on you", which is the all-clear of the triage list and names what takes one off
it (a re-run that passes, or somebody marking it known); in the status views
the page opens on, it is "nothing is
running right now" or "nothing is queued", each offering **Show every status**
rather than a narrower filter, because the way out of a status view is the same
page without it; with other runners included it is "no jobs recorded yet", which
points at webhook delivery; and without them it is "no jobs have run on this
fleet", which offers to widen the view.

**A page past the end is not an empty grid.** When the page an operator is on
answers no rows beside a total that says there are some (the last page emptied
by a live refresh, or a pasted `?offset=` from before the fleet shrank) the grid
steps back to the last page that has rows. The empty state is for a list whose
total is zero.

### Keyboard

Everything reachable, in a sensible order, with a visible focus ring
(`2px` `--z-accent`, `2px` offset, never removed). `Cmd/Ctrl+K` palette,
`g` then `o/p/r/q/w/j/u/h/v/i/m/k/a/s` to jump between sections (`j` is the Jobs
list, one step down from Workflows), `/` focuses the current page's search, `R` refreshes it, `?` opens the shortcut sheet, `Esc` closes the
topmost layer, and a tooltip is the first thing it closes. While a dialog, drawer, menu or the palette is open, `Esc` is
the only one of these the shell answers; the rest belong to the overlay, so a
`g r` typed into a confirmation cannot navigate away from the thing being
confirmed. Tab stays inside the innermost open overlay, and everything outside
it is `inert`.

### Help

Every help that appears on hover or focus is a `Tooltip`. The browser's own
`title` is not used for it: it waits a second, cannot be styled, never shows for
the keyboard, and is read out inconsistently. `title` stays where it carries
meaning in its own right, on an `<abbr>` (the expansion of an abbreviation), an
`<iframe>` and an SVG `<title>`, and nowhere else.

There are three kinds of content, and one bubble under all of them:

| Kind | What it is | Used by |
| --- | --- | --- |
| Plain help | an optional heading, one sentence, an optional reason and the keys that do the same thing | the sidebar, icon buttons, row actions and menu items, `Badge`, `RelativeTime`, truncated values, grid headings |
| Rich card | a `content` snippet with a layout of its own; `text` stays its one-sentence copy | the runner state card, the memory badges, the host memory ledger, the lifecycle flow, pool risks, field help, setting sources, backup badges |
| Data card | a rich card that is not interactive and opens with no wait | the activity matrix |

The heading is shown only for a control that does not show its own name: an icon
button's label, a collapsed sidebar entry's page. When there is a heading the
sentence is muted. A **reason** says why a control cannot be used now and what to
do about it, under a rule and an "i" icon, or a padlock when it is a permission
(`lock`). The keys are `<kbd>` caps.

The surface is `--z-surface-raised` with `--z-border`, `--z-radius-md` and
`--z-shadow-md`, in `--z-text-xs`. It is in the browser's top layer, so a table's
scrolling frame cannot clip it, and it is let go of when its trigger scrolls out
of the frame it lives in. It is at most 280px wide (360px for a `wide` card, 300px
for the matrix's). The caret is an 8px rotated square that points at the
trigger's centre even when the bubble has been held inside the window, and is
kept 14px from the bubble's ends. An invisible halo around the bubble bridges the
gap to the trigger, so the pointer can travel onto it. It prefers the side it is
given (`top` unless told otherwise), flips to the other when there is not room
for it plus an 8px gap and an 8px margin and the other side has more, and is held
8px inside the window. On a phone-width window a `right` preference becomes
`bottom`. A sidebar entry anchors to the rail's edge (`edge`), not its own, so
the bubble clears the rail. It fades in and slides 4px from the trigger's side
over `--z-motion-fast`, which collapses to `1ms` under reduced motion.

The timing is one controller, `lib/tooltip.ts`, shared by every bubble, so only
one is ever open:

| Rule | Value |
| --- | --- |
| The first bubble waits | 250ms (`OPEN_DELAY`), counted from the pointer's first move over the trigger and not from the browser reporting that it entered (see below) |
| After one has shown, and while one is open, a neighbour opens at once | for 300ms (`WARM`), which is what makes a sweep down the sidebar feel instant |
| Leaving closes after a grace, and entering the bubble cancels it | 100ms (`GRACE`) |
| Keyboard focus (`:focus-visible`) | opens at once; blur closes |
| `Esc` | closes the bubble first, in the capture phase, without moving focus and without closing the menu underneath; it is stopped only when a bubble was open |
| A press on the trigger or the bubble | hides it |
| A touch | does not open on hover; the sentence is still the description |
| `wait="none"` | opens at once, for things people scrub across |

Rich cards take the shared 250ms wait. They used to open the instant the pointer
touched them; `RICH_CARD_WAIT` in `lib/tooltip.ts` is the one place to give that
back.

The wait starts on a move because a page that changes under a resting pointer (the
phone's sheet rising where "More" was pressed, a list re-rendering) makes the
browser report the pointer entering whatever is now beneath it, with no move
behind it. Help that opened on that would arrive over the thing the person had
just opened, and take the first `Esc` that belonged to it. `whenPointerMoves` in
`lib/tooltip-intent.ts` is the one place that rule lives; `Tooltip` and
`attachTip` both use it.

What a screen reader gets does not depend on the bubble. The sentence (and the
reason) is the control's `aria-describedby`, unless it would only repeat the
control's accessible name, and a value that is already on the page in full
(a truncated name) is not described a second time. A control that is unavailable
for a reason is marked `aria-disabled`, not `disabled`: a disabled button cannot
be hovered or focused, and the reason is the point. `Button`, `IconButton`,
`Checkbox`, `RowActions` and `DropdownMenu` items all do this when given a
`reason`.

### Responsive

Two thresholds, `--z-bp-md` (768px) and `--z-bp-lg` (1180px), and so three
ranges:

* `< 768px` (**phone.** The navigation becomes a bar along the bottom edge
  carrying the four sections a fleet is watched with) Overview, Pools, Runners,
  Workflows; each under its own word, plus a **More** button opening a sheet from
  the bottom edge that lists all fourteen, with the account, the theme and sign
  out above them. An icon-only target for every section across a 412px screen is
  about 32px apart and told apart only by a glyph, which is not a navigation an
  operator can use one-handed at 3am. The sheet is a modal overlay like any other:
  Escape closes it, the page behind it is inert, and choosing a section closes
  it. Settings has no room for its rail here, so `/settings` is the list of its
  pages and each page carries a way back to it; between 768 and 1180px the rail
  is a strip of the same pages above the one being read.
  Collapsing is a *desktop* idea and the phone must never inherit it; a bar
  along the bottom has nothing to collapse, and `.nav.collapsed` outranking the
  phone's own rules is what once made that bar 56px wide with every entry piled
  into the corner. Metric tiles stack, every table's rows become cards (read
  down, one heading per line, nothing dropped and nothing truncated) text
  controls step up to `--z-control-font-touch` so iOS does not zoom, and every
  control stays usable: the Playwright suite's mobile project runs the whole
  suite at this width, drains and the pool editor included.
* `768–1180px`: **tablet.** The nav starts collapsed to icons unless the
  operator has chosen otherwise. That default is bounded at both ends, in
  `prefs.svelte.ts` and in the inline script in `index.html` that applies it
  before first paint: a phone is not a narrow desktop and has no sidebar to
  collapse.
* `> 1180px`: **full.**

One page also answers to the window's height. On a desktop the sign-in page's
brand panel drops its three facts when the window is under 720px tall (a laptop
with its browser chrome is often less) rather than grow a scrollbar on the one
page that should fit without one. It is the only height query in the product,
and it hides nothing a person needs to sign in.

On a phone that page's brand column becomes a band drawn to the very top of the
screen, so it pads by `--z-safe-top`, the status bar's height, which is zero
anywhere but an installed web app.

An overlay that covers the screen (the drawer, the dialogs, the command
palette, the side menu, the bar along the bottom and the toasts) takes its
width from `--z-window-width`, never from a bare `100%` or `inset`. A phone
answers a page that overflows sideways by laying the whole thing out in a wider
block and showing it smaller, and everything `position: fixed` is laid out
against that block rather than against the screen. One row a few pixels past
the edge is therefore not one row: it put the problems drawer at its desktop
760px, anchored to the right-hand end of a block twice the width of the phone,
and left an operator looking at a sliver of a panel whose close button was off
the side of the screen. `min(100vw, 100%)` is the window on a phone, and on a
desktop it is the side of the two that does not add a scrollbar.

The mobile project emulates a 412px Pixel 7, which is the forgiving end of the
range most phones are in, so two of its tests set 360px instead and walk every
section at it. That is where a settings row (15rem of key, the value beside it)
ran **Change** over the edge, and with it the document and every overlay laid
out against it.

A third threshold is not a judgement call to be made per component. Four
components had one each (560, 640 and 720) and every one of them was a "stack
this on a phone" rule written to a width somebody eyeballed. They are all 768px
now. A media query cannot say "above 1180" without naming the next pixel, so the
one `min-width: 1181px` in `FleetMetrics.svelte` is the same threshold from the
other side and carries a comment saying so.

### Tables use the space they need

Tables begin at content-relevant widths rather than stretching a short state,
date or action column merely to fill the frame. When those useful widths fit,
unused room remains at the end of the table instead of being hidden inside its
columns. When the defaults need more room they still divide the frame, and the
narrow-desktop priorities keep the page readable without a page-level
scrollbar. `web/tests/tables.spec.ts` checks the page itself at 1440, 1180,
1179, 768, 412 and 360 pixels.

Five things make it fit, in this order:

* **Defaults fit before they scroll.** The table is `table-layout: fixed`, and
  `DataGrid` works out each column's width from the frame it measures rather
  than from its content. A declared `width` is the useful measure when all the
  useful measures fit, and a share of the frame when they do not. A cell too
  small for its content truncates and keeps the whole value in a tooltip, which
  opens only on a cell that is actually cut off.
* **A deliberate layout stays deliberate.** Every heading has a drag handle and
  a resize edge. The handle also moves with left/right arrow keys and the edge
  resizes in eight-pixel keyboard steps; double-clicking an edge restores that
  column and the chooser can restore the whole layout. A layout wider than the
  frame scrolls *inside the grid*, because shrinking other columns behind the
  operator's back would make the resize handle lie. Widths and order are saved
  under stable column ids to the signed-in account, with local storage as the
  immediate and offline fallback. New columns append to a saved order.
* **Both gestures answer a finger.** They are pointer gestures rather than
  HTML5 drag-and-drop, which a browser raises from a finger only after a long
  press on Android and never on iOS, so the order was once a preference a
  phone could read and not write, on the Rows layout that exists to give a
  phone columns to arrange. Under `@media (pointer: coarse)` the heading takes
  `--z-control-touch`, and both controls grow into it. The resize edge's line
  is drawn at rest on every pointer, so the handle is findable before anything
  is hovered; it turns accent under the pointer or the keyboard.
  `web/src/lib/actions/columnGesture.ts`
  is the one implementation, shared with the tables that are not grids, and
  `web/tests/column-layout.spec.ts` holds it true under both a cursor and a
  touchscreen.
* **A narrow desktop shows fewer columns.** Twelve columns in the 660 pixels a
  768px window leaves is 55 pixels each, which fits and says nothing. Between
  `--z-bp-md` and `--z-bp-lg`, columns marked `priority: 'wide'` wait for a
  window with the room to show them properly. Which ones those are is each
  page's judgement: the columns that answer the page's own question stay.
* **In Classic a phone keeps the table, and the frame takes the scroll.** A grid
  is the same shape at every width unless it is told otherwise: below
  `--z-bp-md` the columns take the widths they declare rather than dividing a
  frame they cannot meaningfully divide, and the grid's own frame scrolls
  sideways to reach the ones past the edge. Like a deliberately widened desktop
  layout, the scroll belongs to the table's frame rather than the page, so it
  buys a page an operator already knows without moving the application itself.
* **A phone reads down in Mission Control, and in Classic when the operator says
  so.** Ten columns in 412 pixels each say very little, so each grid carries a
  Cards/Rows toggle above its rows and Settings → Appearance holds the default
  every grid that has not chosen follows (Cards in Mission Control, Rows in
  Classic, until it is set). In the card layout every row becomes a card and
  every cell carries its own heading, taken from its `data-label`; the shape the
  usage report and the Hosts page already had. Nothing is dropped and nothing is
  truncated; a `wide` column is back, because a card has a line for it. Two
  things a heading row does that a card cannot are kept as a strip above the
  cards: the sort, and the tick that takes every row on the page. The rest of
  the heading row stays in the document, out of the layout, so a screen reader
  still has a column header to associate each cell with.

The same card layout is written out by hand in the tables that are not grids;
the usage report, the accounts and API-token lists, the outstanding join tokens
and the three check tables on a host's page.
The `tableLayout` action gives those record tables the same measured defaults,
handles and account preference keys without taking ownership of their rows. A
compact matrix whose column order carries meaning (the chronological capacity
card, for example) stays a matrix and does not offer a misleading reorder
control. A new table of any size belongs in one of those two places.

All of them, the grid included, spell their ARIA roles out rather than leaving
them to the element. A browser drops a row's implicit role the moment `display`
stops being `table-row`, so a card layout that says nothing is a run of loose
text to a screen reader; the values are all there and nothing says which record
any of them belongs to.

---

## 5. Performance budget

| Budget | Limit |
| --- | --- |
| App shell (JS + CSS, gzipped) | **< 200 KB** |
| Route chunk | < 80 KB gzipped, with named exceptions |
| Status page (`/status`, all of it, gzipped) | **< 30 KB** |
| First contentful paint on a warm cache | < 400 ms |
| Interaction to next paint | < 200 ms |

How it is kept:

* route-level code splitting; xterm.js and TanStack Table load only on the
  routes that use them;
* no chart library: sparklines and bars are hand-written SVG;
* Lucide icons imported individually so tree-shaking works;
* fonts declared by hand as four Latin faces with `font-display: swap`,
  rather than @fontsource's own stylesheets, which pull in Cyrillic, Greek and
  Vietnamese in both woff and woff2;
* the SSE cache is a plain `Map`, not a reactive deep-proxy over thousands of
  rows.

`npm run build` prints every chunk's gzipped size and **fails** if the shell or
any route is over its budget, so this stays true.

The shell is the entry chunk, what it imports statically, and the CSS those
bring with them, not every stylesheet in the build, which is what it used to
count and why the printed number overstated the first paint.

The status page is its own document, `web/status.html`, built by a second pass
(`vite build --mode status`, which `npm run build` runs after the app) and held
to its own budget. It is read by people with no account, often on a phone, and
it is a heading, four numbers and a list, so it loads the tokens, one component
and the Svelte runtime and nothing from the app. Building it as a second input
to the app's own pass made the two share chunks and moved a kilobyte into the
shell; built apart, the shell is unchanged by it.

One route is over the 80 KB line and is named in `ROUTE_ALLOWANCES` in
`web/vite.config.ts` with the size it is allowed: `xterm`, the terminal
emulator behind live runner and job output, which loads only on the two pages
that show it. A route that grows past the budget without an entry there fails
the build; adding one is a decision to make in a pull request, not a number to
nudge.

---

## 6. Accessibility checklist

Every PR that touches the UI should be able to answer yes to all of these:

- [ ] Operable with the keyboard alone, including grids and dialogs
- [ ] Focus visible everywhere, moved into a dialog, and given back when it closes unless something else has taken it meanwhile
- [ ] Custom widgets carry the right `role`, and state via `aria-*`
- [ ] Colour is never the sole carrier of meaning
- [ ] Text meets WCAG AA in **both** themes (4.5:1 body, 3:1 large and UI)
- [ ] Live regions announce async outcomes once, not on every keystroke
- [ ] Reduced-motion honoured
- [ ] Hover help is a `Tooltip`, never a native `title`, and a control that is unavailable says why
- [ ] Zoom to 200% without loss of function

---

## 7. Adding something new

1. Look for an existing component first. The inventory above is deliberately
   small.
2. If a token you need does not exist, add it to `tokens.css` in **both**
   themes, check the contrast, and record it here.
3. If it is a status, add it to the state map (colour **and** shape).
4. Write the empty, loading and error states before the happy path. They are
   most of what an operator actually sees.
5. Keep the space outside the block when a sentence continues across an `{#if}`,
   `{#each}` or `{:else}`. Svelte drops the whitespace at the start and at the
   end of a block's body, so a space written inside it is not there on the page:
   "in use{#if x}" followed by a line break and "· 1 via Tailcat" reads "in use·
   1 via Tailcat". Put the space before the block, or build the optional part as
   one string. `npm run lint` finds these (`web/scripts/block-spacing.mjs`).

---

### Testing a fleet in trouble

The Playwright suite's shared fixture is a fleet with nothing wrong with it,
and deliberately so: the demo keeps its two starting runners young
(`freshenDemoRunners`) because an instance left open on somebody's desk must
not report a fault in a fleet that has no agent to have one. Those two also
need a queue behind them. The fixture is a snapshot and the reconcile loop
reads it as a fleet, so a pool holding more runners than its demand justifies
has the ones that have not finished starting drained on the very first pass,
which is why `seedBacklog` writes a queued job for every runner in the pool
that is neither busy nor draining, and the fixture survives the loop that
reads it. The cost is that
every page which explains a fault has nothing to render there (the problems
drawer, both stuck-runner shapes, a pool nothing can place, a job GitHub is
holding) so all of them went untested.

`ZOOMIES_SEED_STUCK=true`, on top of `ZOOMIES_SEED_DEMO`, is the opt-in that
breaks three things in that fleet: it ages the demo's two starting runners past
the point where `runners.not_progressing` says so, adds a pool whose host
selector nothing answers, and adds a job held for a deployment review. Its rows
carry demo identifiers, so every guard that keeps a fixture out of a real fleet
applies to them unchanged.

The `diagnostics` Playwright project runs `tests/diagnostics.spec.ts` against
its own controller on that fixture. Put a spec there when what it protects is a
diagnosis (the words an operator reads on their worst day) rather than a
grid.

`ZOOMIES_SEED_UPDATES=<folder>`, on top of `ZOOMIES_SEED_DEMO`, is the opt-in
for the Updates page. The binary the suite drives is a `dev` build, which no
release comparison accepts, so on every other fixture that page can only say the
build is left alone. This makes the controller report 1.3.0 and read a release
list holding v1.3.1 and v1.3.2, and its value is the folder the suite makes for
the controller to do its updating through. That folder also holds the marker an
installed update helper leaves, so the Update button is offered, and nothing
answers the request a press writes there: the spec plays the helper by writing
a `result.json` into it. The `updates` and `updates-mobile`
projects run `tests/updates.spec.ts` and `tests/host-update.spec.ts` against one
controller of their own, in manual mode, which is the mode whose sentences do not move with the clock;
states it cannot make, an auto soak or a list nobody has read, are served to the
page as the documents the controller would send. `host-update.spec.ts` joins
hosts of its own on 1.2.0, which are behind this controller's 1.3.0, and plays
each host's agent by posting its heartbeats.

## 8. Screenshots

The screenshots in `docs/screenshots/`, the ones [The UI](https://zoomies.sh/ui/), the site's
home page and the repository README embed, are captured from the real binary,
never from a design file or a retouched page. `make screenshots` builds, boots
a controller with the demo fleet (`ZOOMIES_SEED_DEMO`) and authentication on,
signs in as a freshly bootstrapped administrator, and photographs every page in
both themes at 1440×900 on a 2× display, plus the sign-in page from a browser
that was never given the session, and the Overview on a phone. The
files are lossless WebP. The script is `web/tests/support/screenshots.mjs`,
and it needs Pillow (`pip install pillow`) to encode them.

Refresh them when a page changes in a way a reader would notice, and commit
the whole set: a gallery in which one page has the new navigation and the rest
the old one is worse than one that is uniformly a release behind. If a shot
needs the fixture to show something new, change the fixture in
`internal/controller/seed.go` rather than the image; that is the fleet the
Playwright suite asserts on, so the picture and the tests stay honest together.

The component gallery is a separate capture set: it is not a product page, so
it never goes in this folder or in the docs gallery. `make screenshots-gallery`
refreshes `design/theme-review/gallery/`; see the README beside those files.

### Eli's contextual assistant

Every user can open Eli from the circular dog button on any page. Drag the
identity in the panel header to move it, or focus it and use arrow keys (Shift
for larger steps). The panel stays within the viewport when moved or resized;
left and right placement reset its position. Thinking is the avatar's own dog icon
and two paw prints on a short track, and nothing else moves: each of its six
routines (zoomies, fetch, sniffing, pouncing, a happy wiggle and chasing circles)
is a set of keyframes for those three marks, so the picture stays as light as a
status icon and a new routine needs no drawing. It cycles the routines with 24
playful quotes. Quotes change every 4.5 seconds, routines every 9 seconds, with a
varied starting point per answer.
Avatar hover greetings vary between a tilt, a boop and a wiggle. All animations
are disabled for reduced motion.
The panel offers Compact, Default
and Expanded sizes, left or right placement, manual corner resizing and a
full-screen view. Size and side are browser preferences. A phone shows the panel
as a sheet inset by `--z-space-3` on every side, above the scrim every modal
shares, with the rest of the page inert; the sizes and sides do not apply there.
The sheet opens with focus on itself rather than on the message box, so the
keyboard waits for a tap, and it is sized from the visual viewport
(`web/src/lib/assistant/sheet.ts`), so when the keyboard does come up it
shortens the sheet from below instead of scrolling Eli's header off the top of
the screen.
Minimising or navigating preserves the conversation and draft; a reload,
sign-out or New conversation clears them. Chat uses the owner-scoped personal API and that user’s enabled default
provider. Installation provider management retains its administrator gate.

`web/src/lib/assistant/AskEli.svelte` is the shared contextual action. It wears
the avatar's own tint (`--z-accent` on `--z-accent-subtle`, ringed by
`--z-accent-border`), so the button on a page and the dog in the corner are
plainly the same way to reach Eli, and it wiggles once when a page first shows
it and again under the pointer so a person who has never noticed it does; a list
of them arrives on three staggered beats rather than in unison, and reduced
motion stops all of it. Supply an
`EliContext` with a kind, title, optional question and an explicit map of displayed
facts. Never pass a whole API object, credentials, environment variables or
unrequested logs. Clicking the action opens Eli and asks with that snapshot.
Questions clicked during an answer wait in order and can be discarded. When no
provider is available, they remain visible until the operator configures one and
reopens Eli. The model has no mutation tools. It reads the fleet only through a
provider whose fleet switch is on, and the snapshot prompt tells it to check the
live state with those tools when it has them, so the facts are a starting point
and not the model's only view.

Host and runner details and problem entries use the same action. Add another
entry point by supplying its own display facts to `AskEli`; it needs no chat
transport or page-specific event listener. `assistant/prompts.ts` holds the pure
prompt builder and `splitFollowUps`. The controller asks the model to end each
answer with a `<follow-ups>` block of questions specific to the conversation;
the page strips the block from the answer, shows the questions under
**Continue the conversation** once streaming finishes, and offers none when the
model gave none. They arrive in the same response, not an additional model
request.
