# Control UI conventions (flagship contract) Shared build patterns every control-ui view follows. Set by the Providers flagship pass; later views (Model Catalog, Settings, Logs, MCP, Governance, Dashboard) must match. When in doubt, read `ProvidersView.tsx` and its `components/providers/*` for a worked example. Design system is **ds-r2** (dense neutral-monochrome dark console). Tokens live in `src/styles/tokens.css` - do not hand-edit component CSS to diverge; consume the Tailwind token classes. ## Taste hard-rules (non-negotiable, from `taste.md`) - **Zero em/en dashes** anywhere. Plain hyphen only. (deno lint + review check.) - **One cool-blue accent** (`ring`, links, active-nav indicator) - never a fill. Primary is a monochrome emphasis surface, not a chromatic color. - **Monochrome-first.** Status/semantic color (success/warning/destructive/info) is a functional vocabulary only; never decorative. - **Dual-theme AA** in both light and dark. Every text/surface pair is contrast-checked. - **6px radius family**, one system. `rounded-sm|md|lg|xl` map to 4/6/8/12px. - **44px min hit area** (`hit-target` class), **2px focus ring @ 2px offset** (global `:focus-visible`), **full keyboard path** on every control. - **lucide-react icons only**; no hand-rolled decorative SVG, no glow, no glassmorphism. Elevation via borders + surface steps. - **Motion is feedback-only** (`--motion-fast|default|slow`); reduced-motion collapses to 0. No decorative animation. - **Same-origin only.** No external CDN/script/font/style/image origins. - **Frosty identity.** Own brand, `sk-`/`vk-` prefixes; never the reference product's name, logos, or hues. ## Page structure - Every view opens with ``. The title is an `h2` and the focus target on nav change (do not add a second `h2`). - Primary actions go in `PageHeader actions` (right cluster). Destructive or bulk actions live inside the relevant card/panel, not the header. - **Keep a destructive operation away from a Save button.** When a panel mixes configuration edits with immediate-effect operations, the operations go below the panel's save footer behind a divider, in their own labelled block - see `CacheOpsPanel` mounted under `CachingPanel`'s `PanelFooter` (`CachingPanel.tsx:322-340`), so a destructive purge is never adjacent to the Save button that applies configuration edits. - Content max width is `--container-max` (110rem / 1760px; the app shell applies it). Page gutters use `--gutter`, which tightens from 24px to 16px at <= 48rem so a tablet does not spend a quarter of its width on padding. Tables and dashboards may use the full width; anything text-heavy takes `.measure` instead. - Errors: `` quoting the gateway's `error.message` verbatim (`ApiError.message`). Info/warn use `tone="info"|"warn"`. - Loading: `TableSkeleton` / `TileSkeleton` / `Skeleton`, never a bare spinner page. ## Tabs: SubTabs vs UnderlineTabs vs Tabs - **`SubTabs`** (pill row) - dashboard-style section switcher across a wide surface (e.g. Overview / Provider Usage / Model Rankings). - **`UnderlineTabs`** - configuration panels and settings sub-navigation (Providers config Network/Proxy/...; Settings Security/Compatibility/...). This is the "sub-page within a view" bar. - **`Tabs`** (segmented, on a muted track) - available for a small binary/ternary local switch inside a card, but currently unused: no view renders ``. Only `tabPanelProps(value)` and the `TabItem` type are consumed today. Logs Live/Stored is a `Button` with `aria-pressed`, not a Tabs instance. Reach for `Tabs` only when a real segmented switch appears; otherwise prefer `SubTabs` / `UnderlineTabs`. - Always pass a unique `label`; spread `tabPanelProps(value)` on the matching panel container for the `role="tabpanel"` wiring. ## Tables: `DataTable` Use `DataTable` for every resource list. Never hand-roll `` sorting. - `columns`: `{ key, header, cell, sortValue?, align?, width? }`. Provide `sortValue` to make a column sortable; `headerLabel` when `header` is not plain text. - `rowMenu`: return a `` for per-row actions (Edit / Make default / Delete). Keep row action clusters out of cells; the kebab is the pattern. - `pageSize`: set to enable the "Showing X-Y of Z" footer + prev/next. - `caption` is required (labels the scroll region + screen readers). - `empty`: pass a node; default copy is "No results." Prefer the shared empty string **"No data available"** for analytics-style empties (see EmptyState). ## Forms - Field grid: **`.field-grid`** (index.css). It is `repeat(auto-fit, minmax(min(100%, 16rem), 22rem))` - as many columns as fit, each capped at 22rem. Do NOT go back to `sm:grid-cols-2`: that sized every field to half the container, so one "ID" input was 350px in a wide pane and 560px at the current container width. The 22rem cap is the point - a field stops growing at a width appropriate to its content and leftover space stays empty. Use **`.field-wide`** (a direct child of `.field-grid`) for fields that genuinely need the row: JSON blobs, PEM, long descriptions. - Wrap prose and single-column form panels in **`.measure`** (`--measure-max`, 60rem). Raising `--container-max` to 110rem means an uncapped label-control pair can span 1760px, which puts the label a screen away from its input. - **`Field`** (`label` + control + hint/error) for text inputs (`Input`, `Textarea`, `NativeSelect`). `NativeSelect` is the only select for plain option lists (G9). - **`NumberField`** for numeric config (unit suffix + help). Emits a raw string so empty stays representable; parse with a `numOrUndef` helper on save. - **`KeyValueRows`** for repeatable Name/Value editors (extra headers). - **`PemTextarea`** for PEM/cert blobs (non-blocking validity hint). - **`SegmentedSelect`** for inline 2-3 option choices (beta-header override default/enabled/disabled). - **`Combobox`** for searchable single-select (reset periods, model/customer filters). - **`ToggleGridItem`** for a labelled switch tile in a responsive grid. - **`Switch`** for a lone boolean; wrap with a label + description row. ## Secrets (never render values) The server returns **redacted** views: secrets become `hasX` presence markers (`hasApiKey`, `hasCloudCredentials`, `hasProxy`, `hasProxyPassword`, `hasCaCert`). The raw value never reaches the browser. - Show a **marker** ("Configured" + `••••••••`) plus a **Replace** affordance that reveals an input to submit a _new_ secret. Use `components/providers/SecretReenter` for single-line secrets; `PemTextarea` (with a "Configured" badge) for `caCertPem`. - Blank input = keep the current server value where possible. - `MaskedSecretCell` (reveal + copy) is for values the browser legitimately holds once - e.g. a freshly minted `vk-` token in a create response - never for a redacted-at-rest secret you cannot actually reveal. - **Gateway PUT is a shallow top-level merge** (`{...existing, ...patch}`). A nested group you send _replaces_ the stored group, dropping any redacted secret it contains. So: diff each config group against its loaded state and send only changed groups (see `ProviderConfigPanel`), and warn when a save would clear a stored secret the operator did not re-enter. ## Status pills (`Badge`) - `tone="muted"` for neutral labels (CUSTOM, default, counts). Prefer muted to stay monochrome. - `tone="ok|warn|err|info"` only for genuine status semantics (enabled, missing key, error, read-only). Soft variant by default; `solid` sparingly. - Key presence in lists: plain `"set"` / `"missing"` micro-text (muted / warning), not a loud pill. ## Empty states - ``; use `tone="info"` for "feature off / coming later" notices. Standard analytics empty copy is **"No data available"** to match the reference. ## Fixed-width rails must clip Any fixed-width flex rail (`TwoPane`'s `aside`) needs `overflow-hidden`, and any badge/marker cluster inside a flex row needs `shrink-0`. Without both, a row whose intrinsic content exceeds the rail paints its trailing badges OUTSIDE the rail and on top of the neighbouring pane - the Providers overlap bug. The combination makes a crowded row a truncation problem instead of an overlap one. Locked by `two-pane.overflow.test.tsx`. ## Density & spacing - Body 13.5px (`text-base`), secondary `text-sm`, micro labels `text-2xs` uppercase tracking-wide muted. Mono (`font-mono`) for all telemetry: ids, keys, latency, cost, tokens, versions. - Control heights: `--control-h` (34px) default, `--control-h-sm` (30px) dense. - Card padding `px-5 py-4`; section gaps `gap-4`/`gap-5`; page section spacing `mb-5`/`mb-6`. ## Navigation & routing - The IA is grouped: **Overview** (Dashboard, Logs, Status) / **Gateway** (Providers, Model Catalog, Extensions) / **Governance** (Virtual keys, Teams, Customers, Pricing) / **System** (Settings). - **Status is an Overview leaf**, not System: it answers "is the gateway healthy right now", which sits with Dashboard and Logs rather than with configuration. - **Cache and Config are Settings tabs**, not views. `#/cache` and `#/config` are kept alive by `REDIRECTS` in `App.tsx`, which `history.replaceState`s them onto `#/settings/caching` and `#/settings/config`. When you fold a view into a tab, add the redirect - a bookmark that lands on the fallback view reads as a broken link, not as a reorganization. - Hash router in `App.tsx` keys off the **first** hash segment (`baseSegment`), so a view owning sub-pages uses `#//` and manages its own sub-nav + `history.replaceState` (see `SettingsView`). Add a view by extending `NAV` + `renderView`; the sidebar, search, and Cmd/Ctrl-K palette pick it up automatically. - **Pinned browser contract:** keep nav leaves matchable by accessible name `Providers`, `Status`, `Logs`, `Extensions` (unique). Do not introduce sibling elements whose accessible name _contains_ a pinned token (avoid a button named "Refresh providers" - collides with "Providers"; keep aria labels distinctive, e.g. "Reload configuration"). ## api.ts client (Phase 3b endpoints, already wired) `src/api.ts` owns the transport, types, and endpoint clients. Consume these; do not add `fetch` calls in views. - `getCatalog(): CatalogView` - `GET /api/catalog` (Model Catalog). - `getSettings(): SettingsView` / `putSettings(update): SettingsView` - `/api/settings`. Each group has `{ values, sources }`; `sources[field]` is `default|env|override` (drive a provenance pill) and `enforcement["g.field"]` is whether the gateway enforces it. - `getCodeModeVfs(binding): CodeModeVfsView` - `GET /api/mcp/codemode/vfs`. - `getRuntime(): RuntimeView` - `GET /api/runtime` (Status > Runtime). Process topology, saturation, and limit state. Two of its numbers are **per-process** (`concurrency.*` and per-window `rateLimit`), and the UI must label them as such on the tile itself, not only in a footnote: under `FROSTY_WORKERS=N` an unqualified "12 in flight" reads as fleet-wide and under-reports load by a factor of N. Budgets are unaffected - those run on shared atomic counters. Render `workers.reason` verbatim; it is the gateway's own explanation of why fan-out did or did not happen. - Providers: `getConfig`, `createProvider`, `updateProvider`, `deleteProvider`, `refreshModels`, `setDefaultProvider`. All clients normalize partial/malformed bodies and (catalog) treat a 404 as "feature off", so consumers stay total.