The palette shipped as 20 primitives and nothing else, which meant
--forest was quietly doing three unrelated jobs at once: the action colour, register
step 2 of the data scale, and structural dark. That is fine right up until you need a dark theme or a second
status — at which point "make the links lighter" also moves a data series. This layer separates the
two. Build with roles. Reach for a primitive only when you are defining a new role.
A named colour with no opinion about where it goes. Fixed. Never themed — --forest
is #166534 in every theme.
--bg --surface --surface-2 --ink-band
--fg --fg-2 --muted --meta
--border --border-soft
--forest-deep --forest --leaf --moss --sage --pale --mist
--volt --amber
What a thing is for. This is the API components consume, and the only layer a theme overrides.
--canvas --evidence --evidence-inset --slab
--ink --ink-prose --ink-quiet --ink-margin
--rule --rule-soft
--action --action-strong --action-on --action-edge --focus
--status-held --status-pending --status-quiet
--grade-1 … --grade-4
The whole component layer was migrated in this pass:
ui_kits/app/app.css now contains zero raw surface or text
primitives — 347 var() references before, 347 after, all pointing at roles.
Four steps, and the order carries meaning: the canvas is paper, white means this is a record, and the two dark slabs are the only places the project speaks in its own voice rather than quoting the record.
The anti-pattern list says "spend volt outside its four roles." Naming each role makes that checkable instead of a thing you have to remember — if your markup needs a fifth volt token, you are about to break the rule.
| Role token | Spends volt on | Budget |
|---|---|---|
| --mark | the highlighter swipe behind one word | 1 per headline |
| --figure-lead | the number that is the story | 1 per data block |
| --cta-dark | the primary action on a dark band | 1 per dark band |
| --wordmark-tld | the .city in the footer lockup |
1 per page |
Re-measuring the linked pipeline turned up something the design system had not accounted for: the product defines a seven-state claim lifecycle on a single axis, not the two states the stamp component shipped with. The fix is not five more hues — it is a role mapping, so every state is expressible inside the one-hue discipline.
| Claim state | Role token | Reads as | Why |
|---|---|---|---|
| Corroborated · Ready · Active | --status-held | Held | We hold the evidence and it survived review. |
| Contested · Disproven | --status-pending | Attention | On the record, not settled. Same signal class as "not yet obtainable." |
| Draft · Open · Withdrawn | --status-quiet | Inert | Not yet asserted, or deliberately retired. Never competes for attention. |
The rule that keeps this honest: the product states that "supporting and contradicting are categories of receipts on the card, never states of it." So the stamp carries the lifecycle state only. Contradicting evidence is a row in the receipts list, not a red badge.
The pipeline grades identity on a four-step ordinal axis, strongest first. That is the same shape of data as the register scale: ordinal, one hue, darkest = strongest. Sharing the ramp is a decision, not an accident — it means a reader who has learned to read the record field can already read a grade. The alternative, a traffic light, would imply good/bad where the product only claims strong/weak.
| Grade | Token | Definition (the product's) |
|---|---|---|
| attested | --grade-1 | The human endpoint occurrence was reviewer-bound to a Civic entity. |
| identified | --grade-2 | Resolved through a source-native identifier appropriate to its kind. |
| keyed | --grade-3 | Name-key match only. |
| ambiguous | --grade-4 | Do not assert. Render, but never let it carry a claim. |
A relationship's grade is the weakest of its two endpoints, never the strongest — so a two-endpoint component must render the minimum, not the average. The product explicitly avoids the word confidence here because it conflated two separate axes.
| Was | Now | Note |
|---|---|---|
| var(--bg) | var(--canvas) | themes |
| var(--surface) | var(--evidence) | themes |
| var(--surface-2) | var(--evidence-inset) | themes |
| var(--fg) / --fg-2 | var(--ink) / --ink-prose | themes |
| var(--muted) / --meta | var(--ink-quiet) / --ink-margin | themes |
| var(--border) / --border-soft | var(--rule) / --rule-soft | themes |
| var(--forest) as an action | var(--action) | themes |
| var(--forest) as data | var(--reg-2) | stays put — data is not action |
| var(--forest-deep) as a band | var(--slab-feature) | dark in both themes |
| hardcoded register hex | var(--reg-1…8) | app.js now reads the tokens |
The brand ribbon (.topline) and the two
permanently-dark bands deliberately keep their primitives: they are fixed devices, not themed surfaces.