Web UI design system

Machine-readable token spec for the server-rendered web surface, transcribed from internal/web/tokens.css and internal/web/app.css.

Overview

exocomp is a single-operator homelab control plane. This document specifies the design tokens for its web surface: a server-rendered HTML UI (templ + vanilla JS over a JSON API, live updates via SSE, no SPA). It is the reference a change reads to keep that UI consistent, and it maps onto the TUI where the two share a vocabulary.

The direction is clean, minimal, functional. The UI reports; it never sells. Five principles drive every token here:

  1. Status before detail. The dashboard answers "is everything OK?" without scrolling. Cards carry only signals that lead to a decision.
  2. Failure is louder than success. Healthy states are quiet — dim ink, small dots. Failures, staleness, and waiting-on-human states carry the weight. This is why the palette spends its saturation on signal colors and keeps surfaces and text near-monochrome.
  3. Everything is a job. One list, one detail view, one event stream. Components are shared (chip, steprail, table) rather than per-feature.
  4. Truth over reassurance. Exit codes, timestamps, and serials render plainly. tabular-nums is on for every digit so columns align.
  5. Quiet surface, dense information. Minimal chrome, a clear type hierarchy, tables that respect density. Whitespace serves scanning.

The token layer is deliberately small. internal/web/tokens.css owns the palette and type ramp; internal/web/app.css applies them to layout and the accessibility affordances (focus ring, touch targets, tabular-nums). A palette change is a one-file edit.

Two themes, both first-class. Dark is the native theme; light is a full warm remap, not an inversion. The in-page toggle cycles system -> light -> dark and persists the choice; "system" carries no data-theme and follows prefers-color-scheme. A pre-paint script applies the stored theme so a light-mode operator never sees a dark flash.

Sources

Three documents own the surface; read them together:

  • Invariants — the design philosophy and hard rules that survive any redesign.
  • The repo-root DESIGN.md — the machine tokens as a single contract.
  • This document — the token spec and the component shapes that implement both.

internal/web/tokens.css and internal/web/app.css are the shipped truth. Where a document disagrees with them, the code wins and the correction lands in docs/log.md.

Colors

Every color is a token; nothing is hard-coded at a call site. Tokens divide into four families, each with a distinct job.

Surfaces form a depth ramp built from fill, not line. Each step up (void -> rail -> card -> tray -> control) is a lighter fill in dark and a distinct warm step in light. Hairlines survive in only two roles: a table header rule (hairline) and inner cell dividers (hairline-faint). Depth is never drawn with borders where a fill will do.

Ink is a five-step neutral ramp with measured contrast against the tray surface. ink-100 for headings and values, ink-200 for body, ink-400 for labels and secondary text, ink-500 for timestamps and meta — the last step that still meets AA for text. ink-600 is for dots and rules only and must never carry text.

Accent is amethyst, deliberately split. accent-fill (#A435CE dark) is for solid fills and primary buttons only — as text on a graphite surface it drops to 3.65:1 and fails AA. accent-text (#BE7BEB dark) is the accent as link, icon, and focus ring. In light, one value (#7A2FA6) carries both roles. accent-on is white text on a solid fill, shared by both themes.

Signals carry state and nothing else: green ok, amber wait, red fail, and a dim neutral for queued/skipped/disabled. Each has a text color, a ~12% tint fill, and a ~30% border, so a state reads as text, as a dot, and as a chip fill. Running/interactive state uses the accent, not a signal color. State is never color-only — every signal pairs with a label or glyph.

All pairings are annotated with their measured contrast ratio in tokens.css and meet WCAG AA or better.

Typography

Two families split by origin: IBM Plex Sans for anything a human wrote (UI chrome, headings, body), JetBrains Mono for anything a machine produced (log tails, code, serials, and the uppercase keyword/kicker labels). Both load from Google Fonts with system-ui / monospace fallbacks. Pure-mono is on-brand for this audience; the sans is the calmer sibling for prose.

font-variant-numeric: tabular-nums is global — every digit aligns, always. This is load-bearing for scanning serials, exit codes, ages, and countdowns.

The ramp is nine named steps, each a single font shorthand token plus letter- spacing:

Token Value Role
display 600 30px/1.1, -0.7px page title, live timer/countdown
section 600 23px/1.2, -0.3px section heading (h2)
card 600 19px/1.3 card title (h3)
bar 500 15px/1.4 app-bar brand, section label
button 500 13.5px/1 buttons, nav links, form labels
body 400 13px/1.6 body text and all data
log 400 12.5px/1.65 mono log tail, code, inputs, .mono cells
keyword 600 11px/1 mono, +0.4px, UPPERCASE state chips, table headers
kicker 600 11px/1 mono, +2.4px, UPPERCASE, accent card eyebrow

Content links carry no resting underline — 500 weight is the affordance — and the underline returns on hover and focus as an interaction signal. :visited matches default: this is a control plane, not a document corpus.

Layout

The frame is a sticky app bar over a centered content column. The bar is header-h (60px) tall, holds the brand, a horizontally scrolling primary nav, the operator identity, and the theme toggle. On a narrow screen the nav scrolls within its space (with a trailing-edge fade signalling more items) rather than pushing the bar wider than the viewport. On phone the bar is a single 56px shell — menu · mark · page title · right meta — and the wordmark drops; the powerline foot is sticky at 36px and main carries 50px bottom padding.

main is capped at 1100px and centered, padded by pad-page (26px 30px); on phone it is 14px 16px with 50px bottom clearance. The dashboard lays cards on a two-column grid (gap-card, 12px); a span2 card spans both columns. Below 719px — the one phone breakpoint — the grid collapses to one column; 720–1099 keeps the desktop layout with the rail collapsed and .config at two columns. A third width breakpoint is a bug until a SPEC says otherwise; touch hints are (pointer: coarse), never a width test.

Spacing is anchored by row-h (44px) — the minimum hit target, shared by rows, controls, and buttons. On the phone approval surface (capped at 390px) touch targets rise to 48px, an accessibility hard requirement.

Wide content never scrolls the page sideways. body uses overflow-x: clip, and each wide block (.tablewrap, the nav) scrolls inside its own box. On phone (≤719px), stackable tables transform: each row becomes a bordered card and each cell prints its column name from data-label, so a row never loses its header. The thead is visually hidden but kept in the accessibility tree.

Elevation

There are no shadows. Depth is expressed entirely through the surface ramp: a raised element sits on a lighter fill than the element behind it (card over void, tray over card). The single exception the token system reserves is an inset state ring drawn with box-shadow: inset 0 0 0 1px <border> for focus and selection, not for drop-shadow elevation. This keeps the surface flat and quiet, consistent with the "no gradients-as-decoration" anti-goal.

Shapes

A radius ramp scales with element size: 2xs (4px) for inline diff marks and funnel bars; xs (6px) for inline code and diff object blocks; chip (8px) for chips, inputs, and the log/plan boxes; sm (9px) for row-inner controls (menus, verb glyphs, playbook rows); row (10px) for tray rows and target tiles; control (11px) for buttons and text inputs; card (14px) for cards, banners, and approval blocks. Corners are consistent within a size class so the UI reads as one system.

Motion is minimal and meaningful. The only animation is breathe — a 1.6s opacity pulse (1 -> .35) on the RUNNING dot and pin. Nothing spins; there is no progress theatre. prefers-reduced-motion collapses all animation and transition to none.

Components

App bar and nav. Rail-surface bar, hairline bottom border, sticky. Nav links are 44px pills in ink-400; the current page fills with accent-tint and switches to accent-text.

Card. The dashboard's unit. Card surface, faint hairline border, 14px radius, pad-card padding. A kicker eyebrow (mono, wide-tracked, accent) names the principle the card serves; an h3 titles it. Empty states are a single quiet meta line naming what would change the state.

Chip. The core state primitive: an 8px dot plus an uppercase mono keyword, in a tinted pill. Five variants — ok, wait, fail, run (accent, breathing dot), dim — map one-to-one to the signal palette. Never color-only.

Privilege chip. A run that opted out of the kernel sandbox carries an unsandboxed chip (chip--unsandboxed, the fail signal the launcher's privileged block already uses — a posture, not a failure) in the job detail's chip row; the jobs list carries the same fact as a mono word beside the source, where the 52px row has no room for a second chip. The rerun dialog names the original's posture the same way and offers the escalation as the launcher's fail-tinted toggle, unchecked: a rerun re-escalates only by a deliberate act.

Run grant tiles. The privilege posture is only half the sandbox picture: a sandboxed run's --egress port set and --ssh-identity files — what the sandbox permitted beyond its defaults — render as RUN CONFIGURATION tiles (Egress ports, SSH identity), each a label over a mono value in the same .config grid the other run facts use. A grant appears only when it exists: a run with no declaration is the quiet default posture and carries neither tile, and a privileged run forfeited the sandbox so no grant applied — the Privileged tile names that posture and the grant tiles stay absent, never implying an unsandboxed run was restricted to the declared grant. A file is shown as a PATH, never as key material.

Step rail. A row of 10px dots rendering a workflow's ledger at a glance, using the same five states as the chip.

Table. Full-width, uppercase mono headers over a hairline rule, cells divided by faint hairlines, hover paints the tray surface. Numeric and mono columns opt in via .num / .mono; an unbounded mono value wraps inside a 32ch cap rather than forcing the table to scroll. Real <th scope> throughout.

Button. Three variants at row-h min height: base (control surface), primary (solid accent fill, white text), danger (fail tint, fail text and border). The global-kill button adds a hold-to-confirm fill.

Banner. A full-width alert (pause / silence / insecure) on every screen until the state lifts, carrying the count of swallowed alerts. Wait and fail variants use the matching signal tint and border.

Approval surface. The phone flow (390px, 48px targets): risk-ordered blocks with the fail-closed countdown in display type, destroys in their own red block, the verbatim plan diff, and secret-backed values rendered as [redacted · secret]. The phone never carries a value the web UI would not show.

Log tail. Void-surface box, mono, capped height, scrollable, with error lines in fail color and system lines dimmed. Its state bands carry the terminal verdicts; the privileged enqueue record is a fail-tinted unsandboxed band so the posture is not buried among the dim system lines.

Login. A single card: paste a token, see the certificate fingerprint. Errors in fail color, insecure-HTTP warned explicitly.

Do's and Don'ts

Do

  • Use a token for every color, size, and radius. A raw hex or px at a call site is a bug — the one-file palette edit depends on it.
  • Pair every state with a label or glyph. Color alone never carries meaning.
  • Keep accent-fill for solid fills and accent-text for text, links, and the focus ring. They are not interchangeable — the fill fails AA as text.
  • Let failures and staleness carry visual weight; keep healthy states quiet.
  • Keep tabular-nums on for every digit.
  • Preserve the visible focus ring (3px, 2px offset). Removing outline without a replacement ring is a review-blocking bug; the ring stays amethyst even on an amber card.
  • Design both themes together. Any new token needs a light and a dark value.

Don't

  • No shadows for elevation; use the surface ramp.
  • No gradients as decoration, no hero sections, no illustrations, no empty-state mascots. If a screen would look at home on a SaaS landing page, it is wrong.
  • No spinners or progress theatre. The only motion is the RUNNING breathe, and it yields to prefers-reduced-motion.
  • No ink-600 as text — it is for dots and rules only.
  • No metric cards without a decision behind them; no vanity metrics.
  • No marketing language. The UI reports state; it does not reassure.

Realignment v0.3 (landed)

The v0.3 handoff realigned the shipped surfaces; this section records the components and the deliberate deviations that landed with it. Canon for values remains internal/web/tokens.css and internal/web/app.css.

App bar (F15). The shell owns the page title in a slim bar (--type-bar, 15px/600) with a mono who · scope meta slot and the page's one primary Add verb at the right. The 30px display h1 is gone from the list pages; the --header-h token stays 60px (the sidebar brand shares it) while the bar reads 58px.

Grouped nav (F7). The rail is grouped by verb — OPERATE / AUTOMATE / PROVISION / ACCESS — each cluster under a mono label. Actionable-state badges show beside Checks (down-target count, fail) and Approvals (pending, wait) and Sources (never-synced, wait): a mono count, never a pill. Netgroups is a tab on /checks; /netgroups is a redirect.

Tray-table (F11). A list surface's table floats inside a padded tray — card padding collapses to 8px, the header reads as a quiet mono label with no rule, and the rows alternate none/--surface-tray with hover on --surface-row. No per-cell hairlines. The stackable phone transform and real <th scope> are untouched. Applied to the 13 stackable list surfaces; the deliberately non-stackable short tables (system, workflows) are untouched.

Check cards (F1/F2/F3). One card per check over a target grid on the tray (minmax(300px, 1fr)). Reachability is the only chip; location is a fixed meta slot in the row's second line — a route glyph + on <netgroup> (link to Netgroups) · age, so the location claim reads down the column — never a chip (invariant 5). A never-seen target is a single dim UNOBSERVED chip with on <netgroup> · away — not probed from here, never a location chip. A down tile carries the inset fail ring; the per-target clock right-aligns in the tile header.

Launch drawer (F4). The source detail leads with identity and facts; the launcher is a right-anchored 460px <dialog> opened by intent, JS-off via ?launch=1 (the open attribute). The submit button is the bare verb (Queue run); the footer states the contract (asks nothing else · same as exocomp run <source> <thing> [--egress …]), so a long collection path wraps the prose line and never the primary. The privileged toggle sits in its own fail-tinted block. The Options fold holds the run-option toggles and timeout.

Quiet/danger row actions (F8). Row actions are 30px: .btn.quiet (control fill, label ink) for Disable/Enable, .btn.danger (transparent, fail ring) for Remove. Danger is a ring, not a fill, until the confirm. Full-size .btn.danger (fail tint) remains for the decision/confirm buttons (the phone Reject, the confirm dialog).

Row actions (v0.6). A tray row's actions cell holds at most two verb glyphs — the actions an operator reaches for daily — then a chevron that opens a menu of the rarer verbs and views. Fewer than three actions → glyphs only. Glyph buttons are 34×34 (44×44 on phones) transparent icons, 18px glyphs (20px on phones), ink-500 → ink-400 on row hover → surface-control fill + ink-100 on button hover; the chevron is 26×34 (32×44 on phones), ink-600 → ink-400, open state fills surface-control with an accent-border ring. The menu is surface-control, min-width 220, radius 11; items are 36px glyph + word with an optional mono count; destructive items sit last in --signal-fail with an "asks first" note. On phones the chevron opens a bottom sheet (surface-control, radius 16 top, a {name} · {kind} identity line) listing every action. The glyph set is fixed per verb (plan, apply, enable, disable, open, rotate, remove, bindings) as inline stroke SVGs; every glyph and the chevron carry an aria-label. The trailing "…" is retired — a destructive button is named plainly and the confirm dialog is the safety.

Powerline segments (F10). Each segment is dot + word in its signal color and links to where it is fixed — dead-man none is wait with a fix verb (— set one), ntfy off is deliberate dim, disk is ok until it drops under 10%. State is never color alone.

Deliberate deviations (reviewed)

  • The secrets columns were kept (Name/Kind/Source/Created/Rotated/Used/ Expiry/Bindings) rather than the handoff render 1e's SECRET/CREATED/LAST-USED/USED-BY: expiry and kind are safety-relevant for a sealed-value inventory. The 1e metadata sentence and the unbound — safe to remove copy landed.
  • Sync is a CLI verb, not a web route. The handoff 1d header's "Sync now" button has no backend (no queued sync job kind exists; sources_write.go documents the deliberate omission). The source detail instead shows the command (exocomp source sync <name>) next to the never-synced fact. The never-synced wait state is git-only: a path source's tree is already on the box and read directly, has no sync verb (source sync refuses it), and so reads n/a (path source) for Last sync and enables the launch select as soon as discovery finds playbooks.
  • TUI keymap. The TUI's post-v1 screens grew past the handoff round's seven-screen model, so two literals were pushed back: the [0-6] help-bar range is the truthful [0-9] [s] [u] (the screen set grew), and r was freed for reject by remapping the runners jump to u. The load-bearing reserved-letters rule and the verbatim per-screen bars are honored. The b/l/x run-now keys are reserved but unwired — their spec construction lives in the CLI (package main), and forking it into the TUI would split the queue-facing contract.