SysDeck/klanker-gate/apps/control-ui/CONVENTIONS.md

211 lines
11 KiB
Markdown
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|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 `<PageHeader title subtitle actions>`. 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: `<Banner tone="error">` 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 `<Tabs>`. 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<T>` for every resource list. Never hand-roll `<table>` 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 `<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.
- `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
- `<EmptyState icon title body />`; 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 `#/<view>/<sub>` 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.