ADR-047: Design Tokens v2 — Role-Based Semantic Naming
Status
Section titled “Status”Accepted (amended 2026-07-05 to record the v2.1 token changes shipped 2026-06-07; amended 2026-10-12 to record the v2.2 tint and accent roles and the justified-literal exemption)
Context
Section titled “Context”The docs/2026-06_design_system/ package introduces a “cold-minimal, dark-first” visual
language. Its token sources restructure the semantic layer away from tiered names
(background.primary, foreground.secondary, three-step status scales) toward role-based
names that map one-to-one with intent (background, surface, foreground,
muted-foreground, border, border-emphasis, link, single-token success /
warning / error).
The change is structural, not cosmetic. It renames the base palettes, collapses status
scales into single light/dark role tokens, adds a fontFamily token group so typography
becomes swappable like colour, and cascades into global.css, ~478 utility-class usages
across 40+ components and pages, the design-token test, the AI constitution, and the
component showcase.
Decision Drivers
Section titled “Decision Drivers”- Intent-revealing names:
bg-surfacereads better thanbg-background-secondary; a utility name should describe the role, not a tier number. - Dark mode by token, not by variant: role tokens flip in
.darkvia generated CSS, removing manualdark:variants from components. - Status colour correctness: the old three-step scales (
-100/-600/-700) encouraged hardcoded tints; single role tokens + opacity utilities (bg-success/10) are leaner and consistent across light/dark. - Swappable typography: a
fontFamilytoken group lets the display face change without editing component files. - Accessibility preserved: every gated pair must still pass the WCAG sweep in
pnpm design:validate.
Considered Options
Section titled “Considered Options”Option 1: Keep v1 tiered names, restyle only values
Section titled “Option 1: Keep v1 tiered names, restyle only values”Description: Re-tune colour values inside the existing background.primary /
foreground.secondary structure.
Pros:
- No component churn
- No test or constitution updates
Cons:
- Locks in the weaker tier-number vocabulary
- Status colours stay as three-step scales
- No typography token group — display face stays hardcoded
Option 2: Role-based v2 token layer (chosen)
Section titled “Option 2: Role-based v2 token layer (chosen)”Description: Replace tokens/base.json + tokens/semantic.json with the Path-B pair:
renamed palettes (gray→slate, moonstone→violet, imperialRed→rose, orangeWeb→amber),
role-based semantic names, single-token status colours, and a fontFamily group. Apply the
rename map across components via a scoped codemod.
Pros:
- Intent-revealing utilities; less manual dark-mode handling
- Status colours become single role tokens + opacity
- Typography swappable via tokens
- Brand scales (
primary/secondary50–950) retained for gradients and hover states
Cons:
- Large one-time codemod (~478 usages)
- Status colour change is a real refactor, not a find-replace
- Touches the AI constitution and showcase
Option 3: Run v1 and v2 token names in parallel (aliases)
Section titled “Option 3: Run v1 and v2 token names in parallel (aliases)”Description: Emit both old and new utilities and deprecate the old gradually.
Pros: No big-bang migration
Cons: Doubles the token surface; CSS budget pressure; ambiguous source of truth; the starter is small enough to migrate atomically
Decision
Section titled “Decision”Adopt the role-based v2 token layer (Option 2).
Palette rename map
Section titled “Palette rename map”| v1 (base) | v2 (base) |
|---|---|
gray | slate |
moonstone | violet |
imperialRed | rose |
orangeWeb | amber |
Semantic role mapping
Section titled “Semantic role mapping”| Role token | Light | Dark |
|---|---|---|
background | slate-50 | spaceCadet |
surface | white | slate-900 |
surfaceRaised † | raw HSL (near-white) | raw HSL (raised slate) |
surfaceAccent † | raw HSL (violet tint) | raw HSL (violet tint) |
foreground | charcoal | slate-50 |
muted-foreground | slate-600 | slate-400 |
border | slate-200 | slate-800 |
border-emphasis | slate-500 | slate-500 |
primary-foreground | white | white |
link | violet-600 | violet-300 |
success † | green-700 | green-400 |
warning | amber-700 | amber-500 |
error | rose-700 | rose-400 |
† Amended in v2.1 — see the amendment below. As originally accepted, success was
violet-600/violet-400 (“a positive state is on-brand rather than introducing a
separate green”) and there were no raised/accent surface tiers.
Amendment — v2.1 tokens (shipped 2026-06-07, recorded 2026-07-05)
Section titled “Amendment — v2.1 tokens (shipped 2026-06-07, recorded 2026-07-05)”The design-system v2.1 merge (commit 89fc9c69) changed the semantic layer in two
ways this record originally required an amendment for:
successmoved off violet onto a dedicated green family (green-700light /green-400dark, with a full green 50–950 scale added totokens/base.json). In practice the violet-success experiment failed the “status colour must read as status” test: success states were indistinguishable from primary/link emphasis.- Two additional surface tiers were added under the Tonal-tiers escape hatch:
surfaceRaised(cards/popovers abovesurface) andsurfaceAccent(violet-tinted emphasis panels). Both are raw HSL values rather than base-palette references.
The drift-guard test (src/__tests__/design-tokens.test.ts) pins the green mapping;
this amendment is the “ADR-047 v2.1” it refers to.
Amendment — v2.2 tint and accent roles (2026-10-12)
Section titled “Amendment — v2.2 tint and accent roles (2026-10-12)”Thirty call sites still spelled out a light and a dark step by hand
(bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200), which is the
per-component theming this record set out to remove and what no-dark-variants reports. Five
roles replace those pairs. Each references the brand scales, so the rendered colours are
unchanged:
| Role token | Light | Dark | Replaces |
|---|---|---|---|
tint | violet-100 | violet-900 | bg-primary-100 dark:bg-primary-900 |
tintForeground | violet-800 | violet-200 | text-primary-800 dark:text-primary-200 |
tintSecondary | rose-100 | rose-900 | bg-secondary-100 dark:bg-secondary-900 |
tintSecondaryForeground | rose-800 | rose-200 | text-secondary-800 dark:text-secondary-200 |
accent | violet-600 | violet-400 | text-primary-600 dark:text-primary-400, and the border- form |
Utilities: bg-tint text-tint-foreground, bg-tint-secondary text-tint-secondary-foreground,
text-accent, border-accent. The contrast sweep gates each foreground on its tint at AA and
accent at the 3:1 non-text bar. src/__tests__/design-tokens.test.ts pins the mapping and
asserts that no dark: utility remains in src/ markup.
Three sites changed colour, deliberately:
BlockquoteusessurfaceAccent, the role that already means a violet-tinted emphasis panel, in place ofprimary-50/primary-950.- The contact page’s icon chips use
tintForeground(one step stronger than theprimary-700/primary-300they had). - The homepage’s tech-category badges passed
bg-secondary-100throughclasson top of the primary variant, so they rendered rose in light and violet in dark. They now use the secondary variant and are rose in both.
dark:prose-invert was removed from the two prose containers: .prose already reads role
tokens in global.css, and computed colours are identical with and without it. Badge gains a
solid variant for the “New” marker so no caller stacks a second background on a variant.
Justified literals (TC-3). Some literals cannot be tokens: a <meta name="theme-color">
attribute cannot read a CSS variable, a mask’s #000 is an alpha channel, and a shadow scrim
must darken in both themes. no-hardcoded-colors accepts a literal that carries an inline
ADR-047 justifying comment within the five lines above it, the same mechanism rule 8 uses for
ADR-030. A comment that names the ADR without a reason is a review failure.
Utility rename map (component codemod)
Section titled “Utility rename map (component codemod)”| Old utility | New utility |
|---|---|
bg-background-primary | bg-background |
bg-background-secondary | bg-surface |
text-foreground-primary | text-foreground |
text-foreground-secondary | text-muted-foreground |
text-foreground-subtle | text-muted-foreground |
border-border-primary / border-border-default | border-border |
text-primary-600 (links) | text-link |
text-primary-300 (dark links) | (deleted — text-link flips in dark) |
bg-success-100 / text-success-700 | bg-success/10 / text-success (and warning/error) |
bg-primary-*, text-primary-*, ring-primary-* | unchanged (scale retained) |
Scale-retention rationale
Section titled “Scale-retention rationale”The primary and secondary colours keep their full 50–950 scales even though the role
layer is flat. The signature gradient headline and button hover/active states reference
specific steps (e.g. violet-400 dark / violet-600 light gradient stops, primary-300
inner-shadow highlights), so collapsing them to a single token would break those.
Tonal tiers
Section titled “Tonal tiers”The canonical token file ships two surface tiers (background, surface) and two
text tiers (foreground, muted-foreground). The Component Sheet illustrates a third
tier; we collapse to two by default and only add muted (slate-500) or a raised surface
token if a visual-diff regression requires it. Any such addition is recorded as an
amendment here. (Exercised by the v2.1 amendment above: surfaceRaised and
surfaceAccent.)
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Intent-revealing utilities; fewer manual
dark:variants - Status colours unified as role token + opacity
- Typography swappable via the
fontFamilytoken group - One source of truth — no parallel alias layer
Negative
Section titled “Negative”- One-time ~478-usage codemod plus a hand-reviewed status refactor
- AI constitution examples and the showcase need updating
Neutral
Section titled “Neutral”- The token build pipeline (
scripts/src/build-tokens.ts) is unchanged — it already resolves nested scales and single role tokens withdarkreferences, and flattens the newfontFamilygroup to--font-family-display/--font-family-text
Validation
Section titled “Validation”- Token build:
pnpm tokens:buildemits the new--color-*and--font-family-*vars - Contrast:
pnpm design:validatepasses every gated pair in both modes - Tests:
src/__tests__/design-tokens.test.tsasserts the role set, source mappings, retained brand scales, and Geist-first display family - No dead classes:
pnpm build+ a light/dark visual diff of/showcaseshows no unstyled utilities - CSS budget: total CSS stays < 50KB (
pnpm perf:budgets)
References
Section titled “References”docs/2026-06_design_system/wiring/path-b-wiring.md— the wiring guidedocs/2026-06_design_system/wiring/base.json,.../wiring/semantic.json— canonical Style-Dictionary sources- ADR-026: Font Strategy
- ADR-032: Dark Mode Strategy
- ADR-048: CSS-Native Motion System
The canonical Style-Dictionary source is the wiring/base.json + wiring/semantic.json
pair (palettes slate/violet/rose/amber, {value} token wrappers). The sibling
tokens/base.json is a flat prototype the Visual Language HTML fetches — it uses the old
palette names (gray/moonstone/imperialRed/orangeWeb) and is reference/intent only. Only
the wiring/* pair is copied into tokens/base.json + tokens/semantic.json and wired
into the build.
Enforcement
Section titled “Enforcement”- Testable consequences:
- TC-1: every gated token pair passes the WCAG contrast sweep.
- TC-2: no
dark:variant utilities appear insrc/— role tokens flip in.darkvia generated CSS. - TC-3: no hardcoded colour literals (hex/rgb/hsl) appear outside
tokens/and generated token CSS.
- Checks:
- TC-1 →
design:validatein CI (status: block, pre-existing gate) - TC-2 → check
no-dark-variants(status: warn) - TC-3 → check
no-hardcoded-colors(status: warn)
- TC-1 →
- Not machine-checkable: whether a token name is intent-revealing is a naming judgment.
- Graduation log:
- 2026-10-12 —
no-hardcoded-colorsstays warn; miscalibration fixed, not graduated (ADR-064). It matched comment lines, including a PR number (#250) read as a hex colour (#456). The check now ignores comments. Calibration window restarts from this date. - 2026-10-12 —
no-dark-variantsstays warn; backlog cleared (30 findings to 0) by the v2.2 roles above. It graduates once it has held zero through the ADR-064 window. - 2026-10-12 —
no-hardcoded-colorsstays warn; it now accepts a literal with an inlineADR-047justification (see the v2.2 amendment). Backlog cleared (5 findings to 0, #472). Calibration window restarts from this date.
- 2026-10-12 —
Date: 2026-06-06
Participants: Chris Pezza, template maintainers
Outcome: Accepted