Skip to main content

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

  1. 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.
  2. Separation by tone, not shadow. Cards are flat white on light gray (--card on --bg0); insets are gray-on-white (--bg1/--bg2 inside cards). box-shadow is reserved for true overlays — dropdown menus and the floating match bar. Nothing else casts a shadow.
  3. 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.)
  4. 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.
  5. 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.
  6. 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 29 is 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 of app.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-block and 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-xs 9 (tile-xs, inline code) · --radius-sm 12 (list rows, controls, inset fields, tile-sm) · --radius 14 (tiles, code blocks, diffs, sidechain runs) · --radius-md 16 (panels, popovers, menus, stat tiles) · --radius-lg 24 (cards). (--radius had 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-mark 4 — 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: --card on the --bg0 canvas, rounded on all four sides (--radius-lg), inset --gutter from 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-solid bar 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-note dot 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. The action variant 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-note only 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 --bg1 ground. 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 --bg2 all but disappears in the light theme), inset (--bg1, one surface down, for controls already standing on --bg2), and ghost — 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 44px header variant left entirely; those are Primary now.) label (aria) is required; tooltip/shortcut wire the standard hover pill; active is 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: ok states itself as --success-fill with a white glyph, disabled drops 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 after app.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 in aria-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). active sets the contrast fill and aria-current together — a route-active button that forgets one of the two is the bug the prop exists to prevent. Half the family are links, so href renders an <a>. (Added, replacing six implementations across four files: .circle, .circle.active, .header-pill, .btn-accent, .stop-btn.armed and the status circle’s hand-rolled anchor.)
  • Segmented (Segmented.tsx/css) — the .view-toggle pill track, radiogroup semantics, '' value = nothing selected (lens took over). Takes the same card/quiet fill 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, the cat-cmd tool dot and the m-cmds spine 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 — AgentBadge wraps it with the registry color and mark). Interactive badges (Stats’ compaction jump) reuse the classes on a <button>. A kind must earn its keep: there was an attach variant that restated the base exactly, and attachment markers now use the default badge. An open kind 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.css had 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 brief sm prop 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 .badge steps to the card tone because a --bg2 badge would vanish into the active row’s wash. The agent badge deliberately does not carry the .badge base class — it replaces the fill outright, and standing apart keeps ground rules from overwriting a brand color. An icon inside a badge is 1em, never a size prop: 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 magnifier icon and clearable ×. 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 same card/quiet fill as the rest. (The compact pair that lived here — .btn and .btn.primary for popover actions — turned out to be Primary wearing 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 the sm search 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 from app.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 from currentColor, 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 a Badge at its one size, the × and + are ghost IconButtons shrunk by ancestor-scoped overrides, the input is a real sm SearchField. (Its first version hand-rolled all three and was rebuilt the same day — the cautionary tale for “closest markup at hand” over checking #/design-system first.)
  • 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/NoteDot own their CSS files too (NoteDot.css includes the .tooltip.note-tip sticky variant it requests). Overlay is the shared scrim behind the command palette and the shortcuts sheet, and it owns backdrop-click and Escape dismissal for both.
Screen-level pieces still in 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.onaccent link circle), .list-card (title + rows).
  • (.btn-accent was the vermilion CTA pill; it is Primary 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 (tileClass in format.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-diff teal, 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. Honors prefers-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 — --success idle, 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 in app.css and 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 tuning IconButton + count. Project/sort/direction/empty live in a filter popover under that button (full sidebar width, pop-row/pop-label chrome — the same vocabulary the share panel uses; it had a duplicate share-row/ share-label spelling); 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|files toggle, 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 --mark highlights.
  • 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 --bg1 day 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; Tooltip on both.
  • Tooltip (components/Tooltip.tsx) — portal contrast pill, clones its single child to attach hover/focus; replaces native title. 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 in web/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 in icons.tsx and drawn in currentColor — 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 in web/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-search uses :focus-within on 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|dark forces 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.