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
and
ADR 0036.
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
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), 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
roleswithabout,lightanddarknaming palette entries (add the entries underpaletteif 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 byexplain. - A status semantic. Add a meaning under
status.roleswithfg,bgandlineper theme, and file its words understatus.states. Everymj-badge--<word>is styled. - A tone. Add it under
toneswithfg,bg,lineandfillper 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, andsite/data/registry/design.jsonlists 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.shdoes exactly this to prove nothing else moves. - A type step. Add it under
type.scalewithsizeandleading;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.cssover 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/ 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.
titleandtextare read,iconis a key of the icon set,tonea tone,hrefwhere it goes,statea 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 linkaria-current, an open groupdetails[open]. The sheet styles the attribute, so no bound class can lose to a static utility (see Alpine:classloses to static utilities). - Nothing needs the script to be read. A copy button and a tab strip are rendered
hiddenand revealed bysite/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 --writeintoapps/majordomus-cli/src/ui/navbar.htmland 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.jsandtheme.js, served fromshare/ui/andshare/cockpit/as byte copies thatscripts/cockpit-assets --checkholds 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).
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:
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-designfrom the page (getComputedStyle(document.documentElement).getPropertyValue('--mj-design')) and compare withGET /api/v1/design(design) orsite/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: runmajordomus generate design, thenscripts/cockpit-assetsorscripts/site-build, and restart the server. - A colour is missing. An undeclared custom property is the empty string, never an
error.
scripts/ci/design-checknames every--mj-read nothing emits; run it. - A badge is grey that should not be. Its word is filed under no status.
explainit; if it answers nothing, file the word understatus.states.scripts/site-checkandscripts/cockpit-proberefuse an unfiled word. - The Cockpit is unstyled.
share/cockpit/cockpit.cssis 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.classfrom the declaration, written onto<html>(site) or<body>(Cockpit) asdata-theme-class; the scripts read it there. A page without the attribute has an invalid compiled declaration:GET /api/v1/designsays 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
designfinding is a stale bundle or a page not carryingdata-design; afontfinding means a stack other than the declared one — usually a third-party stylesheet loaded after ours. generaterefuses 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.