Skip to content

Zoomies 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

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:

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 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, 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). 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.

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 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, 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.