# Web console design system — design **Issue #23. Status (2026-09-25): shipped** (Signal as the baseline; the BAY skin rides the same system — see [`design-skin-system.md`](design-skin-system.md)). This is the durable home of the console's design rules — the skeleton, the content language and the interaction language — extracted from the machine-local working record (`docs/dev/ui-refactor/`: `01-skeleton`, `02-content`, `03-interaction`, `04-contract`) and from the exploration comps under `docs/dev/theme-designs/01-bay/`, which are the **spec** for anything visual. `apps/web` is the implementation; where this page and the code disagree, the code wins and this page is wrong. ## 1. Thesis — structure belongs to the base, materials belong to the skin The interface is a **signal console**: colour encodes connection state only, everything else is a disciplined neutral, and the one accent (`--signal`) marks what is live. The three layers below are **product-level information-design decisions**, so they live in the base and every skin shares them: the skeleton (a rack of numbered bays, panels, page identity in the chrome), the content language (protocol material on a designed surface, status in two channels), and the interaction language (four state surfaces, confirmations that state consequences, four sanctioned motions). A skin decides **materials** only: colour, font family, radius, shadow, texture, the shape of a status mark. It reaches the base through exactly four extension points — the `data-*` hooks, the empty `Region` slots, the topology registry and the token contract ([`design-skin-system.md`](design-skin-system.md) §Hook vocabulary). A skin that needs a structure the base does not have means the base is missing a hook; add the hook. ## 2. The one content principle > **Protocol material gets a designed surface; prose stays prose.** The product's subject matter is protocol material: machine names and hosts, paths, versions, session ids, transport types, job states, credential names (`${cred:NAME}`), command lines, token counts. | | Protocol material (data) | Prose | |---|---|---| | Examples | `a97bb4e6-…`, `/home/you/web`, `sse`, `0.9.4`, `hnx mcp serve`, `succeeded`, `34.1k/200k` | explanations, subtitles, empty-state copy, error descriptions, field hints | | Surface | **Well** (inset, mono, tabular, copyable, truncating) | body/hint type, line length ≤ 68ch | | Never | inside a text paragraph | inside a Well | Three prohibitions, checked in review: 1. **No sentences in a Well** — the one exception is `Note`, which carries an error code plus one sentence (`hnx/prompt-error: Authentication required`) because that pair IS the material. 2. **No protocol material outside a Well** — the single exception is a machine name in a page title, where it is the page's identity rather than a value to copy. 3. **State is never carried by colour alone** — a lamp plus a word/shape; `data-state` is the truth and styling only renders it. The mapping (write cells from this table): | Data | Device | |---|---| | machine name, user name | body type, 600 weight (not a Well); truncates, full value on hover | | host, platform, version, path, URL, session id, credential name | `Well` (single line, truncate + copy) | | transport, scope, role, kind, target, trust | `Badge` (an enum, not a Well) | | state word | `Lamp` + word | | count without a denominator (`12 tools`) | tabular mono, right-aligned | | count with a denominator (online/total) | `Readout` as a fraction + a qualifier | | relative time | mono `12m ago`, absolute time in the title | | a command a human runs | `CommandLine` (Well + copy + placeholders checked against real values) | | config JSON, diff, terminal output | `Well code` (syntax colours from tokens) | | explanation, subtitle, empty-state copy | body / hint type, ≤ 68ch | Two devices look alike and split on one question — **does clicking do something?** `Badge` is an enum (transport, scope, role, kind, target, trust); `Chip` is an interaction (open that machine's chat, pick a model, `+ add model`). **Numbers never lie.** A key count is always numerator/denominator (denominator dimmed) plus a qualifier in hint type — `2/5` and `3 offline`, not `5 agents`. The qualifier states facts (`1 error · 1 down`), never reassurance. A fetch that failed renders **nothing** rather than zeros. **Accent budget — one live thing per screen.** `--signal` is spent in four places only: the current bay, the active tab/channel, the one thing that is genuinely live (a working session, the mesh hub, a running job), and focus rings + links. Primary buttons are ink, not the accent. Review check: count the accent in a screenshot — more than four and something is wrong. ## 3. Shell geometry ``` ┌────┬───────────────┬──────────────────────────────────────────────────────┐ │ │ ▸ ASSET / MCP 文 A ◐ │ 56px chrome bar │ 01 │ HN RACK │ machines 1/1 │ mcp 0/1 │ jobs 0 │ chat 2 │ 38px readout strip │ NAV│───────────────┼──────────────────────────────────────────────────────┤ │ │ Home │ ┌ MCP SERVERS ─────────────── 0/1 online ────────┐ │ │ 02 │ Chat 2 │ │ [search…] [transport ▾] [scope ▾] │ │ │ MESH│ Machines 5 │ ├────────────────────────────────────────────────┤ │ │ │ … │ │ NAME TRANSPORT CREDENTIAL STATUS ACTIONS │ │ └────┴───────────────┴──┴────────────────────────────────────────────────┴──┘ 30 168 content, full width ``` - **Spine (30px, desktop only)** — the rack: one module per nav group, its number (`01`…`05`) above its name, the name running down the column. Numbers are **addresses, not sequence numbers** (from a fixed registry, so hiding a group never renumbers the rack). - **Plate (168px, desktop only)** — brand, nav (grouped, with counts), the user block as a Well. The active item is tray-coloured with a 2px **ink** edge — not the accent, which is reserved for the current bay. - **Chrome bar (56px)** — drawer button (phone) / breadcrumb `▸ GROUP / PAGE` / page title / the page's action slot / language · skin · theme. **Page identity lives in the chrome**: routes declare `title / sub / actions`, and no page draws its own `h1`. - **Readout strip** — the always-on answer to "is this instance alive": figures with lamps whose **scope is part of the number** (an admin's fleet figure says `all`, an owner-scoped figure inside an all-scope answer says `own`). No data → no strip. - **Content frame** — full width (no `max-w-6xl` centring); `max-w` is only for actual prose. **No ruler column** (decided against: the nameplates plus the breadcrumb already answer "where am I", and a scale column would be duplicate navigation). - **Breakpoints** — below 900px the whole chrome column is hidden (spine and plate together), the chrome bar grows a drawer button, and the `navstrip` jump bar carries every destination in the rack's dress (`design-skin-system.md` §Addresses). Mobile tables scroll inside their panel and keep every column. ### How a page plugs into the shell - **A destination is declared once**, in `apps/web/src/nav.ts` (`ROUTES`): `id`, `path`, `group` (which bay it lives in), `titleKey`, plus any of `navLabelKey`, `nav: { icon, end }`, `admin`, `parent` (the breadcrumb ancestor), `layout: 'panes'`, `resourceKind`. The router, the breadcrumb, the spine, the plate and its counts, the phone's `navstrip` and the drawer all read that one table — do not keep a second list. - **Identity and actions go into the chrome**, not into the page: `usePageTitle(text)` and ``. The action is a `PageAction` (icon + label from `sm` up, the icon alone below, the name in `aria-label`/`title`). `` fills the folio column, which sits *outside* the content frame — filling it never re-lays-out the page. - **The shell owns scrolling.** A page that needs its own scrolling panes (the chat session) declares `layout: 'panes'` in its route and takes that over; everything else is a column of panels inside the frame. - **The frame is full width**; `max-w` wraps only actual prose (68ch). The frame's padding is the shell's business, not the page's. - **New strings** go into `src/i18n/strings/*.ts` (an `en` object with `const zh: typeof en` beside it, so a missing key is a compile error), and a new route is added to the route list in `scripts/ui-snap.mjs` so the snapshot rig covers it. ## 4. Page recipes Five archetypes cover the console, and each is a composition of the devices above — not a new design. The comps show them at full fidelity; these are the rules a new page has to satisfy. ### List page — the seven (`Machines`, `Credentials`, `LLM providers`, `Tokens`, `MCP`, `Profiles`, `Users`) - Identity and the create action live in the chrome; the panel is the page's body. - The panel's nameplate carries its NAME on the left and **facts** on the right (counts, refresh semantics, a legend) — never a repeat of the page title. - `FilterBar` sits under the nameplate (search / enum filters / sort) and **every one of those is in the URL**, so a filtered list is linkable and survives a refresh. - `DataTable` under it: an uppercase label header on the tray, and **every cell a device** — a `Well` for protocol values (the chip scale inside cells), `Chip` for interactions, `Badge` for enums, `Lamp` for status, `Switch` for toggles. "No value" is `—` in hint ink, never a blank cell. - Row actions live in a `⋯` `DropdownMenu` (row controls read `--row-ctl-h`); a delete opens a `ConfirmDialog` that names the object in a `Well` and states the consequence. - Loading, empty and error are **rows** (`TableStateRow`), never inline `colSpan` text. An empty table is an `EmptyState` (a fact + a hint + a CTA); a filtered-empty list says so and offers "clear filters" instead of a CTA. - Drilling in is an expandable row (MCP tools) or a secondary page (machine detail) — never a second row stuffed into the table. - On a phone the last column is pinned right (`sticky` + a hairline edge), so the row menu is reachable without scrolling the table sideways first. ### Resource-kind page — the five (`Skills`, `Sub-agents`, `Rules`, `Commands`, `Hooks`) - One page per kind: each kind has its own columns and its own editor, and a shared generic cell is exactly how this area loses its design. - Protocol values in cells are Wells (a rule's path, a command's trigger word), enums are badges (a hook's events), and a hook's body is a **structured** editor (event → matcher → command rows) rather than a JSON textarea. - Skills carries a second tab in the same route (`?tab=hub`, deep-linkable) holding the skill hub; trust badges stay neutral — colour in this product is not a rating. ### Detail page (machine detail) - Identity (name, host, status, version) lives in the **panel header**, not the page header; the page title stays the machine's name in the chrome. - Tabs are the panel's own bottom edge, not a page-level tab strip. - Read-only values are `Well`s, editable ones `Field`s, and an action that changes the machine says what it will do next to itself ("apply config = the credential goes to that machine in the clear"). ### Chat page - The rail (288px) groups sessions by working directory: the directory is a copyable `Well`, and a row is two lines (title over a mono `when · short-id`), the active row on the tray. It is desktop-only; on a phone it becomes a drawer beside the stream. - The stream is a fixed block grammar: user → thinking → assistant → tool card → turn tail → note → block cards (permission, question), and the tool cards collapse but always keep their head. - The composer is a panel on the bottom of the column (plan, queue, input, controls). The send control is the panel's `bg-primary` action — never `--signal`; the config selects are `bare`; the toolbar sits on its baseline. - **The context meter is one device in two shapes**: from `sm` up the figures plus the comp's 62×7 bar, below it a **ring** with no text — the figures-and-bar row was the widest thing in a toolbar that also has to hold four controls and Send on a 390px phone. Both shapes are the same control and open the same details on click (label, fraction, percentage, a full-width bar), on the popover material the menus already use. The adapter is the only source: no occupancy reported, nothing renders. Tone is the pressure — neutral, warn above 80%, danger above 95% — and it drives every form on both surfaces, `data-tone` on the elements a skin keys on (`meter`, `meter-fill`, `meter-ring`, `meter-ring-fill`). ### Overview - The topology degrades honestly: real states only, no liveness colour without data, and it still has to read with one upstream and one machine. - Figures are `Readout`s (fraction + qualifier) and are clickable through to the page they count. ## 5. Type roles | Role | Use | Family | Size / weight | Case | |---|---|---|---|---| | Title | page title | sans/display | 25px / 700 | sentence | | Section | panel sub-heading | sans/display | 17px / 700 | sentence | | Body | prose | sans | 14px / 1.5 | sentence | | Hint | explanations, qualifiers | sans | 12.5px | sentence | | **Label** | panel nameplates, table heads, nav groups, buttons, tabs, bay names | **`--app-font-label`** | 11px / 600, +0.09em | **uppercase** | | Label-sm | readout labels, bay numbers | label | 9.5px / 600, +0.12em | uppercase | | **Data** | all protocol material, numbers, times | mono | 12px, tabular-nums | — | | Data-sm / Data-lg | badges and chips / readout numerators | mono | 11px / 27px | — | Uppercase appears in the label role only; negative tracking in headings only; mono + `tabular-nums` is the default for every number and protocol string. ## 6. Interaction language **Four state surfaces**, each designed (loading, empty, filtered-empty, error), plus a degraded variant when a dependency is offline (`note.warn` + the dependent actions disabled, saying what is missing rather than pretending there is no data): | State | Form | |---|---| | loading | skeleton rows/blocks sized to the real columns, appearing after 300ms (so fast data never flashes a skeleton); local refreshes dim values instead | | empty | `EmptyState`: the lamp-socket drawing + one fact + hint + machine-readable state + CTA — an invitation, not a mood | | filtered-empty | one hint line + `clear filters`, deliberately without a CTA (nothing is actually missing) | | error | `TableStateRow error`: the error code (mono) + one sentence + `retry` — failures are retryable and codes are never swallowed | **Confirmations.** Unexpected failures follow `AppError`; destructive actions follow `ConfirmDialog`: nameplate = the action's name, body = what will happen in prose (never "are you sure"), affected objects (machine, job) as Wells, then the buttons. Two levels by reversibility: a plain delete confirms once; remove-machine / revoke-token / kill-process / overwrite-config require typing the object's name. **One name throughout**: button → confirm button → success toast (`部署` → `已部署`). `hazard` striping is reserved for the physical, irreversible actions (killing an adapter process, removing a machine). **Overlays** — the comps are static and never drew a dialog, toast, menu or disabled state, so these are designed here and built from the same materials as a panel: `FormDialog` (nameplate title + body + right-aligned actions), a right drawer (an `absolute` overlay with `--scrim`, not `fixed` — container queries would re-anchor `fixed`), dropdown menus (30px rows, sentence case for actions, tray on hover), popovers (a readout the user asked for, not a list of actions — `components/ui/popover.tsx` copies the menus' surface classes on purpose), toasts (a `Note`-shaped bar, bottom-right), tooltips (explanation only — machine data gets a Well with a copy button instead). One floating material: whatever opened it, a card that floats is `bg-popover` + 1px border + `shadow-md`, and a skin restyles them together or not at all. **Disabled** always states its reason next to the control ("a job is already queued"), rather than only greying out. **Keyboard and focus** — a visible `:focus-visible` ring in the accent (2px) or a border change for Well-like controls; never `outline: none` without a replacement. The skip link lands focus on the content frame. Radix supplies focus traps, Esc and arrow keys, and the regressions that matter (dialog focus, quota on every focusable control) are asserted. **Four motions, no more:** a panel seating (180ms, staggered 40ms per panel), a lamp breathing (2.4s, the only resident animation — it encodes "still running"), the mesh drawing its edges on first paint, and the 2px press displacement that reads as a key going down. `prefers-reduced-motion` switches all four off globally **and** restores the two final states that would otherwise stay invisible (lamp lit, edges drawn); skeletons go static. **Phones.** Row actions live in the last table column, so that column is **pinned right** (a sticky cell with a hairline edge) — the measure-first conclusion was that the real problem on a phone was reachability, not "dropdown vs bottom drawer": a bottom drawer is revisited only when a row menu grows to ≥5 actions or a row-wide action (batch) appears. Tap targets expand as **hit areas** (`::after` overlays), never as bigger boxes — see `design-skin-system.md` §The control scale. The chat composer sits on the bottom of a scrolled column (the stream scrolls, the composer does not) and its tool row wraps; dialogs are full-width and top-aligned so the keyboard cannot throw them off; layouts use `min-h-svh`. **URL is the state.** List filters, search, sorting and pagination live in the query string (so a filtered list is linkable); tabs and open channels too. Drawers and dialogs deliberately do not. ## 7. Contract and enforcement - **Tokens, not literals** (`apps/web/src/index.css`): density (`--control-h*`, `--row-h`, `--row-ctl-h`, `--touch`, `--panel-*`), material (`--tray`, `--well*`, `--radius-*`, `--shadow-*`, `--press-shift`), status (`--lamp-*`, `--state-*-ink`), type roles (`--label-size*`, `--data-size*`, `--readout-size*`), fonts (`--app-font-*`). A literal is invisible to every skin. - **Devices live in `components/kit/`** (`Panel`, `Well`, `Lamp`, `Readout`, `Chip`, `DataTable`, `TableStateRow`, `Field`, `FilterBar`, `EmptyState`, `Skeleton`, `Note`, `CommandLine`, `ConfirmDialog`, `Region`, `LabelText`/`DataText`). `ui/` stays the untouched Radix/shadcn wrappers (`Badge` continues to live there, being a plain enum chip). A new primitive must replace at least two existing hand-rolled copies before it is allowed in. - **Skin contract** — see [`design-skin-system.md`](design-skin-system.md): two selector prefixes, no `content:` text injection, no negative-margin surgery (both asserted by `skins/registry.test.tsx`), and every manifest field must have a consumer. - **i18n** — every user-visible string lives in `src/i18n/strings/*.ts` as an `en` object with `const zh: typeof en` beside it, so a missing key is a compile error; protocol and wire values stay English in both locales; dates go through `dateLocale(lang)`. - **Gates** — `pnpm -r build/typecheck/test` on **Node 20** (`task verify`) before merging; `scripts/ui-snap.mjs` (route × skin × mode × viewport, pixel-compared, re-baselined with `--update`) before merging anything skin-visible; `scripts/test-touch-targets.mjs` for the coarse-pointer rules; agent-browser for end-to-end walks of real UI paths. ## 8. Not covered here - The skin axis itself — tokens per skin, the status device, the topology registry, the snapshot rig: [`design-skin-system.md`](design-skin-system.md). - The per-module surfaces inside those archetypes (what the machine page's inventory tab shows, what a deploy job row carries): the feature guides (`chat.md`, `machines-and-daemon.md`, `mcp-management.md`, …) own the behavior, and the machine-local `01-skeleton.md §7` holds the long-form comps inventory. - Verified but not measurable on this box: touch feel on a real device, and a pixel-level side-by-side against the comps.