SysDeck/klanker-gate/docs/design/ui-design.md

88 KiB
Executable File

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, 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 for how the SPA fits the gateway (one process, or N under FROSTY_WORKERS), and ../../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 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 <link> 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-<tone> bg-<tone>/16 border-<tone>/32 (badge.tsx:7-14).
  • Solid badge: bg-<tone> text-<tone>-foreground - the solid prop exists but no call site passes it (dead prop, badge.tsx:27).
  • Banner: border-<tone>/32 bg-<tone>/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 <main> (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 <thead> (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 <aside> (neutralised on md+ by md:z-auto), VK token-reveal alertdialog
--z-toast 60 Toast stack; focused skip-link chip

1.10 Tailwind v4 @theme inline mapping

Block at tokens.css:264-339, loaded via @import "tailwindcss" then @import "./styles/tokens.css" (index.css:4-5). 65 entries, no config file:

  • 38 colors (:266-303): --color-X: var(--X) for every surface, primary, supporting, semantic, line, chart-1..5, and sidebar token. This is what makes bg-card, text-muted-foreground, border-input, text-chart-3, bg-success/16 resolve.
  • 18 typography (:305-323): --font-sans, --font-mono, and --text-<step> + --text-<step>--line-height pairs. --text-2xs is a custom step Tailwind does not ship.
  • 4 radius (:325-329): sm/md/lg/xl only; --radius-full intentionally absent.
  • 3 shadows (:331-334), self-referential.
  • 2 easings (:336-338), neither utility used.

Everything else (--space-*, --font-weight-*, --tracking-*, --control-h*, --tap-target, --sidebar-width*, --container-max, --measure-max, --gutter, --motion-*, --z-*, raw --font-size-*) is deliberately not mapped and is reached through Tailwind v4's arbitrary-variable syntax utility-(--token) (which compiles to var(--token)), e.g. h-(--control-h), z-(--z-modal), duration-(--motion-fast), w-(--sidebar-width), px-(--gutter), max-w-(--container-max).

1.11 Iconography

  • Library: lucide-react ^1.25.0 only, imported by name (never a size prop; sizing is by Tailwind class - size-4 dominates). The LucideIcon type is the prop type for icon-valued props.
  • Documented owner-granted exception: components/ui/provider-logos.tsx (601 lines) holds 28 hand-rolled inline-SVG brand marks plus a shared Svg shell, a ChipSvg light-chip wrapper, and a Monogram helper. Its only exports are the LogoProps type, providerLogo(key) (:591, returns null for any *-compatible key), and PROVIDER_LOGO_KEYS (:601); the LOGOS map (:530) and the 21-entry ALIASES map (:562) are module-private. Marks bake in the brand's own colors (~60 hardcoded hex literals), never currentColor; all are inline and same-origin - no <img>, no remote URL. This file is the only source of hardcoded color in components/ui.
  • ProviderIcon resolution order (provider-icon.tsx:101-146): brand logo by explicit logoKey, then brand logo by provider, then a neutral lucide glyph from PROVIDER_GLYPHS (20 entries), then Blocks when custom, then an initials avatar. A matched brand logo renders on a plain tile; glyph/initials keep a bordered bg-muted tile.

2. Layout and responsive behavior

2.1 Shell

The shell is composed in App.tsx:255-334 from three parts:

  • App.tsx - the composition root: ToastProvider wraps a flex h-screen overflow-hidden outer div containing a skip link, a mobile scrim, the Sidebar, and a content column.
  • Sidebar (shell/Sidebar.tsx) - the nav rail (brand, collapse toggle, search, grouped leaves, admin-token + theme footer), a sibling of the content column with its own scrollable <nav>.
  • PageHeader (ui/page-header.tsx) - rendered as the first element of every view; its h2 is the focus target on navigation.

The scroll container is <main id="main" tabIndex={-1}> (overflow-y-auto py-6 px-(--gutter), App.tsx:311), not the document - the outer div is h-screen overflow-hidden. Content is centred and capped at --container-max (110rem / 1760px). A remount key key={view:authNonce} (App.tsx:313) forces a full remount of the active view when the admin token changes, re-firing every fetch. AdminTokenDialog and CommandPalette render outside the flex shell (fixed). The only React portal in the app is DropdownMenu, which portals its panel to document.body with fixed positioning so no overflow ancestor can clip it; Dialog/Sheet/CommandPalette use plain position: fixed inline.

2.2 Grid and flex systems

There is no grid framework; layout is Tailwind flex/grid utilities.

  • Shell: flex row (rail + content column), each column flex-col.
  • Master/detail: ui/two-pane.tsx - flex min-h-0 flex-col md:flex-row md:items-stretch, rail width injected as a local --rail-w custom property seeded from listWidth and then driven by a draggable role="separator" handle between the panes (pointer drag + Arrow/Home/End keys, clamped to minListWidth/maxListWidth, persisted under storageKey). Used by ProvidersView only. The aside carries overflow-hidden (two-pane.tsx): without it, a rail row whose intrinsic content exceeds the current width paints its trailing badges outside the rail and on top of the detail pane. Callers pair it with shrink-0 on the badge cluster, which turns a crowded row into a truncation problem instead of an overlap one; the resizer then lets the operator widen the rail to read the full content. Locked by two-pane.overflow.test.tsx.
  • Card grids: grid gap-4 + responsive grid-cols-* (dashboard, status, catalog, logs KPI rows).
  • Form grids: the codified pattern is .field-grid (index.css:64-69, CONVENTIONS.md:76-83) - repeat(auto-fit, minmax(min(100%, 16rem), 22rem)), as many columns as fit with each capped at 22rem, plus .field-wide for fields that need the whole row. CONVENTIONS.md:78-80 explicitly says "Do NOT go back to sm:grid-cols-2". Call sites use it: AddCustomProviderForm.tsx, AddProviderForm.tsx:131, CachingPanel.tsx:163,196, SecurityPanel.tsx:134, ExtensionsView.tsx:339.
  • Tables: ScrollContainer (table.tsx:13-33) wraps every table in w-full overflow-x-auto rounded-lg with an optional inline minWidth - horizontal scroll rather than card reflow, a deliberate density trade-off.

2.3 Breakpoints (exact and exhaustive)

Tailwind v4 defaults, not overridden anywhere (no --breakpoint-*, no config, no screens key): sm 640px, md 768px, lg 1024px, xl 1280px. Exhaustive occurrence counts across src/**/*.tsx: md 42, sm 18, lg 3, xl 6, 2xl 0. md is the only structural breakpoint; sm is almost entirely form-grid and filter-width tuning; lg/xl only widen dashboard/status card grids.

Prefix Effect Site
md:hidden mobile nav scrim and mobile top bar disappear at >=768px App.tsx:261,280
md:static md:z-auto md:translate-x-0 md:transition-[width] sidebar stops being an off-canvas drawer and becomes an in-flow column Sidebar.tsx:147
md:w-(--sidebar-width-icon) / md:w-(--sidebar-width) 3rem icon rail vs 15rem expanded, desktop only; below md the rail is always 15rem Sidebar.tsx:148
md:sr-only brand h1, leaf labels, admin-token label become screen-reader-only in the collapsed rail Sidebar.tsx:159,272,299
md:grid / md:hidden collapse toggle is desktop-only; mobile "Close navigation" X is mobile-only Sidebar.tsx:168,178
md:flex-row ... TwoPane stacks below md, side-by-side at md+ with a draggable rail; below md exactly one pane renders and the resizer is hidden two-pane.tsx
md:grid-cols-[18rem_1fr] / md:grid-cols-[14rem_1fr] zero-provider layout / logs facet rail go 2-column at md+ ProvidersView.tsx:673, LogsView.tsx:323
md:grid-cols-2 xl:grid-cols-3 dashboard chart grids 1 -> 2 -> 3 columns DashboardView.tsx:217,...
md:grid-cols-3 xl:grid-cols-6 status KPI tiles 2 -> 3 -> 6 columns (traffic row and the Runtime row) StatusView.tsx:241, StatusView.tsx:423
lg:grid-cols-4 / sm:grid-cols-3 lg:grid-cols-5 catalog KPI tiles / logs KPI row ModelCatalogView.tsx:195, LogsAnalytics.tsx:60

Every file in components/ui/ except two-pane.tsx has zero responsive behavior; they adapt via max-w-* + w-full + flex-wrap + overflow-x-auto, never media queries. Sheet is w-full max-w-[28rem] (full-bleed only below 448px, no sm: class). DataTable density is preserved by horizontal scroll plus per-view minWidth ("60rem" Logs/Virtual Keys, "52rem" Model Catalog/Teams, "44rem" Customers, "36rem" Pricing).

2.4 Sidebar layout modes (four states)

Mode Trigger Geometry
Mobile drawer, closed viewport < md, mobileOpen === false fixed inset-y-0 left-0 w-(--sidebar-width) + -translate-x-full, z-(--z-modal)
Mobile drawer, open hamburger sets mobileNavOpen translate-x-0; scrim behind; scrim click or any onNavigate closes it
Desktop expanded viewport >= md, collapsed === false md:static md:w-(--sidebar-width) = 240px
Desktop icon rail viewport >= md, collapsed === true md:w-(--sidebar-width-icon) = 48px; brand/labels md:sr-only, all groups force-open, leaves gain title={label}

Collapse state persists in localStorage["frosty.sidebar"] as "rail"/"expanded" (read/write both try/caught). Rail vs expanded animates via md:transition-[width]; the drawer slides via transition-transform duration-(--motion-default).


3. Component inventory

apps/control-ui/src/components/ui/ holds 40 primitive source files (39 .tsx + use-modal.ts), of which 16 have colocated tests and 24 do not (16 components across 17 test files: two-pane carries two). There is no barrel/index file - every consumer imports by explicit relative path, and all import cn from ../../lib/utils.

3.1 Primitives (src/components/ui/)

# Component(s) File Purpose Key props Used by
1 Badge badge.tsx Status pill tone? (default muted); solid? (never passed) LogsTable, ProviderConfigPanel, SecurityPanel, facet-rail, provider-icon, lib/governance, and 5 views
2 Banner, ErrorBanner banner.tsx Page/card message tone: info/warn/error, action?; role="alert" for error, status otherwise App (401), AddProvider/Custom, ProviderConfigPanel, SecurityPanel, 12 views
3 Button button.tsx Primary control variant? (6), size? (4), isLoading? (prepends Spinner) 22 files incl. 10 views
4 Card, CardHeader, CardTitle, CardContent card.tsx Raised surface + slots plain HTML attrs ChartCard, LogsAnalytics, settings panels, stat-tile, 9 views
5 Chart, ChartLegend chart.tsx Dependency-free SVG chart type: line/bar, series, ariaLabel (required), unit? ChartCard, LogsAnalytics, lib/analytics, dashboard/adapters
6 Checkbox checkbox.tsx Native checkbox (accent-primary, no custom indicator) Omit<InputHTMLAttributes,"type"> ColumnPicker, facet-rail, lib/governance
7 Collapsible collapsible.tsx Disclosure title, defaultOpen?, controlled/uncontrolled LogsFacetRail, CodeModeVfsPreview, facet-rail
8 Combobox combobox.tsx Searchable single-select options, value: string|null, onChange, label dashboard/adapters, ProviderConfigPanel, CachingPanel, McpPanel, 3 views
9 CopyButton copy-button.tsx Clipboard copy, 2s confirm value, label? masked-secret, ConfigPanel, VirtualKeysView
10 DataTable<T> data-table.tsx Generic sortable/paginated table columns, rows, getRowId, caption (required), pageSize?, rowMenu?, stickyHeader? LogsTable, ProviderConfigPanel, 4 views
11 Dialog, ConfirmDialog dialog.tsx Modal / alert dialog Dialog role="dialog" overlay-dismissible; ConfirmDialog role="alertdialog", overlay does NOT dismiss, destructive? default true AddProvider/Custom, AdminToken, 7 views
12 DropdownMenu dropdown-menu.tsx Kebab / row-actions menu items, label, align?; panel portalled to document.body LogsView, ProvidersView
13 EmptyState empty-state.tsx Zero-data placeholder icon, title, body, action?, tone? 7 views
14 ExportButton<T> export-button.tsx Client-side CSV download rows, columns, filename; self-disables when empty DashboardView, VirtualKeysView
15 FacetRail (+ FacetSearch, FacetCheckbox) facet-rail.tsx Collapsible checkbox facet groups groups, value, onChange LogsFacetRail
16 Input, Textarea input.tsx Text field primitives plain attrs; shared FIELD_CLASSES incl. aria-invalid:border-destructive 19 files
17 KeyValueRows key-value-rows.tsx Repeatable Name/Value editor value, onChange, valueInputType? ProviderConfigPanel
18 Label, Field label.tsx Label + control + hint/error stack Field{id,label,required?,error?,hint?}; hint/error mutually exclusive 17 files
19 maskSecret, MaskedSecret, MaskedSecretCell masked-secret.tsx Reveal/copy secret display constant 10-dot mask so length never leaks VirtualKeysView
20 NavTabs, SubTabs, UnderlineTabs nav-tabs.tsx View-level tab bars value, tabs, label, variant? ProviderConfigPanel, DashboardView, ExtensionsView, SettingsView
21 NumberField number-field.tsx Numeric config field emits raw string (empty stays representable); unit? overlay ProviderConfigPanel
22 PageHeader page-header.tsx Screen header + focus target title, subtitle?, actions?; h2 tabIndex={-1} all 11 views
23 isLikelyPem, PemTextarea pem-textarea.tsx Monospace PEM editor non-blocking aria-invalid + text-warning hint ProviderConfigPanel
24 providerGlyph, initialsFrom, ProviderIcon, Avatar, CustomBadge provider-icon.tsx Provider identity tile provider, logoKey?, custom?, size? AddProviderDialog, ProviderConfigPanel, ModelCatalogView, ProvidersView
25 providerLogo, PROVIDER_LOGO_KEYS (28 marks, module-private) provider-logos.tsx Full-color inline-SVG brand marks (owner-granted) LogoProps; 21 aliases provider-icon.tsx only
26 SegmentedSelect<T> segmented-select.tsx Inline 2-3 option radio group options, value, onChange, label; arrows move and select ProviderConfigPanel
27 NativeSelect select.tsx The only select in the app appearance-none + ChevronDown overlay AddCustom/AddProvider, lib/governance, ConfigPanel, ExtensionsView, TeamsView, VirtualKeysView
28 Sheet sheet.tsx Right-side edit panel open, onClose, title; w-full max-w-[28rem] ExtensionsView (edit MCP), VirtualKeysView (create/edit)
29 Skeleton, TableSkeleton, TileSkeleton, PanelSkeleton skeleton.tsx Loading placeholders composites are aria-hidden CodeModeVfsPreview, data-table, stat-tile, 8 views
30 Spinner spinner.tsx Loading glyph lucide LoaderCircle, animate-spin, aria-hidden button.tsx only (a bare spinner page is structurally impossible)
31 StatTile stat-tile.tsx Overview metric tile label, value, caption?, loading?; value font-mono text-2xl LogsAnalytics, ModelCatalogView, StatusView
32 Switch switch.tsx Boolean toggle role="switch" on a <button> AddCustom, ProviderConfigPanel, settings helpers, toggle-grid-item, 5 views
33 ScrollContainer, Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption table.tsx Presentational table primitives ScrollContainer is role="group" + tabbable; real <caption> data-table + 5 views hand-rolling tables
34 Tabs, tabPanelProps tabs.tsx Segmented tablist on a muted track Tabs is never rendered anywhere; only tabPanelProps and the TabItem type are consumed ProviderConfigPanel, DashboardView, SettingsView (via tabPanelProps)
35 TagInput tag-input.tsx Chip editor for string lists Enter/,/blur commit; duplicates rejected SecurityPanel
36 TimeRangePicker, DEFAULT_TIME_RANGES time-range-picker.tsx Range menu button defaults 1h/24h/7d; role="menu" + menuitemradio DashboardView, LogsView
37 ToastProvider, useToast, ... toast.tsx Transient notifications success/error/info; auto-dismiss 5000ms (6000ms with action); useToast() returns a no-op API outside a provider App + AdminTokenDialog + 10 views
38 ToggleGridItem toggle-grid-item.tsx Labelled switch tile label, checked, onCheckedChange, onSettings? AddCustomProviderForm
39 TwoPane two-pane.tsx Master/detail layout; draggable rail (clips via overflow-hidden) list, detail, listWidth?, minListWidth?, maxListWidth?, storageKey?, listLabel?, detailLabel?, detailActiveOnMobile?, className? ProvidersView only
40 useModal (hook) use-modal.ts Focus trap + scroll lock useModal(open, onClose, dismissible, initialFocus?); capture-phase keydown; restores focus on cleanup dialog (x2), sheet, CommandPalette, VK token reveal

Kit test coverage (16 components across 17 test files, of 40 source files): collapsible, combobox, data-table, dropdown-menu, export-button, facet-rail, key-value-rows, masked-secret, nav-tabs, number-field, pem-textarea, provider-icon, segmented-select, time-range-picker, toggle-grid-item, two-pane (two files: two-pane.test.tsx and two-pane.overflow.test.tsx). The 24 untested include the entire focus-trap machinery (use-modal), dialog, sheet, button, input, toast, table, and chart.

3.2 Composites (feature components)

Component Location Purpose Used by
Sidebar (+ NavItem) shell/Sidebar.tsx Nav rail: brand, collapse, search, grouped leaves, admin-token + theme footer App
CommandPalette shell/CommandPalette.tsx Cmd/Ctrl-K overlay that navigates between views App
AdminTokenDialog shell/AdminTokenDialog.tsx Enter / clear the admin bearer token App
ChartCard dashboard/ChartCard.tsx Analytics card: title, filter slot, bar/line toggle, legend, x-ticks, auto-empty DashboardView
adapters (13 exports) dashboard/adapters.ts Rollup -> ChartSeries / ComboboxOption / CsvColumn conversion DashboardView
LogsKpiRow, RequestVolumeCard logs/LogsAnalytics.tsx Logs KPI tiles + collapsible request-volume chart LogsView
LogsTable (+ LiveBar) logs/LogsTable.tsx Logs DataTable + the aria-live live-connection bar LogsView
LogsFacetRail logs/LogsFacetRail.tsx Outcome facets, honest-empty facet shells LogsView
ColumnPicker logs/ColumnPicker.tsx Disclosure of column checkboxes (last visible cannot be unchecked) LogsView
logs-model (17 exports) logs/logs-model.ts Pure logs domain model (outcome, columns, windows, formatters) LogsView + logs composites
AddProviderDialog providers/AddProviderDialog.tsx Vendor gallery: 27 filterable preset cards + Custom escape hatch ProvidersView
AddProviderForm providers/AddProviderForm.tsx Create-provider form incl. Azure / Bedrock / Vertex branches ProvidersView
AddCustomProviderForm providers/AddCustomProviderForm.tsx Bring-your-own provider (inline in the detail pane) + advisory request-type grid ProvidersView
ProviderConfigPanel providers/ProviderConfigPanel.tsx 6-tab deep provider config, per-group diffing, sticky footer ProvidersView
SecretReenter providers/SecretReenter.tsx Write-only secret field ("Configured" marker + Replace) ProvidersView, ProviderConfigPanel, SecurityPanel
constants (17 exports) providers/constants.ts Provider taxonomy, PROVIDER_PRESETS (27 presets), locked option lists provider components
SecurityPanel settings/SecurityPanel.tsx Settings > Security SettingsView
CompatibilityPanel settings/CompatibilityPanel.tsx Settings > Compatibility SettingsView
CachingPanel settings/CachingPanel.tsx Settings > Caching: semantic-cache config plus the Operations block SettingsView
CacheOpsPanel settings/CacheOpsPanel.tsx Cache invalidation ops (purge all / purge one entry), formerly the Cache view CachingPanel
ConfigPanel settings/ConfigPanel.tsx Settings > Config: default provider, export/import/reload, formerly the Config view; owns its own load and error state SettingsView
PerformancePanel settings/PerformancePanel.tsx Settings > Performance SettingsView
McpPanel settings/McpPanel.tsx Settings > MCP incl. Code Mode binding preview SettingsView
CodeModeVfsPreview settings/CodeModeVfsPreview.tsx Read-only VFS tree + per-file source viewer (React text only) McpPanel
settings helpers (17 exports) settings/helpers.tsx Shared coercers, provenance (SourceTag/EnvHint), PanelFooter, NumberCard all five settings panels
lib/governance (18 exports) lib/governance.tsx Shared budget / limit sub-forms and money formatters TeamsView, CustomersView, VirtualKeysView

3.3 Page-level components (views)

All 11 render PageHeader as their first element.

View File Route
DashboardView views/DashboardView.tsx #/dashboard
LogsView views/LogsView.tsx #/logs
ProvidersView views/ProvidersView.tsx #/providers (default)
ModelCatalogView views/ModelCatalogView.tsx #/model-catalog
ExtensionsView views/ExtensionsView.tsx #/extensions
VirtualKeysView views/VirtualKeysView.tsx #/virtual-keys
TeamsView views/TeamsView.tsx #/teams
CustomersView views/CustomersView.tsx #/customers
PricingView views/PricingView.tsx #/pricing
SettingsView views/SettingsView.tsx #/settings, #/settings/<sub>
StatusView views/StatusView.tsx #/status

Cache and Config are no longer views. Cache invalidation is Settings > Caching (the "Operations" block below the save footer, components/settings/CacheOpsPanel.tsx); raw config export/import/reload is Settings > Config (components/settings/ConfigPanel.tsx). The legacy hashes #/cache and #/config are rewritten by REDIRECTS in App.tsx:81-84.


4. Screens and views

Data sources cite the src/api.ts client function and its exact endpoint. api.ts exports 55 functions (48 HTTP endpoint clients + 7 auth/transport helpers), 1 class (ApiError), and 37 types/interfaces. All view transport goes through these clients - no fetch in views (verified by grep over views/ and components/). ApiError.message carries the gateway's canonical error.message verbatim.

4.1 #/dashboard - Dashboard

  • Purpose: analytics rollup over a selectable window. Renders explicit "not recorded yet" cards for dimensions the gateway does not track, rather than hiding them.
  • Header: title Dashboard, no subtitle; actions = section-aware ExportButton, TimeRangePicker, and an icon button aria-label="Reload analytics".
  • Sections (SubTabs, pill): overview (default), provider-usage, model-rankings, mcp-usage, user-rankings. Time range 1h/24h/7d, default 24h, 12 buckets.
  • Data: getAnalytics(range) -> GET /api/analytics?window=<range> (404 -> untracked empty rollup; any rejection degrades silently to rollup = null); getStoredLogs({limit:500}) -> GET /api/logs/stored?limit=500 (404 sets store off, else error banner); ensureEurRate() -> GET /api/config.
  • Cards: Request Volume (bar), Token Usage (bar, with a flat-zero "Cached" series for legend parity), External/Local Cache Hit Rate (hardcoded empty), Cost (bar, EUR), Model Usage (bar), Latency (line, derived client-side from stored logs). Provider Usage: Provider Cost, Provider Token Usage, and a permanently empty Provider Latency. Model Rankings: two bar charts + a DataTable (Model / Provider / Requests / Tokens / Cost). MCP usage and User Rankings: permanently empty cards with section notes.
  • Actions: change time range, switch section, per-card bar/line toggle (hidden when empty), per-card model/provider filter, export CSV, reload.

4.2 #/providers - Providers (default route)

  • Purpose: manage provider accounts - create, key, enable, set default, refresh models, deep config, delete. The flagship view.
  • Header: title Providers, subtitle "Accounts the gateway can route inference to"; action = an icon button aria-label="Reload configuration" (named to avoid colliding with the pinned nav name "Providers").
  • Data: getConfig() -> GET /api/config (providers, defaultProvider, eurRate pushed into the currency module); getProviderHealth() -> GET /api/providers/health (fire-and-forget, never blocks the list).
  • Layout: a TwoPane master/detail (left rail ProviderList, right pane) with a draggable divider (persisted rail width). A mode state machine (add/custom/keys/config); in config mode the whole two-pane is replaced by a full-width ProviderConfigPanel. Three body states: skeleton while loading, a two-cell zero-provider layout, and the normal two-pane.
  • Left rail: each row is a button whose accessible name is the provider id, with a ProviderIcon, a traffic-light Badge (disabled/no key/error/ online), a CUSTOM chip, a default star badge, and a hover/focus-revealed delete button. Below the list sit stacked Add New Provider (opens the gallery) and Add Custom Provider (opens the inline custom form) buttons.
  • Detail (ConfiguredKeys): a DataTable of API Key (dots / cloud credentials / no key), Weight (hardcoded 1), Enabled (Switch); row kebab Replace API key / Make default / Refresh models / Delete provider.
  • Writes: createProvider -> POST /api/providers; updateProvider -> PUT /api/providers/:id; deleteProvider -> DELETE /api/providers/:id; refreshModels -> POST /api/providers/:id/refresh-models; setDefaultProvider -> PUT /api/config {defaultProvider}. Every mutation runs through run() which sets busy, reloads on success, and on failure sets the page error banner plus an error toast.
  • Add gallery (AddProviderDialog): 27 vendor presets as filterable cards (search matches displayName / key / type) plus a Custom provider button.

4.3 #/model-catalog - Model Catalog

  • Purpose: read-mostly overview of configured providers, their advertised models, and 24h traffic/cost.
  • Header: title Model Catalog, subtitle "Overview of all configured providers, models, and usage." No header actions.
  • Data: getCatalog() -> GET /api/catalog (404 normalises to an empty catalog); ensureEurRate() first so money renders converted on first paint.
  • KPI tiles (lg:grid-cols-4): Total Providers, Total Models, Total Requests (24h), Total Cost (24h, EUR at 4 dp).
  • Table: DataTable "Model catalog by provider", minWidth="52rem", columns Provider / Models (first 6 chips then +N more) / Total Traffic (24h) / Total Cost (24h). A row click opens ProviderModelsDialog.
  • Actions: a provider Combobox filter and a per-row refresh button -> POST /api/providers/:id/refresh-models then a catalog reload (row-refresh failures surface only as a toast, never the page error state).
  • Model dialog (ProviderModelsDialog): clicking a provider row opens a compact toggle grid of every model the provider advertises (GET /api/providers/:id/available-models, read-only) unioned with the currently-enabled set. Each tile is an on/off Switch; Save persists the enabled subset via updateProvider {models} (the set the gateway routes on). A search box plus Enable/Disable-all act on the filtered list; provider types without live listing degrade to editing their stored models.
  • Empty / error: zero providers -> EmptyState tone="info"; a genuine transport error -> Banner tone="error".

4.4 #/extensions - Extensions

  • Purpose: register / sync MCP servers, inspect synced tools, list plugins.
  • Header: title Extensions, subtitle "MCP servers, synced tools, and plugins"; action = an outline Sync all -> POST /api/mcp/sync.
  • Tabs (UnderlineTabs): mcp-servers (default), tools, plugins. Card ids #mcp-servers/#tools/#plugins are in-page anchors the router deliberately ignores.
  • Data: getMCPClients() GET /api/mcp/clients; getMCPTools() GET /api/mcp/tools; getPlugins() GET /api/plugins; getMCPHealth() GET /api/mcp/health polled every 30s.
  • MCP servers table (raw Table primitives): ID / URL / Transport / Tools / Health (tone badge + xN failure superscript) / Last sync / Actions (Sync, Edit, Remove). Remove offers an Undo toast that re-creates the client, suppressed when unrestorable data (headers / command / url credentials) was stored.
  • Add form (sm:grid-cols-2): ID (required), URL (required, type="url"), Transport (http-sse default / streamable-http / auto), plus an Advanced disclosure (timeout + repeatable password header rows). Submits POST /api/mcp/clients.
  • Edit sheet: URL, Transport (disabled for stdio clients), Enabled, timeout, headers. Stored header values are never prefilled - saving replaces the whole set. Saves via PUT /api/mcp/clients/:id; remove via DELETE /api/mcp/clients/:id; per-row sync via POST /api/mcp/clients/:id/sync.
  • Tools tab: Tool / Server / Description / Side effects (read-only vs needs confirmation). Plugins tab: a plain <ul> of names, or "No plugins loaded."

4.5 #/logs - Logs

  • Purpose: faceted request-log console over two sources (live SSE stream and the durable stored-log history).
  • Header: title Logs, subtitle "Live request stream and stored history"; actions = a Live toggle button (aria-pressed, disabled when the store is off) and an icon Refresh button.
  • Source probe: getStoredLogs({limit:1}) -> GET /api/logs/stored?limit=1; a 404 sets store off, forces Live on, and shows an info banner reading "Stored logs are off. Set FROSTY_LOG_STORE=pg on the gateway..." (LogsView.tsx:313-315). The copy names pg, the live "on" value, matching the Postgres-backed store; the rest of the Logs copy was de-KV-ified earlier (LogsView.tsx:81-82, :405).
  • Live source: readLogStream -> GET /api/logs/stream using fetch + ReadableStream SSE (EventSource is forbidden because it cannot carry the admin bearer header). Ring buffer capped at 500, duplicate-suppressed, exponential-backoff reconnect. State connecting/streaming/disconnected rendered by the aria-live LiveBar.
  • Stored source: getStoredLogs({q, limit:500}) -> GET /api/logs/stored?q=...&limit=500, debounced 300ms.
  • Facets (LogsFacetRail): only Outcome is live (Success / Error / Processing / Cancelled with counts); "Models" is a disabled input with "Not recorded yet"; 12 further groups are honest-empty disclosures.
  • Toolbar: search input, TimeRangePicker (default 1h), ColumnPicker, and a "More actions" DropdownMenu whose only item is Clear stored logs (destructive, disabled while Live or when the store is off) -> DELETE /api/logs/stored, behind a ConfirmDialog naming the exact entry count.
  • Table: DataTable "Request logs", pageSize={25}, Time desc, minWidth="60rem". Columns Time / Type / Provider / Model / Message / Latency / Tokens / Status; Type/Provider/Model/Tokens render N/A title="Not recorded". There is no row-detail drawer or payload inspector anywhere in the UI.

4.6 #/virtual-keys - Virtual Keys

  • Purpose: create / edit / delete virtual keys with scope, budget, and rate limits. The largest view.
  • Header: title Virtual Keys, subtitle "Manage virtual keys, their permissions, budgets, and rate limits."; actions = ExportButton (virtual-keys.csv) and Add Virtual Key.
  • Data (all via Promise.allSettled): getVirtualKeys() GET /api/virtual-keys (the only failure that sets the error banner); getTeams() GET /api/teams; getCustomers() GET /api/customers; getConfig() GET /api/config (provider picker only).
  • Filters: name search, customer Combobox, team Combobox.
  • Table: DataTable "Virtual key list", pageSize={8}, minWidth="60rem". Columns Name / Assigned To / Key (MaskedSecretCell reveal + copy) / Budget / Rate Limits / Status (deliberately non-sortable). Row actions Edit / Delete.
  • Create/Edit sheet (Sheet -> VKForm): Name, Description, Active switch, Provider Configurations (a Combobox that adds removable provider chips), Allowed models (an all sentinel means unrestricted), Budget (Max Budget EUR
    • Max Requests), Rate Limiting (tokens + reset period, requests + reset period). POST /api/virtual-keys / PUT /api/virtual-keys/:id.
  • Token reveal: the create response carries the full token once; it is held in state only and shown in a non-dismissible role="alertdialog" with a Copy token and a Done button that nulls the state and fires the created toast.
  • Delete: ConfirmDialog with a special last-key warning about reopening inference to unauthenticated traffic. DELETE /api/virtual-keys/:id.

4.7 #/teams - Teams

  • Header: title Teams, subtitle "Shared budgets across virtual keys"; actions = an outline Refresh + New team.
  • Data: getTeams(), getCustomers(), getVirtualKeys() (per-team key count), ensureEurRate().
  • Table (raw Table, minWidth="52rem"): Name / Status / Customer / Keys / Budget / Used / Actions; a dangling customerId renders warning-coloured.
  • Dialog (TeamDialog): Name, Enabled, Customer NativeSelect, shared BudgetField. Delete confirm is reference-aware (counts virtual keys).
  • Writes: POST /api/teams, PUT /api/teams/:id, DELETE /api/teams/:id.

4.8 #/customers - Customers

  • Header: title Customers, subtitle "Top-level budget rollups above teams"; actions = an outline Refresh + New customer.
  • Data: getCustomers(), getTeams() (team count), ensureEurRate().
  • Table (raw Table, minWidth="44rem"): Name / Status / Teams / Budget / Used / Actions.
  • Dialog (CustomerDialog): Name, Enabled, BudgetField. Delete confirm counts referencing teams.
  • Writes: POST /api/customers, PUT /api/customers/:id, DELETE /api/customers/:id.

4.9 #/pricing - Pricing

  • Header: title Pricing, subtitle "Per-model token prices in EUR per million tokens"; the header action is the primary Save pricing button (disabled unless dirty && valid && !busy).
  • Data: getPricing() -> GET /api/pricing; putPricing() -> PUT /api/pricing. Stored values are canonical USD; rows are shown/edited in EUR (usdToEur on load, eurToUsd on save).
  • Editable grid (raw Table, minWidth="36rem"): Model / Input EUR/MTok / Output EUR/MTok / remove. Live validity: >=1 row, non-empty trimmed model ids, non-negative finite numbers, no duplicate ids; duplicates set aria-invalid. Removing previously-saved models triggers a ConfirmDialog.

4.10 #/settings and #/settings/<sub> - Settings

  • Header: title Settings, subtitle "Gateway security, compatibility, caching, performance, MCP, and raw configuration" (SettingsView.tsx:177). No header actions - the primary action is each panel's Save changes footer, while the Config panel and the Caching panel's Operations block carry their own buttons and act immediately.
  • Sub-tabs (UnderlineTabs, six entries, SettingsView.tsx:22-29): security (default), compatibility, caching, performance, mcp, config. subFromHash() reads parts[1], validates it against SUB_TABS, and falls back to security; select() writes history.replaceState(..., "#/settings/" + next).
  • Data: getSettings() -> GET /api/settings (shape {settings:{group:{values,sources}}, enforcement}). Save is per-group and diff-only: putSettings({[group]: values}) -> PUT /api/settings (groups are flat at the root); an empty diff returns early.
  • Panels (feature composites): Security (password protect, auth toggles, four TagInput lists); Compatibility (four conversion switches); Caching (semantic cache config + embedding provider/model, TTL, threshold, and the CacheOpsPanel Operations block below its save footer); Performance (Initial Pool Size, Max Request Body Size); MCP (agent depth, tool timeouts, Code Mode binding preview, external base URLs); Config (raw export / import / reload, self-loading).
  • Provenance UI: SourceTag renders an env or override badge (nothing for default); EnvHint adds advisory copy. Env-sourced fields are not disabled - the hint is advisory only. The enforcement map is fetched and typed but never rendered.

Settings > Caching, Operations block

CachingPanel renders the semantic-cache configuration form and its PanelFooter, then a divided "Operations" region holding CacheOpsPanel (CachingPanel.tsx:325-340).

  • Purge everything: a destructive Purge cache button behind a ConfirmDialog -> clearCache() DELETE /api/cache (a no-op when FROSTY_CACHE is unset).
  • Purge one entry: a Request JSON textarea (.measure, font-mono, live JSON validation, 2 MB cap) -> deleteCacheEntry(body) DELETE /api/cache/by-key; the result toast distinguishes "Cache entry deleted" from "No matching cache entry".
  • These act immediately and are not part of the settings save above. There is still no cache browsing, statistics, or hit-rate UI.

Settings > Config (#/settings/config)

No PageHeader of its own - it is a panel inside the Settings view. Unlike the other five panels it loads its own data and is not gated on the settings tree, so a failing /api/settings cannot leave it on a permanent skeleton (SettingsView.tsx:188-193).

  • Default provider card: a NativeSelect + Save -> setDefaultProvider() PUT /api/config.
  • Store-off detection: isStoreOff - a 400 mentioning "No persistent config store" replaces the Export/Import/Reload cards with an info banner pointing at FROSTY_PG_URL. The gateway raises that 400 only when ctx.config is absent (routes/admin.ts), which createDefaultContext() never produces, so in a normally booted gateway this branch is unreachable.
  • Export card: Redacted vs Include secrets radio. Secrets mode is gated behind an "Export secrets?" confirm; Download fetches exportConfig(true) -> GET /api/config/export?include_secrets=true and keeps the secret-bearing body in a local const, never React state. Preview is disabled in secrets mode and always fetches the redacted export.
  • Import card: importConfig() -> POST /api/config/import; validated on every keystroke (2 MB cap, JSON validity, Missing config.providers check); the ConfirmDialog names exact provider counts. Reload card: reloadConfig() -> POST /api/config/reload behind a non-destructive confirm.

4.11 #/status - Status

  • Header: title Status, subtitle "Process topology, saturation, limits, and live traffic" (StatusView.tsx:209); action = a Refresh icon button. Polls every 10s (:173).
  • Data (Promise.allSettled over eight calls): getHealth() GET /healthz; getVersion() GET /api/version; getConfig() GET /api/config; getMCPClients() GET /api/mcp/clients; getLogs(500) GET /api/logs?limit=500; getVirtualKeys() GET /api/virtual-keys; getModels() GET /v1/models; getRuntime() GET /api/runtime (StatusView.tsx:87-96).
  • Six traffic KPI tiles (md:grid-cols-3 xl:grid-cols-6): Total requests, Error rate, Avg duration, Providers, MCP servers, Virtual keys - all derived from the in-memory log buffer (max 500, resets on gateway restart).
  • Runtime section (StatusView.tsx:291, defined :414-614) - eight further StatTiles in their own md:grid-cols-3 xl:grid-cols-6 row:
    • Worker processes, captioned with the platform limit when configured !== effective (the platform refused to fan out), otherwise the worker index.
    • Connections open, with peak.
    • Longest open (oldest live connection).
    • Avg lifetime over the last 1000 completed connections, plus max.
    • Dispatching - handlers executing, explicitly excluding streaming.
    • Rate-limited keys as keysWithLimits/totalKeys.
    • DB connections as the estimated fleet total, captioned with the pool size per process.
    • Uptime for this process.
  • Per-process honesty paragraph: with more than one effective worker it states that connection counts and lifetimes are measured per process and that a per-window rate limit admits up to N times its stated value, while budgets are unaffected because they use shared counters (:517-528). With a single worker it prints workers.reason verbatim (:529-533).
  • Rate limits card (only when rateLimit.windows is non-empty): Virtual key / Max requests / Window, mono, numerics right-aligned, caption "Per-key request windows, enforced per process".
  • Shared state card: the PostgreSQL target with credentials stripped; a cross-process invalidation badge reading listening or not active (with the "other workers keep cached entries until TTL" caveat when inactive); and the response-cache mode badge plus this process's local entry count and a shared-tier marker (:597-608).
  • Gateway health card: tone badge + gateway v<version> on Deno <deno> + a checked HH:MM:SS stamp. Model catalog card: uses /v1/models; on a 401 (governance requires a virtual key) it falls back to the configured models with an explanatory line.
  • Network failure: a TypeError from /healthz sets netError and renders an error Banner with a Retry action instead of the cards (:222-239).

5. Navigation and information architecture

5.1 Sitemap

/  (any non-matching hash resolves here)
|
+-- Overview
|   +-- #/dashboard       Dashboard
|   |     sections (SubTabs, no URL): overview | provider-usage |
|   |                                 model-rankings | mcp-usage | user-rankings
|   +-- #/logs            Logs   (Live / Stored is a Button, not a route)
|   +-- #/status          Status
|
+-- Gateway
|   +-- #/providers       Providers   <== DEFAULT ROUTE
|   |     modes (no URL): add | keys | config (config replaces the two-pane)
|   |     config tabs (UnderlineTabs, no URL): Network | Proxy | Performance |
|   |                                          Governance | Beta Headers | Debugging
|   +-- #/model-catalog   Model Catalog
|   +-- #/extensions      Extensions
|         tabs (UnderlineTabs, no URL): mcp-servers | tools | plugins
|
+-- Governance
|   +-- #/virtual-keys    Virtual keys
|   +-- #/teams           Teams
|   +-- #/customers       Customers
|   +-- #/pricing         Pricing
|
+-- System
    +-- #/settings        Settings
        +-- #/settings/security        (default sub-route)
        +-- #/settings/compatibility
        +-- #/settings/caching         (includes cache purge operations)
        +-- #/settings/performance
        +-- #/settings/mcp
        +-- #/settings/config

Legacy, rewritten in place by REDIRECTS (App.tsx:81-84):
    #/cache   -> #/settings/caching
    #/config  -> #/settings/config

11 routes + 6 settings sub-routes = 17 addressable URLs, across 4 nav groups, plus two legacy hashes (#/cache, #/config) that redirect rather than resolve. Overlays are routeless: AdminTokenDialog, CommandPalette, AddProviderDialog, the VK create/edit Sheet, the VK token-reveal alertdialog, the Extensions edit Sheet, and every ConfirmDialog.

5.2 Router mechanism

No router library - a hand-rolled hash router in App.tsx.

Step Behavior Evidence
Legacy rewrite applyRedirect(hash) replaceState-rewrites #/cache / #/config before routing App.tsx:81-122
Route-key extraction baseSegment(hash) strips #//, takes split("/")[0] App.tsx:96-99
Route resolution hashToView returns the base segment if it is in IDS, else "providers" App.tsx:101-104
Live updates a hashchange listener sets the view only when the base segment is a known id; unknown or in-page anchors are ignored App.tsx:197-210
Programmatic nav navigate(id) sets state, closes the mobile drawer, then history.replaceState(null, "", "#/" + id) App.tsx:212-220
View rendering renderView(id) is a switch; default: renders <ProvidersView/> App.tsx:124-149

The canonical URL form is #/<key>. The default route is Providers, encoded twice (hashToView fallback and renderView default). Because both App and Settings only ever call replaceState - there is no pushState anywhere - browser Back/Forward does not traverse view history.

5.3 Nav model

Source of truth is NAV at App.tsx:35-72; groups are derived first-seen by lib/nav.ts, so the order is Overview, Gateway, Governance, System.

# Group id Label (verbatim) Icon
1 Overview dashboard Dashboard LayoutDashboard
2 Overview logs Logs ScrollText
3 Overview status Status Activity
4 Gateway providers Providers Plug
5 Gateway model-catalog Model Catalog Boxes
6 Gateway extensions Extensions Puzzle
7 Governance virtual-keys Virtual keys KeyRound
8 Governance teams Teams Users
9 Governance customers Customers Building2
10 Governance pricing Pricing CircleDollarSign
11 System settings Settings SlidersHorizontal

DatabaseZap and FileCog, the icons for the retired Cache and Config leaves, are no longer imported anywhere.

Label casing is intentionally mixed (Virtual keys lowercase k vs Model Catalog title case); the Virtual Keys view's own PageHeader title is Virtual Keys (capital K), so nav label and page title differ for that route. Nav grouping matches CONVENTIONS.md:156-159 exactly: Logs and Status both sit in Overview, the contract's stated rationale being that Status answers "is the gateway healthy right now".

5.4 Navigation patterns

  1. Persistent grouped sidebar (primary): <nav aria-label="Sections"> with collapsible group headers, roving tabIndex over visible leaves, aria-current="page" on the active leaf, and a left accent bar via a before: pseudo-element.
  2. Sidebar search box (placeholder Search... (Ctrl K)): filters through filterNav (case-insensitive on label OR group); no matches renders "No matching views."
  3. Command palette (Cmd/Ctrl-K): a global keydown toggles it; it searches the same NAV with the same filterNav (items={NAV}, App.tsx:330), and onSelect(id) is navigate - so it can only navigate between the 11 views; there are no commands, actions, or resource search.
  4. View-level tabs: SubTabs (Dashboard), UnderlineTabs (Settings, Extensions, provider config). Only Settings mirrors its tab into the URL.
  5. Master/detail: TwoPane in Providers only.
  6. Mobile drawer: hamburger opens the off-canvas sidebar; scrim, X, or any leaf selection closes it.

5.5 Route guards and redirects

There is no authentication route guard and no login screen. Unknown or garbage hashes resolve to Providers as a render fallback and the URL is left alone; an unknown hashchange does nothing; #/settings/<invalid> falls back to security.

Two legacy hashes are the exception: #/cache and #/config are rewritten in place to #/settings/caching and #/settings/config by applyRedirect (App.tsx:111-122), using replaceState so Back does not bounce between the old hash and its replacement. It runs on boot (:153) and on every hashchange (:202). Only a BARE legacy hash redirects - #/cache/anything was never a minted route, so rewriting it would invent a destination (App.tsx:90-93, locked by App.nav.test.tsx).

Auth is a banner, not a guard: a 401 on an admin surface sets a module singleton to denied (api.ts), and the app renders a red Banner with a Set token action above <main>; views keep rendering their own error/empty states underneath. AdminTokenDialog is never auto-opened. Saving or clearing the token bumps authNonce, which changes the content wrapper key and remounts the active view.

5.6 Primary user flows

Set the admin token (prerequisite for everything). A 401 renders the red banner. Click Set token (or the sidebar Admin token button) -> type into a single type="password" field -> Save token writes sessionStorage["frosty.admin-token"], resets auth to unknown, toasts "Admin token saved", and closes. onTokenChange bumps authNonce so the active view remounts and refetches. The token is sent as Authorization: Bearer on every /api and /metrics request and dies with the tab.

Add a provider. Providers -> Add New Provider -> AddProviderDialog gallery (27 presets, optionally searched) -> pick a preset -> the right pane renders AddProviderForm prefilled with {id, type, baseUrl} -> enter the API key plus type-specific fields (Azure endpoint + version + deployment/model name; Bedrock region + keys; Vertex project + service-account JSON) -> submit -> POST /api/providers -> toast + list reload + the new provider selected in keys mode. The Add Custom Provider flow renders the inline AddCustomProviderForm in the detail pane (Name, Base Format, Base URL, Is Keyless?, and an advisory request-type grid that is never sent).

Deep provider configuration. From ConfiguredKeys click Edit Provider Config -> ProviderConfigPanel replaces the two-pane, with six UnderlineTabs (Network, Proxy, Performance, Governance, Beta Headers, Debugging). Each group is diffed by a deterministic JSON.stringify signature and only dirty groups enter the patch (because the gateway PUT shallow-merges at the top level, re-sending an unchanged group would wipe its redacted secrets). Live "at-risk" warn banners fire while editing. A sticky footer holds Remove configuration and Save configuration -> PUT /api/providers/:id.

Create a virtual key. Virtual Keys -> Add Virtual Key -> Sheet in create mode -> Name, Description, Active -> add providers one at a time via the Combobox -> Allowed models (default All models) -> optional Budget and rate limits -> Create -> POST /api/virtual-keys -> the one-time token opens in a non-dismissible reveal dialog with Copy token and Done.

Inspect a log. Logs opens in Live mode; the SSE stream connects and LiveBar announces connection state. To search history, toggle Live off (only when the KV probe succeeded); the stored query runs debounced with a server-side q param. Narrow with free text, the TimeRangePicker, and the Outcome checkboxes; adjust visible columns via Choose columns; sort by Time or Latency, 25 rows a page. There is no per-row payload inspector.

Change a gateway setting. Settings -> pick a sub-tab (hash updates to #/settings/<sub>) -> edit fields -> Save changes is disabled until the computed diff is non-empty -> Save -> PUT /api/settings with only the changed group -> the full tree is re-read plus a "Changes saved" toast.

Explore / invalidate cache. The only cache-facing surfaces are Settings > Caching - the semantic-cache configuration form plus the Operations block below its save footer (purge everything behind a confirm, or purge one entry by pasting the exact request JSON) - and the Dashboard's two permanently empty hit-rate cards. Status > Shared state additionally shows the response-cache mode and this process's local entry count (StatusView.tsx:597-608). There is still no cache key browser, entry listing, or hit/miss statistics view.


6. Interaction states

6.1 Loading

Skeletons over a bare spinner page, enforced structurally: Spinner has exactly one importer, button.tsx, so it is unreachable except inside a button.

Mechanism Site
Skeleton / TableSkeleton / TileSkeleton / PanelSkeleton (composites aria-hidden) skeleton.tsx
DataTable loading (one row of TableSkeleton) data-table.tsx:204-211
StatTile loading / ChartCard loading stat-tile.tsx:15, ChartCard.tsx:105
Settings LoadingPanel (four Skeleton bars) SettingsView.tsx:199-210
Button isLoading (disables + prepends Spinner) button.tsx:36,57,70
ConfirmDialog pending (disables Cancel, isLoading on Confirm) dialog.tsx:125,131

6.2 Empty

EmptyState (dashed block with icon / title / body / optional action); DataTable empty row (default "No results."); ChartCard auto-empty ("No data available" + optional note - an all-zero window never renders a misleading flat line, and the toggle is hidden); Combobox/FacetRail "No matches."; sidebar/palette "No matching views." The shared analytics empty string "No data available" is used at five render sites.

6.3 Error

Banner tone="error" (role="alert"); ErrorBanner({message}); field error text text-destructive with id={id}-error; aria-invalid:border-destructive on every input; PemTextarea non-blocking text-warning hint; inline <p role="alert" class="text-destructive"> (ConfigPanel, CacheOpsPanel); Toast tone="error"; the app-level 401 banner with a "Set token" action. Error banners quote the gateway's error.message verbatim via ApiError.message.

6.4 Success / confirmation

ToastProvider + useToast().success/error/info; auto-dismiss 5000ms (6000ms with an action); container aria-live="polite", fixed bottom-right, tone-colored icon only on bg-popover / shadow-lg. CopyButton shows a 2s "Copied" state; MaskedSecretCell copy flips its aria-label to "Copied" with a text-success check. useToast() returns a no-op API outside a provider so views render standalone in tests.

6.5 Destructive confirmation

Every destructive action is ConfirmDialog-gated, and the body always names the concrete blast radius (exact stored-entry counts on Logs; the last-key governance warning on Virtual Keys; reference counts on Teams/Customers; exact provider counts on Config import; a plaintext-secret warning on Config export; priced-model counts on Pricing). ConfirmDialog defaults to destructive={true} and its overlay click does not dismiss.

6.6 Form validation

There is no shared validation library; every form hand-rolls its checks. Two patterns coexist:

  • Banner-at-the-bottom, submit-time, first-failure-wins for the dialogs and the two big forms: AddProviderForm, AddCustomProviderForm, VKForm, TeamDialog, CustomerDialog.
  • Inline, live, per-field for PricingView (every keystroke), ConfigPanel import (every keystroke), CacheOpsPanel purge-one (live), and BudgetField (Teams/Customers).

ProviderConfigPanel has no field validation - only live "at-risk" warn banners; nothing blocks Save. Settings panels have no validation - Save is disabled until the diff is non-empty. A notable gap: clearing a numeric settings field to blank produces no diff, so a numeric setting can be changed but not unset from the UI.

6.7 Honest-empty / advisory UI

Large parts of the analytics and logs surface are deliberately "honest empty": they render explicit "not recorded yet" copy rather than hiding. Examples: the Dashboard cache-hit-rate cards, Provider Latency, MCP usage, and User Rankings;

The Logs Type/Provider/Model/Tokens columns are no longer in this category - decision-log item 56 wired the telemetry enrichment that populates them, and the Models/Provider/Type facet groups became live filters. Those cells still render N/A on a row that carries no value (a health probe has no model), which is row-level honesty rather than a missing data source; the Cost facet group says "No filter yet" instead of "Not recorded yet" because cost IS recorded per entry and only the range control is unbuilt. Remaining honest-empty examples: the 10 honest-empty Logs facet groups; the Providers "Weight" column (hardcoded 1); the Add Custom Provider request-type grid (advisory, never sent); the provider-config "Proxy Type" (advisory); the Settings "Code Mode Binding Level" (preview-only, never persisted). No UI is gated on a build-time flag or a browser-read env var; every "off" state is either a server response (404, tracked:false, empty list) or hardcoded.


7. Theming

Light and dark are both fully supported, and a user-facing theme switcher DOES exist - it is the second button in the sidebar footer (an icon button labelled Switch to {light|dark} theme, rendering lucide Moon when dark and Sun when light, Sidebar.tsx:308-322).

Step Evidence
Default class on the document index.html:2 - <html lang="en" class="dark">
Pre-paint resolution (blocking inline script) index.html:7-22: reads localStorage["frosty.theme"]; if unset, dark = !matchMedia("(prefers-color-scheme: light)").matches (dark unless the OS explicitly asks for light; no-preference stays dark); toggles .dark and sets documentElement.dataset.theme, all in try/catch
React state seeded from the DOM App.tsx:168-170
Toggle handler App.tsx:234-247 - flips state, toggles .dark, sets dataset.theme, persists to localStorage in try/catch
CSS mechanism Light values are the :root base; dark is a .dark override; color-scheme per theme so native widgets follow
data-theme attribute set on <html> but no CSS keys off [data-theme] - it is informational/hook-only; the .dark class is the sole styling switch

Token mapping across themes: 38 color tokens + 3 shadow tokens are re-declared under .dark; every other token (typography, spacing, radius, motion, z-index, control metrics) is theme-independent. Light->dark inverts luminance while holding hue (neutrals stay hue 265; semantic hues stay red 25 / green 155 / amber 70->82 / blue 250-255 / violet 300). --primary is the clearest inversion (near-black in light, near-white in dark). --sidebar is always the recessed surface. --sidebar-primary-foreground is the only color token with an identical value in both themes. Alpha-composited surfaces (bg-<tone>/16, etc.) re-resolve automatically because they composite the theme-current token.

There is no "system / auto" tri-state. Once the user clicks the toggle the stored value wins forever; there is no "follow system" option and no matchMedia change listener after boot. The theme key frosty.theme lives in localStorage (persists across tabs), whereas the admin token frosty.admin-token lives in sessionStorage (dies with the tab) and the sidebar collapse state frosty.sidebar in localStorage.


8. Accessibility

8.1 Implemented global measures

Measure Evidence
Visible focus ring on every focusable element: 2px solid var(--ring) at 2px offset index.css:29-32
44px minimum hit-area helper .hit-target (a centred max(100%, var(--tap-target)) pseudo-element); 34 applications across 24 files index.css:77-90
Skip link <a href="#main">Skip to content</a> -> <main id="main" tabIndex={-1}> App.tsx:258,308-311
Heading focus on nav change: #main h2 is focused on every view change except first mount App.tsx:186-195, page-header.tsx:26-32
lang="en" + viewport meta index.html:2,5
Reduced motion honoured globally (tokens to 0, keyframes off, spinner motion-reduce:animate-none, reduceMotion() guard) tokens.css:250-258, index.css:124-129, spinner.tsx:11, LogsTable.tsx:189
Semantic landmarks: <main>, <nav aria-label="Sections">, <aside>/<section> with aria-label App.tsx:308, Sidebar.tsx:206-207, two-pane.tsx:44-56
Semantic headings h1-h4 (brand / page title / dialog / card / settings sub-section) Sidebar.tsx:156, page-header.tsx:26, dialog.tsx:50, card.tsx:36, helpers.tsx:98
Real table semantics: thead/tbody/th scope="col"/real <caption> table.tsx:35-102
Native label htmlFor associations label.tsx:9,33, facet-rail.tsx:150, number-field.tsx:50

Per-component ARIA is thorough: Banner role="alert"/status; Dialog role="dialog" + aria-modal + aria-labelledby; ConfirmDialog role="alertdialog"; Sheet + useModal provide a capture-phase focus trap, scroll lock, and focus restoration; DropdownMenu / Combobox / TimeRangePicker carry full menu/listbox/combobox ARIA with roving tabIndex and keyboard handling; DataTable exposes aria-sort; Tabs/NavTabs/SegmentedSelect use tablist/radiogroup roles; Switch is role="switch"; Chart is role="img" + ariaLabel; the log LiveBar is aria-live="polite". A pinned accessible-name contract (CONVENTIONS.md:172-175) keeps the nav leaves Providers, Status, Logs, Extensions uniquely matchable - visible in four deliberate accommodations (aria-label="Reload configuration", the non-sortable Virtual Keys "Status" column, the "Providers" -> "Provider" facet relabel, and the "Command menu" palette input name).

8.2 Contrast evidence (asserted, not code-verified)

DESIGN.md:333-450 publishes a computed contrast audit - 22 text/UI pairs, 5 chart marks, and 5 soft badges per theme - claiming "0 failures across 64 checked pairs (both themes)". Representative dark/light figures: --foreground on --background 18.84:1 / 17.59:1 (AAA); --muted-foreground on --muted 6.52:1 / 6.04:1 (AA); --ring on --background 5.41:1 / 4.76:1 (AA). Weakest declared margins are the dark soft-destructive badge at 4.64:1 and the light accent link at 4.76:1. These numbers are prose only - no OKLCH-to-sRGB converter, contrast function, fixture, or test exists in the repo to recompute them, so contrast is UNVERIFIED from code.

8.3 Known accessibility gaps (found in review)

# Gap Evidence
A1 Tab-panel aria-labelledby is dangling. tabPanelProps(v) emits aria-labelledby="tab-${v}" but NavTabs buttons carry id="navtab-${v}". Every panel using tabPanelProps is paired with NavTabs (Settings, ProviderConfigPanel, Dashboard), so the reverse link is broken app-wide (aria-controls -> panel-${v} does match) tabs.tsx:73 vs nav-tabs.tsx:61
A2 Menu-item focus indicator suppressed. DropdownMenu and TimeRangePicker items set outline-none and rely on focus-visible:bg-accent; in dark, --accent on --popover is a very low-contrast wash dropdown-menu.tsx:251,253, time-range-picker.tsx:172,174
A3 Loading is silent to assistive tech. All skeleton composites are aria-hidden and no aria-busy, role="status", or role="progressbar" exists anywhere skeleton.tsx:21,43,53; grep
A4 Field does not wire aria-describedby/aria-invalid. It renders #{id}-hint/#{id}-error and defers association to each caller label.tsx:27,42-51
A5 aria-controls targets are often absent. Inactive tabs reference panel-${v} ids for panels that are conditionally unmounted tabs.tsx:47, nav-tabs.tsx:63
A6 Combobox has no <label for> and does not restore focus after a mouse selection; it relies on aria-label combobox.tsx:88-95,148
A7 Overlapping hit-target boxes. .hit-target::after is a 44px box with no z-index; adjacent 28px icon buttons at gap-2 (8px) produce overlapping hit areas, and the later DOM sibling wins the gap (read off the CSS, not observed) index.css:82-90, masked-secret.tsx:100,128
A8 ScrollContainer is an unconditional tab stop (tabIndex={0}) on every table, even with nothing to scroll (a deliberate documented trade-off) table.tsx:22
A9 Programmatic focus is visually silent. The PageHeader h2 that App.tsx focuses on every nav change carries outline-none; <main> (the skip-link target) also carries outline-none page-header.tsx:29, App.tsx:194,311
A10 TagInput input has no aria-label and commits on blur; its remove-chip button lacks hit-target tag-input.tsx:46-53,56-64
A11 Checkbox relies on accent-primary only (native mark), so the checked-state contrast is the browser's, uncovered by the audit checkbox.tsx:13
A12 Nested live regions in the toast stack (container aria-live="polite" plus each toast's own role="alert"/status) may double-announce (read off markup) toast.tsx:101,109
A13 ChartCard bar/line toggle buttons have no hit-target - a 28px grid size-7 target, below the 44px minimum ChartCard.tsx:150-161
A14 ProviderIcon/Avatar tiles carry no hit-target (though they are decorative role="img", not controls) provider-icon.tsx:119-165
A15 Chart data is image-only. Chart exposes only role="img" + ariaLabel; values, axes, tooltip, and legend markers are all aria-hidden, with no table fallback or textual summary chart.tsx:123,141-143,225-227
A16 No disclosure semantics on the Logs rail toggle. "Hide filters" / "Show filters" are plain buttons that conditionally mount/unmount the rail without aria-expanded LogsView.tsx:301-323

Notes on divergences and what could not be verified

  • The shipped app does not use shadcn/ui or Radix; every primitive is hand-written on clsx + tailwind-merge + lucide-react, despite DESIGN.md's shadcn install instructions. DESIGN.md also prescribes overlay enter animations, toast semantic-left-borders, xl dialog titles, and 3xl stat values that the code does not implement (it uses no overlay animation, uniform toast borders, text-lg titles, and text-2xl stats).
  • The provider-logos file used to carry 28 em dashes (U+2014) inside its own comments/JSDoc, contradicting the "zero em/en dashes" house rule; they have been replaced with plain hyphens and the file is now clean. No lint rule enforcing that rule was found in the SPA's deno.jsonc or the root deno.jsonc, so machine enforcement is still unverified - the rule holds by review only.
  • Document title, sidebar h1, and mobile bar all read "Klanker Gateway Manager" (index.html:6, Sidebar.tsx:162, App.tsx:290), while DESIGN.md:16 names the surface "Frosty Control Plane" - naming is inconsistent between the design doc and the shipped shell.
  • Rendered appearance was not observed in a browser; Tailwind v4 layer ordering, @theme inline substitution, and the unlayered :root block beating @layer theme for --tracking-*/--font-weight-* are reasoned from CSS cascade rules. Tailwind's default breakpoint values were not read from node_modules (they are the documented v4 defaults; no override exists here).
  • The separate tests/browser/ Playwright harness (outside deno task test, manual-only) was not read; any visual-regression or automated a11y assertions there are unaccounted for.