Skip to content

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:

  1. 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.
  2. 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.
  3. 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.json entirely. 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.json emits to :root (the desktop-first baseline at ≥ 1440 px). device/tablet.json and device/mobile.json emit inside @media blocks 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.json is 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.json via 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.
  • $extensions stripping 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.json dependency (installed globally from a local clone).
  • Code stays the source of truth. figma-cli’s token-import paths (import, tokens presets) 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.