A snapshot: read at commit 707a04c7ce8 on 2026-10-18T00:06:01Z by majordomus 0.29.0. Nothing on this page is read again. See it live
docs/DESIGN_SYSTEM.md
The design system — one declaration, every surface
What it is
- URI
majordomus://document/docs/DESIGN_SYSTEM.md- Kind
document- Identity
docs/DESIGN_SYSTEM.md- Path
docs/DESIGN_SYSTEM.md- Provenance
- declared
- Media type
text/markdown- As a capability
- document.docs/DESIGN_SYSTEM.md
Front matter
{}Content
# The design system — one declaration, every surface
How the site published to GitHub Pages, the Cockpit, the pages the executable renders
itself and the Swagger shell come to look like one tool: where the visual identity is
decided, how it reaches each surface, how it is extended, how drift is caught, and what to
do when a page looks wrong. Behaviour as implemented and tested; where implementation and
this document disagree, the document is wrong and changes in the same commit as the fix.
The rule is `project.design-tokens-declared-once@2`; the decisions are
[ADR 0031](../.ai/repo/adrs/0031-the-design-is-one-declaration-and-every-surface-is-a-project.md)
and
[ADR 0036](../.ai/repo/adrs/0036-the-design-system-is-a-typed-declaration-and-every-first-party-surface-consumes-its-projections.md).
## Why
Four surfaces put HTML in front of a person, and each had grown a design of its own. The
site was Flowbite's default theme over Tailwind's palette; the Cockpit a second Tailwind
build with its own palette, a dark theme at a different hue (which, it turned out, never
applied), its own favicon, its own theme key and six bracketed pixel sizes; the report pages
seven hexadecimal literals; the Swagger shell whatever a third party shipped. Status was
three palettes, and which word meant which status was written in a stylesheet's selector
lists *and* in four template macros. Nothing could say what `--mj-text-faint` was for.
None of it was a wrong value. It was a design with no canonical home, so every surface
decided it again, and no check could tell obedience from invention.
## The source of truth
`share/design/tokens.yaml`. One file, in the layer's own YAML subset, holding:
| section | what it decides |
|---|---|
| `identity` | the name and the marks under `share/design/brand/` |
| `font` | the system stack and the monospace stack |
| `palette` | the raw colours — Tailwind's, quoted; internal, never read by a consumer |
| `roles` | the semantic surface: `bg`, `raised`, `sunken`, `line`, `fg`, `body`, `muted`, `faint`, `accent`, `accent-strong`, `accent-soft`, `accent-line`, `accent-fill`, `on-accent`, each a palette entry per theme |
| `status.roles` | five meanings — `ok`, `warn`, `bad`, `info`, `neutral` — each with text, ground and border per theme |
| `status.states` | the vocabulary: every state word any surface renders, filed under one meaning |
| `tones` | hues for identity and never meaning — blue, violet, green, orange, rose, cyan — each with ink, ground, border and fill per theme |
| `type` | the named scale (`meta`, `label`, `small`, `dense`) and letter-spacings |
| `layout`, `radius`, `motion` | the shared measures, corner radii and durations |
| `theme` | the class that means dark and the `localStorage` key of the reader's choice |
| `viewports` | the widths every browser audit measures at |
| `alias.flowbite` | the site's vocabulary, each Flowbite name a synonym of a role or a status colour |
The crate reads it as a typed model (`apps/majordomus-cli/src/design/`): declaration order
kept, every reference resolved, every inconsistency refused before anything is written, and
a SHA-256 fingerprint over the canonical form. The first twelve digits of that fingerprint
are what every stylesheet carries as `--mj-design`.
## The pipeline
```mermaid
flowchart TD
yaml["share/design/tokens.yaml"]
subgraph sheets["share/design/"]
theme["theme.css<br>Tailwind @theme + Flowbite's names<br>as var(--mj-*)"]
surface["surface.css<br>every role and status colour,<br>light and dark"]
status["status.css<br>one selector group per state word"]
base["base.css<br>(hand-written) focus ring, reduced motion"]
primitives["primitives.css<br>(hand-written) badge, table, card,<br>code, form …"]
end
tailwind["site/tailwind.css"]
appcss["site/static/app.css"]
pages["GitHub Pages"]
cksrc["share/cockpit/src/cockpit.css"]
ckcss["share/cockpit/cockpit.css"]
cockpit["Cockpit"]
webtokens["apps/majordomus-cli/src/web/tokens.css<br>the same tokens, no Tailwind"]
webout["reports, home page, Swagger shell"]
decl["apps/majordomus-cli/src/design/tokens.yaml<br>the declaration, compiled in"]
declout["design.* capabilities,<br>Cockpit shell and Design page"]
logo["apps/majordomus-cli/src/cockpit/logo-mark.svg<br>the mark, compiled in"]
topbar["the UI's hero mark"]
brand["share/cockpit/favicon.svg, site/static/favicon.svg,<br>site/static/images/*.svg<br>every copy of the brand"]
registry["site/data/registry/design.json<br>vocabulary, theme contract, widths"]
registryout["templates, site-probe, ui audit"]
inventory["docs/generated/design.json, design.yaml, design.md<br>the inventory"]
yaml -->|"majordomus generate design (crate::design::render)"| sheets
sheets --> tailwind
tailwind --> appcss
appcss --> pages
sheets --> cksrc
cksrc --> ckcss
ckcss --> cockpit
webtokens --> webout
decl --> declout
logo --> topbar
registry --> registryout
```
Two hand-written files sit beside the generated ones and are imported by both Tailwind entry
points: `base.css` (the accessible defaults) and `primitives.css` (the `.mj-*` components
both surfaces render). They name tokens and choose nothing. A third, `figure.css`, is the
site's alone: the frame of the diagram viewer and of a chart
([`DIAGRAMS.md`](DIAGRAMS.md)), over the same tokens and the pairs the primitives already
use. It is unlayered on purpose, because the frame is the diagram's own `<pre>` and must
win over whatever utilities its page gave it.
Both Tailwind builds import the generated sheets in the same order, after Flowbite's theme.
The site keeps Flowbite's vocabulary in its templates — `text-heading`, `border-default`,
`bg-brand` — and the generated theme declares each of those names as `var(--mj-<role>)`,
unlayered and after Flowbite's own `.dark` block, so the name is a synonym and the value is
the declaration's in both themes. The Cockpit reads `--mj-*` directly. A report page and
the Swagger shell compile the same tokens in and fetch nothing.
Status works through one indirection. The generated status sheet emits, for each meaning,
one selector group over the meaning and every word filed under it —
`.mj-badge--succeeded, .mj-alert--succeeded, .mj-status--succeeded, …` — setting
`--mj-status-fg`, `--mj-status-bg` and `--mj-status-line`; the shared badge, alert and
status primitives read those three with a neutral fallback. So a surface writes
`mj-badge--<word>` and nothing else, and the colour is whatever the word is filed under.
Nothing is run by hand. `scripts/derive` (and `just derive`) runs `majordomus generate`,
which includes the design target; `scripts/cockpit-assets` and `scripts/site-build` compile
the two Tailwind bundles from the generated sheets; the pre-commit hook and the CI plan
refuse a tree whose projections are stale.
## Extending it
Every extension is one edit to `share/design/tokens.yaml`, then `majordomus generate design`
(or `just derive`), then the two asset builds. Nothing else is edited.
- **A semantic colour.** Add a role under `roles` with `about`, `light` and `dark` naming
palette entries (add the entries under `palette` if the colour is new — Tailwind's value,
quoted). It is `--mj-<name>` on every surface, a swatch class for the inspector, a row on
the Cockpit's Design page and in the inventory, and answered by `explain`.
- **A status semantic.** Add a meaning under `status.roles` with `fg`, `bg` and `line` per
theme, and file its words under `status.states`. Every `mj-badge--<word>` is styled.
- **A tone.** Add it under `tones` with `fg`, `bg`, `line` and `fill` per theme. A tone is a
hue for identity — a step of a sequence, one surface's card — and never a verdict, so it
is not a status. `.mj-tone--<name>` sets the four `--mj-tone-*` properties every kit
component reads, the contrast measurement pairs the tone with itself only, and
`site/data/registry/design.json` lists it for the templates.
- **A state word.** Add it to the list under its meaning. That is the whole change:
`test/cases/109_design_system.sh` does exactly this to prove nothing else moves.
- **A type step.** Add it under `type.scale` with `size` and `leading`; `text-<name>` exists
in both Tailwind builds and `--text-<name>` on every page.
- **A spacing or layout value.** Add it under `layout`; it is `--mj-<name>`.
- **A component primitive.** Write it once in `share/design/primitives.css` over tokens and
scale steps; both surfaces have it. The Cockpit's own stylesheet keeps only what the
Cockpit alone has.
- **A mark.** Replace the file under `share/design/brand/`; every copy is regenerated.
- **A Flowbite name the site uses that should follow a role.** Add it under
`alias.flowbite`. A name absent there keeps Flowbite's value, which is a statement that
it is not a shared decision.
The process never includes editing the site's templates and the Cockpit's stylesheet for the
same reason. If it does, the architecture has a hole; file it.
## The kit
The components the site's product and documentation pages are composed of: a hero and a
doctrine quote, panels and stages, a file tree, points, interface rows, link tiles, a band of
claims, a terminal with structured output, a command with its copy button, a tab strip in
three looks, the rows of an HTTP API, a dashboard window with its counts, chart, feed,
actions and health list, a trail, a search field, a table of contents, a step flow, and three
diagrams — a stack of layers, a fan that joins rows to a hub, and a cycle of four.
[/kit/](https://majordomus.dev/kit/) renders every one of them from this repository's data.
| file | what it holds |
|---|---|
| `share/design/kit.css` | how each component looks, as `.mj-*` classes over the roles, the status indirection and the tone indirection; measured by the contrast check like the primitives |
| `site/templates/kit/*.html` | what each component is, as a Tera component: `base` (icon, glyph, step, button, pill, state, copy), `content` (hero, quote, section, panel, stages, tree, points, routes, tiles, links, strip, figure, claims, table), `code` (terminal, term_line, term_out, term_box, cmd, cmd_list, tabs, tab_panel, method, request, endpoint, listing), `dashboard` (window, sidenav, kpis, chart, legend, feed, actions, health), `docs` (trail, search, toc, flow), `diagrams` (stack, fan, cycle) |
| `site/data/kit/icons.toml` | the outline glyphs a component names by key; an unknown key fails the build |
| `site/kit.js` | the three behaviours markup cannot carry: copy, switch tabs, mark the section being read |
| `site/kit.tailwind.css`, `site/templates/partials/kit-assets.html` | the kit's own sheet and script, which a page opts into |
Conventions a component keeps, so that a page composes them without reading their source:
- **An item is a map, and a key means one thing everywhere.** `title` and `text` are read,
`icon` is a key of the icon set, `tone` a tone, `href` where it goes, `state` a state word.
- **No value is written in a component.** A count, a command, a transcript or a release is
handed to it by the page, and the page reads it from a generated dataset. A mockup's
numbers are not data.
- **State is an attribute.** A selected tab is `aria-selected`, the current link
`aria-current`, an open group `details[open]`. The sheet styles the attribute, so no bound
class can lose to a static utility (see *Alpine `:class` loses to static utilities*).
- **Nothing needs the script to be read.** A copy button and a tab strip are rendered
`hidden` and revealed by `site/kit.js`; every tab panel is rendered, under the heading its
tab would carry; a disclosure is a native `<details>`; a fan's curves are computed by the
template from how many rows each side has.
- **The kit is a page's choice.** A page composed of it includes
`{% block kit %}{% include "partials/kit-assets.html" %}{% endblock kit %}`; every other
page loads none of it, which keeps the homepage within its byte budget.
**Screens.** A screen is a whole page composed of nothing but the kit, rendering one of the
owner's page designs (architecture, integrations, documentation, CLI, Cockpit, features, how it
works, landing) from this repository's data at `/kit/<screen>/`. A screen template
(`site/templates/screens/<name>.html`) extends `site/templates/screen.html`, which loads the
datasets a screen may read under one set of names — `design`, `registry`, `kinds`, `oas`, `cl`,
`term`, `cli`, `commands`, `tones`, `base` — and its page is `site/content-src/kit/<name>.md`.
The designs carried values nobody measured (a version never released, counts of objects that do
not exist, latencies, an issue number, a port); a screen shows none of them, and
`test/cases/791_a_screen_is_composed_of_the_kit_and_of_real_data.sh` refuses one that does, one
that chooses a colour, a style or a class of its own, and one outside the frame.
To add a component: write its classes in `share/design/kit.css`, its markup in the file of
`site/templates/kit/` it belongs to, and an example on `site/templates/kit-page.html` from
real data; a control it carries needs a spec under `scripts/lib/interaction-specs/`.
`test/cases/790_the_kit_is_one_vocabulary_and_its_page_renders_all_of_it.sh` refuses a
component the page does not render, a class no template emits or no sheet defines, an icon
the set does not hold, and a kit loaded by every page; `scripts/interaction-probe` drives
every copy button, tab and search field in a browser. Zola 0.23's Tera 2 types a
component's argument by its default, takes a literal argument only in braces
(`count={4}`), allows one `for` per comprehension and arrays two levels deep; the kit is
written within those limits.
## The navbar: the site is the reference
majordomus.dev is the reference for how the product looks and behaves, and the surfaces the
executable serves derive from it rather than imitate it
(`project.the-ui-wears-the-sites-navbar`). The first thing derived is the bar at the top of
the page. It has one declaration (`site/data/nav.toml`, `project.navigation-is-declared-once`)
and one renderer (`site/templates/partials/navbar.html`); every screen of `majordomus ui`
and every page of the Cockpit wears that renderer's output:
- **the markup** is the bar of the built site, cut out by `scripts/ui navbar --write` into
`apps/majordomus-cli/src/ui/navbar.html` and compiled into the executable — with the
release in the badge left for the executable to fill in, and the build's comments dropped;
- **the styles** are the same utilities over the same tokens: the UI's and the Cockpit's
Tailwind entries (`share/ui/src/ui.css`, `share/cockpit/src/cockpit.css`) import what the
site's does and name the copy as a source, so each compiled sheet carries exactly the rules
the bar is written in;
- **the behaviour** is the site's own `nav.js` and `theme.js`, served from `share/ui/` and
`share/cockpit/` as byte copies that `scripts/cockpit-assets --check` holds to their
originals.
`scripts/ui navbar` (run first by `scripts/ui audit`, the `ui-audit` gate) refuses a copy that
is not the built bar, and compares the bar on the site's front page with the bar on `/ui` in a
browser, and the bar on `/cockpit` likewise — every element's tag, classes, text, box and computed styles, light and dark, at
every width `viewports` declares, closed and with a menu open. The executable decides only
what it alone knows: the version, and that the product entry leads to the UI itself rather
than to its published snapshot. The UI's manual says the rest
([`MAJORDOMUS_UI.md`](MAJORDOMUS_UI.md#the-navbar-comes-from-the-site)).
## Validation
| what | where | when |
|---|---|---|
| the declaration is consistent | `crate::design::DesignSystem::validate`, run by `generate` | before anything is written |
| every projection is current | `majordomus generate --check` (`rust-integration` / `generation-converges`) | every plan that touches the crate, the share or the generated tree |
| no source carries a design decision of its own | `scripts/ci/design-check` (`design` gate, structure job) | every plan that touches a stylesheet, template, script or the crate's web code |
| the compiled Cockpit stylesheet matches its source, and the palette matches the installed Tailwind | `scripts/cockpit-assets --check` (`cockpit-assets` gate) | the site job |
| every badge word on the site is filed, and every page carries the fingerprint | `scripts/site-check` (`site-build` gate) | the site job |
| the stylesheet, the page and the dataset agree, at every declared width | `scripts/site-probe` (`site-probe` gate) | the site job |
| the stylesheet, the page and the executable agree; every badge word is filed; no overflow at every declared width | `scripts/cockpit-probe` (`cockpit-probe` gate) | the cockpit job |
| every page is keyboard-operable and passes contrast at every width | `scripts/ui audit` (`ui-audit` gate) | the site job |
| the UI's navbar is the site's: the compiled copy is the built bar, and the two bars agree element by element and style by style | `scripts/ui navbar`, run first by `scripts/ui audit` (`ui-audit` gate) | the ui job |
| every colour the declaration pairs with a ground can be read on it, in both themes | `crate::design::contrast` through `design.contrast`, asked by `scripts/ci/design-check` question 9; `test/cases/120_design_contrast.sh` | every plan that touches the declaration or a primitive |
| the model, the renderer and the zero-registration property | the crate's tests; `test/cases/107_design_tokens.sh`; `test/cases/109_design_system.sh` | the rust and suite jobs |
The gate's questions are the failure modes that were found: a colour literal, a raw palette
utility (`text-green-700`), a bracketed size (`text-[11px]`), a second declaration of a
canonical property, a read of a `--mj-` token nothing emits, a copy of the brand that
differs from the canonical file or lives elsewhere, and a theme key or class named by hand.
Each exemption in the gate is a reason, stated where the gate is written.
The last question is not about where a decision was made but about whether the decision is
readable. Which foreground is rendered on which ground is derived — the declaration files
each status's text with its own ground, and the primitives say the rest: a rule setting a
colour and a background states a pair, a rule setting only a colour states a foreground that
lands on every ground a container sets — so a role added tomorrow is measured tomorrow and
no list has to be kept. The threshold is WCAG 2.1 AA, 4.5:1 for text, the same constant
`scripts/lib/ui-contrast.mjs` holds for the generated highlighting palettes; a border is
measured against 3:1 and reported rather than enforced, because the primitive that draws it
always shows the word beside it.
## Introspection
| surface | how |
|---|---|
| MCP | `majordomus_design`, `majordomus_design_tokens`, `majordomus_design_explain`, `majordomus_design_contrast`; resource `majordomus://design` |
| HTTP | `GET /api/v1/design`, `/api/v1/design/tokens?kind=role`, `/api/v1/design/explain?token=fg`, `/api/v1/design/contrast` |
| OpenAPI, Swagger | the same four operations, from the registry |
| Cockpit | `/cockpit/design`: every role with both themes' swatches, every status with its words as badges, the type scale at its own sizes, the projections — and the badge that says whether the stylesheet the page loaded agrees with the executable |
| reference | `docs/generated/design.md`, and the JSON and YAML beside it |
`explain` takes a role, a status, a state word, a type step, a palette entry, or the custom
property any of them becomes:
```console
majordomus_design_explain {"token": "--mj-ok"}
→ kind: status, about: healthy, done, passing …,
parts: fg --mj-ok emerald-900 / emerald-300, bg --mj-ok-bg …, line --mj-ok-line …,
states: ok pass passed succeeded completed done …,
aliases: --color-fg-success-strong --color-success-soft --color-success-subtle,
projections: share/design/theme.css share/design/surface.css …
```
## Troubleshooting
- **A page looks wrong on one surface.** Read `--mj-design` from the page
(`getComputedStyle(document.documentElement).getPropertyValue('--mj-design')`) and
compare with `GET /api/v1/design` (`design`) or `site/data/registry/design.json`. The
Cockpit does this itself and shows an alert on mismatch. A different fingerprint means a
stylesheet compiled before the declaration moved: run `majordomus generate design`, then
`scripts/cockpit-assets` or `scripts/site-build`, and restart the server.
- **A colour is missing.** An undeclared custom property is the empty string, never an
error. `scripts/ci/design-check` names every `--mj-` read nothing emits; run it.
- **A badge is grey that should not be.** Its word is filed under no status. `explain` it;
if it answers nothing, file the word under `status.states`. `scripts/site-check` and
`scripts/cockpit-probe` refuse an unfiled word.
- **The Cockpit is unstyled.** `share/cockpit/cockpit.css` is missing or stale:
`scripts/cockpit-assets`. The shell says so in a banner.
- **Dark mode does not switch.** The class the page toggles is `theme.class` from the
declaration, written onto `<html>` (site) or `<body>` (Cockpit) as `data-theme-class`;
the scripts read it there. A page without the attribute has an invalid compiled
declaration: `GET /api/v1/design` says why.
- **A visual regression probe fails.** The finding names the route, the width and the
assertion. An overflow is a layout defect on that page; a `design` finding is a stale
bundle or a page not carrying `data-design`; a `font` finding means a stack other than the
declared one — usually a third-party stylesheet loaded after ours.
- **`generate` refuses the declaration.** The message names the key path and the reason
(`roles.sunken.light: 'gray-51' is not a palette entry`). Nothing was written.
## What is deliberately not shared
Below the bar, the surfaces share a language, not a layout: the bar itself is the site's on
the UI and the Cockpit too (above). The site is Flowbite components in
templates at reading density; the Cockpit is semantic classes over the same tokens at
operational density. The Swagger UI's own component tree keeps its third-party stylesheet
inside a frame that is ours. Flowbite names the site uses that are not shared decisions
(`neutral-tertiary`, `brand-strong`, the non-strong `fg-*` variants) keep Flowbite's values
until somebody files them under a role.