ADR-002 — Three-Layer Token Model
Date: 2026-06-11
Amended: 2026-06-17, 2026-07-06, 2026-07-07, 2026-07-13, 2026-10-01
Status: accepted
Context
A design token system needs to serve three distinct concerns simultaneously:
- Syncing with Figma — raw values (hex colours, px sizes) exported from Figma variables must be stored somewhere without modification, so re-exports are a clean replacement.
- Supporting multiple colour modes — light and dark mode require the same semantic token names to resolve to different underlying values. This cannot live in a single flat file.
- Adapting to breakpoints — typography scales, spacing, and grid values differ per viewport. Desktop, tablet, and mobile are distinct enough to warrant separate token sets rather than one-size-fits-all values.
A single flat file satisfies none of these cleanly. A two-layer model (primitives + semantic) handles colour modes but conflates responsive layout decisions with colour semantics, and pollutes the primitives file with Figma-sync noise.
Decision
Tokens resolve in three ordered layers. Later layers override earlier ones and may only reference tokens from earlier layers via {path.to.token} alias syntax — never raw values.
| Layer | Files | Responsibility |
|---|---|---|
| Primitives | primitives.json |
Raw, context-free values. Single source of truth. Synced directly from Figma. Never hand-edited. |
| Theme | theme/light.json, theme/dark.json |
Semantic colour aliases. Maps intent (color.background.brand) to a primitive ({color.terracotta.9}). Switching theme files switches the entire colour mode. |
| Device | device/desktop.json, device/tablet.json, device/mobile.json |
Responsive spacing, grid, and typography per breakpoint. Breakpoints: desktop ≥ 1440 px, tablet ≥ 768 px, mobile < 768 px. |
Alias references use curly-brace syntax ({color.brand.9}) and resolve depth-first. The deepest available primitive path is always preferred ({color.terracotta.9} not {color.terracotta}).
Token format follows W3C DTCG: every token carries $type and $value. The $extensions block is stripped on Figma export — it must never be committed.
Consequences
-
Primitives are Figma-owned. Re-exporting from Figma replaces
primitives.jsonentirely. Hand-edits to this file will be overwritten. All intentional value changes must happen in Figma first. -
Theme files are code-owned. The mapping from primitives to semantic names (
color.background.button.$root → color.brand.$root) is a design decision made in code, not exported from Figma. -
Device files are the responsive contract. Components reference device-layer tokens for layout-sensitive values (font size, spacing, grid). The same component file works across breakpoints because the underlying custom property values change via media queries.
-
Style Dictionary must resolve all three layers in order: primitives first, then theme, then device. Alias chains that cross layer boundaries (e.g. a device token aliasing a theme token aliasing a primitive) are valid and expected.
-
Style Dictionary CSS output strategy for device tokens:
device/desktop.jsonemits to:root(the desktop-first baseline at ≥ 1440 px).device/tablet.jsonanddevice/mobile.jsonemit inside@mediablocks that override the same custom property names. The result is a single CSS file — no per-breakpoint imports required in components.:root {--font-size-body-default: 16px;--space-stack-md: 16px;}@media (max-width: 1439px) { /* tablet */:root { --font-size-body-default: 15px; --space-stack-md: 12px; }}@media (max-width: 767px) { /* mobile */:root { --font-size-body-default: 14px; --space-stack-md: 8px; }}This requires a custom Style Dictionary platform config — the default CSS platform does not wrap outputs in media queries. See roadmap Phase 1.5.
-
Adding a new colour mode (e.g. high-contrast) requires only a new
theme/file — no changes to primitives or device layers. -
Adding a new breakpoint requires only a new
device/file and a new media query block in the Style Dictionary config — no changes to primitives or theme layers.
Amendment (2026-06-17) — Source of truth is the repo, not Figma
The original decision declared primitives “Figma-owned”: primitives.json was to be replaced wholesale by Figma re-exports, never hand-edited, with all intentional value changes happening in Figma first. That direction is reversed. The committed DTCG JSON in packages/tokens/src/ is now the single source of truth; Figma is a downstream mirror and design-exploration surface.
The three-layer model itself is unchanged — only which side is canonical, and the sync direction, change.
Why
Automated Figma→code sync is impossible on the available Figma plan, so the original “Figma is upstream” model could never be automated and silently required a manual re-export on every token change — directly at odds with the lite-agentic, one-maintainer, CI-friendly goals.
- Variables REST API (
GET /v1/files/:key/variables/local) — Enterprise org only. - Code Connect — Organization/Enterprise only.
- Token Studio GitHub sync — available on lower tiers, but the free plan syncs a single file. This architecture is deliberately multi-file (primitives +
theme/light+dark+device/desktop+tablet+mobile); collapsing it to fit the tool would invert the dependency the wrong way.
The only automatable sync direction available is code→Figma (push via the Figma plugin/MCP, interactively). Code-first is therefore not merely preferable — it is the only model that supports any automation, and it aligns with the governance layer (Airtable, PR review) which already treats committed files as canonical.
Consequences of the reversal
- Primitives are now code-owned and hand-editable.
primitives.jsonis authored and changed via PR, like the theme and device layers. The “never hand-edited / will be overwritten” warning on the Primitives row above no longer applies. - Figma changes are proposals, not the source. A value invented in Figma is not “real” until it lands in
primitives.jsonvia PR. For a one-maintainer system this is a governance feature, not a limitation. - The Figma token audit (agentic moment 1) is retargeted from an import gate to a drift/reconciliation check — “does Figma still match canonical code?” It still reads Figma variables via the Figma MCP (
get_variable_defs), which works without the Enterprise REST API because it reads Dev Mode, not the REST endpoint. $extensionsstripping and sRGB→hex conversion still apply to any values brought over from Figma during reconciliation — that cleanup guidance is unchanged.
Amendment (2026-06-22) — Figma representational constraints exclude unitless tokens from drift
Figma variables cannot represent unitless values. Line-heights are authored in code as unitless ratios (1, 1.25, 1.4, 1.5, 1.75) — see the line-height convention — because the ratio adapts to any font size. Figma has no unitless number variable, so the same tokens must be entered there as fixed values (px or %). They therefore differ between code and Figma by construction.
These are representational divergences, not drift. They are excluded from the drift comparison in both Figma moments (/figma-variable-audit and /figma-variable-push) and are tagged or omitted in figma-variables.json, so a faithful “matches Figma” comparison is never the success criterion for them. Code remains authoritative for the unitless value; the Figma fixed value is a display approximation that never flows back into primitives.json. The running list of accepted divergences lives in the drift memory note (figma-file-variable-drift.md).
This refines, not reverses, the mirror model: it names a class of tokens where a faithful mirror is structurally impossible, and fixes the rule that such tokens are out of scope for drift reconciliation.
Amendment (2026-07-06) — Brand layer
The token model is now four layers: primitives → brand → theme → device. A brand layer sits between primitives and theme, owning per-brand color ramp mappings, font families, and border radius. See ADR-012 for the full decision, including why the brand layer is code-only and out of scope for the Figma mirror.
Amendment (2026-07-07) — Primitive hue list adds red (dedicated feedback hue)
The primitive color hue list grew to include a new red hue (light 1–12 + dark 1–12, from the official Radix UI red scale), bringing the set to: terracotta, cyan, gold, teal, sand, grey, black, white, amber, red. red was added specifically to give feedback.error a dedicated hue that never collides with a brand accent, and it is deliberately not brand-eligible. The same pass darkened several step-11 values and regenerated cyan’s light ramp to be monotonic.
This does not change the layer model — it records that the primitive layer’s hue set (governed by this ADR) expanded, and the new convention that a feedback semantic must not alias a brand-eligible hue. See ADR-014 for the full decision, including the terracotta-luminance-matching ramp-regeneration method.
Amendment (2026-07-13) — Deprecation state mirrored into DTCG $deprecated
The DTCG Format Module’s first stable version (2025.10) defines an optional, first-class $deprecated property. Token deprecation state now lands there in the committed source, not only in Airtable.
Airtable remains the governance UI — humans still set status and successor per token in the primitives/semantic tables. But the committed JSON becomes the durable record: scripts/token-deprecation-mirror.js mirrors governance into $deprecated on the affected leaf after every governance pull (npm run airtable:pull:governance), and CI (npm run tokens:deprecations:check, wired into tokens-check.yml) fails a PR where source and governance have drifted apart. Message convention: true when there is no successor, otherwise "Replaced by {successor.path}." (the successor’s dot-path in curly braces, matching the alias syntax already used elsewhere in source).
$deprecated is a spec property, not an $extensions block — it does not conflict with the “never commit $extensions” rule above. It is machine-managed only; nobody hand-authors it (deprecate via Airtable, then pull). Consumers: /token-deprecation-pass now reads $deprecated from source as the durable record, using airtable-governance.json only as a cross-check.
Amendment (2026-10-01) — figma-cli is the Figma Plugin API transport
The 2026-06-17 amendment named the Figma plugin/MCP as the only way to reach Figma variables. A second local transport now exists: figma-cli (silships/figma-cli), which drives Figma Desktop over the Chrome DevTools Protocol and runs the same Plugin API code use_figma does — no API key, no REST, no plan gate, and no per-call MCP round-trip, which matters for the repetitive canvas work (renaming, restructuring components, binding variables) that MCP made expensive.
Decision. figma-cli becomes the primary transport for both Figma moments — /figma-variable-audit (reads) and /figma-variable-push (writes) — and for ad-hoc mechanical canvas tasks via /figma-cli. The Figma MCP stays as the fallback when figma-cli can’t connect (e.g. right after a Figma update reverts the Yolo patch) and remains the tool for design context during scaffolding. Neither moment’s invariants change: the push still writes only clean-missing variables through the naming map, nothing is deleted or overwritten without confirmation, representational divergences and the brand layer stay out of scope, and figma-variables.json remains the only committed Figma snapshot.
Constraints that keep it inside the lite model.
- It is interactive and local: it needs Figma Desktop running on the maintainer’s machine, so it is in the same category as an MCP call — never CI, never a scheduled run, never a
package.jsondependency (installed globally from a local clone). - Code stays the source of truth. figma-cli’s token-import paths (
import,tokenspresets) are bypassed because they ignore the repo’s code→Figma naming map and would overwrite drift silently; its Figma→code paths (export dtcg) and its own snapshot/contract file (snapshot→design.json) would create a second source of truth. All are denied in.claude/settings.json. - figma-cli writes its own agent rules (
AGENTS.md,.cursor/rules/) into any project it connects from; those are gitignored. The repo’s rules — which override the tool’s bundled ones (e.g. its “never show terminal commands”) — live in.claude/commands/figma-cli.md, keeping CLAUDE.md within its ADR-017 budget.
Alternatives considered. Keeping MCP-only (no new dependency, but repetitive canvas work stays costly and slow); using figma-cli import tokens.json for the push (one command, but loses the naming map and the clean-missing-only guarantee). Browser and Safe modes avoid patching the desktop app and are acceptable substitutes for Yolo mode; the choice of connection mode is a machine concern, not a repo one.
Consequence. The flatten-and-diff half of the push (steps 2–4) can now become a deterministic script fed by a figma-cli dump, leaving the agent only the drift/extras judgement — a candidate follow-up, not part of this amendment.