Design system
The visual language of the viewer UI (web/). Read this before writing UI: the system
is small and opinionated, and imitating whatever component happens to be nearby is the
reliable way to break it.
Every value lives as a CSS custom property in web/src/theme.css — this document
explains the intent behind them. Change tokens there; keep this file in sync when the
rules change.
The live specimen sheet is #/design-system in the running app (internal and
unlinked — type the URL). It renders the real primitives and reads token values off the
DOM at runtime, so it cannot drift. This document explains why; it can drift, and it
has. If the two disagree, the page is right.
1. Principles
- Full-bleed. The background is the app surface, edge to edge. No floating app frame, no outer canvas. Whitespace lives between cards, never around the app.
- Separation by tone, not shadow. Cards are flat white on light gray
(
--cardon--bg0); insets are gray-on-white (--bg1/--bg2inside cards).box-shadowis reserved for true overlays — dropdown menus and the floating match bar. Nothing else casts a shadow. - One hot accent. Vermilion is the colour allowed to shout: primary CTAs and the live-indexing pulse. (The user speaker rail is now the accent too, so a replay shows it once per user turn. The rule still holds for chrome and for every screen outside the replay — two vermilion actions on one surface is still a mistake.)
- Color carries meaning or stays out. Blue (search), the two category hues and the speaker rails are data accents in small doses. The state ramps — success, warning, danger — also draw every diff, and syntax highlighting is semantic; none of it is ever stripped. Everything else is ink and gray.
- Emphasis is black. The near-black surface (
--contrast-solid) marks “current/important”: the stat card, active toggle segments, active turn numbers, command badges, the match pill. In dark mode it inverts to white — same trick, mirrored. - Round is the default. Pills and circles for interactive elements, radius-24 for cards, radius 12–16 for insets. Sharp corners don’t exist.
2. Color tokens
Both themes define the same token names; components never hardcode colors.
(
--mint, --purple and the --badge-* palette were retired.
Green now means success and nothing else; the two hues that were doing
category work became --c-diff and --c-command.)
Nothing outside theme.css may name a color. The live token sheet — every
value, resolved, in whichever theme you’re looking at — is #/design-system.
Role rails (replay): user and agent are peers and separate by hue
(--c-user, --c-assistant); tool is faint gray, meta is --c-dim, a
failed turn takes --danger. A subagent keeps --c-assistant and separates
by texture, not by hue.
3. Typography
- UI: Plus Jakarta Sans — variable, 200–800, latin woff2, bundled; nothing loads from the network, ever (brand promise; applies to all assets).
- Content: Space Mono — latin woff2, static, 400 and 700 only, bundled likewise.
The mono has no 500. CSS weight matching looks down from a requested 500 before it looks up, so every mono element asking for the emphasis weight renders at 400: the identity pills, mono labels, the display figures. Sans emphasis is real; mono emphasis is not. Either live with it (mono carries emphasis by being mono, arguably enough), or pick a mono with a 500 — JetBrains Mono and Roboto Mono are variable, IBM Plex Mono ships a static 500.
Which family — one question
Mono is for what a machine named or measured. Sans is for what a person named or wrote.(The old wording — “anything the agent produced or that identifies data” — left thirteen dates, costs, counts, and model names in sans, including two cases where the same datum changed family depending on which component it landed in:
.side-item-model vs .badge-model, and
.turn-n vs .outline-n.)
Two tiebreakers:
- A sentence is sans, whatever digits are in it. “across 3 files” and
“3 unrecognized events — kept raw” are prose;
$0.41 · 38 turns · Jul 29is a data strip. - A mixed metadata line goes fully mono, not token by token. The word “turns” inside a figure strip rides along; wrapping each number in its own span would be precise and unreadable.
--sans is set once on body, so it is only ever declared to recover an
element that escaped it — form controls (a <textarea> falls back to the
browser’s mono) and pre.sans (a <pre> rendering prose). Everything else
inherits. code, pre, and kbd are mono at the element level.
Mono is wider than sans at the same size — converting a line inside a
fixed-width container (the 356px sidebar) can start it ellipsizing. Check
before assuming.
The type ladder
Every font-size in the app is a--fs-* token, there are eight, and every
one is in use. No reserve steps — an unused token is an invitation to reach
for it, and adding a size should be a deliberate act. If you need 20px, add
20px and say why.
The token set is also the whitelist: there is no --fs-13, so an off-scale
value can’t resolve, where a bare 13px used to work silently.
(Built from 17 ad-hoc sizes — nine of them between 10.5 and 15,
half-pixel values throughout. Trimmed the same day from fourteen steps to
eight: 8/20/22/28/48 were never used, and 36 held the hero alone one notch
under the accent figure it had no reason to differ from.)
- Weights are 400 and 500. Nothing else. (Collapsed from 400/500/600 with the font change — 600 went to 500 everywhere.)
- Uppercase text is one of exactly two tiers, one ladder step apart,
both tracked at 0.07em, both weight 500 — the ladder above allows no other:
section (
--fs-12— names a block:.block-label,.pop-label,.stat-label,.outline-title) and badge (--fs-10— the text inside a small filled pill:.wn-current-badge,.update-banner-copy). The identity pills are not in this system: an adapter is a proper noun and a model id is a literal, so both are written as they are. The type is set once in a shared block near the top ofapp.css; each site contributes only color and layout. (Consolidated — this idiom had drifted to five tracking values across five sizes.) - Display figures carry no negative tracking: they’re mono now, and tightening a fixed advance width works against the reason for choosing it.
- Inline
<code>in chrome is one chip everywhere:--bg2, 6px radius,1px 6px, 12px — the same 6px it shares with<kbd>, both being glyph-scale marks set into a line of text. Fenced code is.code-blockand is a different thing. - Secondary lines under headings are
--tx2, regular weight — the two-tone heading (ink line + gray line) is a recurring signature.
4. Shape & spacing
- The radius ladder — five steps, all tokens, plus 999 for pills and
circles. Every rounded control or surface lands on one of them; nothing
in between.
--radius-xs9 (tile-xs, inline code) ·--radius-sm12 (list rows, controls, inset fields, tile-sm) ·--radius14 (tiles, code blocks, diffs, sidechain runs) ·--radius-md16 (panels, popovers, menus, stat tiles) ·--radius-lg24 (cards). (--radiushad been defined but never used, and 12px — the commonest radius in the app — had no token at all.) - Data marks are not controls, and they have their own step:
--radius-mark4 — spend bars, calendar blocks, timeline columns. A chart bar rounded to 9px stops reading as a measurement. (The spend bar capped at 4 and the timeline column at 6, so two marks of the same kind rounded differently, and the ladder claimed neither existed.) - Three glyph-scale artifacts sit outside both ladders and say so where
they are declared: the keycap at 6 (a raised object with its own
--key-*tokens),<mark>at 3 (it wraps running text), the note-dot fold at 2 (an 11px paper square). Nothing else may. - Circular buttons: 44 (
Primary) · 34 (IconButton) · 26 (its ghost). Tiles: 44/36/28. - Gutter 14px (
--gutter) — the screen’s horizontal/bottom padding, the sidebar card’s inset, and any full-width band under the header all read from it. Bento gap 20px; card padding 20–32px. - List rows are rounded hover pills (r12–14): separation comes from spacing and the hover/active wash, never from hairline rules between rows. (Revised — per-row hairlines read as a table grid on wide viewports and boxed the hover wash in; the sidebar, home lists, spend splits, search hits, files, disk, file-history rows, and the spine’s turn rows all follow the pill pattern now.) Hairlines remain for structural dividers only: card/panel headers (palette input row, file-history path bar). Never nested cards.
- Content goes full width — no centered max-width columns. The home bento, search screen, spine, log blocks, and file-history all fill the window; “full-bleed” applies to content, not just the background. (Revised — the old 880–1220px centered measures are gone.)
- The sessions sidebar is a floating card:
--cardon the--bg0canvas, rounded on all four sides (--radius-lg), inset--gutterfrom the edges, its width animating between the two states. The card is the rail — surface, radius and inset live there, and the list inside keeps its full width so nothing reflows mid-slide. - It never closes to nothing. Collapsed it keeps a
--rail-w(68px) rail holding the brand — which is the way back: the mark cross-fades to the panel glyph under the pointer, and the button is focusable at all times, so the affordance never depends on a hover that touch and keyboard cannot perform. Below it, the same list as the open state, one tile per session, in the same order, scrolling: a collapsed sidebar showing a different set would be a different list wearing the same column. Tiles carry the two row states that survive at 36px — active as a--contrast-solidbar on the card ground beside the tile (a ring on the tile is white on white, since tiles invert to a light block in the dark theme), and pinned as a--c-notedot at the corner. The open/close handoff is staged: the zone fades out over 160ms while the rail fades in after a 150ms delay, so the two are never both on screen. Both controls live in this column in both states — the toggle does not move to the header and back. (Reverted to the original “zone, not a card” treatment, which the floating card had superseded. The card spent 28px of a 356px column on margins and rounded away four corners of a list that runs the full height of the screen; the tone contrast was doing the separating either way. This bullet has now been wrong in both directions — check the code.)
5. Components
The primitives are React components with co-located CSS:web/src/components/<Name>.tsx + <Name>.css, imported by the component.
app.css keeps screen layout and contextual overrides only (e.g.
.calendar-head .view-toggle { background: var(--card) }); base looks live
with their component. Components emit the long-standing class names, so
context rules keep targeting them. Never restyle a primitive in app.css —
change its own CSS file.
IconButton(IconButton.tsx/css) — every round icon-only button. Theactionvariant is a badge: a gradient fill lit from the top-left, set by two custom properties (--badge-hue,--badge-glyph) so a new colored action is one line at the call site. A button that stands for a colored dimension wears it — the four lenses. Everything else (find, reveal, share, stats, resume, the name/note editor) stays neutral, because color carries meaning or it stays out. Note-yellow is a state, not a tool. It marks a thing that HAS been marked — the note dot, the bookmark rail, the unseen-release ring — so the pin button wears--c-noteonly while the session is pinned, and is a plain neutral badge otherwise. It uses that instead of the generic pressed look for the same reason: “pinned” already has a color on the button itself. (The pinned sidebar row dropped its yellow wash for a neutral--bg1ground. Yellow says “there is a note here”, and a pinned session usually has no note; the filled pin in the row is what says it is pinned, and the row only needs a ground quiet enough to group the block.) The name/note editor is just a panel toggle, like find and stats, and takes the contrast-pressed look. Pressed is the contrast surface in every case, flat: the hue says what the button is, black says which one is on, and one color can’t say both — inverting to hue-on-contrast puts a light pastel on a near-white surface in the dark theme. A lens wears its category as the fill, with the glyph reversed out of it via--c-on— the hue is the button, not a tint on the icon. (Badges added .) One size, 34px, and four fills —quiet(--bg2, the default, on a card),card(--card, on the bare app background where--bg2all but disappears in the light theme),inset(--bg1, one surface down, for controls already standing on--bg2), andghost— transparent until hover, and the single exception to the size at 26px, because it rides inside list rows and floating nav pills where a 34 crowds the row. Row and floating-pill secondary controls are ghosts: pin, reveal, dismiss ✕, match/error rail chevrons. (This was four variants at three sizes — 34, 34 and 32 — separated by nothing but their rest colour. The colour was the real difference, so it became the fill and the sizes collapsed. The 44pxheadervariant left entirely; those arePrimarynow.)label(aria) is required;tooltip/shortcutwire the standard hover pill;activeis the contrast fill and the only way to say “pressed” (It used to be--bg3, which is also the hover colour, so a toggle that was on looked like a toggle you happened to be pointing at). Two result states, not toggles:okstates itself as--success-fillwith a white glyph,disableddrops to 35% so the shape stays readable as a control. Glyph size is pinned in the CSS (16px, 14 on a ghost) so no call site can drift. Fills beat nothing; states beat fills. A fill and a state are the same specificity, so the state rules are declared after the fills — a pressed button is pressed whatever it is sitting on. Load-order rule: component CSS bundles afterapp.css, so a context override must out-specify the base (add an ancestor class) — never rely on file order.Primary(Primary.tsx/css) — the app frame’s button, and the one type behind every instance of it: the header’s nav pills and round icon buttons, the sidebar toggle, the hero call to action, the stop button. One height (44), one padding (0 18),--fs-14, a 16px glyph pinned in the CSS, radius 999 — a circle is this button with no visible label, so the pill simply closes up to its own height and the name lives inaria-label. Only the fill varies, and each one means something:card(the rest fill, on the background) ·quiet(--bg2, one surface up — the dismissive half of a pair, and any frame button standing on a card) ·contrast(“you are here”) ·accent(the call to action, at most one per screen) ·danger(the armed half of an arm-then-confirm).activesets the contrast fill andaria-currenttogether — a route-active button that forgets one of the two is the bug the prop exists to prevent. Half the family are links, sohrefrenders an<a>. (Added, replacing six implementations across four files:.circle,.circle.active,.header-pill,.btn-accent,.stop-btn.armedand the status circle’s hand-rolled anchor.)Segmented(Segmented.tsx/css) — the.view-togglepill track, radiogroup semantics,''value = nothing selected (lens took over). Takes the samecard/quietfill as every other type, picked by its ground. It is a tab group, not a button — the track, not the segment, owns the ground, which is why it has its own section on the design-system page.Badge(Badge.tsx/css) — every small rounded label.kind: default · cmd (--c-command— the same token the commands lens, thecat-cmdtool dot and them-cmdsspine count use; it was black, which is the emphasis fill and said nothing about what it is) · summary (blue) · failed (error fill) · model (mono) · tool (brand-filled agent badge —AgentBadgewraps it with the registry color and mark). Interactive badges (Stats’ compaction jump) reuse the classes on a<button>. Akindmust earn its keep: there was anattachvariant that restated the base exactly, and attachment markers now use the default badge. Anopenkind went the same way — declared and styled for “this one is current”, rendered nowhere. There is one badge size. A kind changes the wash, the family, and the casing — never the metrics. (app.csshad been overriding the sidebar model badge’s size, color, background and padding plus two row-state rules, so the same component rendered four ways and no longer matched the agent badge beside it. A briefsmprop replaced those overrides and was then dropped too — one size is the rule.) The only thing a screen may say about a badge is its ground:.side-item.active .badgesteps to the card tone because a--bg2badge would vanish into the active row’s wash. The agent badge deliberately does not carry the.badgebase class — it replaces the fill outright, and standing apart keeps ground rules from overwriting a brand color. An icon inside a badge is1em, never asizeprop: it derives from--fs-*and cannot drift out of step.SearchField(SearchField.tsx/css) — every search/filter input; wrapper carries surface + focus ring.sm= inset bg1 row (sidebar filter, in-session find bar, spend filter — the last takes the card tone by context on the bare header),lg= card-surface query box (search screen, home hero via context height override). Optional magnifiericonandclearable×. Form fields (annotate panel) and the palette input are NOT SearchFields.Button(Button.tsx/css) — one shape: the gray inset.pill, the quiet screen-level action (CSV/JSON exports, “This week”, maintenance). Takes the samecard/quietfill as the rest. (The compact pair that lived here —.btnand.btn.primaryfor popover actions — turned out to bePrimarywearing smaller metrics, so the share panel’s copy/download became Primary fills and both variants were deleted.)TextArea(TextArea.tsx/css) — the multi-line field, and the inset sibling of thesmsearch field: same ground, radius and focus ring, so a form mixing the two reads as one set. Vertical resize only — a text field is not a layout control. (Added ; the session-note field was a bare<textarea>styled fromapp.css.)Facts(Facts.tsx/css) — label/value pairs, stacked: the one way a set of measurements is presented, in tooltips (calendar blocks, spend bars, the sidebar info button) and on cards alike. Ink comes fromcurrentColor, so the same component reads on a contrast pill and on a card. Run-together fact lines (10:37–16:02 · 2,355 turns · 504k tok) are the thing it replaced — added when the sidebar’s stacked layout won.TagEditor(TagEditor.tsx/css) — badges + the field that adds them. Every control is a system primitive: the badge is aBadgeat its one size, the × and + are ghostIconButtons shrunk by ancestor-scoped overrides, the input is a realsmSearchField. (Its first version hand-rolled all three and was rebuilt the same day — the cautionary tale for “closest markup at hand” over checking#/design-systemfirst.)NowCard(NowCard.tsx/css) — the home “what is running now” card; renders only while something is, labelled mono figures on the right.Tooltip/Dropdown/Overlay/NoteDotown their CSS files too (NoteDot.cssincludes the.tooltip.note-tipsticky variant it requests).Overlayis the shared scrim behind the command palette and the shortcuts sheet, and it owns backdrop-click and Escape dismissal for both.
app.css:
.card— white, r24, flat. Variants:.dark-card(contrast surface, colored data dots),.accent-card(vermilion,--accent-on*ink, one big number, an.icon-btn.onaccentlink circle),.list-card(title + rows).- (
.btn-accentwas the vermilion CTA pill; it isPrimary fill="accent".) .tile— rounded-square project mark with initial; a white glyph on one of 8 categorical hues (--tile-0…7) chosen by project-key hash (tileClassinformat.ts), so a project keeps its color everywhere. The eight are validated as a categorical set (all-pairs CVD ΔE 11.8, white-text ≥ 3.49:1) and theme-invariant; the hash follows the key, never list position, and the initial + name are the secondary encoding past 8 projects..dot— 8px data dots labeling numbers and legend entries.- Lens legend — the four session dimensions own fixed colors everywhere
they appear (pills, spine summary counts, tool dots): diffs =
--c-diffteal, commands =--c-command, errors =--danger, prompts =--c-user. Only two need a hue of their own: a prompt is the user and an error is a failure, so those borrow. Text-safe variants (--c-diff-tx,--c-command-tx) exist for small type on light grounds. Skeleton.tsx(.skel,SkeletonLines,SkeletonRows) — shimmer placeholders replace all “loading…” text: spine turn bodies, log initial load, sidebar list, home numbers (.skel-onaccent/ dark-card variants), search results. Honorsprefers-reduced-motion..match-bar— floating contrast pill (popovers/menus and it share the allowed overlay shadow)..find-bar— in-session find (Cmd/Ctrl-F), inset pill input in the replay header; drives the shared?q=state..error-nav— floating bottom-right pill (white, error-tinted border, vermilion count) cycling failing results;.you-are-here— floating current-turn breadcrumb pill atop the log view..turn-n— spine turn number in a 30px circle: gray idle → contrast when the turn is open. Errors mark the outline number vermilion..status-dot—--successidle, vermilion pulsing while indexing, inside a Primary circle. Unseen release notes ring that circle in the note yellow — a state of the status button alone, which is why it lives inapp.cssand not in the component.
6. Screen anatomy
- Header (on the bg, not a bar): sidebar-toggle circle · black brand circle + two-tone wordmark · right: Files and Spend route pills, then the search, theme, status, and stop circles. With the sidebar open, toggle + brand live inside the sidebar card instead. Directional marks are Solar alt-arrow chevrons (vendored like the rest of the icon set) — never text arrows.
- Micro-motion voice — five durations, each with a job, nothing between
them: 70ms press-down scale · 140ms soft overlay entrances (tooltip and
the lifted note, fade-slide 4px from the anchor side) · 150ms ease, the
default for every hover (color, background, opacity, filter) · 200ms
soft for a control’s own transform (the sort-direction flip) and the
sidebar’s opacity · 260ms soft for the sidebar slide. “Soft” is
cubic-bezier(0.25, 0.8, 0.3, 1). (Consolidated from eleven values.) - Home: hero (two-tone headline + search input + the accent Primary) → bento grid: black “Indexed history” card (three dotted numbers) · vermilion “Est. spend” card · “Recent sessions” list card (tiles, badges, ↗ circles) · “Projects” list card · the full-width index-health band.
- Sidebar: one controls row — quick-filter
SearchField+ a tuningIconButton+ count. Project/sort/direction/empty live in a filter popover under that button (full sidebar width,pop-row/pop-labelchrome — the same vocabulary the share panel uses; it had a duplicateshare-row/share-labelspelling); an accent dot on the button + a reset link flag active hidden filters. Below: the session list (tile, name, cost, sub-line, agent + model badges). - Replay: white header card (back circle, title, badges,
spine|log|filestoggle, lens pills with legend dots, stats pill) → spine (outline card + turn list card), log (single virtualized card), or files (touched-file list card + cumulative per-file diffs card). Match bar floats bottom-center; error-nav pill bottom-right. - Search: pill input (vermilion focus ring), meta line with the cost-of-
this-work aggregate, white group cards with rounded hit rows, blue
--markhighlights. - Spend (
#/spend): headline total, single-series ink bar chart (hover in accent, contrast tooltip, daily|weekly granularity toggle in the card head), split list cards, prompt-caching dark card. - Calendar (Spend view): week timeline of
--bg1day rows (time runs across; sessions are horizontal project-tile-colored blocks at real times, stacking into lanes when they overlap), and a month grid with per-day cost-heat cells + project dots; black today-circle;Tooltipon both. Tooltip(components/Tooltip.tsx) — portal contrast pill, clones its single child to attach hover/focus; replaces nativetitle. Used for rich content (calendar blocks/cells, spend bars) and for labelling icon-only buttons (header sidebar/theme/status circles, replay back/download, error & match nav arrows, calendar prev/next, sidebar sort direction). Text buttons carry their own label — no tooltip.
7. Rules of thumb
- Never introduce a new color for a new feature; find the meaning it carries
and reuse the token that owns that meaning. Two sanctioned identity
palettes exist beside the semantic tokens: the 8 project tile hues, and the
agent palette (
--agent-*, one per adapter — Anthropic clay#de7356, OpenAI green#10a37f, registry inweb/src/agents.ts). These are each adapter’s true brand hue, not a darkened derivative — a colour that identifies something stops identifying it once you adjust it. Both sit under the 3.5:1 white-text bar as a result, so the badge label carries the load, the way a project tile’s letter does. Each adapter also carries its brand mark, vendored as path data inicons.tsxand drawn incurrentColor— so an agent badge always shows two encodings, the mark for recognition and the word for certainty. A new adapter is one--agent-*token, one mark, and one registry entry. (The marks are third-party trademarks used nominatively — credited inweb/public/CREDITS.txt, not MIT, not endorsements.) They fill the uppercase agent badges, and in the calendar the color-by toggle decides: the chosen dimension (project or agent) fills each block and the other becomes its 3px edge stripe — both encodings always present. - Dark mode is a token swap, not a redesign — if a component needs theme-specific CSS beyond tokens, the tokens are wrong.
- Focus rings: accent-tinted outline (
:focus-visible), never removed without a visible replacement (.hero-searchuses:focus-withinon the wrapper). - Before adding a value, look for the one that already exists. Every
drift this document has had to correct started as one reasonable local
choice: a 0.04em label, an 11px
<code>, a 130ms hover. The specimen page (#/design-system) exists so the existing answer is one keystroke away. - Screenshots for review:
?theme=light|darkforces a theme (used by headless Chrome verification).
8. Where the system lives
The specimen page renders the real primitives and reads token values off the
DOM at runtime — the overlays even open for real, since an overlay is fixed
to the viewport and cannot be shown in place — so it cannot drift. This
document explains why; it can drift, and it has: if the two disagree, the
page is right.
One rule runs through every type.
Primary, IconButton, Button and
Segmented all pick their rest fill from the ground they stand on rather
than from how important they are: card on the bare app background, quiet
(--bg2) on a card, inset (--bg1) one surface further down. That is why
app.css no longer carries .spend-head .view-toggle, .calendar-head .pill, .search-view, .sidebar-brand .circle or .annotate-save — a
control now says where it stands where it is written.