Skip to content

ADR-047: Design Tokens v2 — Role-Based Semantic Naming

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)

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.

  • Intent-revealing names: bg-surface reads better than bg-background-secondary; a utility name should describe the role, not a tier number.
  • Dark mode by token, not by variant: role tokens flip in .dark via generated CSS, removing manual dark: 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 fontFamily token 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.

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 / secondary 50–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

Adopt the role-based v2 token layer (Option 2).

v1 (base)v2 (base)
grayslate
moonstoneviolet
imperialRedrose
orangeWebamber
Role tokenLightDark
backgroundslate-50spaceCadet
surfacewhiteslate-900
surfaceRaised †raw HSL (near-white)raw HSL (raised slate)
surfaceAccent †raw HSL (violet tint)raw HSL (violet tint)
foregroundcharcoalslate-50
muted-foregroundslate-600slate-400
borderslate-200slate-800
border-emphasisslate-500slate-500
primary-foregroundwhitewhite
linkviolet-600violet-300
success †green-700green-400
warningamber-700amber-500
errorrose-700rose-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:

  • success moved off violet onto a dedicated green family (green-700 light / green-400 dark, with a full green 50–950 scale added to tokens/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 above surface) and surfaceAccent (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 tokenLightDarkReplaces
tintviolet-100violet-900bg-primary-100 dark:bg-primary-900
tintForegroundviolet-800violet-200text-primary-800 dark:text-primary-200
tintSecondaryrose-100rose-900bg-secondary-100 dark:bg-secondary-900
tintSecondaryForegroundrose-800rose-200text-secondary-800 dark:text-secondary-200
accentviolet-600violet-400text-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:

  • Blockquote uses surfaceAccent, the role that already means a violet-tinted emphasis panel, in place of primary-50 / primary-950.
  • The contact page’s icon chips use tintForeground (one step stronger than the primary-700 / primary-300 they had).
  • The homepage’s tech-category badges passed bg-secondary-100 through class on 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.

Old utilityNew utility
bg-background-primarybg-background
bg-background-secondarybg-surface
text-foreground-primarytext-foreground
text-foreground-secondarytext-muted-foreground
text-foreground-subtletext-muted-foreground
border-border-primary / border-border-defaultborder-border
text-primary-600 (links)text-link
text-primary-300 (dark links)(deleted — text-link flips in dark)
bg-success-100 / text-success-700bg-success/10 / text-success (and warning/error)
bg-primary-*, text-primary-*, ring-primary-*unchanged (scale retained)

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.

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.)

  • Intent-revealing utilities; fewer manual dark: variants
  • Status colours unified as role token + opacity
  • Typography swappable via the fontFamily token group
  • One source of truth — no parallel alias layer
  • One-time ~478-usage codemod plus a hand-reviewed status refactor
  • AI constitution examples and the showcase need updating
  • The token build pipeline (scripts/src/build-tokens.ts) is unchanged — it already resolves nested scales and single role tokens with dark references, and flattens the new fontFamily group to --font-family-display / --font-family-text
  • Token build: pnpm tokens:build emits the new --color-* and --font-family-* vars
  • Contrast: pnpm design:validate passes every gated pair in both modes
  • Tests: src/__tests__/design-tokens.test.ts asserts the role set, source mappings, retained brand scales, and Geist-first display family
  • No dead classes: pnpm build + a light/dark visual diff of /showcase shows no unstyled utilities
  • CSS budget: total CSS stays < 50KB (pnpm perf:budgets)

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.

  • Testable consequences:
    • TC-1: every gated token pair passes the WCAG contrast sweep.
    • TC-2: no dark: variant utilities appear in src/ — role tokens flip in .dark via generated CSS.
    • TC-3: no hardcoded colour literals (hex/rgb/hsl) appear outside tokens/ and generated token CSS.
  • Checks:
    • TC-1 → design:validate in CI (status: block, pre-existing gate)
    • TC-2 → check no-dark-variants (status: warn)
    • TC-3 → check no-hardcoded-colors (status: warn)
  • Not machine-checkable: whether a token name is intent-revealing is a naming judgment.
  • Graduation log:
    • 2026-10-12 — no-hardcoded-colors stays 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-variants stays 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-colors stays warn; it now accepts a literal with an inline ADR-047 justification (see the v2.2 amendment). Backlog cleared (5 findings to 0, #472). Calibration window restarts from this date.

Date: 2026-06-06
Participants: Chris Pezza, template maintainers
Outcome: Accepted