Workbench language
The dashboard is a dense developer workbench on graphite surfaces with one teal accent. Adjoining panes are flat and quiet: do not add saturated colours, gradients, blurred cards or elevated nested panels. The desktop launch splash is an intentional original-animation exception; see Run state and motion.
Type
Fonts ship in the bundle; nothing is fetched from Google.
| Face | Use | Source |
|---|---|---|
| DM Sans (variable, Latin subset) | All UI text | @fontsource-variable/dm-sans, one WOFF2 |
| Saira (variable, Latin subset) | text-title only |
web/public/fonts/saira-latin.woff2 |
| VT323 (Latin subset, 400) | Header and launch-splash wordmarks | web/public/fonts/vt323-latin.woff2 |
| JetBrainsMono NFM | xterm primary font; UI private-use symbols through Aether Nerd Symbols |
web/public/fonts/jetbrains-mono-nfm-*.woff2 |
| Noto Sans Symbols 2 | Braille and geometric symbols through Aether Symbols |
web/public/fonts/noto-sans-symbols-2-spinners.woff2 |
ui-monospace, 'SF Mono', Menlo, Consolas, monospace |
Code, paths and SHAs (font-code), after symbol fallbacks |
system |
DM Sans loads with font-display: swap behind DM Sans Fallback, Arial
scaled to DM Sans's metrics to reduce layout shift. Sans, title and code
stacks include two symbol-only fallbacks: Aether Symbols covers Geometric
Shapes (U+25A0–25FF) and Braille Patterns (U+2800–28FF), including OMP's
spinner frames; Aether Nerd Symbols covers the Unicode private-use areas
used by agent title icons. The latter reuses the terminal's regular and bold
font files. unicode-range keeps both aliases from replacing ordinary text.
Other scripts and symbols outside those ranges still use platform fallbacks.
The aliases use font-display: swap and load only when a matching character
appears; xterm keeps its own full-font family and cell metrics.
The header wordmark is lowercase aether and uses text-wordmark: VT323 at
24px, 400 weight and line height 1. The header logo and wordmark sit 1px above
the row's center to align visually with navigation buttons. The splash keeps
its original clamp(32px, 4vw, 48px) size and 0.12em letter spacing. Both use
--font-pixel; other headings stay Saira.
The type scale, in web/src/index.css:
| Class | Size / line height | Use |
|---|---|---|
text-ui-xs |
11/16 | Dense meta |
text-ui-sm |
12/16 | Supporting copy, section labels |
text-ui |
13/20 | Default UI text; the body is 13px |
text-prose |
14/22 | Agent prose and rendered markdown |
text-title |
16/24, Saira 600 | Pane, dialog, step and empty-state headings |
text-avatar (10px) is reserved for avatar initials. Settings >
Appearance > Text size (Default, Large, Larger) sets data-text-size on
the root, which raises every step of the scale and the title size;
terminals keep their own zoom.
Weights are 400 and 500, 600 for titles. Times, counts and +a -d use
tabular-nums. Labels are sentence case; nothing is uppercase. cn() in
web/src/lib/utils.ts knows the scale, so cn('text-ui', 'text-muted')
keeps both classes. text-title sets its own family and weight; do not pair
it with font-* classes.
Semantic palette
web/src/index.css is authoritative: light values sit on :root, dark ones
on .dark. The theme preference is system, light or dark; system
follows prefers-color-scheme live.
| Token | Utility | Light | Dark | Use |
|---|---|---|---|---|
--canvas |
bg-canvas |
#ffffff |
#141516 |
Main view, fields |
--chrome |
bg-chrome |
#f7f7f7 |
#1b1c1d |
Sidebar, app header, toolbars, code, browser chrome |
--hover |
bg-hover |
#f3f3f3 |
#1d1e1f |
Hovered rows and cards on canvas |
--hover-chrome |
bg-hover-chrome |
#ececec |
#262728 |
Hover on chrome and raised surfaces: sidebar and list rows, menu items, ghost and secondary buttons |
--raised |
bg-raised |
#ffffff |
#222324 |
Floating surfaces, secondary buttons |
--seam |
border-seam |
black 8% | white 8% | Every border; the default border colour |
--control-border |
border-control |
#8a8a8a |
#767676 |
Fields, checkboxes, radios |
--text |
text-text |
#1f1f1f |
#e8e8e8 |
Text |
--text-muted |
text-muted |
#6b6b6b |
#9a9a9a |
Placeholders, timestamps, meta |
--icon-faint |
text-icon-faint |
#8a8a8a |
#7a7a7a |
Disabled text, decorative icons, scrollbar thumbs, tree connectors |
--accent-fill |
bg-accent, border-accent |
#367f77 |
#367f77 |
The one primary button, focus ring, control outline |
--accent-text |
text-accent |
#306f69 |
#5fb3a8 |
Links, accent text |
--on-accent |
text-on-accent |
#ffffff |
#ffffff |
Text on bg-accent |
--selection |
bg-selection |
#dfebe9 |
#17413d |
Selected rows |
accent is a fill for backgrounds and borders but the link colour for text;
bg-accent-hover darkens the fill for a hovered primary button. The accent
marks selection, focus, the one primary button per view and links, nothing
else. Inside a selected row, secondary text uses text-text. A floating
surface is bg-raised, a 1px seam and shadow-overlay (0 8px 24px -12px,
black 18% light, 60% dark). Dialogs sit over bg-scrim.
The five run states (see dashboard-frontend.md), used on dots, state lines, badges, callouts and request cards:
| State | Utility | Light | Dark |
|---|---|---|---|
| Needs you | state-needs-you |
#8a5d00 |
#e0a52a |
| Working | state-working |
#1b65c2 |
#4a9eff |
| Paused | state-paused |
#6e6e6e |
#8a8a8a |
| Done | state-done |
#1a7a36 |
#45c26a |
| Failed | state-failed |
#c8321f |
#f05c4a |
Each state has a -soft fill (bg-state-failed-soft) at 8% light, 12% dark;
bg-accent-soft likewise. text-on-failed is the text on a bg-state-failed
fill. Diffs use text-diff-add/text-diff-del (the done and failed colours)
and bg-diff-add-bg/bg-diff-del-bg at 8% light, 10% dark.
Agent vendor colours (text-agent-claude, -codex, -pi, -omp,
-opencode) appear only on the Agents page and the launch picker, through
AgentGlyph colored.
Member colours are the only arbitrary server data applied inline: avatar rings, the owner rail in run lists, the owner mark on sidebar run rows and member names on conflict chips. Identity colour never replaces run-state colour.
Primitives
web/src/components/ui/ holds the shared primitives: Button, Input,
Textarea, Select, Checkbox, Radio, Label, FormField, Menu,
Popover, Tooltip, Dialog, AlertDialog, Command, Tabs,
Collapsible, Card, ListRow, PaneHeader, SectionLabel,
EmptyState, Callout, Badge, RequestCard, StatusDot and
StateLine, Avatar, AgentGlyph, Code and CodeBlock, Kbd,
Markdown, RelativeTime, Spinner, Skeleton, Separator, Toaster,
LineGutter (a code line's clickable number gutter, for the Changes view's
comments) and the Session view's timeline rows and blocks. Reach for one
before writing markup, and pass its variant, size or tone rather than
restyling it.
Button variants:
| Variant | Look | Use |
|---|---|---|
primary (default) |
bg-accent, text-on-accent |
The one primary action in a view |
secondary |
Seam border on bg-raised |
Other actions |
ghost |
Muted text, bg-hover-chrome on hover |
Toolbar and icon buttons |
danger |
bg-state-failed, text-on-failed |
Confirming a destructive action |
link |
Accent text, underline on hover | Inline actions that read as links |
quiet |
Muted text; text-text and an underline on hover |
Inline secondary actions inside muted copy |
Sizes are md (28px, the default), sm (24px), icon (28px square),
icon-sm (24px square), and the composer's send control: round (28px
circle) and pill (28px, fully rounded); each grows to 44px on a coarse
pointer. link and quiet have no height or padding of their own and widen
their touch target to 44px. An icon, icon-sm or round button requires
label, which becomes its accessible name and tooltip; hint puts a tooltip
on any button.
Charts
Charts are hand-drawn SVG; the dashboard has no chart library. Marks use
fill-* and stroke-* token utilities or currentColor, never inline
colours. Outcomes use the run state tokens (state-done, state-failed,
state-paused). A neutral count such as runs launched is outlined in
stroke-icon-faint on fill-chrome, so it never reads as the solid paused
grey. A sequential scale steps one hue: fill-accent at 25%, 50%, 75% and
100% over fill-chrome for zero. Every colour has a text legend, and every
mark is named by its own label or a summary, so nothing depends on colour
alone. Axis and legend text stays at text-ui-xs or text-ui-sm in
text-muted; bars carry no gradients, shadows or animation. A chart scaled
with preserveAspectRatio="none" draws strokes with
vector-effect: non-scaling-stroke.
Enforcement
web/src/design-system.test.ts scans every non-test source file under
web/src and fails on:
| Rule | Fails on |
|---|---|
| Type scale | text-xs to text-9xl, text-base, or an arbitrary size such as text-[13px] |
| Token colours | Tailwind palette colours (bg-gray-100, text-white) or arbitrary hex, rgb, hsl or oklch colours |
| Sentence case | uppercase; a group heading is a SectionLabel |
| No HeroUI | Any @heroui/ import |
| Icons through components/icons | A lucide-react import outside web/src/components/icons.ts |
| One name per icon | lucide aliases such as XIcon, Loader2, AlertTriangle or MoreHorizontal |
| No bare buttons | <button outside components/ui/; use Button |
| No opacity on rows | opacity-* in components/shell/ and routes/board/; a receding row uses text-muted |
| Primitives are not restyled | A primitive given text-, bg-, border-, rounded-, h-, min-h- or padding classes through className, which is for layout only |
| Token names | Colour names that are not tokens and render nothing: background, foreground, card, popover, sidebar, primary, secondary, muted-foreground, destructive, input, ring, border, toolbar-hover, field, and state-attention, -success, -warn, -waiting, -needs-attention, -idle |
A rule can carry a legacy list of files that predate it; only no bare buttons and primitives are not restyled still have one. The test also fails when a listed file already complies, so the lists only shrink.
The same file checks contrast in both themes: text and text-muted on
every surface, text on selection, accent text on canvas and chrome,
on-accent on the accent fill, on-failed on the failed fill, and the
states and diff colours on canvas at 4.5:1; control borders, icon-faint
and the agent colours on canvas at 3:1. It also checks that every text-*
and bg-* class in components/ui/ names a token defined in index.css.
Icons
Icons come from web/src/components/icons.ts, which re-exports the lucide
icons the dashboard uses at stroke width 1.75. Add a name there rather than
importing lucide-react. The same module holds Claude, the Claude mark that
AgentGlyph draws for the claude agent; it is filled, not stroked.
Geometry and responsive behavior
Use compact workbench geometry rather than landing-page ornament:
- A branded header with horizontal navigation; destinations that do not fit move into More navigation, except Board and Swarms. One sidebar, 260px by default and resizable from 220px to 400px; under 768px it is a side sheet, opened from a second, 48px toolbar. The desktop window bar is 35px.
- 44px view headers (
PaneHeader), 56px for a run's header, 32px terminal toolbars, 28px list rows and menu items (44px coarse). - 28px fields and buttons, 24px small and toolbar icon buttons (44px on a
coarse pointer), 12px form gaps, 4px label gaps, 16px content gutters and
12px compact gutters.
Labelcaptions are block-level, so stacked caption-to-field spacing measures 4px, including wrapped fields. - Two radii:
--radius-control4px (rounded-sm,rounded-control) for buttons, fields, chips, rows, code and keys;--radius-panel6px (rounded-md,rounded-lg,rounded-panel) for cards, dialogs, menus, popovers and callouts. The run composer alone uses--radius-composer14px (rounded-composer).rounded-fullis for dots, radios, avatars, meters and the composer's send control only. - Only floating surfaces cast
shadow-overlay; content panels have none. Headers stay attext-titleor smaller. - Scrollbars are 8px with an
--icon-faintthumb. - A board card is three lines: the state line (shaped dot, reason, relative
time), a title of at most two lines, and the meta line (agent glyph and
name, owner avatar,
+a −d, thecard:metaslot). A Needs you reason reads in the text colour, the others inmuted, never with a second dot or a New pill. See Dashboard SPA: Board. - Board's Map reuses those cards at 320×160 inside a bounded pan/zoom
canvas. Owner groups use
bg-chromeandborder-seam, cardsbg-canvas; identity colour stays on the shared avatars. Muted directed connectors link integrators to workers, dashed across owners. The Runs toolbar wraps its scope, Mine, archive and camera controls on phones. Fixed geometry accommodates the larger text settings and coarse-pointer card actions; live presentation updates do not move nodes or reset the camera.
At 390px every operation remains available through compact navigation or
overflow, stacked forms, bounded dialogs and tree-to-file navigation. The main
page never gains horizontal overflow; text and code may scroll inside their
own surfaces. Floating menus stay inside the available viewport and scroll to
their last action. Route roots own the shell's bounded height; their content
regions use min-h-0 and vertical overflow.
The run Browser view has one bg-chrome toolbar row over the page: ghost
icon buttons for Back, Forward and Reload, the address field,
Ports with its count, Viewport, the beside-or-tab toggle and
Browser actions, then who is driving. A listening port is a secondary
button, its address in font-code and its process name muted, with a ghost
icon button beside it. A second page adds a row of small tab buttons. Messages are laid
over the top of the page on a floating surface and never take a row. A page
that does not fill its pane is centred on bg-chrome and never enlarged, and
the page that has the keyboard wears a 1px inset accent outline. Beside
another view the Browser is a pane with a seam and a resize handle like the
sidebar's. Browser view has the
behaviour.
The terminal toolbar holds the terminal tabs, Tools (a menu; a bottom sheet under 768px) and, at its end, who controls the terminal beside Take control or Release (terminal.md). A run shell adds Shell actions. The Environment page puts Save environment and Environment actions in its header.
Shell, palette and focus
The shell has a branded navigation header above one sidebar and the content
view, with no icon rail or status bar. The Aether mark and title sit at its
left; Board through Templates follow as horizontal ListRows. Measure
available width rather than assuming every label fits a breakpoint, and
keep Board and Swarms outside the overflow menu. The header and sidebar use
bg-chrome with seams against the content; navigation rows have muted icons
and the open page in bg-selection. New run at the sidebar's top is the
only filled button in the shell.
Run rows carry a shaped StatusDot, a 6×7px owner mark (a right-pointing
triangle in the owner's colour, 4px from the dot and from the title), the
agent's title, a compact age ("15m") in text-ui-sm muted tabular figures
and a monochrome AgentGlyph. The people on a run follow as 16px avatars
overlapping by 6px, the first one whole at the right edge, each ringed in
the row's own background so the one in front cuts into the one behind.
A swarm's root also has a Waypoints graph-node button before the age; it
opens swarm controls while the row opens the run. Use
the same icon for swarm navigation and commands. Swarm children are
indented 26px under the root and joined to it by 1px icon-faint tree
connectors: a trunk under the centre of the root's status dot, and for each
child a wire from the trunk to the row's left edge, level with the child's
dot. The trunk ends at the last child's wire. Connectors stay in the indent,
outside every row's hover, selection and focus box. A row that recedes shows
its title in text-muted, never with opacity; a finished row no member has
opened yet keeps text-text (see
dashboard-frontend.md). A row's details box
is a 288px floating surface docked flush to the row's right edge, its
top-left corner square where it meets the row. Group headers
are sentence-case 12px muted disclosure buttons with their count. The footer
holds the avatar, name and a connection dot; the
update notice above it is one 12px muted line with an Update link.
Under 768px a second, 48px toolbar (bg-chrome) holds the sidebar button,
with a needs-you dot while anything needs the viewer, the view title in
13/20 medium, Search and New run. When the page has its own primary
action, the toolbar's New run becomes a ghost + icon so the screen keeps
one filled button. The sidebar opens as a left side sheet. Content views draw
PaneHeader: the title in text-title, one optional muted line under it,
and actions on the right. A connection problem is a failed-tone StateLine,
in the header on desktop and under the top bar on a phone.
The command palette is mounted exactly once in AppShell and opened from the
Search buttons, Mod+K or Mod+Shift+P, top-centred, max 600px, with
compact rows. Theme is in Settings > Appearance and the footer menu, with
explicit System, Light and Dark choices and matching palette
commands.
focusRing, fieldFocus and field in web/src/lib/utils.ts are the shared
focus and field styles. Buttons and navigation show their outlines only
after Tab, never after a click or another key; text fields use a 1px inset accent outline over the existing border with a
faint 4px glow and no offset gap. The outline remains visible in forced-colors
mode, where box shadows disappear. The Session composer draws this treatment
on its enclosing box, not its embedded textarea, so attachments do not clip
the highlight. Preserve readable placeholders, disabled and read-only states,
menu roving focus, typeahead, portalling and viewport flipping. Shared
primitives retain their props, events, refs and accessibility contracts.
The run header shows one primary action for the run's state, a Details
toggle and More; destructive verbs stay last in More, behind their
existing gates and confirmations. The view switch (Session, Terminal,
Changes, Browser) is a segmented tab list. A run with a pull request ends
that row with its number as a muted link and its check state as a ghost
button led by a StatusDot: failed, working, done or neutral. Details is a 320px side panel at
1280px and wider, a side sheet below that and a bottom sheet on phones; it
never overlays the terminal on wide screens. Standard runs keep control in
the terminal toolbar; Enhanced runs keep a compact Multiplayer strip
below the view switch on every view. Names and member avatars identify the
controller and watchers directly in that strip, which stays one row while
the controls fit beside a 6rem roster and otherwise wraps them to a second
line. The roster scrolls horizontally, and below 34rem watchers show as avatars and
below 30rem Multiplayer as its icon.
Takeover progress uses state-failed with on-failed text in both themes.
One run-owned holder decision dialog sits above every sheet and dialog.
Request cards use the needs-you soft fill; the Session transcript column is
736px wide with no row borders. Its composer fills the available pane with
balanced 16px side gutters, including when Details is open. An empty ACP
conversation shows a subdued Aether mark above the centered run title and
metadata rows: workspace, agent, branch, owner and creation time. Symmetric
scrollbar gutters keep the overview centered when its content needs scrolling.
Run state and motion
The --state-* tokens in Semantic palette are the
status vocabulary for a run's presentation state. Domain status enums remain
unchanged.
--duration-overlay (120ms) and --ease-out are the overlay motion
tokens: menus, popovers, selects, tooltips and dialogs fade and scale in over
it, sheets slide in from their edge, a run's details box wipes in from the
row it docks to, and every one leaves at once. Panels
change instantly. The state-pulse dot (the run header's working dot, the
Session view's live row and the working rows on a swarm's Tasks list) and the live-shimmer text sweep (also on those rows' reasons) step through a few
frames per cycle rather than tweening, and stop under
prefers-reduced-motion: reduce, where the shimmer leaves plain muted text.
The desktop launch splash deliberately retains its original animation,
independent of these workbench motion tokens: the grain, three drifting cloud
layers, stepped starfield and coloured stars, satellites and shooting stars,
700ms logo entrance and 260ms fade. Its status dot uses the local
launch-status-pulse: the original 1.8s ease-in-out loop from opacity 1
to 0.4 and back, not the shared stepped state-pulse. The splash's original
palette and keyframes stay scoped to launch-splash in web/src/index.css;
do not restyle them to match the workbench. Reduced motion skips the splash
entirely, and its CSS stops animation if the preference changes while it is
visible. Launch splash defines the
session and startup timing.
Live local control lights the top edge of the terminal viewport or the
Enhanced Session conversation, just under the toolbar; the other edges stay
dark. The light is a uniform bar across the whole edge: a 1.5px
--control-light rim with a tight halo and a soft glow spilling down. It is
at 70% strength while it moves or turns red. Once acquisition settles it
dims to half over 1.2s, then breathes between 42% and 60% on an 8-second
cycle. --control-light is the accent teal in light
theme and a brighter teal in dark; --control-glow dims the glow to 0.3 in
light theme. Release, shown while you control, has the accent fill;
Take control is a secondary button. The light is pointer-transparent and
does not change layout. It appears only with live, acknowledged writable
control. Terminal replay and history reading hide it; reading earlier
Enhanced items does not disable the composer or hide its light. The right
end, under Take control and Release, is the light's source:
acquisition pours it leftward across the edge and voluntary release drains
it back to the right, each over 720ms and decelerating toward the endpoint.
A control-lost takeover turns the light red over 540ms and drains it to the
right over 1440ms. An occupied five-second hold fills Take control and
the holder's Release diagonally in red and spreads red light leftward
from the right end of the holder's edge, replacing the teal it reaches; text
on either side of the fill edge keeps its own contrast. A seven-second holder
decision follows. Under prefers-reduced-motion: reduce, ownership changes
are instant, the settled light holds at half without breathing, and takeover
fills and light become static red indicators with countdowns; labels keep the state meaning. Loading spinners and delayed skeletons remain functional
feedback.