Design invariants
The design philosophy, hard rules and component vocabulary that survive any redesign.
The rules that survive any redesign. A change that violates one of these is wrong — or this file moves with it in the same commit.
Authoritative references, read together: internal/web/tokens.css (token values),
internal/web/app.css (layout), DESIGN.md (component token spec), and
the repo-root DESIGN.md (machine tokens). Where a document and the shipped code
disagree, the code wins and the correction lands in docs/log.md.
Philosophy
- Instrument panel, not SaaS dashboard. One operator, a handful of hosts. The UI's job is to make state legible and decisions fast — never to reassure, never to market. No vanity metrics, no progress theatre.
- Healthy is quiet, failure is loud. A nominal system renders almost nothing: quiet counts, an empty attention band. Signal color is spent only where a decision is needed — that is what keeps it legible.
- Signals lead to decisions. Anything highlighted must end in a verb — a link or button that resolves it ("Open checks →", "dead-man none — set one"). A highlight with no action is decoration.
- State is a word plus a color, never color alone. Every state chip pairs a dot with an uppercase mono keyword. Must survive greyscale.
- Reachability ≠ location. A check's UP/DOWN/UNOBSERVED is state and chips; a netgroup (home/away) is a network-path fact and renders as prose meta. Two equal chips claim two states — that is a lie.
- Gaps are signals, not grey facts. "dead-man none" is a wait state with a fix verb, not a neutral label. Empty states name the verb AND the trigger that changes them ("No jobs yet. They appear when a schedule, rule or manual run fires."); a bare "Nothing here" is a bug.
- Fail closed, refuse loudly. Approvals auto-reject at TTL; a stale plan is a refusal, not a disabled button; destructive actions go through the confirm dialog (label carries "…": "Remove…").
- The queue is the only executor. Every run states what it will do — the launch drawer's footer names the exact ask ("same as exocomp run home-lab deploy/site.yml"), and the submit button is the bare verb so a long collection path can never wrap it. Nothing in the UI executes directly.
- Read first, act second. Detail pages lead with identity and facts; launch/mutation UIs are focused surfaces (dialog/drawer) opened by intent.
- Code is truth for concepts and routes; pixels retune freely. Concepts and their states come from the implementation; markup/CSS/JS are always negotiable in service of the rules above.
Hard rules (a violation is a review-blocking bug)
- Token or bug: every color, size, radius comes from
tokens.css. No raw hex/px in templates. - No shadows, no gradients, no heros, no illustrations, no mascots, no spinners. Depth is fill (the surface ramp); the only motion is the RUNNING breathe (reduced-motion-respecting).
- Hairlines in exactly two places: a wide table's header rule and inset state
rings (
box-shadow: inset 0 0 0 1px <state-border>). - accent-fill vs accent-text are not interchangeable (fill fails AA as text).
The focus ring is always amethyst, 3px, 2px offset,
:focus-visibleonly — never removed, never recolored to match state. - ink-600 is never text — dots, rules, dividers only.
- tabular-nums on every digit; ligatures off on every mono run. Numbers align
always. Mono for anything a machine produced (values, keywords, logs,
timestamps); sans for anything a human wrote. Every mono run sets
font-feature-settings: "liga" 0, "calt" 0, so operators like==render as separate glyphs, never one fused shape. - Icons are inline stroke SVGs — 24 viewBox, stroke-width 1.8, currentColor, round caps. Never emoji or emoji-renderable glyphs. Typographic marks (‹ ▾ ▸ ·) are fine inside text.
- Copy: commands render as
exocomp(neverexo). Buttons one word where possible; the trailing "…" is retired — a button that opens a confirm dialog is named plainly and the dialog is the safety. Findings-not-praise tone; no marketing language. Insecure/cleartext warnings read as plain English for a human, never internal vocabulary ("on the wire", "listener", "served over cleartext"). - Row actions: a tray row shows at most two verb glyphs — the actions an
operator reaches for daily on that resource — then a chevron that opens a
menu. Fewer than three actions total → glyphs only, no chevron. Menu items
are always glyph + word; destructive items live last, in
--signal-fail, with an "asks first" note. The glyph set is fixed per verb (the same shape means the same verb on every resource); every glyph and the chevron carry an aria-label. Views (Saved plans, Backend config, Bindings, Open) live on the resource's detail page, not as per-row buttons. The phone chevron opens a bottom sheet listing every action. - One button-height ladder: 34px default, 30px in tray rows and card feet,
44px for a form's single dominant verb (the
.actionsblock: modal submits, the launch drawer, login), 48px floor on every verb and primary button on phone (≤719px). The one documented exception is a 36px compact-secondary class for a secondary shell action (e.g. Raw log). - A list card's zero-state sentence shows only at zero (a non-zero count never contradicts it), and it names the verb that changes the state.
- A11y gates: 48px touch targets on the phone surface (44px rows on desktop), 200% reflow, keyboard reachable everywhere (mouse-only control = bug), greyscale legible, visible focus ring, TalkBack live regions on the approval surface.
- Both themes always. Dark is native, light is a warm low-chroma remap — a
palette change is a
tokens.css-only edit, and every component ships in both. - JS-off baseline. Server-rendered forms POST; dialogs degrade (
?launch=1renders<dialog open>); enhancements (row-click, xselect, drawer) are additive. - One color for "never" — wait. Never-seen / never-synced / never-backed-up is
a gap, not a failure:
--signal-waiteverywhere (Sources, Backups, UNOBSERVED). Exception: a dead-man policy that makes a missing backup a hard fail — then it is--signal-fail, and the copy says so. - Header meta is the page's own fact. The app-bar meta slot carries the page's
own fact (host · uptime,
{n} checks,1 of 1 down); the operator identity renders once per screen, in the powerline only.
Tofu surfaces (v0.10)
The round that surfaced the job-log and workspace-state problems. Source:
tofu/SPEC.md in the v0.10 handoff. Four invariants, each a hard rule:
- A log owns its scroll. The log box ends above the powerline, follows the tail while streaming (FOLLOWING TAIL), and shows a PAUSED word + a Tail verb when the operator scrolls up. The page never scrolls to reach a log.
- One apply per workspace. Only the latest SAVED plan carries an apply affordance (a glyph); plan history is read-only, one lifecycle word per plan (SAVED · SUPERSEDED · CONSUMED). Drift is a finding (wait), never a failure.
- Secrets never render in a paste block. Config the operator copies references a secret by variable or name; reveal is a separate, logged, timed action beside the block; copy never includes the value.
- Mutations preview their diff. Apply and destroy open a confirm showing the structured change set with +N ~N −N counters; a stale plan renders REFUSED with "Plan again", never a disabled button.
Responsive (v0.9)
The web surface serves every width with one template structure re-flown by the stylesheet; phone is not a second design.
- One breakpoint.
@media (max-width: 719px)is phone, and phone only. 720–1099 keeps desktop layouts with the rail collapsed and.configat two columns;max-width: 1440pxis the one wide-desktop tweak (the system-backups footer stacks below it). Any other width breakpoint is a bug until a SPEC says otherwise. Touch hints are(pointer: coarse), never a width test. - Rows, not tables, on phone. Every list row (jobs, queue, events) stacks
into two lines — a primary line over a mono meta line — the whole row is one
link, 44px minimum. The wide table's header row is
display: noneon phone but stays in the a11y tree. A value is never ellipsized on phone:overflow-wrap: anywherebefore truncation. - Logs are pre by default. On phone the log is
white-space: prein a 300px scroll box with a 60px clock column; wrapping is the operator's opt-in toggle (wrap), never the default. - One phone shell bar. Phone chrome is a single 56px bar — menu · mark ·
page title · right meta — and the wordmark drops. The powerline foot is
sticky at 36px;
maincarries 50px bottom padding. - Size-agnostic markup. One template structure serves both sizes; the stylesheet re-flows it. Copy differs per size only where the SPEC names it.
- Touch floor, documented exception. The 48px floor (a11y) stays for verbs and primary buttons; a 36px compact-secondary class (e.g. Raw log) is the documented exception. The app bar's right meta slot stays visible on phone (clock · count · last 24h) — it is not hidden.
Events + job output (v0.12)
The round that rebuilt the Events filter row and the job Output status. Source:
events-job-output/SPEC.md in the v0.11 handoff. Four invariants, each a hard
rule:
- Filters: scope is a switch, state is a chip. A dimension with exactly one value at a time (event source) is a segmented switch; a closed enum you filter by (event outcome) is a chip row in enum order, all positions always present, zero-count positions dim and unlinked. The active chip is the only accent fill on the row and is its own clear.
- One status word per surface. A live-updating status chip (the job log's pin) is rendered by the server in its initial state and moved by JS; never a second static copy of the same fact.
- A count is a column. Word and number are two cells (tabular, right
aligned), never
word · Nin one run of text. - Constant verbs hold fixed slots from the right edge. In any glyph cluster the always-present verbs own the right-most positions on every row; a conditional verb (apply) is prepended on the left and is absent — not disabled, not a placeholder — when it does not apply. A constant glyph never changes x between rows or states.
Component vocabulary (one of each, everywhere)
chip · card · tray-table (44px radius-10 rows in a padded card) · quiet/danger buttons (danger is a ring until confirmed) · banner · native dialog + confirm · step rail · log tail · powerline (signal segments) · grouped nav rail (OPERATE / AUTOMATE / PROVISION / ACCESS + System foot).
Component token specs and shipped CSS: internal/web/tokens.css,
internal/web/app.css, DESIGN.md.