--- name: polyrouter description: One endpoint for every model — the self-hosted router's operator console. colors: accent: "#4f5dff" accent-bg: "color-mix(in oklab, #4f5dff 9%, #ffffff)" accent-deep: "color-mix(in oklab, #4f5dff 78%, #17181a 22%)" accent-fill-hover: "color-mix(in oklab, #4f5dff 78%, #17181a 22%)" accent-fill-pressed: "color-mix(in oklab, #4f5dff 66%, #17181a 34%)" bg: "#fafafa" panel: "#ffffff" border: "#ececea" border2: "#f1f1ef" hover: "#f4f4f2" chip: "#f1f1ef" border-control: "#898b92" text: "#17181a" text2: "#55575e" text3: "#6a6c73" faint: "#b4b6bc" green: "#3bb273" green-text: "#22794c" green-bg: "#e9f6ef" amber: "#c8901a" amber-text: "#8a5d00" amber-bg: "#fbf3dc" red: "#e5484d" red-text: "#b42318" red-bg: "#fdecec" red-fill: "#b42318" red-fill-hover: "color-mix(in oklab, #b42318 85%, #17181a 15%)" red-fill-pressed: "color-mix(in oklab, #b42318 70%, #17181a 30%)" accent-bg-dark: "color-mix(in oklab, #4f5dff 9%, #16171b)" accent-deep-dark: "color-mix(in oklab, #4f5dff 78%, #f0f1f4 22%)" bg-dark: "#0e0f12" panel-dark: "#16171b" border-dark: "#26272d" border2-dark: "#1f2025" hover-dark: "#1c1d22" chip-dark: "#22232a" border-control-dark: "#6b6d76" text-dark: "#f0f1f4" text2-dark: "#a6a8b0" text3-dark: "#898b93" faint-dark: "#4e5058" green-dark: "#46c482" green-bg-dark: "#12291d" amber-dark: "#e7b03c" amber-bg-dark: "#2e2712" red-dark: "#f2555a" red-bg-dark: "#391a1c" red-fill-dark: "#c4342b" red-fill-hover-dark: "color-mix(in oklab, #c4342b 85%, #17181a 15%)" red-fill-pressed-dark: "color-mix(in oklab, #c4342b 70%, #17181a 30%)" green-text-dark: "#46c482" amber-text-dark: "#e7b03c" red-text-dark: "#f2555a" typography: display: fontFamily: "Geist, sans-serif" fontSize: "26px" fontWeight: 600 lineHeight: "32px" letterSpacing: "-0.03em" fontFeature: "tnum" title: fontFamily: "Geist, sans-serif" fontSize: "15px" fontWeight: 600 lineHeight: "20px" letterSpacing: "-0.01em" wordmark: fontFamily: "Geist, sans-serif" fontSize: "15px" fontWeight: 600 lineHeight: "20px" letterSpacing: "-0.02em" section: fontFamily: "Geist, sans-serif" fontSize: "13px" fontWeight: 600 lineHeight: "20px" body: fontFamily: "Geist, sans-serif" fontSize: "13px" fontWeight: 400 lineHeight: "20px" body-control: fontFamily: "Geist, sans-serif" fontSize: "13px" fontWeight: 400 lineHeight: "16px" button: fontFamily: "Geist, sans-serif" fontSize: "13px" fontWeight: 500 lineHeight: "16px" body-dense: fontFamily: "Geist, sans-serif" fontSize: "12px" fontWeight: 400 lineHeight: "16px" label: fontFamily: "Geist, sans-serif" fontSize: "12px" fontWeight: 500 lineHeight: "16px" caption: fontFamily: "Geist, sans-serif" fontSize: "11px" fontWeight: 400 lineHeight: "16px" overline: fontFamily: "Geist, sans-serif" fontSize: "11px" fontWeight: 500 lineHeight: "16px" letterSpacing: "0.06em" mono: fontFamily: "Geist Mono, monospace" fontSize: "12px" fontWeight: 400 lineHeight: "16px" mono-sm: fontFamily: "Geist Mono, monospace" fontSize: "11px" fontWeight: 400 lineHeight: "16px" rounded: mark: "4px" control: "6px" surface: "10px" sheet: "16px" spacing: "1": "4px" "2": "8px" "3": "12px" "4": "16px" "5": "20px" "6": "24px" "8": "32px" card: "16px" card-narrow: "12px" page: "24px" page-narrow: "16px" cell: "0 16px" drawer-head: "16px 20px" drawer-body: "20px" modal: "24px" components: button-primary: backgroundColor: "{colors.accent}" textColor: "#ffffff" typography: "{typography.button}" rounded: "{rounded.control}" padding: "7px 13px" height: "32px" button-primary-hover: backgroundColor: "{colors.accent-fill-hover}" button-primary-pressed: backgroundColor: "{colors.accent-fill-pressed}" button-secondary: textColor: "{colors.text2}" typography: "{typography.button}" rounded: "{rounded.control}" padding: "7px 13px" height: "32px" button-secondary-hover: backgroundColor: "{colors.hover}" button-ghost: textColor: "{colors.text2}" typography: "{typography.label}" rounded: "{rounded.control}" padding: "3px 10px" height: "24px" button-ghost-hover: textColor: "{colors.accent-deep}" button-ghost-destructive: textColor: "{colors.red-text}" typography: "{typography.label}" rounded: "{rounded.control}" padding: "3px 10px" height: "24px" button-ghost-destructive-hover: backgroundColor: "{colors.red-bg}" button-destructive: backgroundColor: "{colors.red-fill}" textColor: "#ffffff" typography: "{typography.button}" rounded: "{rounded.control}" padding: "7px 13px" height: "32px" button-destructive-hover: backgroundColor: "{colors.red-fill-hover}" button-destructive-pressed: backgroundColor: "{colors.red-fill-pressed}" input: backgroundColor: "{colors.bg}" textColor: "{colors.text}" typography: "{typography.body-control}" rounded: "{rounded.control}" padding: "7px 11px" height: "32px" panel: backgroundColor: "{colors.panel}" rounded: "{rounded.surface}" padding: "{spacing.card}" chip: backgroundColor: "{colors.chip}" typography: "{typography.caption}" rounded: "{rounded.control}" padding: "2px 8px" height: "20px" nav-item: textColor: "{colors.text2}" typography: "{typography.body-control}" rounded: "{rounded.control}" padding: "7px 9px" height: "32px" nav-item-active: backgroundColor: "{colors.accent-bg}" textColor: "{colors.accent-deep}" segmented-option: textColor: "{colors.text3}" typography: "{typography.body-dense}" rounded: "{rounded.mark}" padding: "4px 11px" height: "26px" segmented-option-selected: backgroundColor: "{colors.accent-bg}" textColor: "{colors.accent-deep}" toggle-on: backgroundColor: "{colors.text}" rounded: "{rounded.surface}" width: "36px" height: "20px" toggle-off: backgroundColor: "{colors.border-control}" rounded: "{rounded.surface}" width: "36px" height: "20px" modal: backgroundColor: "{colors.panel}" rounded: "{rounded.surface}" padding: "{spacing.modal}" width: "480px" toast: backgroundColor: "{colors.text}" textColor: "{colors.bg}" typography: "{typography.button}" rounded: "{rounded.surface}" padding: "8px 16px" height: "32px" --- # Design System: polyrouter ## Overview **Creative North Star: "The Switchboard"** The polyrouter dashboard is an operator's switchboard. Every request comes in at one point and leaves along one route, and the board's job is to show which route was lit and why. The brand mark sums it up at 20px: one source node fans out to three routes, and one route is lit in Ink Indigo while the other two stay grey. The system repeats that grammar wherever traffic is explained. Above all, it shapes the **Route Trace**, the signature component that draws a request's real path through the router. Everything else stays quiet so the lit route can be read. Near-white panels sit on a flat board divided by 1px hairlines, and greys carry the structure. Indigo is spent only on what is active, selected or focused (The Indigo Budget Rule). Figures are honest before they are pretty. Every number is either exact or says what it is instead: estimated, unpriced, pending, stale or reserved (see *Figures and their states*). Density is compact because operators scan rather than read, and type grows only where a number is the point. Controls are crisp and tactile. They are small and exact, and they share one height family. Every state (hover, pressed, selected, focus, busy, invalid) is shown as a tone, border or label change, never as lift or glow. On a first visit the theme follows the operating system. Dark mode mirrors every token and drops every shadow. **How this file relates to the code.** DESIGN.md is the target: it describes what the dashboard should ship next, not only what ships today. Where target and code differ, the **As-built delta** table below names the gap and the OpenSpec change that closes it. Until that change lands, two things stay authoritative: the as-built value in `packages/frontend/src/styles.css` (for code) and the locked axes in `STYLESEED.md`. Each implementing change updates `STYLESEED.md`, `styles.css` and the contract tests together, then removes its rows from the delta. **Key Characteristics:** - One brand hue, spent deliberately. Each view gets one indigo-filled control and one indigo data series. Washes, rings and indigo text mark state. - The Route Trace draws a request's path in the brand mark's grammar. - Honest figures: each state has one treatment, and the state is always named in words. - Flat surfaces. Hairlines separate them. Shadows are faint in light mode and absent in dark mode. - One control-height family (24 / 32px, and 44px under a coarse pointer) and five type sizes (11 / 12 / 13 / 15 / 26px). - Geist for words and numbers (numbers use tabular figures). Geist Mono for identifiers only. - Snap motion, and only state moves: the Live lamp blinks once per event, and the only loops are in-progress pulses and skeletons. ### As-built delta | Target (this file) | As built today | Closed by | |---|---|---| | First-token commit marker on the served node | Not recorded by the proxy, so the marker is never drawn | Follow-up: commit-timing telemetry (request logging) | | Per-request deep link (`#/requests/`) | Not supported: no owner-scoped read-by-id, and the fragment grammar is page-only | Follow-up: request detail read + deep link | | Consent confirmations share the dialog's busy and in-dialog failure | The body-capture consent dialog closes before it saves, with no busy state, and reports a failure on the card | Follow-up: consent dialog on the shared confirm structure | | Failures appear inline, with `role="alert"` | Several failures surface as toasts | Follow-up: inline failure homes | | The focal readout figure compares spend against a budget (`of $25.00`) | Spend for the range only | Follow-up: budget comparison on Overview and Costs | | A permanently unavailable action is not rendered | Some show as disabled | Follow-up: permission-aware actions | | Cancelling and Reconciling pulse an Ink 3 dot; Finishing shows a steady Ink 3 dot (Lifecycle states) | Cancelling pulses amber, kept as a status hue by `adopt-design-tokens-v2` (design.md D11, task 7.2); Finishing (settling) and Reconciling show a Faint dot | Follow-up: lifecycle dot tones | | Relative time in request lists (`12m ago`; absolute on hover, on focus and in the inspector) | Absolute `HH:MM:SS` in the list and the step announcement | Follow-up: relative time in request lists | | Unverified notice copy: `Routing may have changed and the refresh failed.` | `Last loaded targets — routing may have changed; see Targets` | Follow-up: unverified-notice copy | | Newer-rows offer reads `12 new` | `N new · load` | Follow-up: newer-rows copy | | Aggregate coverage: estimate-priced spend includes `listed` pricing; the unpriced count includes attempts | Omitted from the summary | Follow-up: aggregate coverage (needs an API change) | | Money grouping follows the browser locale | Not adopted: pinned contract strings fix today's format | Follow-up: locale formatting (needs a spec change) | | The Users Members email takes its own line at table width | Just above `table-fit-users` the Members email is cut to nothing, leaving the name and `(you)` | Follow-up: Members email line | | Overview's provider dots state their health in visible words (Status-Speaks Rule) | The state word (`healthy`, `failing`, `not tested`) is only each dot's accessible name and `title` | Follow-up: provider-dot state words | ## Colors The palette uses a single accent. One indigo signal sits over warm-cool greys, and three status hues are kept strictly for state. ### Primary Ink Indigo has four roles, and each has its own token so that no role borrows another's contrast: | Role | Token | Light | Dark | Used for | |---|---|---|---|---| | **Fill** | `{colors.accent}`, ≈ oklch(56.9% 0.237 272.4) | #4f5dff | #4f5dff | The primary button, the focus ring, the Route Trace's lit branch, and the view's one focal data series | | **Fill hover** | `{colors.accent-fill-hover}` | ≈ #414fc7 | ≈ #414fc7 | Primary hover. It darkens in **both** themes, so white text stays at 6.63:1. | | **Fill pressed** | `{colors.accent-fill-pressed}` | ≈ #3947aa | ≈ #3947aa | Primary pressed: one step darker again (white 7.91:1). | | **Text** (Deep Ink) | `{colors.accent-deep}` | ≈ #414fc7 | ≈ #6c83ff | All indigo text and hover edges: links, the selected label, icon and edge, ghost-button and endpoint-chip hover. In dark mode it is mixed toward the near-white Ink, so it gets *lighter*. | | **Wash** | `{colors.accent-bg}` | ≈ #edf2ff | ≈ #1a1e2d | The selected-state fill, and the area under the focal series. A 9% mix into the panel. | In CSS, write each derived token as a `color-mix(in oklab, var(--accent) …)` expression and never as the hex. The lock forbids hard-coded accent values. ### Neutral - **Canvas** (`{colors.bg}` / dark `{colors.bg-dark}`): the page behind the panels. Inputs sit on it too, so a field reads as a well cut into the panel. - **Panel White** (`{colors.panel}` / dark `{colors.panel-dark}`): every card, drawer, modal and docked panel, and the segmented control's track. It is also the toggle knob. - **Hairline** (`{colors.border}`): the 1px line that separates *surfaces*: panels, the top bar, table frames and buttons. It is a separation line, not a control boundary. - **Control Edge** (`{colors.border-control}` / dark `{colors.border-control-dark}`): the boundary of every **entry control** whose edge is its affordance and drawn by the stylesheet. That covers inputs, selects, textareas, comboboxes, filter chips and the off toggle track. It clears 3:1 on every neutral surface: canvas, panel, chip and hover. Entry controls never sit on a status wash, where it drops below 3:1. Checkboxes and radios stay native: the browser draws their edge under the theme's `color-scheme`, and it is held to 3:1 by measuring the rendered pixels, not drawn in Control Edge. - **Soft Hairline** (`{colors.border2}`): dividers *inside* a panel: table rows, receipt rules, drawer header rules and chart grid lines. - **Hover Tone** (`{colors.hover}`) and **Chip Tone** (`{colors.chip}`): one-step tonal lifts. Hover Tone fills hovered rows and nav items. Chip Tone fills static chips, skeletons, pressed states, the Info notice and disabled fields. - **Ink** (`{colors.text}`): primary text and figures, and the toggle's on track. It is near-black (#17181a), never pure black. - **Ink 2** (`{colors.text2}`): row text, secondary controls and field labels. - **Ink 3** (`{colors.text3}`): stat labels, captions, table heads and axis ticks, plus **every glyph that carries meaning**, such as the copy icon, chevrons and close buttons. It is the lowest-contrast token allowed for readable text. - **Faint** (`{colors.faint}`): **decorative marks only**, such as the idle nodes in the brand mark or a dot that sits next to a word saying the same thing. At 1.79–2.38:1 it can't carry meaning on its own. ### Status (semantic, not brand) - **Signal Green / Amber / Red** (`{colors.green}`, `{colors.amber}`, `{colors.red}`): fills for dots, bars, borders and chips. On white they read 2.7–3.9:1, so they never carry small text in light mode. - **Status Text** (`{colors.green-text}`, `{colors.amber-text}`, `{colors.red-text}`): darkened variants that clear 4.5:1 for status words such as "OK", "Live" and "free". In dark mode the fill already clears 4.5:1, so the text variant equals the fill. - **Status Washes** (`{colors.green-bg}`, `{colors.amber-bg}`, `{colors.red-bg}`): quiet backgrounds for status notes and alerts. - **Red Fill** (`{colors.red-fill}` / dark `{colors.red-fill-dark}`): the commit button of a destructive confirm dialog, and nothing else. White text on it is 6.57:1 (light) and 5.42:1 (dark), and the fill clears 3:1 against the panel in both themes. The dark value is lighter than the light one because `#b42318` sinks to 2.72:1 on the dark panel. ### Contrast floors (WCAG 2.2 AA; light / dark) Each pairing the system prescribes is measured against its floor: 4.5:1 for text, 3:1 for control boundaries and state indicators. A new pairing gets added here, and to the contrast suite, before it ships. | Pairing | Ratio (light / dark) | Floor | |---|---|---| | Ink · Ink 2 · Ink 3 on panel | 17.77 / 15.86 · 7.21 / 7.55 · 5.24 / 5.27 | 4.5 | | Ink 3 on its weakest surface (chip) | 4.64 / 4.60 | 4.5 | | Indigo text (`accent-deep`) on panel · wash · chip | 6.63 / 5.40 · 5.92 / 4.99 · 5.86 / 4.72 | 4.5 | | White on the primary fill · hover · pressed | 4.84 · 6.63 · 7.91 (both themes) | 4.5 | | White on Red Fill · hover · pressed | 6.57 · 7.81 · 9.30 / 5.42 · 6.62 · 8.12 | 4.5 | | Control Edge vs panel · canvas · chip · hover | 3.40 / 3.47 · 3.26 / 3.72 · 3.01 / 3.04 · 3.09 / 3.26 | 3 | | Focus ring vs any surface it can touch, status washes included | ≥ 4.23 / ≥ 3.07 | 3 | | Toggle on track vs off track · knob vs either track | 5.22 / 4.56 · ≥ 3.40 / ≥ 3.47 | 3 | | Selected border (`accent-deep`) vs wash | 5.92 / 4.99 | 3 | | Status text on its wash | 4.84–5.76 / 4.65–7.54 | 4.5 | | Faint on any surface | 1.79–2.03 / 1.95–2.38 | decorative only | ### Named Rules **The Indigo-Text Rule.** Text is never set in `--accent`. Indigo text, labels and icons use `accent-deep`, which reaches 5.86:1 or better in light mode and 4.72:1 or better in dark on every neutral surface (panel, canvas, chip, hover, wash). It never sits on a status wash, where dark Amber Wash drops it to 4.47:1. Raw accent text fails in dark mode (3.70:1) and on light washes (4.32:1). **The 3:1 Edge Rule.** Anything whose edge *is* the affordance has an edge of at least 3:1. An empty field, a filter chip and an off switch draw it in Control Edge; a native checkbox or radio keeps the edge the browser draws, which is measured to clear 3:1. A state is shown by a change in shape, border or weight that clears 3:1, never by a tint alone. The selected wash is only 1.07–1.16:1 against its surroundings, so it never carries the state by itself. **The Indigo Budget Rule.** Indigo is spent, not sprinkled. Each view gets: - **One indigo-filled control:** the primary action. Every other action is a neutral button, and switches are Ink. - **One indigo data series:** the view's focal measure. Examples: the Pipeline's bars, the Limits spend line, the request mix's two intensities, the Route Trace's lit branch. Every other series is Ink or Ink 3, told apart by dash pattern and direct labels. - **State marks without limit:** the focus ring, the focused input border and the caret; Deep Ink text and hover edges (links, selected labels, ghost and endpoint-chip hover); the Selected wash; and `accent-color` on a checked native checkbox or radio. These mark state, so they don't count against the budget. - **Identity:** the brand mark's lit route. It is the product's signature, not spend. A docked panel and the page beside it count as one view. Running dots, hover fills, icon chips and decorative accents are never indigo. A second accent hue breaks the board. **The Status-Speaks Rule.** Green, amber and red mean state, with one exception: red also marks an action that destroys data, abandons in-flight work, or cuts off access (Delete, Remove, Purge, Disable, Cancel batch). Every status color appears beside words that name the state. Never encode status by color alone, and never set small status text in the fill variant on a light surface. **The No-Rainbow Rule.** Never give each card, series or category its own hue. Identity is carried by text. A categorical palette may arrive only through an explicit change to the `STYLESEED.md` lock (`+categorical`), not one surface at a time. ## Typography **Display Font:** Geist (with sans-serif) **Body Font:** Geist (with sans-serif) **Label/Mono Font:** Geist Mono (with monospace) **Character:** One neutral grotesque carries words *and* numbers at small, exact sizes. Its mono sibling marks only the identifiers a machine produced. Both fonts are bundled locally (`/fonts/fonts.css`), and the dashboard makes no third-party font fetches. ### Scale Five sizes: **11, 12, 13, 15 and 26px**. That's a small step ratio for dense data, plus one deliberate jump for the focal figure. Line heights sit on the 4px grid. Sizes are authored in `rem` (px ÷ 16) so that a user's font-size setting scales the text, and the values above are what a default root renders. | Size | Roles | |---|---| | **26 / 32px** | **Display** (600, −0.03em, tabular figures): the view's one focal figure, e.g. a page's headline spend. It is the largest type in the product. | | **15 / 20px** | **Title** (600, −0.01em): the page title in the top bar (followed by a 12px Ink 3 subtitle on the same baseline), and modal and drawer titles. **Wordmark** (600, −0.02em). Supporting figures in readout cells. | | **13 / 20px** | **Section** (600): card and section titles. **Body** (400): working copy, input text and nav items. Inside a 32px control it takes a 16px line height (`body-control`). Prose sits in a measure of 72ch or less. **Button** (500, 16px line height inside controls). | | **12 / 16px** | **Body dense** (400): table rows, segmented options and top-bar meta. **Label** (500): field labels and ghost buttons. **Mono** (400): identifiers in rows, the endpoint chip, the secret reveal. | | **11 / 16px** | **Caption** (400, Ink 3): help text and sub-lines. **Overline** (500, uppercase, 0.06em, Ink 3): navigation group labels and table column heads. **Mono small**: identifiers inside 11px tags. | **11px is the floor.** No text is smaller. Roles that share a size differ in weight, color or case, never by a half-pixel: - Section 13/600 vs Body 13/400. - Label 12/500 in Ink 2 vs row text 12/400. - Overline 11/500 uppercase vs Caption 11/400. ### Line breaks Headings and short centered blocks use `text-wrap: balance`. Prose uses `text-wrap: pretty`. Never hand-break a heading. ### Named Rules **The Instrument-Scale Rule.** The dashboard's type lives between 11px and 26px, on five sizes. A page title is 15px, not a marketing headline. When something needs emphasis, step up in weight or contrast (Ink 3 → Ink 2 → Ink), not in size. Only the view's one focal figure uses Display. **The Machine-String Rule.** Geist Mono is for identifiers a machine emitted or will parse: URLs, model ids, tier names, headers, tags, key fragments, price versions, decision layers and failure kinds. **Numbers are not identifiers.** Money, counts, durations and percentages are Geist with tabular figures everywhere, from readout cells to table rows. Never set prose or figures in Mono. ## Layout **Spacing scale.** Every space is a step on the 4px grid: 4, 8, 12, 16, 20, 24 or 32px (tokens `1`–`8`). A value off this list is a defect, not a choice. Control padding is the one exemption: it is derived from the height family ((height − line height − 2px border) ÷ 2). Every gap, margin, gutter and offset is on the scale. **Rhythm.** - **Within a group** (label and field, icon and text, chips in a row): 4–8px. - **Between groups** inside a panel: 12–16px. - **Between panels:** 16px. - **Between page sections:** 24px. - **Above a heading:** more space than below it. The tighter space binds the heading to its own content. **Shell.** - A fixed 208px sidebar. - A top bar with a Hairline bottom rule, on the Canvas. - A scrolling page with a 24px gutter. - Pages are built from panels with 16px padding, laid out on grid templates: - two, three or four equal columns; - label plus field (140px | 1fr); - main plus side (1fr | 300px); - an auto-fill grid of cells with a 220px minimum. **Navigation groups.** The sidebar's pages fall into three groups, each under an Overline label: | Group | Pages | |---|---| | **Observe** | Overview, Requests, Costs, Batches | | **Configure** | Routing, Providers, Agents, Limits | | **Admin** | Settings, Users | The Setup card sits below the groups until setup is complete. Overline labels name groups only. They never sit above a heading as an eyebrow. **Control heights.** Every interactive control belongs to one height family, so controls in a row line up because of their size, not because of hand alignment. Each height includes a 1px border on every control (transparent on filled buttons), with `box-sizing: border-box`. | Height | Controls | Padding (block × inline) | |---|---|---| | **32px** (default) | Primary, cancel and destructive-commit buttons; inputs and selects (selects set `height` explicitly); nav items; the segmented track | 7 × 13px (buttons), 7 × 11px (inputs), 7 × 9px (nav items) | | **24px** (small) | Ghost buttons, the endpoint chip, filter chips, the Live pill, inspector Prev / Next; a small select in a row of 24px controls (the Requests agent filter, a Targets cell's picker) | 3 × 10px (ghost buttons), 3 × 8px (small select) | | **26px** | Segmented options, inside the 32px track's 2px padding | 4 × 11px inside a 1px edge | | **20px** | Static chips (tags, which are not controls) and the toggle track (36 × 20px; 32 × 18px small) | 2 × 8px (chips) | | **44px** | Every control under a coarse pointer or below `narrow`, in the block axis. Icon-only controls are also 44px wide. | 13px block | - **Centering:** a toggle, chip or icon inside a taller row is centered in it (`align-self: center`), never baseline-aligned. - **One row, one height:** controls that share a row share a height, so mix only one height class per row. **Tables.** Requests, Batches, Agents and Users share one table spec: - **Rows:** at least 32px tall, with a Soft Hairline between rows and 16px inline cell padding. - **Head row:** 32px, using the Overline role. - **Alignment:** figures are right-aligned with tabular figures. Text and identifiers are left-aligned. - **Overflow:** identifiers truncate (see Long content). Figures never truncate. - **States:** hover takes Hover Tone, and the selected row takes the Selected state. - **Columns** carry a priority, so a narrower container drops detail before it drops a row's meaning: - **Requests — P1, always shown:** time, model, status, cost. **P2:** tokens, latency. **P3:** provider, tier, decided-by. - **Batches — P1:** submitted, model, status, cost, with the row action on line one. **P2:** progress, wall time. **P3:** provider, tier, results-until. - **The agent is never a column.** It is the group boundary above its rows, and it survives every form: one line, both narrowed forms, the docked list and stacked records. - **Narrowing:** below the table's fit width, P3 moves to a second line under the model, then P2 joins it. The table becomes stacked records only below 640px of container width. While the inspector is docked, the list shows P1 only, and a row that can't open (running, finishing, a live batch job) keeps every field on a second line. Below the locked `narrow` threshold (768px viewport), the nav collapses to a 56px icon rail with each label available as the control's name and tooltip. Page grids drop to one column, the gutter becomes 16px, and card padding becomes 12px. That is the only spacing change; controls also grow to 44px and input text to 16px. Wide tables and composite surfaces reflow by **container** width, not viewport width. Container thresholds, re-measured whenever text or control sizes change: - Requests moves its P3 fields to a second line at 980px and its P2 fields at 640px (its one-line and two-line fits); Batches does so at 1000px and 700px. Both become stacked records only below 640px. - Agents reflows at 740px and Users at 660px. - The Targets matrix reflows at 900px. - The Pipeline reflows at 820px (two-line rows) and again at 540px (phone records). - Layers cells stack at 480px. - The subscription picker shows its plan list and the chosen plan's detail as two steps below a 660px card. **Docked inspector.** On the Requests page, at viewports of 1440px and wider, the inspector docks as a 480px panel at the right edge (see Request Inspector), and the page grid gives up that width. The Overview card's inspector stays modal at every width. A request list shows its P1 columns while docked, so the table never falls into stacked records just because the inspector is open. The page and the docked panel count as one view for The Indigo Budget Rule. **The Literal Breakpoint Rule.** Breakpoints are literal numbers inside media and container queries, mirrored in `STYLESEED.md`, because `@media (max-width: var(--x))` is invalid and fails silently. Change the stylesheet and the lock together, never one alone. **The Class-Not-Inline Rule.** Any geometry that changes at a breakpoint lives in an `rs-*` class. An inline `style` outranks every class, so a responsive rule aimed at it parses, matches and does nothing. `responsive.test.ts` guards the known sites. ## Elevation & Depth The system is flat, and separation is drawn rather than cast: - **Lines:** every surface is defined by a 1px Hairline. - **Depth from tone:** the Canvas sits behind Panel White, and inputs drop back to the Canvas. - **Shadows:** only faint whispers (≤ 8% alpha), on panels, popovers, the drawer, toasts and the toggle knob. All of them are `none` in dark mode, where tone alone carries the layers. - **Scrims:** modal overlays dim the page with a near-black scrim, 28% behind the drawer and 32% behind modals. - **Docked inspector:** no scrim and no shadow, only a Hairline on its leading edge. It is part of the board, not a layer above it. ### Shadow Vocabulary - **Whisper** (`box-shadow: 0 1px 3px rgba(20, 20, 25, 0.05)`): the resting panel, barely there beneath the hairline. - **Pop** (`box-shadow: 0 18px 50px rgba(10, 10, 14, 0.08)`): modal cards. - **Drawer** (`box-shadow: -12px 0 32px rgba(10, 10, 14, 0.08)`): the right-hand drawer's leading edge. - **Toast** (`box-shadow: 0 8px 24px rgba(10, 10, 14, 0.08)`): the floating confirmation pill. - **Knob** (`box-shadow: 0 1px 2px rgba(10, 10, 14, 0.08)`): the toggle knob. ### Named Rules **The Hairline Rule.** Separation is always a 1px border: Hairline around a surface, Soft Hairline inside it. Never use a shadow, a heavier border or a filled band to separate surfaces. Tonal fills mark *state* (hover, selected, a notice), never structure. For example, a group-boundary row in a table is an Overline label above a Soft Hairline, not a filled band. Inset notes and key–value boxes (Canvas fill, 6px radius) are the one structural tone: like inputs, they read as wells cut into the panel. **The Dark-Is-Flat Rule.** In dark mode every shadow token is `none`. Depth comes only from the tonal steps from canvas to panel to chip. ## Shapes Corners are gently rounded. There are four radii, and they step down as surfaces nest: | Radius | Token | Used for | |---|---|---| | **16px** | `sheet` | Bottom sheets below `narrow`, top corners only | | **10px** | `surface` | Panels, modals, popovers, toasts, the toggle track (a 20px track makes this an exact capsule) | | **6px** | `control` | Buttons, inputs, selects, nav items, chips, the endpoint chip, inset notes, key–value boxes, the segmented track | | **4px** | `mark` | Segments inside the segmented track, bar tracks and their fills, skeleton blocks, keyboard hints | - **Chips:** at 20px tall and 6px radius they read as soft tags, not pills. - **Circles:** only in status dots, Route Trace nodes, the toggle knob and the brand mark. - **Flush edges:** a surface flush with the viewport has square corners on that side, as the docked inspector does. **The Dashed-Means-Absent Rule.** A dashed *border* means something is absent. There are three cases: not set up yet (the Setup card), not available on this instance (an unavailable switch), and never contacted (a skipped Route Trace attempt). A solid border means present. In charts, a dashed *line* is a reference line (a threshold or ceiling) or a non-focal series, always labelled directly. **The Nested Radius Rule.** An inner element's radius is its container's radius minus the gap between them. A 6px track with 2px padding holds 4px segments; a 10px toggle track holds an 8px knob. Where the padding is larger than the outer radius (a 10px panel with 16px padding), the inner element simply takes its own role's radius. ## Components ### Route Trace (signature) The board's signature: a request's recorded route, drawn in the brand mark's grammar. It answers the product's defining question, "why did this request go there?", and it is the one place the inspector spends indigo. It sits at the top of the Request Inspector, above the verbatim reason row. The trace adds to that row and never replaces it. - **Anatomy (top to bottom):** - A vertical spine in a 32px graph gutter (spine at 4px, attempt nodes at 16px) starts at the **source node**: the agent's name, or the text `(no agent)`, never a raw id. - Next is the **decision chip**, `router · `. Any layer value the dashboard doesn't recognise still renders here as a neutral chip. - Then one **branch per recorded attempt**, in attempt order, ending with the **served branch**. - Labels sit to the right of the gutter. Each row is at least 32px tall, and labels may wrap to two lines. - **The lit route:** the served branch is a 2px Ink Indigo stroke ending in an 8px filled indigo node. Its label is the provider name, then the model id (Mono), then `served · 1.24s`. That is the only indigo in the trace. - **Unlit outcomes:** a failed, cancelled or unrecognised final outcome ends in an unlit node stated in words. It names the recorded provider first when that provider was the last one tried: `OpenAI · gpt-4o · failed · 1.20s`; `OpenAI · failed · 2.10s` for an exhausted chain, whose last attempt already names the model; `OpenAI · gpt-4o · cancelled by client · 800ms`; or the provider, then the recorded value verbatim. Without such a provider it reads `failed · 2.10s` or `gpt-4o · cancelled by client · 800ms`. Only a served request lights its branch. - **Failed attempts:** a 1px `border-control` stroke ends in an 8px hollow node with a `border-control` ring. The label is the model id, then the failure in Red Text, with the kind in Mono: `rate_limit · HTTP 429`, or the kind alone when no status was recorded. Recorded markers follow. In a cascade, the leg tag (`cheap leg` / `escalation`) is appended. - **Skipped attempts (never dispatched):** a 1px dashed Ink 3 stroke ends in a hollow dashed node. The label is the full contract string, **`skipped — circuit open (provider not contacted)`**, never a shortened form. - **Superseded cheap call (cascade):** shown only from what was recorded, e.g. `cheap attempt superseded · q=0.42 · $0.0031`, with no model name until the API exposes one. - **Never drawn:** chain members that were never walked, and anything reconstructed from today's routing config. The trace shows history, never configuration. - **Commit marker:** reserved for `committed at first token · 412ms` on the served node. It appears only when the served row records its commit time, which is not recorded yet (see As-built delta). Never infer it from keepalives or from the early response commit. - **Text first:** the trace is an ordered list (`
    `), and each node's text states its outcome. Strokes, nodes and dashes are `aria-hidden` decoration, and color or dash pattern is never the only signal. - **Edge cases:** - A recovered request shows its failed attempts and its served branch, with no ERROR card. - An error row adds the ERROR note above the trace: an inset note (Canvas fill, 6px radius; never a nested panel) whose headline is the failure in Red Text, e.g. `rate_limit · HTTP 429`, never a blank slot. If every attempt was skipped, the card says the terminal member was never contacted. - A recovered (`fallback`) row with no attempt data shows source, decision and served, with the caption *attempt history was not recorded for this request*. Error and success rows without attempts show no caption: they can't be told apart from legacy rows. - **Motion:** none. The path never animates in, including when stepping between rows. ### Price Receipt This block states what the request cost and on what terms. It sits under the Route Trace. - **Header:** `Price snapshot` (Section style), with an Ink 3 caption. For a normal request: *Recorded with the request — later price changes never apply.* For a batch request: *Batch rate, recorded at submission.* Never claim pricing at request issue time: the snapshot is taken when the request completes, or when a batch is submitted. - **Rows:** a key–value list split by Soft Hairline rules. - Input and output rows always render. A cache-read or cache-write row renders only when its count or its snapshot is recorded, showing the unit price alone when the count is null. Each reads count × unit price, with at most six significant digits on the price (`$3 / 1M`, `$0.1 / 1M`). Each marker follows its own figure: `310 · est. usage × $15 / 1M · est.` - A `source` row, e.g. `bundled`, `native family · estimate` or `provider-listed · estimate`. - An `attempts` row when earlier attempts cost money. - The **total**, above a Hairline rule, at 13px/600. It equals served cost plus attempt cost, computed exactly in micro-dollars. - **Figures:** Geist with tabular figures. Identifiers (model, price version) use Mono. Estimate, unpriced, not-billed and free figures follow *Figures and their states*: - The total carries `· est.` whenever any price in it was estimated. - A not-billed request reads `$0 · not billed`, plus one note line, and never shows `· est.`. - A null snapshot reads `unpriced` and drops the "Recorded with the request" caption. ### Request Inspector This is where the Route Trace and Price Receipt live, opened from a request row. Its presentation depends on viewport width, and its contents are the same at every width. | Viewport | Presentation | Behavior | |---|---|---| | ≤ 768px (`narrow`) | Bottom sheet (see Overlays) | Modal | | 769–1439px | 440px drawer anchored right, on the 28% scrim | Modal: traps focus, Escape closes, focus returns to the row | | **≥ 1440px** (Requests page; the Overview card stays the drawer) | **480px panel docked at the right edge, with no scrim and no shadow, and a Hairline on its leading edge** | **Non-modal:** the list stays live and switches to its compact columns (time, model, status, cost). ArrowUp / ArrowDown step between rows while focus is in the list or on the panel's Prev / Next buttons (24px; 44px coarse). Escape closes the panel. Each step announces a one-line summary through a polite `role="status"` region. | - **Keyboard:** stepping skips rows that can't open (running, finishing, group boundaries). It never steps past the last loaded row into a paging reset, and never into the "N new" offer. - **Stability:** the panel keeps a snapshot of the selected row, so a list refresh that drops the row from page 1 never unmounts it. Changing account clears the selection, the snapshot and any cached payload text. ### Figures and their states "Numbers never lie" is a component, not a slogan. Every slot that can hold a figure has exactly one rendering per state, and **the state is always in words**. Color repeats the words; it never replaces them. The strings below are contract strings, so quote them exactly. **Value states: what the number is** | State | Rendering | Notes | |---|---|---| | **Exact** | Ink, Geist tabular figures: `$12.40`, `1,204` | The default | | **Estimated price** | The figure + `· est.` in Ink 3. Provenance rows read `native family · estimate` or `provider-listed · estimate`. | Never amber; an estimate is provenance, not a warning. Never `~`. | | **Estimated usage** (tokens the provider didn't report) | The count + `· est. usage` in Ink 3 | Never merged with the price estimate: they are different axes. | | **Below display precision** | `<` + one unit of the field's precision, no space: `<$0.01` for cent fields, `<$0.0001` for four-decimal fields | A non-zero amount never prints as zero. Unit prices keep up to six significant digits instead (`$0.1 / 1M`). | | **Free** | `$0` + `free` in green text; an estimated free price reads `$0 free · est.` | Local or free-tier models. Never `unpriced`. | | **Not billed** | `$0 · not billed` in Ink 2, plus one note line | Never `· est.`. Aggregates add one muted caption, e.g. `3 not billed`, and no caption when there are none. | | **Unpriced / unknown** | The word, `unpriced` or `unknown`, in Ink 3. No dash, never `$0.00`. Aggregates qualify the total, e.g. `· 4 unpriced`. | Provider pricing reads `unpriced — cost not tracked`. | | **Notional** | The figure + `valued at API rates` | Subscription usage. It is never called cash. | **Availability states: whether there is a number** | State | Rendering | Notes | |---|---|---| | **Pending** (in flight) | A lone `—` in Ink 3 | The *only* meaning of a lone dash in a figure slot. In prose, a dash is ordinary punctuation. | | **Loading** | A skeleton block the width of the expected figure (Chip Tone, 4px radius, 1.6s pulse), with visually hidden text `Loading ` | On a refetch, keep the old figures visible instead. Surfaces whose contract says "absent until loaded" render nothing. | | **Failed** | Figures removed (never shown stale), plus a neutral notice: ` couldn't be loaded.`, a **Retry** button, and `Routing is unaffected.` where that is true | After Retry, focus returns to the panel. A hidden tab, an idle poll and a missing stream are **not** failures: they show no error. | | **Unavailable** | `Unavailable` + the reason, e.g. `per-stage counts are unavailable from this server` | Distinct from Failed: nothing was attempted. Never `0` or `0%`. | | **Absent** | Nothing: no slot, no zero, no placeholder chip | Where the contract says a null aggregate renders nothing | | **Not applicable** | `not applicable` in Ink 3 | Never `NaN%`. Example: shares of zero evaluated traffic. | | **Zero vs no data** | A measured zero renders `0` / `$0.00` with zero-activity copy. No data renders a sentence (`No requests in this range`). | Never draw zeros for missing data, and never a zero in place of loading or failed. | **Freshness states: how old the number is** | State | Rendering | Notes | |---|---|---| | **Stale** (data kept after a failed refresh, or older than 45s) | The figure stays in Ink, with a caption `as of 14:02 · stale` in amber text (UTC, after the period's `Resets … UTC`), plus **Retry** | Only on polled surfaces that keep their data, such as Limits. The snapshot time never advances on a failed refresh. Surfaces whose contract says "absent, never stale" use Failed instead. The plan-usage line is not one of them: it is an observation and states its own time (Observation, below). | | **Expired** | `expired · awaiting refresh` | The previous period's balance after the UTC rollover | | **Observation** (plan usage) | `as of 13:02 UTC` in Ink 3, ending the line, with the date when it is not today | The time of the **oldest** figure the line shows, so the line never claims newer data than it has. Absolute, never relative, so it needs no ticking clock. Never stale: no amber caption and no Retry, even after a failed Refresh usage, which keeps the line and its time and reports the failure beside the button. | | **Reset** (plan usage) | `