# Conflicts: # docs/DEVELOPMENT.md # web/modules/claudexor_status_store.js # web/modules/harness_accounts.js # web/modules/reviewer_slots.js # web/tests/claudexor_status_store.test.js # web/tests/harness_accounts.test.js # web/tests/reviewer_slots.test.js # web/tests/subagents_settings.test.js Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
10 KiB
Ouroboros Design System
Normative authority for the visual and interaction semantics of the Ouroboros UI: what a size means, what a colour claims, what a chip is allowed to say.
Authority split:
- This file decides semantics — type scale, hierarchy, foreground and state colour, status conventions, card/row anatomy, density.
docs/DEVELOPMENT.md→## Design Systemdecides the engineering rules that keep those semantics intact — where values may live, which component is the SSOT, what counts as review debt, how a visual change is verified.
Values themselves live in web/style.css :root (and are mirrored by value in
web/onboarding.css, which is inlined standalone and cannot import it). This
file names roles; it does not copy an inventory.
The theme is dark only. There is no light-theme plumbing, and adding a second theme is an architecture change, not a styling change.
1. Type scale
Four sizes. There is no fifth.
| Token | Value | Role |
|---|---|---|
--type-meta |
12px | Labels, notes, chips, timing/quota lines, captions |
--type-body |
14px | Default reading text, values, controls, row titles |
--type-section |
16px | Section and card titles |
--type-page |
24px | Page / wizard step title, and display text (a login code) |
Line heights: --line-meta (1.35) for short meta lines, --line-body (1.5) for
prose, --line-title (1.3) for headings.
Rules:
- A value with no exact token rounds to the nearest token. It never mints a
fifth size. 13px becomes
--type-body, 15px becomes--type-section, 11px becomes--type-meta. - No new raw
10px/11pxtext on a migrated surface. Below 12px the dark theme forces a choice between illegible and glaring, and the glaring option is what produced the owner's "too much small high-contrast white text" report.tests/test_web_typography_static.pyenforces this. - Control chrome keeps its own dimension tokens (
--button-font-size,--pill-font-size). They are control geometry, not the reading scale; do not replace them with type tokens or vice versa.
2. Hierarchy rule
In any row, card or field, exactly one thing is primary. Everything else steps down. Concretely:
- Label →
--type-metain--text-meta, sentence case. - Value (the thing the owner came to read or change) →
--type-bodyin--text-primary. - Meta (last run, quota, effort, timing, provenance) →
--type-metain--text-meta. - Section title →
--type-sectionsemibold; subsection heading →--type-bodysemibold. A bare<h3>/<h4>/<strong>that inherits the browser default is a defect: it lands at bold 16px full white and ties with, or beats, the content it introduces.
The 12px UPPERCASE label pattern is retired on migrated surfaces. All-caps
at a small size costs legibility, widens every label, and when a panel repeats
it dozens of times the labels collectively out-shout the values they describe.
Authored label strings should read as sentence case; CSS must not manufacture
caps with text-transform.
3. Foreground and state colour
| Token | Meaning |
|---|---|
--text-primary |
The one thing this row/card is about; interactive control labels |
--text-meta |
Real secondary content: labels, notes, hints, meta lines |
--text-disabled |
Genuinely inert or incidental content only |
--text-muted is a legacy alias of --text-disabled; new work names
--text-meta or --text-disabled so the intent is readable in the diff.
Two failure modes this table exists to prevent, both observed in this codebase:
- Secondary content parked at the disabled foreground. A load-bearing
caveat rendered at
--text-disabledreads as greyed-out chrome and gets skipped. If the owner is meant to read it, it is--text-meta. - Secondary content with no foreground at all, inheriting
--text-primary. This is the loudest of all failures because it is invisible in the CSS — the rule simply declares a size and says nothing about colour.
.muted
.muted is a colour-only utility: color: var(--text-meta), nothing else.
It must never set font-size. It is written at ~50 call sites that already
sized themselves, and a size here would silently resize all of them. A scoped
rule (.some-context .muted) still wins on specificity where a surface needs a
local variant.
Dark-theme contrast
WCAG 4.5:1 is a floor, not a target. On near-black, pushing small text toward pure white causes halation — the glyphs bloom, and because everything is maximally bright, nothing is emphasised. The result is a screen that is simultaneously harder to read and flatter in hierarchy.
De-emphasise the secondary rather than amplifying the primary. When
something needs to stand out, drop the ink around it, do not raise its own. All
Primary (15.9:1) and meta (9.2:1) clear the 4.5:1 floor against --bg-primary
with room to spare. --text-disabled is deliberately BELOW it (3.5:1) and is
therefore reserved for genuinely disabled or incidental content, which WCAG
exempts; it must never carry meaning a reader has to obtain.
4. Status and chips
A status has an explicit foreground/background pair, never a foreground derived from whatever generic opacity happens to sit on the element.
| Role | Foreground | Background |
|---|---|---|
| Success / connected | --status-ok-fg |
--status-ok-bg |
| Warning / degraded | --status-warn-fg |
--status-warn-bg |
| Error / failed | --status-error-fg |
--status-error-bg |
| Neutral / classification | --status-neutral-fg |
--status-neutral-bg |
- Status renders as dot + text. The dot carries the state at a glance, so the sentence does not have to shout it in saturated colour and can sit at ordinary reading contrast.
- Neutral is a real state, not an absence of one. A classification chip
(which agent, which family) is neutral: it is a tag, not an alarm.
A tone value the code actually emits (
muted) must have a rule; falling through to a default is how chips end up white. - Chips are
--type-meta, not smaller, and are not uppercased. --tone-ok/--tone-warn/--tone-dangerremain the saturated role hues for large fills, borders and indicators. The--status-*-fgtints are for 12px text on near-black; do not swap them.
5. Card and section composition
- A panel is one
.ui-card-family surface:--ui-card-border,--ui-card-bg,--radius. Nested emphasis uses--ui-card-bg-soft, not a second border weight. - A section is: title (
--type-section) → optional one-paragraph description (--type-body,--text-meta) → content → optional note (--type-meta,--text-meta). The description explains what the section decides; the note carries consequences and caveats. - Subsections inside a section use a
--type-bodysemibold heading and stay visually grouped with their own rows and their own action toolbar. A heading that floats equidistant between two groups belongs to neither. - Spacing comes from the 8pt tokens (
--space-*); a new visual dimension becomes a CSS variable before it becomes a page-local literal.
6. Account group / row anatomy
For a repeated identity row (a connected agent account, a reviewer slot, a server entry):
- Classification chip — neutral pair,
--type-meta. Only where the row's family is not already expressed by the group it sits in; inside a per-family card the chip repeats the header and is dropped. - Name —
--type-bodysemibold,--text-primary. The one primary thing. - Identity detail (email, plan) —
--type-meta,--text-meta. - Status — dot + text from the status pairs.
- Meta line —
--type-meta,--text-meta, on its own line under the name. Quantities are stated in human words ("38% used · resets in 2h"), and an instant is humanized. A row never leads with a raw ISO timestamp. - Actions — docked right, legible at rest. A control rendered at
secondary ink reads as disabled; if the owner can click it, it is
--text-primary.
Rows of the same kind are equivalent: no row gets extra visual weight for being first, default, or native. Grouping and section-level actions express which family a row belongs to, and a section-level action (add, connect) belongs in its group's header rather than attached to one privileged row.
A degraded row is emphasised, not dimmed. Lowering a whole row's opacity
greys out the sentence that reports the problem and makes its still-clickable
controls read as disabled. Tint the row with the matching --status-*-bg
instead, and let the status text carry the claim.
7. Onboarding density
The first-run wizard is a compact flow that must not scroll at the default desktop window size merely because a step has several fields.
- Step title
--type-page; card titles--type-section; field labels and notes--type-meta. No display size above--type-page. - Short-viewport adaptation hides explanatory copy rather than shrinking type. Once copy is hidden, shaving pixels off a title buys nothing and costs the scale.
- Field labels are sentence case at meta ink — a wizard step shows a dozen at once, and its job is to get one value typed, not to present a grid of headings.
8. Migration state
The scale is applied surface by surface. Migrated today:
web/settings.css(settings shell, model/effort cards, MCP cards)web/onboarding.css(the whole first-run wizard)web/style.cssbetween thedesign-system:migrated-beginanddesign-system:migrated-endmarkers — harness accounts and reviewer slots- the global
.muted,.form-section h3and shared.ui-statustone rules
Not yet migrated: chat, skills, marketplace, widgets, logs, evolution. They are historical and keep their literals until their own pass. Do not part-migrate a surface: a half-tokenised stylesheet is harder to reason about than an untouched one.
tests/test_web_typography_static.py guards the migrated set only. Extending
the guard to a new surface and migrating that surface are the same commit.