Skip to content

UI design system

The web UI is hand-written HTML, CSS and JavaScript with no framework and no build step. What keeps it consistent is one stylesheet of design tokens and components (leader/web/templates/shared/theme.css) and one small client library (shared/app.js, exposed as window.SS). This page is the reference for both. Signed in as an admin, open /ops/ui-kit to see every component in every state, in whichever theme you have picked.

The direction is "quiet console": dark by default, neutral surfaces, one accent (coral) for the one thing you should do next, hierarchy through type and spacing rather than borders and coloured bars.

Principles

  1. One primary action per view, filled coral (.btn-primary). Everything else is a secondary (.btn) or ghost (.btn-ghost) button.
  2. Hierarchy through surface and type, not outlines. A card is one surface step up with a hairline border; the coloured left bar only marks a callout (.card--callout).
  3. Sentence case everywhere. No letter-spaced uppercase in controls, labels or table headers. Mono is for code, ids, hashes and numbers only.
  4. Accessible by construction. The tokens guarantee WCAG AA contrast in every theme; components carry their own ARIA; nothing is said by colour alone.
  5. Nothing below 12 px.

Tokens

Three layers. Components and page sheets use only the semantic and component tokens - never raw colours and never the primitives - which is what makes a theme a token swap.

Layer Examples Use
Primitives --gray-0…--gray-12, --night-0…--night-5, --coral-1…--coral-12, --blue-*, --green-*, --amber-*, --red-*, --fs-*, --sp-* The palette and the scales. Only theme.css references the colour primitives.
Semantic --bg, --surface-1/2/3/4, --border, --border-strong, --border-field, --text, --text-secondary, --text-muted, --text-subtle, --accent, --accent-solid, --accent-fg, --accent-subtle, --link, --success/--warning/--danger/--info (+ -subtle, -fg), --focus-ring, --input-bg, --code-bg, --overlay, --shadow-pop What a colour is for. Redefined per theme.
Component --radius-sm/md/lg/full, --btn-h-sm/md/lg, --field-h, --card-pad, --w-form, --w-page Sizes of controls.

Surfaces stack upwards: --bg (page) → --surface-1 (cards) → --surface-2 (menus, modals, toasts) → --surface-3 (hover, secondary buttons) → --surface-4 (hover on surface-3). A tone colour (--success, …) is for text and icons on any surface; its -subtle variant is the tint behind it (badges, alerts); -fg is text on a solid fill of it.

For a translucent tint not covered by a token, mix from the ink triplet: rgba(var(--ink), .06) is a 6% overlay of white in dark and black in light.

Scales

Scale Tokens
Type --fs-xs 12 · --fs-sm 13 · --fs-md 14 (UI default) · --fs-base 16 (body copy) · --fs-lg 18 · --fs-xl 20 · --fs-2xl 24 · --fs-3xl 30 · --fs-4xl 36 · --fs-5xl 48 (landing)
Line height --lh-tight 1.25 headings · --lh-ui 1.5 · --lh-prose 1.65
Spacing (4 px base) --sp-1 4 · --sp-2 8 · --sp-3 12 · --sp-4 16 · --sp-5 20 · --sp-6 24 · --sp-8 32 · --sp-10 40 · --sp-12 48 · --sp-16 64
Radius --radius-sm 4 (inputs, badges) · --radius-md 8 (cards, buttons, modals) · --radius-lg 12 (popovers) · --radius-full (avatars)
Controls --btn-h-sm 32 · --btn-h-md 36 · --btn-h-lg 44 (touch, landing) · --field-h 36
Motion --dur-fast 120 ms · --dur 160 ms · --dur-slow 200 ms, --ease; all off under prefers-reduced-motion

Fonts: --font-sans (Inter) for all UI including headings, --font-display (Space Grotesk) for display only - the landing hero and the docs title - and --font-mono (JetBrains Mono) with font-variant-numeric: tabular-nums for code, ids and numeric columns (.mono, .num).

Themes

dark (default), light and system. The choice lives in the swarm_theme cookie, set by GET /theme/{mode} from the account menu. The server writes <html data-theme="..."> while rendering (web/nav_render.py), so the first paint is already in the right theme - no flash. system follows prefers-color-scheme. On top of any theme:

  • prefers-contrast: more strengthens borders and lifts muted text;
  • forced-colors: active (Windows High Contrast) draws every tinted boundary as a real border and uses the system highlight for focus and selection;
  • prefers-reduced-motion: reduce switches off transitions and animations.

The landing page stays dark until its own renewal.

Code highlighting (highlight.js) is coloured from the tone tokens in theme.css, so highlighted code reads in both themes; no highlight.js colour theme is vendored.

The consoles act on the active pool: SS.activePool() returns ?pool= when the URL names one, otherwise the sidebar's active pool (rendered from the swarm_pool cookie).

Charts and canvases cannot read CSS directly. Read the token at draw time with SS.cssVar('--accent') rather than hard-coding a hex value. Categorical series use --chart-1 … --chart-6 (each 3:1 on the chart background in both themes); give series a second cue - marker shape or dash - as well.

Shells

Every page is one of four shells, chosen by its builder (auth_ui._page(template, shell)) and written onto <body> as shell-<name>. One assembly path builds them all, the consoles included.

Shell Pages Structure
public landing, imprint, privacy, accessibility top bar (wordmark, Docs, GitHub, language, Log in / Register or Open app), content, full footer with the provider block
auth login, register, reset, verify, account status the wordmark above one centred card, language and theme below it, full footer. No sidebar.
app dashboard, pools, buckets, admin, profile, UI kit sidebar + main: .page-header, content, a one-line footer of legal links
console training, inference the app shell at full viewport height; main scrolls on its own; .page-header--compact

The sidebar collapses to an icon rail on desktop (the swarm_rail cookie, read by the server so the first paint is right) and becomes a drawer below 1024 px, opened from a mobile app bar. DOM order is the keyboard order: skip link, sidebar, page header, content (test_shells.py). The admin pages share a sub-navigation (__ADMIN_NAV__, auth_ui.ADMIN_SECTIONS).

Components

Buttons

Class Use
.btn Secondary - the default.
.btn.btn-primary The one primary action of the view.
.btn.btn-ghost Low-emphasis actions, toolbars, icon buttons.
.btn.btn-danger Destructive actions (tinted; solid on hover). Confirm them with SS.confirm.
.btn-sm / .btn-lg 32 px / 44 px high.
.btn-icon Square, icon only - must carry aria-label.
aria-busy="true" Loading: adds a spinner, keeps the label.
.link-btn A button that looks like a link (inline, in text).

Fields

<div class="field">
  <label for="pool-name">Name <span class="req" aria-hidden="true">*</span></label>
  <input id="pool-name" required aria-describedby="pool-name-hint pool-name-err">
  <div class="field-hint" id="pool-name-hint">Shown to members.</div>
  <div class="field-error" id="pool-name-err"></div>
</div>

Set aria-invalid="true" on the control and put the message in .field-error for an inline error. .field-affix groups a control with a prefix/suffix (<span class="affix">GB</span>) or a button. <input type="password" data-reveal> gets a show/hide toggle automatically. SS.dropzone(input) wraps a file input in a drop target without taking away its label or keyboard behaviour.

SS.field({label, id, type, value, hint, required, options, ...}) builds the same structure in script and returns {element, input, setError} - setError(msg) sets aria-invalid and the message, setError("") clears both. Use it in dialogs instead of hand-wiring label, hint and error ids.

Choices:

  • Toggle switch: <label class="switch-row"><input type="checkbox" role="switch" class="switch"> Open join</label> - a native checkbox, so it keeps its keyboard and form behaviour.
  • Segmented control: <fieldset class="seg"><legend class="sr-only">Log level</legend> with <label><input type="radio" name="…"><span>All <b>12</b></span></label> per option - native radios keep the arrow keys.

Layout

  • Page header: .page-header > .breadcrumbs, .page-header-row (the h1 and a .page-desc line on the left, .page-actions on the right).
  • Card: .card (surface + hairline), .card--callout (accent bar; add warning, danger or info), .card--interactive (hover state, for a card that is a link), .card-head (title + actions in one row).
  • Stat tile: .stats > .stat > .stat-label, .stat-value, .stat-sub. Two per row on phones, as many as fit on desktop. .stats--compact for dense panels.
  • Meter: .meter (with role="meter" or "progressbar" and its aria-value*) > .meter-fill; ok, warn, danger, info tones; a .meter-row above it carries the label and the number.
  • Compact page header (console shell): .page-header.page-header--compact with a .page-context line (pool, status).
  • Person: .avatar (.is-admin tints it) and .user-cell > .avatar + .who > .nm, .em - initial, name and email in one table cell.
  • Count after a title: <h1>Users <span class="count">(12)</span></h1>.
  • Utilities: .sr-only-sm (visually hidden below 520 px - an icon button's text label), .row, .row-wrap, .row-end, .stack, .spacer, .grow, .mt-*/.mb-* (4/8/12/16/24), .text-sm, .text-xs, .muted, .mono, .num, .nowrap, .break, .maxw-form, .w-auto, .sr-only. Prefer these to style=""; inline styles are for JavaScript-toggled state only.

Status

  • .badge with neutral, info, success, warning, danger or accent (SS.badge(text, kind) builds one in sentence case). Always text, optionally an icon - never a colour alone.
  • .status-dot with ok, busy, warn, err, idle: a dot and its label.
  • .alert with error, success, info, warning for in-page messages (role="status").

Feedback and overlays

  • SS.toast(message, kind) or SS.toast({title, body, kind}): icon, text and a close button; errors are role="alert", the rest role="status"; hover and focus pause it; the stack moves out of the way of the focused element.
  • SS.confirm({title, body, confirm, danger}) → Promise<boolean>. Use it instead of window.confirm() (there are none left). A destructive confirm focuses Cancel.
  • SS.modal.open(id) / SS.modal.close(id) for a .modal-backdrop > .modal in the page (.modal--sm, .modal--lg for width).
  • SS.menu(button, items): a popover of actions on the disclosure pattern (aria-expanded on the button, Escape and outside click close it, arrow keys move). Items are {label, icon, onClick | href, danger} or "sep"; put destructive actions last.
  • SS.tabs(container, {hash}): [role=tablist] of button[role=tab] with aria-controls; roving tabindex and arrow keys; optional URL hash sync.
  • SS.dialog({title, description, content, actions, size}): a modal built in script for a flow ("New bucket", "Create token"); actions are {label, primary, danger, submit, onClick(close)} and onClick may return false to keep it open (validation). Enter submits the primary action.
  • SS.prompt({title, label, value, hint, confirm}) → Promise<string|null>, the themed window.prompt().
  • A dialog is also a multi-step flow: the returned setTitle, setDescription, setContent and setActions swap it in place (ask for a label, then show the new secret once) - setActions moves focus to the new primary button. onClose runs however the dialog was closed.
  • SS.announce(text, {assertive}): say something to screen readers without moving focus (a finished reply, a cleared chat).
  • SS.empty({icon, title, body, action}): an empty state with one action.
  • SS.skeleton({lines}): placeholder lines before the first data arrives.
  • SS.codeblock(text, {label}) and SS.copyButton(getText, label): code with an always-visible copy button that confirms ("Copied") and announces it.

Data tables

SS.table(container, opts) renders a sortable, filterable table:

Option
columns {key, label, sort, numeric, render, sortValue}; numeric right-aligns in tabular mono. A column with menu: (row) => items renders the row's actions as an overflow menu.
search, searchKeys, filters The toolbar - search and filters inline.
actions Nodes for the right end of the toolbar (the primary action).
label Accessible name of the table and its scroll region.
maxHeight Caps the height and makes the header sticky.
stack: false Opt out of the stacked-card layout below 640 px.
empty Text or a node for the empty state.
rowKey, onRowClick, isSelected Row selection: rows become clickable and keyboard-operable (Enter/Space on the focused row); the selected one gets aria-current.
tableClass Classes on the <table> itself.

The returned controller has update(rows), refresh(), setFilter(key, value) and setQuery(q). Focus survives every re-render - the search box, a filter or the focused row stays focused when update() replaces the rows. For a static table that should stack the same way on phones, add class="stack-table", data-label on each cell and explicit table/row/cell roles.

Headers are real buttons with aria-sort; below 640 px every row becomes a card with its column names, so nothing is cropped.

Icons

Lucide (ISC licence), vendored as one SVG sprite by scripts/vendor_frontend.py and served from /vendor/icons.svg. SS.icon(name) returns an aria-hidden <svg> in currentColor; pass {label} when the icon alone carries meaning. In server-rendered markup:

<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
  <use href="/vendor/icons.svg#database"></use></svg>

The sprite contains only the icons in the script's ICONS tuple; to use a new one, add its name there and re-run the script. No Unicode glyphs as icons.

What the tests hold you to

tests/unit/leader/web/test_design_tokens.py computes the contrast of every semantic text/background pair in both themes (4.5:1 for text, 3:1 for field borders and the focus ring), checks that dark and light define the same tokens and that the system theme equals light, and fails on: a font size below 12 px, text-transform: uppercase, Space Grotesk outside display use, a colour primitive or a retired v1 token name in a page sheet. test_ui_kit.py checks the component library and the /ops/ui-kit page, and scripts/frontend_smoke.mjs runs axe-core with colour contrast over every page in both themes.