# UI design (control plane) This document records the Frosty Deno control-plane SPA (`apps/control-ui`) **exactly as implemented in the working tree**. It is a reference for the interface a reader will actually see, not a wishlist. Where the shipped code diverges from the declared design source [DESIGN.md](DESIGN.md), the code is authoritative here and the divergence is called out. The control plane is served same-origin by the gateway from the same port (default 8080); it renders API-only until `deno task build-ui` has produced `apps/control-ui/dist`. See [../concepts/architectural-overview.md](../concepts/architectural-overview.md) for how the SPA fits the gateway (one process, or N under `FROSTY_WORKERS`), and [../../apps/control-ui/CONVENTIONS.md](../../apps/control-ui/CONVENTIONS.md) for the binding SPA contract. ## Stack (verified) | Concern | Choice | Evidence | | --- | --- | --- | | Framework | React 19 (`^19.2.8`) + react-dom, mounted via `ReactDOM.createRoot` inside `React.StrictMode` | `package.json:9-10`, `src/main.tsx:6-10` | | Styling | Tailwind CSS v4 (`^4.3.3`) CSS-first, wired through `@tailwindcss/vite`; **no `tailwind.config.*`, no `postcss.config.*`** | `vite.config.ts:7`, `package.json:14,22` | | Class utility | `cn()` = `twMerge(clsx(...))` | `src/lib/utils.ts:4` | | Icons | `lucide-react ^1.25.0` only (plus a documented brand-SVG exception) | `package.json:8` | | Router | none - a hand-rolled hash router in `App.tsx` | grep: no router dependency | | State / component libraries | none - no Radix, no shadcn runtime, no state library; every primitive is hand-written | `package.json`; grep | | App version | `0.7.0` | `apps/control-ui/package.json` | | Design revision | `ds-r2` (supersedes ds-r1 "glacier-blue"); dark is the default theme | `tokens.css:2,9`; `index.html:2` | Design tokens live in `apps/control-ui/src/styles/tokens.css` (340 lines, OKLCH). A near-identical mirror ships at [tokens.css](tokens.css) in this folder (CRLF, 331 lines pre-`deno fmt`); the two carry **zero value differences** and are synced by hand, with no generator or CI check tying them together. --- ## 1. Design system ### 1.1 Identity and principles `ds-r2` is a dense, near-neutral monochrome developer console, register shadcn "new-york"/neutral (`DESIGN.md:29-35`). The load-bearing rules, all verified against code: - **Dark is the default and only pre-paint-resolved theme.** The document mounts with `class="dark"` (`index.html:2`). - **Neutrals are hue 265, chroma <= 0.006** - a whisper of cool, never pure gray (`tokens.css:11-12`). - **`--primary` is an emphasis surface, not a hue.** It is the shadcn-neutral inversion: near-black in light, near-white in dark (`tokens.css:44,202`). - **One cool-blue accent (hue 252 dark / 255 light), used only for the focus ring, links, and the active-nav indicator - never as a fill.** Verified: there is no `bg-ring` anywhere in the app. - **Semantic status color (success/warning/destructive/info) is a functional vocabulary only**, never decorative. - **lucide-react icons only; same-origin only.** No external CDN, font, script, or image origin exists in `index.html`, `index.css`, `tokens.css`, or any component. No `@font-face`, no `` to a font, no font files shipped. Declared design dials (informational): DESIGN_VARIANCE 3, MOTION_INTENSITY 2, VISUAL_DENSITY 8 (`DESIGN.md:37-41`). Token counts: **105 custom properties in `:root`**, of which **41 are re-declared in `.dark`** (38 colors + 3 shadows) and **5 are re-declared under `prefers-reduced-motion`**. Light values are the `:root` base (`tokens.css:32-187`); dark is a `.dark` class override (`tokens.css:192-245`); `color-scheme` is set per theme (`:33,193`) so native widgets follow. ### 1.2 Color palette Every emitted value is OKLCH. The **hex** columns are the fallback hexes written in the token file's own comments - documentation only, not the rendered value. Line numbers reference `apps/control-ui/src/styles/tokens.css`. #### Surfaces | Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | | --- | --- | --- | --- | --- | --- | | `--background` | `0.99 0.002 265` (36) | `#fbfcfd` | `0.15 0.004 265` (195) | `#0a0b0d` | `body` fill (`index.css:20`); sticky config-footer fill (`ProviderConfigPanel.tsx:370`) | | `--foreground` | `0.2 0.006 265` (37) | `#151619` | `0.985 0.001 265` (196) | `#fafafb` | `body` text (`index.css:21`); every modal scrim as `bg-foreground/40` | | `--card` | `1 0 0` (38) | `#ffffff` | `0.185 0.004 265` (197) | `#121314` | Card surface; every field fill; sticky table header; active tab/segment; Sheet body | | `--card-foreground` | = foreground (39) | `#151619` | = foreground (198) | `#fafafb` | Card / Sheet text | | `--popover` | `1 0 0` (40) | `#ffffff` | `0.205 0.004 265` (199) | `#161719` | Dialog, DropdownMenu, Combobox listbox, TimeRangePicker, ColumnPicker, CommandPalette, Toast, chart tooltip (`/95`), skip-link chip | | `--popover-foreground` | = foreground (41) | `#151619` | = foreground (200) | `#fafafb` | Same set as popover | #### Primary (shadcn-neutral inversion - an emphasis surface, not a chromatic hue) | Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | | --- | --- | --- | --- | --- | --- | | `--primary` | `0.24 0.006 265` (44) | `#1e1f22` | `0.92 0.004 265` (202) | `#e3e4e7` | Button `default` fill; Switch ON track; Checkbox `accent-primary`; Toast action link; Badge `primary` tone | | `--primary-foreground` | `0.985 0.001 265` (45) | `#fafafb` | `0.205 0.006 265` (203) | `#16171a` | Button `default` label; Switch thumb when ON | #### Supporting surfaces | Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | | --- | --- | --- | --- | --- | --- | | `--secondary` | `0.965 0.003 265` (48) | `#f2f3f5` | `0.255 0.004 265` (205) | `#222325` | Button `secondary`; active pill in NavTabs `pill`; TagInput chips | | `--secondary-foreground` | = foreground (49) | `#1e1f22` | = foreground (206) | `#fafafb` | Button `secondary` label | | `--muted` | `0.965 0.003 265` (50) | `#f2f3f5` | `0.235 0.004 265` (207) | `#1d1e20` | Skeleton bar; Switch OFF track; Tabs / SegmentedSelect track; provider-icon tile; Badge `muted`; table row hover (`/40`) | | `--muted-foreground` | `0.475 0.008 265` (51) | `#5a5c61` | `0.712 0.008 265` (208) | `#a0a2a7` | All secondary text, placeholders, chart axis labels + gridlines, table column headers, icon-button rest color | | `--accent` | `0.965 0.004 265` (52) | `#f2f3f6` | `0.255 0.006 265` (209) | `#212326` | The single hover/active wash across buttons, menus, combobox, nav tabs, masked-secret, etc. | | `--accent-foreground` | `0.24 0.006 265` (53) | `#1e1f22` | = foreground (210) | `#fafafb` | Exactly one usage: the highlighted CommandPalette result (`CommandPalette.tsx:160`) | #### Semantic status | Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | | --- | --- | --- | --- | --- | --- | | `--destructive` | `0.52 0.2 25` (56) | `#c21725` | `0.665 0.19 25` (212) | `#f25855` | Destructive buttons; Badge `err`; Banner `error`; required marker; field error text; `aria-invalid` border; Toast error icon; sidebar `denied` token dot | | `--destructive-foreground` | `0.985 0.005 25` (57) | `#fdf9f8` | `0.205 0.04 25` (213) | `#280e0c` | Destructive button / solid badge label | | `--success` | `0.48 0.13 155` (58) | `#00723b` | `0.72 0.15 155` (214) | `#43c07a` | Badge `ok`; Toast success icon; copied check; sidebar `ok` token dot | | `--success-foreground` | `0.985 0.005 155` (59) | `#f8fbf9` | `0.18 0.04 155` (215) | `#021709` | Solid success badge label | | `--warning` | `0.52 0.11 70` (60) | `#8f5d14` | `0.8 0.13 82` (216) | `#e7b551` | Badge `warn`; Banner `warn`; PEM hint | | `--warning-foreground` | `0.985 0.005 80` (61) | `#fcfaf6` | `0.24 0.04 82` (217) | `#291d07` | Solid warn badge label | | `--info` | `0.5 0.15 255` (62) | `#1762b6` | `0.68 0.13 250` (218) | `#549de5` | Badge `info`; Banner `info`; Toast info icon; EmptyState `tone="info"` icon | | `--info-foreground` | `0.985 0.005 250` (63) | `#f8fafd` | `0.17 0.04 250` (219) | `#021020` | Solid info badge label | Deliberate hue note: light `--warning` is hue 70 while `--warning-foreground` is hue 80; dark uses hue 82 for both (`DESIGN.md:62-63`, "amber 70 to 82"). #### Lines and focus | Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | | --- | --- | --- | --- | --- | --- | | `--border` | `0.92 0.004 265` (66) | `#e3e4e7` | `0.27 0.006 265` (221) | `#252629` | Global `* { border-color: var(--border) }` hairline default (`index.css:8-10`) plus explicit borders | | `--input` | `0.89 0.004 265` (67) | `#d9dbdd` | `0.3 0.006 265` (222) | `#2c2e31` | Every field border; Button `outline`; Checkbox; Switch OFF border | | `--ring` | `0.55 0.15 255` (68) | `#2971c6` | `0.62 0.13 252` (223) | `#4589d2` | **Global focus ring only** (`index.css:29-32`). No `bg-ring` anywhere - the accent-blue-is-never-a-fill rule holding | #### Chart family Consumed only through literal Tailwind classes (`text-chart-1..5`, `bg-chart-1..5`) in `chart.tsx`; dynamic `text-chart-${n}` is forbidden because the Tailwind JIT would purge it (verified: no dynamic chart-class construction exists). Contrast figures are declared in `DESIGN.md` (see 8.3). | Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Role | | --- | --- | --- | --- | --- | --- | | `--chart-1` | `0.52 0.15 255` (71) | `#1f68bc` | `0.66 0.14 252` (225) | `#4b95e5` | blue | | `--chart-2` | `0.52 0.14 155` (72) | `#007f43` | `0.72 0.15 155` (226) | `#43c07a` | green | | `--chart-3` | `0.6 0.12 70` (73) | `#ad721c` | `0.8 0.13 82` (227) | `#e7b551` | amber | | `--chart-4` | `0.52 0.2 25` (74) | `#c21725` | `0.665 0.19 25` (228) | `#f25855` | red | | `--chart-5` | `0.5 0.18 300` (75) | `#7541b8` | `0.62 0.16 300` (229) | `#966cd7` | violet | #### Sidebar family | Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | | --- | --- | --- | --- | --- | --- | | `--sidebar` | `0.975 0.003 265` (78) | `#f6f7f9` | `0.13 0.004 265` (231) | `#070709` | Rail surface (`Sidebar.tsx:143`); always the recessed surface (darker than background in dark, lighter in light) | | `--sidebar-foreground` | `0.24 0.006 265` (79) | `#1e1f22` | `0.8 0.006 265` (232) | `#bcbec2` | Rail text | | `--sidebar-primary` | `0.52 0.15 255` (80) | `#1f68bc` | `0.62 0.13 252` (233) | `#4589d2` | Brand snowflake (`:154`); search focus border (`:200`); **active-nav left inset bar** `before:bg-sidebar-primary` (`:263`) | | `--sidebar-primary-foreground` | `0.985 0.001 265` (81) | `#fafafb` | identical value (234) | `#fafafb` | **The only color token with an identical value in both themes; no code usage found** | | `--sidebar-accent` | `0.955 0.005 265` (82) | `#eef0f4` | `0.235 0.006 265` (235) | `#1d1e21` | Search fill (`/40`); collapse/close hover; active leaf fill; leaf hover (`/60`) | | `--sidebar-accent-foreground` | `0.24 0.006 265` (83) | `#1e1f22` | `0.985 0.001 265` (236) | `#fafafb` | Active nav label | | `--sidebar-border` | `0.91 0.004 265` (84) | `#e0e1e4` | `0.24 0.006 265` (237) | `#1e1f22` | Rail borders | | `--sidebar-ring` | `0.55 0.15 255` (85) | `#2971c6` | `0.62 0.13 252` (238) | `#4589d2` | Mapped to Tailwind; **zero usage in app code** | #### Alpha / tint conventions actually used - Soft badge: `text- bg-/16 border-/32` (`badge.tsx:7-14`). - Solid badge: `bg- text--foreground` - the `solid` prop exists but **no call site passes it** (dead prop, `badge.tsx:27`). - Banner: `border-/32 bg-/12 text-foreground` - a 12% tint, not the badge's 16% (`banner.tsx:8-16`). - Modal scrim `bg-foreground/40`; table row hover `bg-muted/40`; sidebar leaf hover `bg-sidebar-accent/60`. - Chart tooltip `bg-popover/95` plus `backdrop-blur-sm` (`chart.tsx:245`) - the only blur in the app, contradicting `DESIGN.md:300` ("never a blur"). ### 1.3 Typography Families (`tokens.css:97-100`). No font files ship; JetBrains Mono is a progressive enhancement that renders only where locally installed. | Token | Value | | --- | --- | | `--font-family-sans` | `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif` | | `--font-family-mono` | `"JetBrains Mono", ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace` | Size / line-height scale (`tokens.css:103-118`). The token file states "13.5px body for high dashboard density". | Step | font-size | px | line-height | px | Role (token comment) | Weight prescribed (`DESIGN.md:141-150`) | | --- | --- | --- | --- | --- | --- | --- | | 2xs | `0.6875rem` | 11 | `1rem` | 16 | micro | 500, tracking 0.02em | | xs | `0.75rem` | 12 | `1rem` | 16 | caption | 400 | | sm | `0.8125rem` | 13 | `1.125rem` | 18 | secondary | 400 | | base | `0.84375rem` | **13.5** | `1.25rem` | 20 | body | 400 | | lg | `1rem` | 16 | `1.375rem` | 22 | emphasis | 500 | | xl | `1.125rem` | 18 | `1.5rem` | 24 | section | 600 | | 2xl | `1.3125rem` | 21 | `1.625rem` | 26 | page title | 600, tracking -0.01em | | 3xl | `1.6875rem` | 27 | `2rem` | 32 | display, stat | 600, tracking -0.01em | Weights: `--font-weight-regular: 400`, `--font-weight-medium: 500`, `--font-weight-semibold: 600` (`tokens.css:120-122`). Note `--font-weight-regular` is inert because Tailwind's key is `--font-weight-normal`. Tracking: `--tracking-tight: -0.01em` (titles 2xl+), `--tracking-wide: 0.02em` (tiny labels) (`tokens.css:124-125`). Where typography is actually applied: | Register | Class / rule | Site | | --- | --- | --- | | Global body | sans family, 13.5px / 20px, antialiased | `index.css:22-25` | | Page title | `text-2xl font-semibold tracking-tight` (`h2`) | `page-header.tsx:29` | | Card title | `text-lg font-semibold` (`h3`); ChartCard downgrades to `text-base` | `card.tsx:37`, `ChartCard.tsx:67` | | Dialog / Sheet title | `text-lg font-semibold` (`h2`) | `dialog.tsx:50,119`, `sheet.tsx:49` | | Settings sub-heading | `text-sm font-semibold` (`h4`) | `helpers.tsx:98` | | Sidebar brand | `text-base font-semibold tracking-tight` (`h1`) | `Sidebar.tsx:158` | | Table column header | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | `table.tsx:79-80` | | StatTile value | `font-mono text-2xl font-semibold` | `stat-tile.tsx:20` | | Sidebar / command-palette group header | `text-2xs ... uppercase tracking-wide text-muted-foreground` | `Sidebar.tsx:226`, `CommandPalette.tsx:139` | | Form label / Button / Badge | `text-sm font-medium` / `text-sm font-medium` / `text-xs font-medium` | `label.tsx:11`, `button.tsx:60`, `badge.tsx:37` | Notes: `font-mono` is used for all telemetry (ids, keys, latency, cost, tokens, versions), 52 sites app-wide. `text-2xs` (11px) is used at 15 render sites. `text-xl` (18px) and `text-3xl` (27px) have **no usage in `components/ui`** - `StatTile` uses `text-2xl`, not the `3xl` the spec calls the "one deliberately large figure". Because `--tracking-*` / `--font-weight-*` are declared in an unlayered `:root` block (which beats Tailwind's `@layer theme` defaults), `tracking-tight`/`tracking-wide` resolve to -0.01em/0.02em and `font-medium`/`font-semibold` to 500/600. ### 1.4 Spacing scale `tokens.css:128-137`, base unit 4px. | Token | Value | px | | --- | --- | --- | | `--space-1` | `0.25rem` | 4 | | `--space-2` | `0.5rem` | 8 | | `--space-3` | `0.75rem` | 12 | | `--space-4` | `1rem` | 16 | | `--space-5` | `1.25rem` | 20 | | `--space-6` | `1.5rem` | 24 | | `--space-8` | `2rem` | 32 | | `--space-10` | `2.5rem` | 40 | | `--space-12` | `3rem` | 48 | | `--space-16` | `4rem` | 64 | Critical usage fact: only `--space-2` (3 refs) and `--space-4` (1 ref) are referenced anywhere outside the token file, and all four sit in the single `.sr-only-focusable:focus-visible` rule (`index.css:142,143,150`). The other eight `--space-*` tokens have **zero references**. Components use Tailwind's own `--spacing`-derived utilities (`px-5`, `gap-3`, `py-2`), which coincidentally share the 4px base but are not driven by these tokens - the `@theme inline` block does not map `--space-*` onto `--spacing`. De-facto conventions (`CONVENTIONS.md:145-152`): card padding `px-5 py-4`, section gaps `gap-4`/`gap-5`, page-section spacing `mb-5`/`mb-6`. ### 1.5 Border radii `tokens.css:140-145`. One family from a single 6px base. | Token | Value | px | Role | Utility usage | | --- | --- | --- | --- | --- | | `--radius` | `0.375rem` | 6 | base | base only (not a Tailwind key) | | `--radius-sm` | `calc(base - 2px)` | 4 | badges, small controls | `rounded-sm` (~16 sites) | | `--radius-md` | `= base` | 6 | buttons, inputs | `rounded-md` (~56 sites) | | `--radius-lg` | `calc(base + 2px)` | 8 | cards, panels | `rounded-lg` (~12 sites) | | `--radius-xl` | `calc(base + 6px)` | 12 | dialogs, sheets | `rounded-xl` x4 (`dialog.tsx:44,114`, `CommandPalette.tsx:95`) | | `--radius-full` | `9999px` | - | pills, switch | **0 direct refs**; `rounded-full` (8 sites) resolves to Tailwind's built-in `calc(infinity * 1px)` because `--radius-full` is deliberately absent from `@theme inline` | One outlier: a bare `rounded` at `data-table.tsx:180` (Tailwind's default 0.25rem), outside the declared 6px family. ### 1.6 Shadows | Token | Light | Dark | Usage | | --- | --- | --- | --- | | `--shadow-sm` | `0 1px 2px 0 oklch(0.2 0.01 265 / 0.06)` | `0 1px 2px 0 oklch(0.03 0.006 265 / 0.5)` | Card; all fields; Switch thumb; active tab / segment | | `--shadow-md` | `0 2px 8px -1px .../0.1, 0 1px 2px 0 .../0.06` | `0 2px 8px -1px .../0.6, 0 1px 2px 0 .../0.5` | DropdownMenu, Combobox listbox, TimeRangePicker, ColumnPicker, chart tooltip, skip-link chip | | `--shadow-lg` | `0 8px 24px -4px .../0.16, 0 2px 6px 0 .../0.08` | `0 10px 30px -5px .../0.7, 0 4px 8px -2px .../0.5` | Dialog, ConfirmDialog, Sheet, CommandPalette, Toast, VK token-reveal dialog | `--shadow-lg` is the **only** shadow whose geometry (not just alpha) differs by theme - the dark ramp is deeper. The self-referential mapping `--shadow-sm: var(--shadow-sm)` etc. (`tokens.css:331-334`) is the shadcn-v4 `@theme inline` convention: the literal `var(...)` text is substituted so the value re-resolves per theme at runtime. ### 1.7 Layout and control metrics `tokens.css:148-170`. | Token | Value | px | Where used | | --- | --- | --- | --- | | `--border-w` | `1px` | 1 | **0 refs** (components use Tailwind `border`) | | `--ring-w` | `2px` | 2 | `index.css:30`; `table.tsx:25` | | `--ring-offset` | `2px` | 2 | `index.css:31` | | `--control-h-sm` | `1.875rem` | 30 | Button `sm`/`icon-sm`, dense rows | | `--control-h` | `2.125rem` | 34 | Default control height (Button, Input, Select, Combobox, ...) | | `--control-h-lg` | `2.625rem` | 42 | **0 refs** (Button has no `lg` size) | | `--tap-target` | `2.75rem` | 44 | `.hit-target::after` (`index.css:87,88`) | | `--sidebar-width` | `15rem` | 240 | `Sidebar.tsx:145,148` | | `--sidebar-width-icon` | `3rem` | 48 | `Sidebar.tsx:148` | | `--container-max` | `110rem` | 1760 | content wrapper (`App.tsx:315`); raised from 78rem so dense tables stop scrolling horizontally inside unused whitespace | | `--measure-max` | `60rem` | 960 | `.measure` and `.field-grid > .field-wide` (`index.css:51,74`); caps prose and single-column forms so a label is not a screen from its input | | `--gutter` | `1.5rem` | 24 | page gutter, `px-(--gutter)` on `
` (`App.tsx:311`); tightens to `1rem` at `<= 48rem` (`index.css:39-43`) | | `--opacity-disabled` | `0.5` | - | **0 refs**; components hardcode `opacity-50` (value matches, token does not drive it) | ### 1.8 Motion `tokens.css:172-180`. Motion is feedback-only; there is no decorative animation. | Token | Value | Refs in code | | --- | --- | --- | | `--motion-fast` | `100ms` | ~21 (hover/focus/press feedback on Button, menus, tabs, toggles, ...) | | `--motion-default` | `150ms` | 5 (Switch, Collapsible, sidebar drawer slide) | | `--motion-slow` | `220ms` | **0 refs** | | `--motion-spin` | `800ms` | 1 (`spinner.tsx:9`) | | `--motion-ease-out` | `cubic-bezier(0.2,0,0,1)` | 0 direct (mapped to Tailwind `--ease-out`, unused) | | `--motion-ease-in-out` | `cubic-bezier(0.4,0,0.2,1)` | 2 (the two keyframes) | | `--motion-rise` | `-2px` | **0 refs** | | `--motion-enter` | `8px` | **0 refs** | Reduced motion (`tokens.css:250-258`) collapses fast/default/slow/rise/enter to `0ms`/`0px`; `--motion-spin` is deliberately not collapsed (the spinner instead carries `motion-reduce:animate-none`). Two keyframes exist app-wide: `.skeleton-pulse` (live) and `.stream-pulse` (dead CSS - never applied; the live indicator is a spinning lucide `RefreshCw`). **Overlays (Dialog, ConfirmDialog, Sheet, CommandPalette) have no enter/exit animation at all** - they appear instantly. The only overlay motion is the sidebar drawer slide. ### 1.9 Z-index layers `tokens.css:182-186`. Consumers use the Tailwind arbitrary-variable form `z-(--z-*)`. | Token | Value | Sites (complete) | | --- | --- | --- | | `--z-sticky` | 20 | sticky `` (`data-table.tsx:142`); sticky provider config footer (`ProviderConfigPanel.tsx:370`) | | `--z-overlay` | 40 | DropdownMenu panel, Combobox listbox, TimeRangePicker menu, ColumnPicker popover, mobile nav scrim | | `--z-modal` | 50 | Dialog, ConfirmDialog, Sheet, CommandPalette, the whole sidebar `