Preview card 11 · token roles

Primitives name a colour. Roles name a job.

Added in the refinement pass
colors_and_type.css § semantic role layer

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.

The two layers

Primitives

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

Roles

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.

Surfaces — the evidence ladder

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.

--canvasthe page · paper, not screen
--evidencewhite = "this is a record"
--evidence-insetfootnote strips, callouts
--slabfooter / utility dark
--slab-featurethe ONE lead-story band
--rulestructural 1px
--rule-softdivider INSIDE a container

Volt has four roles, and now they have names

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

Status — seven states, still two colours

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.

Draft Open Contested Corroborated Ready off-path: Disproven Withdrawn
Lifecycle terms are the product's own, from the linked pipeline's glossary (lorain-data/CONTEXT.md § Claim card). The mapping is this design system's.
Claim stateRole tokenReads asWhy
Corroborated · Ready · Active--status-heldHeldWe hold the evidence and it survived review.
Contested · Disproven--status-pendingAttentionOn the record, not settled. Same signal class as "not yet obtainable."
Draft · Open · Withdrawn--status-quietInertNot 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.

Confidence — the ramp does double duty on purpose

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.

GradeTokenDefinition (the product's)
attested--grade-1The human endpoint occurrence was reviewer-bound to a Civic entity.
identified--grade-2Resolved through a source-native identifier appropriate to its kind.
keyed--grade-3Name-key match only.
ambiguous--grade-4Do 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.

Migration — what to change in your markup

WasNowNote
var(--bg)var(--canvas)themes
var(--surface)var(--evidence)themes
var(--surface-2)var(--evidence-inset)themes
var(--fg) / --fg-2var(--ink) / --ink-prosethemes
var(--muted) / --metavar(--ink-quiet) / --ink-marginthemes
var(--border) / --border-softvar(--rule) / --rule-softthemes
var(--forest) as an actionvar(--action)themes
var(--forest) as datavar(--reg-2)stays put — data is not action
var(--forest-deep) as a bandvar(--slab-feature)dark in both themes
hardcoded register hexvar(--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.

← All preview cards