Skip to content

The design system

the design system: the one declaration every surface is rendered from, the pipeline from `share/design/tokens.yaml` to the site, the Cockpit and the executable's own pages, how to add a role, a status word or a type step without touching a consumer, how drift is caught, and how to read a page that looks wrong

Rendered from docs/DESIGN_SYSTEM.md — the same Markdown GitHub shows.

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:

sectionwhat it decides
identitythe name and the marks under share/design/brand/
fontthe system stack and the monospace stack
palettethe raw colours — Tailwind's, quoted; internal, never read by a consumer
rolesthe 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.rolesfive meanings — ok, warn, bad, info, neutral — each with text, ground and border per theme
status.statesthe vocabulary: every state word any surface renders, filed under one meaning
toneshues for identity and never meaning — blue, violet, green, orange, rose, cyan — each with ink, ground, border and fill per theme
typethe named scale (meta, label, small, dense) and letter-spacings
layout, radius, motionthe shared measures, corner radii and durations
themethe class that means dark and the localStorage key of the reader's choice
viewportsthe widths every browser audit measures at
alias.flowbitethe 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 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/ renders every one of them from this repository's data.

filewhat it holds
share/design/kit.csshow 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/*.htmlwhat 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.tomlthe outline glyphs a component names by key; an unknown key fails the build
site/kit.jsthe three behaviours markup cannot carry: copy, switch tabs, mark the section being read
site/kit.tailwind.css, site/templates/partials/kit-assets.htmlthe 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).

Validation

whatwherewhen
the declaration is consistentcrate::design::DesignSystem::validate, run by generatebefore anything is written
every projection is currentmajordomus 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 ownscripts/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 Tailwindscripts/cockpit-assets --check (cockpit-assets gate)the site job
every badge word on the site is filed, and every page carries the fingerprintscripts/site-check (site-build gate)the site job
the stylesheet, the page and the dataset agree, at every declared widthscripts/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 widthscripts/cockpit-probe (cockpit-probe gate)the cockpit job
every page is keyboard-operable and passes contrast at every widthscripts/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 stylescripts/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 themescrate::design::contrast through design.contrast, asked by scripts/ci/design-check question 9; test/cases/120_design_contrast.shevery plan that touches the declaration or a primitive
the model, the renderer and the zero-registration propertythe crate's tests; test/cases/107_design_tokens.sh; test/cases/109_design_system.shthe 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

surfacehow
MCPmajordomus_design, majordomus_design_tokens, majordomus_design_explain, majordomus_design_contrast; resource majordomus://design
HTTPGET /api/v1/design, /api/v1/design/tokens?kind=role, /api/v1/design/explain?token=fg, /api/v1/design/contrast
OpenAPI, Swaggerthe 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
referencedocs/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-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.

Searches the titles and descriptions of every page this site publishes.