11 KiB
Executable File
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|xlmap to 4/6/8/12px. - 44px min hit area (
hit-targetclass), 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
<PageHeader title subtitle actions>. The title is anh2and the focus target on nav change (do not add a secondh2). - 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
CacheOpsPanelmounted underCachingPanel'sPanelFooter(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.measureinstead. - Errors:
<Banner tone="error">quoting the gateway'serror.messageverbatim (ApiError.message). Info/warn usetone="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<Tabs>. OnlytabPanelProps(value)and theTabItemtype are consumed today. Logs Live/Stored is aButtonwitharia-pressed, not a Tabs instance. Reach forTabsonly when a real segmented switch appears; otherwise preferSubTabs/UnderlineTabs.- Always pass a unique
label; spreadtabPanelProps(value)on the matching panel container for therole="tabpanel"wiring.
Tables: DataTable
Use DataTable<T> for every resource list. Never hand-roll <table> sorting.
columns:{ key, header, cell, sortValue?, align?, width? }. ProvidesortValueto make a column sortable;headerLabelwhenheaderis not plain text.rowMenu: return a<DropdownMenu>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.captionis 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 isrepeat(auto-fit, minmax(min(100%, 16rem), 22rem))- as many columns as fit, each capped at 22rem. Do NOT go back tosm: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-maxto 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).NativeSelectis the only select for plain option lists (G9).NumberFieldfor numeric config (unit suffix + help). Emits a raw string so empty stays representable; parse with anumOrUndefhelper on save.KeyValueRowsfor repeatable Name/Value editors (extra headers).PemTextareafor PEM/cert blobs (non-blocking validity hint).SegmentedSelectfor inline 2-3 option choices (beta-header override default/enabled/disabled).Comboboxfor searchable single-select (reset periods, model/customer filters).ToggleGridItemfor a labelled switch tile in a responsive grid.Switchfor 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. Usecomponents/providers/SecretReenterfor single-line secrets;PemTextarea(with a "Configured" badge) forcaCertPem. - Blank input = keep the current server value where possible.
MaskedSecretCell(reveal + copy) is for values the browser legitimately holds once - e.g. a freshly mintedvk-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 (seeProviderConfigPanel), 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;solidsparingly.- Key presence in lists: plain
"set"/"missing"micro-text (muted / warning), not a loud pill.
Empty states
<EmptyState icon title body />; usetone="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), secondarytext-sm, micro labelstext-2xsuppercase 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 gapsgap-4/gap-5; page section spacingmb-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.
#/cacheand#/configare kept alive byREDIRECTSinApp.tsx, whichhistory.replaceStates them onto#/settings/cachingand#/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.tsxkeys off the first hash segment (baseSegment), so a view owning sub-pages uses#/<view>/<sub>and manages its own sub-nav +history.replaceState(seeSettingsView). Add a view by extendingNAV+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]isdefault|env|override(drive a provenance pill) andenforcement["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-windowrateLimit), and the UI must label them as such on the tile itself, not only in a footnote: underFROSTY_WORKERS=Nan 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. Renderworkers.reasonverbatim; 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.