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¶
- One primary action per view, filled coral (
.btn-primary). Everything else is a secondary (.btn) or ghost (.btn-ghost) button. - 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). - Sentence case everywhere. No letter-spaced uppercase in controls, labels or table headers. Mono is for code, ids, hashes and numbers only.
- Accessible by construction. The tokens guarantee WCAG AA contrast in every theme; components carry their own ARIA; nothing is said by colour alone.
- 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: morestrengthens 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: reduceswitches 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(theh1and a.page-descline on the left,.page-actionson the right). - Card:
.card(surface + hairline),.card--callout(accent bar; addwarning,dangerorinfo),.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--compactfor dense panels. - Meter:
.meter(withrole="meter"or"progressbar"and itsaria-value*) >.meter-fill;ok,warn,danger,infotones; a.meter-rowabove it carries the label and the number. - Compact page header (console shell):
.page-header.page-header--compactwith a.page-contextline (pool, status). - Person:
.avatar(.is-admintints 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 tostyle=""; inline styles are for JavaScript-toggled state only.
Status¶
.badgewithneutral,info,success,warning,dangeroraccent(SS.badge(text, kind)builds one in sentence case). Always text, optionally an icon - never a colour alone..status-dotwithok,busy,warn,err,idle: a dot and its label..alertwitherror,success,info,warningfor in-page messages (role="status").
Feedback and overlays¶
SS.toast(message, kind)orSS.toast({title, body, kind}): icon, text and a close button; errors arerole="alert", the restrole="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 ofwindow.confirm()(there are none left). A destructive confirm focuses Cancel.SS.modal.open(id)/SS.modal.close(id)for a.modal-backdrop>.modalin the page (.modal--sm,.modal--lgfor width).SS.menu(button, items): a popover of actions on the disclosure pattern (aria-expandedon 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]ofbutton[role=tab]witharia-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)}andonClickmay returnfalseto keep it open (validation). Enter submits the primary action.SS.prompt({title, label, value, hint, confirm})→Promise<string|null>, the themedwindow.prompt().- A dialog is also a multi-step flow: the returned
setTitle,setDescription,setContentandsetActionsswap it in place (ask for a label, then show the new secret once) -setActionsmoves focus to the new primary button.onCloseruns 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})andSS.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.