Skip to content

design ui system

Ubuntu edited this page Sep 28, 2026 · 3 revisions

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).

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 §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 <PageSlot slot="actions">. The action is a PageAction (icon + label from sm up, the icon alone below, the name in aria-label/title). <PageSlot slot="margin"> 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 Wells, editable ones Fields, 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 Readouts (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: 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.
  • 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.

Clone this wiki locally