Token pipeline
What it is
Design tokens are the system’s single source of design decisions — colors, spacing, typography, radii. They are authored as W3C DTCG JSON in packages/tokens/src/ and resolved through a fixed four-layer model. A Style Dictionary build then turns them into the CSS custom properties and JS/TS constants that components actually consume. Figma holds a mirror of these tokens as variables, but the committed JSON is the source of truth (ADR-002, 2026-06-17 amendment).
Why it’s built this way
Four layers, not one flat file
ADR-002 records the forcing constraints: a flat token file cannot simultaneously (1) hold raw exported values cleanly, (2) let the same semantic name resolve differently in light and dark mode, and (3) let spacing/typography differ per breakpoint. A two-layer model (primitives + semantic) handles color modes but “conflates responsive layout decisions with colour semantics.” So the model was originally three ordered layers; a fourth — brand — was inserted between primitives and theme once the system needed to support more than one brand identity (ADR-012, 2026-07-06). Later layers override earlier ones, and aliases may only point to earlier layers:
| Layer | Files | Responsibility |
|---|---|---|
| Primitives | primitives.json |
Raw, context-free values. Single source of truth. Hand-edited via PR. |
| Brand | brands/<brand>.json (upskill, horizon) |
Per-brand color ramp mappings (brand/accent/neutral/surface slots), font.family.*, and literal border-radius.*. |
| Theme | theme/light.json, theme/dark.json |
Brand-agnostic semantic color aliases — maps intent (color.background.brand) to a brand slot or functional primitive. Switching theme files switches the entire color mode; switching a data-brand attribute switches the brand, independently. |
| Device | device/desktop.json, device/tablet.json, device/mobile.json |
Responsive spacing, grid, typography per breakpoint. Desktop ≥ 1440px, tablet ≥ 768px, mobile < 768px. |
Code is the source of truth — a reversal forced by plan limits
ADR-002 originally declared primitives Figma-owned (“never hand-edited”). The 2026-06-17 amendment reversed this, and the reason is worth stating plainly because it shapes everything downstream: automated Figma→code sync is impossible on the available plan. The Figma Variables REST API is Enterprise-only, Code Connect is Org/Enterprise-only, and Token Studio’s free GitHub sync is single-file while this architecture is deliberately six files (primitives + two theme + three device). The only automatable direction is code→Figma, interactively via the Figma Plugin API — since the 2026-10-01 amendment through figma-cli against Figma Desktop, with the Figma MCP as fallback. Consequences:
primitives.jsonis hand-editable via PR, like every other layer.- A value invented in Figma is a proposal until it lands in
primitives.json. /figma-variable-auditwas retargeted from an import gate to a drift/reconciliation check, and/figma-variable-pushwrites code-side tokens into Figma when code is ahead.
Representational divergence is not drift
The second ADR-002 amendment (2026-06-22) covers a subtler case: Figma cannot store unitless values. Line-heights are authored in code as unitless ratios (1, 1.25, 1.4, 1.5, 1.75) so they adapt to any font size; Figma must hold them as fixed px values. This permanent difference is an accepted representational divergence, not drift: both Figma moments exclude it from their diffs, figma-variables.json tags or omits it, and Figma’s fixed value never flows back into primitives.json.
One brand, then two
For its first phase this was a single-brand system — theme files referenced primitive ramps directly ({color.terracotta.9}), so a second brand would have meant forking every theme file. ADR-012 inserted the brand layer instead: theme files now reference brand slots ({color.brand.9}), and a brand file maps those slots to whichever hue it wants. upskill (the default) maps brand → terracotta, accent → teal, neutral → sand, surface → gold; the second brand, horizon, maps brand → cyan, accent → amber, neutral/surface → grey, with Playfair Display headlines and sharper radii. Runtime selection is a data-brand attribute, mirroring data-theme.
Two rules keep this tractable for a one-maintainer system: a brand slot needs a full light + dark ramp (a ramp is the glossary’s Scale — a hue’s numbered 1–12 lightness sequence; this rules out hues authored with only one, historically cyan — since patched to qualify, see below), and brands swap hues, not steps — a shared semantic like border.selected always sits at the same ramp step across every brand, so a brand that fails contrast at that step gets a different hue mapping or a tracked waiver, never a per-brand override of the shared step.
Naming decisions with teeth
Three further ADRs pin down naming so token names encode intent, not just numbers:
- ADR-003 (superseded 2026-06-14) — the group-default naming debate.
$rootwas originally chosen, then fully reversed to.defaultonce research confirmed the W3C DTCG community and tooling ecosystem (Tokens Studio, Style Dictionary) converge on.default;$rootrequired a custom preprocessor and produced meaningless-rootsuffixes in CSS output. The ADR is kept with a supersession note rather than deleted — see Governance for why that matters. - ADR-004 —
space.*answers “how much space goes here inside a component?” (inset/stack/inline, the Nathan Curtis spacing model — inset is padding inside an element, stack is vertical gaps between stacked elements, inline is horizontal gaps in a row);grid.*answers “how is the page grid structured at this breakpoint?” (margin/gutter/columns/screen-size) and is consumed by exactly one place, the.containerutility class. Cross-use is forbidden even when the pixel values coincide. - ADR-005 —
space.*is for empty space (gap, padding, margin);size.*is for filled elements (icon, avatar, control dimensions). Same 4px base unit, different intent:size.300 = 24pxandspace.300 = 24pxare the same raw value describing different things. T-shirt names (sm/md/lg) live only at the semantic layer, mapped per component —icon.size.smandavatar.size.smare both “small” but resolve to different primitives.
How it works, concretely
Every token carries $type and $value. Concrete values and aliases look like this (real shapes from packages/tokens/src/):
{ "$type": "color", "$value": "#D15D50" }{ "$type": "color", "$value": "{color.terracotta.9}" }No $extensions blocks are ever committed — they are stripped when reconciling anything brought over from Figma. $deprecated is the one exception: a stable DTCG spec property (2025.10), not $extensions, machine-mirrored from Airtable governance by scripts/token-deprecation-mirror.js after every governance pull, never hand-authored (ADR-002, 2026-07-13 amendment). Each color hue (terracotta, cyan, gold, teal, sand, grey, black, white, amber, red) has three sub-scales that must never be mixed on one token: 1–12 (light mode), dark-1–dark-12 (dark mode), alpha-1–alpha-12 (transparent variants). cyan originally shipped without a dark ramp; its light ramp was replaced and a dark ramp added (2026-07-06, a custom Radix-generated scale) specifically so it could qualify as a brand hue — every other hue’s ramp is original. The newest hue, red (2026-07-07, the official Radix red scale, light + dark ramps), exists for one reason: feedback.error needed a dedicated hue that is deliberately not brand-eligible, so an error state can never visually collide with a brand’s accent (ADR-002 amendment; the full rationale, including the ramp-regeneration method, is ADR-014).
npm run tokens:build runs Style Dictionary with the custom transforms defined in packages/tokens/build.js: px→rem, font-weight string→numeric, the (historical) $root rename, and a media-query combiner. Brand files build to brand.<brand>.css, and theme files build with outputReferences: true so every theme token emits as a var() chain against brand slots rather than a resolved value — one theme.<mode>.css serves every brand (ADR-012). The device-layer output strategy comes straight from ADR-002: desktop tokens emit to :root as the baseline, tablet and mobile override the same custom property names inside @media blocks — one CSS file, no per-breakpoint imports:
: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; }}The invariant that holds it together: components only ever consume the built output (var(--token-name) in CSS Modules, constants in TS) — never the source JSON. The authoring procedure, including when a transform change needs an ADR, lives in the /tokens-author command (.claude/commands/tokens-author.md).
Diagrams
Token build graph
Every value resolves through the four layers in order, later layers overriding earlier ones — shown here with one real token as it passes through each stage:
flowchart LR
P["Primitives<br/>color.terracotta.9"] --> B["Brand<br/>color.brand.9"]
B --> T["Theme<br/>color.background.brand"]
P --> D["Device<br/>space.stack.md"]
T --> SD["Style Dictionary build<br/>emit order: primitive → brand → theme → device"]
D --> SD
SD --> OUT["CSS custom properties<br/>+ JS/TS constants"]
OUT --> C["Components<br/>var(--color-background-brand)"]
Figma and Airtable aren’t in this diagram — they’re mirrors that sit outside the build, not steps in it. The full pipeline below places them alongside version control and CI.
Worked example: what the built CSS actually looks like
--ds- is a fixed system prefix, not a per-brand one — every layer’s Style Dictionary platform config passes the same prefix: 'ds' (packages/tokens/build.js). Brand identity lives in the CSS selector ([data-brand="upskill"]), never in the variable name, so --ds-color-background-brand is the same name under every brand; only the value it resolves to changes.
Take color.background.brand under data-brand="upskill" + data-theme="dark". Because theme and brand build with outputReferences: true, the browser walks a live 3-hop var() chain at paint time — Style Dictionary never flattens it to a hex value:
/* theme.dark.css — [data-theme="dark"] */--ds-color-background-brand: var(--ds-color-brand-dark-9);
/* brand.upskill.css — :root, [data-brand="upskill"] */--ds-color-brand-dark-9: var(--ds-color-terracotta-dark-9);
/* primitives.css — :root */--ds-color-terracotta-dark-9: #d15d50;Device tokens don’t chain — the device build only ever includes primitives, never brand or theme (its Style Dictionary include is [primitives.json, device/<name>.json] only), so a mobile value is flat:
@media (max-width: 767px) { :root { --ds-space-stack-md: 0.75rem; }}Full pipeline, end to end
The build graph above is the critical path from source to var(). The wider system wraps it in six stages — source, version control + governance, CI, build + deploy, the frozen-snapshot side-channel, and the consuming platforms. This is a lite system: the packages are npm-workspace-internal ("private": true), so there is no registry publish, CDN, or public API — the one real distribution surface is GitHub Pages.
flowchart LR
subgraph SRC["① Source · DTCG JSON"]
direction TB
P[primitives.json] --> BR[brands/*.json]
BR --> TH[theme/light · dark]
P --> DV[device/desktop · tablet · mobile]
end
subgraph VC["② Version control · Governance"]
direction TB
GIT[Git / main<br/>agent output via PR]
AT[(Airtable<br/>token + component<br/>governance)]
GIT <-->|sync.yml ⇄ airtable-pull.yml| AT
end
subgraph CI["③ CI · GitHub Actions"]
direction TB
TC[tokens-check.yml<br/>build · contrast AA · diff]
CO[components-check.yml<br/>lint · a11y · metadata · patterns]
SH[showcase-check.yml<br/>layout:validate]
DK[docs-check.yml<br/>staleness · claudemd gate]
end
subgraph BD["④ Build · Deploy"]
direction TB
SD[Style Dictionary<br/>tokens:build] --> OUT[CSS custom props + JS/TS constants]
GP[[GitHub Pages<br/>deploy-showcase.yml]]
end
subgraph SNAP["⑤ Frozen snapshots · read-not-call"]
direction TB
SNS[sense.js · pipeline-status · status] --> FZ[STATUS_QUO.md<br/>token-usage · governance<br/>pipeline-status · patterns]
end
subgraph PLAT["⑥ Platform · Consumers"]
direction TB
CMP[Components<br/>CSS Modules · var--token]
SB[Storybook]
DOC[Docs site · Starlight]
SW[Showcase app]
FIG[Figma variables<br/>downstream mirror]
end
SRC --> GIT
GIT --> CI
CI --> SD
OUT --> CMP
CMP --> SB
CMP --> SW
GP --> SW
GP --> DOC
P -.->|/figma-variable-push| FIG
FIG -.->|proposals · /figma-variable-audit| P
GIT -.-> SNS
CI -.-> SNS
FZ -.->|agentic moments · npm run status| PLAT
Related
- ADRs: 002 — Three-layer token model (+ amendments), 003 —
$rootconvention (superseded), 004 —space.*vsgrid.*, 005 —sizevsspace, 012 — Brand layer - Commands:
/tokens-author,/figma-variable-audit,/figma-variable-push,/figma-cli(all in.claude/commands/) - Config:
packages/tokens/build.js· Scripts:npm run tokens:build,npm run tokens:usage,npm run tokens:contrast-check,npm run tokens:deprecations— see the CLI reference