Design Tokens: Teaching AI Agents to Build On-Brand UI
Posted on: 10/26/2026 3:00:28 PM
Table of contents
- 1. Why AI agents drift off your design system
- 2. Three tiers: primitive, semantic, component
- 3. Anatomy of DTCG 2025.10
- 4. Theming without combinatorial explosion: the Resolver module
- 5. The pipeline: from Figma and Penpot to every platform
- 6. Putting the design system within an AI agent's reach
- 7. Guardrails: let the codebase block drift by itself
- 8. Accessibility lives in the tokens
- 9. A four-week adoption roadmap
- 10. Common mistakes
- Conclusion
A product team has let an AI coding agent write its UI for three months. New screens ship three times faster, but when a designer finally opens the codebase they find 47 different shades of blue, a "Save" button with four different corner radii, and a dark mode that is dark in a different way on every page. The agent did nothing wrong in the usual sense: it learns from the surrounding code, and the surrounding code is full of hand-typed hex values. Seeing #3b82f6 in three places, it confidently writes a fourth — or #3a7ff0, because it "looks about right".
The root cause is not the model. It is that design decisions do not exist in machine-readable form. They live in a Figma file, in designers' heads, on a wiki page nobody opens. Design tokens turn those decisions into named, typed, related data that build tools, web and mobile apps, and AI agents can all read. In October 2025 the W3C Design Tokens Community Group (DTCG) published the first stable version of its specification; a year later the tooling is mature enough for production. This deep dive covers the structure of DTCG 2025.10, theming with the Resolver module, a build pipeline with Terrazzo, how to put a design system into an AI agent's context, and the technical guardrails that stop agents from drifting off-brand. Every code sample in this article has been run.
1. Why AI agents drift off your design system
A coding agent writes UI from three sources: the instructions you give it (prompts, AGENTS.md, CLAUDE.md), the code it can read in the repo, and general knowledge from training. When your design system lives only in Figma, the first two are nearly empty, so the agent fills the gap with the third — the "average interface of the internet". The result works and looks fine, but it is not your UI. This is usually called UI drift: every screen is slightly off, and the small deviations add up to a product that no longer feels consistent.
Hex codes are the least visible culprit. #165dfc says nothing about intent: is it an accent, a link, a button background or a focus ring? Humans can guess from context; an agent cannot be sure. var(--color-action-primary), on the other hand, says exactly what it is for. When you rebrand or turn on dark mode, the value changes but the meaning stays, and every place that uses the token follows.
A token is a contract, not just a color variable
A design token is a named design decision: a name that describes meaning, a value with an explicit type, and optionally a reference to another token. A CSS variable is only one of many outputs. The same token files can generate CSS for the web, Swift for iOS, resources for Android, a Tailwind configuration — and context for AI agents.
2. Three tiers: primitive, semantic, component
Most mature design systems organize tokens into three tiers. The point is not a pretty diagram; it is that each kind of change touches exactly one place.
| Tier | Example | Answers | Changes when |
|---|---|---|---|
| Primitive | color.brand.600, space.4 | Which values exist? | The palette or spacing scale changes |
| Semantic | color.action.primary, color.text.muted | What is this value for? | Theme, brand, or high-contrast mode changes |
| Component | button.primary.background | What does this component use? | One component needs a specific tweak |
flowchart LR
subgraph P["Primitive: raw values"]
P1["color.brand.600 · oklch 0.546 0.245 262.9"]
P2["color.neutral.0 · white"]
P3["space.4 · 1rem"]
end
subgraph S["Semantic: meaning"]
S1["color.action.primary"]
S2["color.action.on-primary"]
S3["space.inset.md"]
end
subgraph C["Component: narrow scope"]
C1["button.primary.background"]
C2["button.primary.text"]
C3["button.padding"]
end
P1 --> S1 --> C1
P2 --> S2 --> C2
P3 --> S3 --> C3
C1 --> UI["Checkout button · web, iOS, Android"]
C2 --> UI
C3 --> UI
T["Switch brand or theme"] -.->|"only rewires primitive to semantic"| S
classDef hot fill:#e94560,stroke:#fff,color:#fff
classDef light fill:#f8f9fa,stroke:#e94560,color:#2c3e50
classDef dark fill:#2c3e50,stroke:#fff,color:#fff
class P1,P2,P3 light
class S1,S2,S3 hot
class C1,C2,C3 light
class UI,T dark
Figure 1: Components never point straight at primitives. Switching themes just rewires the arrows between the primitive and semantic tiers.
The most important rule: components and application code use semantic (or component) tokens, never primitives. If a button points directly at color.blue.600, the day you rebrand to orange you will have a token named "blue" holding an orange value — and an agent reading that name will be completely misled. For AI agents the semantic tier is the most valuable one: it carries design intent, which a hex code never can.
3. Anatomy of DTCG 2025.10
On October 28, 2025, the Design Tokens Community Group published its first stable version, made of three modules: Format (file structure and token types), Color (modern color spaces) and Resolver (theming and context permutations). Note that these are Community Group reports, not a W3C Recommendation and not on the Standards Track — but this is the shared format that Figma, Adobe, Penpot, Sketch, Framer, Style Dictionary, Tokens Studio, Terrazzo, zeroheight, Supernova and many others helped build.
3.1. Tokens, groups and the $ properties
An object with a $value property is a token; an object without one is a group. Every property defined by the spec starts with $, so your own token names must not start with $ and must not contain {, } or a period.
$value(required): the token's value, or an alias to another token.$type: the token type. If omitted, it is inherited from the nearest parent group. Tools must not guess the type from the value.$description: plain-text description — for AI agents, this is where design intent lives.$deprecated:true, or a string explaining what to use instead.$extensions: tool-specific data under reverse-domain keys; tools must preserve data they do not understand.
Groups additionally support $extends, which inherits every token from another group (deep merge: local tokens override inherited ones at the same path), and the reserved name $root for a group's base token, for example color.accent.$root next to color.accent.subtle. The example below packs in most of these concepts:
{
"radius": {
"$type": "dimension",
"md": { "$value": { "value": 6, "unit": "px" } }
},
"button": {
"primary": {
"$description": "Primary action button, at most one per screen",
"background": {
"$type": "color",
"$value": "{color.action.primary}",
"$extensions": { "com.figma": { "scopes": ["FRAME_FILL", "SHAPE_FILL"] } }
},
"text": { "$type": "color", "$value": "{color.action.on-primary}" },
"radius": { "$type": "dimension", "$value": "{radius.md}" },
"elevation": {
"$type": "shadow",
"$value": {
"color": { "colorSpace": "oklch", "components": [0.2, 0.04, 265], "alpha": 0.18 },
"offsetX": { "value": 0, "unit": "px" },
"offsetY": { "value": 1, "unit": "px" },
"blur": { "value": 2, "unit": "px" },
"spread": { "value": 0, "unit": "px" }
}
}
},
"secondary": {
"$extends": "{button.primary}",
"background": { "$type": "color", "$value": "{color.surface.default}" },
"text": { "$type": "color", "$value": "{color.text.default}" }
},
"cta": {
"$extends": "{button.primary}",
"$deprecated": "Use button.primary instead; this group is removed in v4"
}
}
}
button.secondary inherits radius and elevation from button.primary and only overrides background and text. button.cta still works, but every tool (and agent) reading the file knows it is deprecated and what to use instead.
3.2. Aliases: curly braces and JSON Pointer
The spec has two reference syntaxes. Curly braces, {color.brand.600}, always point at a token's entire $value, and you will use them 95% of the time. The $ref syntax uses JSON Pointer (RFC 6901) and differs in two ways: you write the full path including /$value, and it can point inside a value. For example, a very light background that keeps the exact hue of the brand color (within the same document):
{
"color": {
"tint": {
"brand-subtle": {
"$type": "color",
"$description": "Very light background that keeps the exact brand hue",
"$value": {
"colorSpace": "oklch",
"components": [0.97, 0.02, { "$ref": "#/color/brand/600/$value/components/2" }]
}
}
}
}
}
Aliases can chain (A points to B, B points to C) and tools follow them until they reach a concrete value; circular references are errors that must be reported.
3.3. Token types and value formats
| Group | Types | Notes |
|---|---|---|
| Primitive | color, dimension, fontFamily, fontWeight, duration, cubicBezier, number | dimension and duration are { value, unit } objects: px/rem and ms/s; the unit is required even when the value is 0 |
| Composite | strokeStyle, border, transition, shadow, gradient, typography | Each sub-property can be a value or an alias, like shadow.color above |
| Color (Color module) | colorSpace + components | 14 spaces: srgb, srgb-linear, hsl, hwb, lab, lch, oklab, oklch, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz-d65, xyz-d50; plus alpha and a hex fallback |
The most valuable change in 2025.10 is that a color is no longer a hex string. It is described by a color space and its components, for example { "colorSpace": "oklch", "components": [0.546, 0.245, 262.9], "hex": "#165dfc" }. OKLCH is a great fit for design systems because its lightness L is perceptually uniform: two colors with the same L look equally light, so building scales and predicting contrast is far easier than with HSL. Components may also be none (which interpolates differently from 0). Token files use the .tokens or .tokens.json extension and the application/design-tokens+json media type.
4. Theming without combinatorial explosion: the Resolver module
A real SaaS product rarely has a single look. Three white-label brands × light/dark × regular/compact density × normal/high contrast is already 24 combinations. The old approach was to copy files with naming conventions like acme-dark-compact.json and hope nobody forgot to update one of the 24. The Resolver module solves this with one file that describes the relationships between token sets instead of listing every combination:
sets: token collections that are always loaded (such as shared primitives); each has asourcesarray of$reffile pointers or inline tokens.modifiers: "switches" such asbrandortheme, each withcontexts(the options) and an optionaldefault.resolutionOrder(required): the order of application; later entries override earlier ones on conflict.
{
"name": "Acme Design System",
"version": "2025.10",
"sets": {
"foundation": { "sources": [{ "$ref": "foundation/neutral.tokens.json" }] }
},
"modifiers": {
"brand": {
"contexts": {
"acme": [{ "$ref": "brand/acme.tokens.json" }],
"nova": [{ "$ref": "brand/nova.tokens.json" }]
},
"default": "acme"
},
"theme": {
"contexts": {
"light": [{ "$ref": "theme/light.tokens.json" }],
"dark": [{ "$ref": "theme/dark.tokens.json" }]
},
"default": "light"
}
},
"resolutionOrder": [
{ "$ref": "#/sets/foundation" },
{ "$ref": "#/modifiers/brand" },
{ "$ref": "#/modifiers/theme" }
]
}
A note on the version field
The final Resolver report is inconsistent about the value of version: the property table says 2025-10-01, while the prose says 2025-11-01. Terrazzo, the tool used in this article, only accepts "2025.10". Follow your tool and re-check when the spec gets an erratum.
Given an input such as {"brand": "nova", "theme": "dark"}, the resolver runs four stages: validate that the input matches the modifiers and contexts, walk resolutionOrder to merge the sources into one token tree, resolve aliases, and output the final tokens. The number of possible resolutions is the product of every modifier's context count — here 2 × 2 = 4.
flowchart TB IN["Input: brand = nova, theme = dark"] --> V["Step 1: Validate input against modifiers and contexts"] V --> O["Step 2: Walk resolutionOrder"] O --> F["set foundation · neutral.tokens.json"] O --> B["modifier brand picks nova · nova.tokens.json"] O --> TH["modifier theme picks dark · dark.tokens.json"] F --> M["Merge into one token tree · later sources win"] B --> M TH --> M M --> A["Step 3: Resolve aliases action.primary to nova brand.400"] A --> R["Step 4: Final tokens for nova + dark"] classDef hot fill:#e94560,stroke:#fff,color:#fff classDef light fill:#f8f9fa,stroke:#e94560,color:#2c3e50 classDef dark fill:#2c3e50,stroke:#fff,color:#fff class IN,R dark class V,O,M,A hot class F,B,TH light
Figure 2: The four resolver stages. light.tokens.json and acme.tokens.json are never loaded for this permutation.
The subtle part is stage 3: aliases are resolved after merging. That is why a theme file can simply say "{color.brand.600}" without knowing which brand is active; the brand modifier decides whether brand.600 is blue or orange. Here is the first version of the light theme, and the palette for the nova brand:
{
"color": {
"$type": "color",
"surface": { "default": { "$value": "{color.neutral.0}" } },
"text": {
"default": { "$value": "{color.neutral.900}" },
"muted": { "$value": "{color.neutral.600}" }
},
"action": {
"primary": { "$value": "{color.brand.600}", "$description": "Primary action background" },
"on-primary": { "$value": "{color.neutral.0}", "$description": "Text and icons placed on action.primary" }
},
"border": { "focus": { "$value": "{color.brand.500}" } }
}
}
{
"color": {
"$type": "color",
"brand": {
"400": { "$value": { "colorSpace": "oklch", "components": [0.75, 0.183, 55.9], "hex": "#ff8b1f" } },
"500": { "$value": { "colorSpace": "oklch", "components": [0.705, 0.213, 47.6], "hex": "#fc7100" } },
"600": { "$value": { "colorSpace": "oklch", "components": [0.646, 0.222, 41.1], "hex": "#ec5600" } },
"700": { "$value": { "colorSpace": "oklch", "components": [0.553, 0.195, 38.4], "hex": "#c43e00" } }
}
}
}
It looks perfectly reasonable. But this version hides accessibility bugs that are hard to spot by eye — we will catch them in section 7.
5. The pipeline: from Figma and Penpot to every platform
The foundational rule: the source of truth is JSON in Git, not the design file and not the CSS. Token changes go through pull requests with review, diffs and versions — exactly like code. Design tools are where designers author tokens; CI is where platform outputs are generated.
flowchart TB
subgraph D["Design"]
direction LR
FG["Figma Variables · via Tokens Studio"]
PP["Penpot · native DTCG tokens"]
end
subgraph G["Source of truth: Git"]
direction LR
PR["Pull request · review and diff"]
TK[("tokens/*.tokens.json · ds.resolver.json")]
end
subgraph CI["CI"]
direction LR
VA["Validate · types, aliases, cycles"]
CT["Contrast check · every permutation"]
BU["tz build"]
end
subgraph OUT["Outputs"]
direction LR
CSS["tokens.css"]
TW["Tailwind v4"]
MOB["Swift, JS/TS, Sass"]
AG["Agent context"]
end
FG --> PR
PP --> PR
PR --> TK --> VA --> CT --> BU
BU --> CSS
BU --> TW
BU --> MOB
BU --> AG
classDef hot fill:#e94560,stroke:#fff,color:#fff
classDef light fill:#f8f9fa,stroke:#e94560,color:#2c3e50
classDef dark fill:#2c3e50,stroke:#fff,color:#fff
class FG,PP light
class PR,TK dark
class VA,CT,BU hot
class CSS,TW,MOB,AG light
Figure 3: Tokens are authored in design tools, reviewed in Git, checked and built in CI.
On the design side, Penpot describes itself as the first design tool with native design tokens (since version 2.6, built with Tokens Studio), following the DTCG format with themes, sets, and JSON import/export. In Figma, designers work with Variables; syncing to DTCG usually goes through the Tokens Studio plugin (which has a W3C DTCG format option) or the Variables REST API. On the build side there are two popular choices:
| Tool | Role | DTCG 2025.10 support | Good fit when |
|---|---|---|---|
| Terrazzo (2.x) | Parser, linter, multi-platform build | Full since 2.0, including Resolvers | Starting fresh, multi-dimensional theming, OKLCH colors |
| Style Dictionary (5.x) | Multi-platform build, largest ecosystem | DTCG since v4; its docs say full 2025.10 support is still a work in progress in v5 | Existing pipeline, many custom transforms, Android |
| Tokens Studio | Figma plugin, Git sync | W3C DTCG format option | Designers work in Figma |
| Penpot | Open-source design tool | Native DTCG tokens | You want tokens as a core feature, self-hosted |
The Terrazzo configuration for the resolver above is compact. Each permutations entry is one input combination plus a function that wraps it in a CSS selector:
import { defineConfig } from "@terrazzo/cli";
import css from "@terrazzo/plugin-css";
export default defineConfig({
tokens: ["./tokens/ds.resolver.json"],
outDir: "./dist/",
plugins: [
css({
filename: "tokens.css",
permutations: [
{ input: { brand: "acme", theme: "light" }, prepare: (css) => `:root {\n ${css}\n}` },
{ input: { brand: "acme", theme: "dark" }, prepare: (css) => `[data-theme="dark"] {\n ${css}\n}` },
{ input: { brand: "nova", theme: "light" }, prepare: (css) => `[data-brand="nova"] {\n ${css}\n}` },
{ input: { brand: "nova", theme: "dark" }, prepare: (css) => `[data-brand="nova"][data-theme="dark"] {\n ${css}\n}` },
],
}),
],
});
Running npx tz build produces 21 tokens across four permutations. An excerpt:
/* dist/tokens.css (trimmed excerpt of tz build output) */
:root {
/* Primary action background */
--color-action-primary: var(--color-brand-strong);
--color-brand-400: oklch(70.4% 0.1584 253.5);
--color-brand-600: oklch(54.6% 0.245 262.9);
--color-brand-strong: var(--color-brand-600);
--color-surface-default: var(--color-neutral-0);
--color-text-muted: var(--color-neutral-600);
--space-4: 1rem;
}
@media (color-gamut: p3) {
:root {
--color-brand-400: oklch(70.7% 0.165 254.6);
}
}
[data-theme="dark"] {
--color-action-primary: var(--color-brand-400);
--color-surface-default: var(--color-neutral-950);
--color-text-muted: var(--color-neutral-400);
}
[data-brand="nova"] {
--color-brand-600: oklch(64.76% 0.217 36.96);
--color-brand-strong: var(--color-brand-700);
}
Two details stand out. First, aliases are kept as var() chains instead of copied values, so DevTools shows exactly where action.primary comes from. Second, some OKLCH colors are outside the sRGB gamut: Terrazzo gamut-maps them to sRGB for the default value and emits a @media (color-gamut: p3) block that restores the original color on wide-gamut displays. You declare a color once; iPhone and MacBook users see the more vivid version, and sRGB screens still render correctly.
6. Putting the design system within an AI agent's reach
Once you have tokens, the next step is getting agents to see and use them. Experience shows that stuffing ever more instructions into one rules file does not work: the longer it gets, the more the agent skips. A better approach is to split context into layers, each loaded when it is needed.
| Context layer | Provides | When the agent reads it |
|---|---|---|
AGENTS.md / CLAUDE.md | A short map: hard rules, where tokens live, reference screens, the verify command | At the start of every session |
Built tokens (tokens.css) | The list of valid token names, with $description as comments | When writing styles |
DESIGN.md | Design rationale, do's and don'ts, in prose | When building a new screen |
| Storybook MCP | Component list, props, examples, test runs | Before creating or changing a component |
| Figma MCP | Frame structure and the variables in use | When implementing a specific design |
6.1. AGENTS.md: a map, not a textbook
The root file should be short and point elsewhere, because it is loaded into every session:
## UI
- Colors, spacing, radii: ONLY use the variables in `dist/tokens.css`
(generated from `tokens/`, never edit by hand). No hex codes, no ad-hoc px.
- Before creating a new component: call Storybook MCP `docs-list`, `docs-show`
to find and reuse an existing one.
- Follow the reference screens in `src/examples/` (settings-form, data-table, detail-page).
- Design rationale, do's and don'ts: see `DESIGN.md`.
- When done, run `npm run verify:ui`. Never add eslint-disable,
never delete or weaken tests to get a pass.
6.2. DESIGN.md: what tokens cannot say
Tokens tell you the value, but not when not to use it. Google Labs has open-sourced DESIGN.md (Apache-2.0, currently alpha): a Markdown file whose YAML front matter holds tokens (colors, typography, radii, spacing, components) and whose prose explains how to apply them. The @google/design.md CLI offers lint (structure, references and WCAG contrast), diff, and export to Tailwind or the DTCG format.
---
version: alpha
name: Acme
colors:
primary: "#165dfc"
on-primary: "#ffffff"
surface: "#ffffff"
text: "#0f172b"
rounded:
md: 6px
spacing:
md: 16px
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
rounded: "{rounded.md}"
---
## Overview
Finance UI for business accountants: information-dense, little decoration.
## Colors
`primary` is reserved for the ONE main action on each screen.
Destructive actions never use `primary`.
## Do's and Don'ts
- Do: right-align money columns in data tables.
- Don't: place two primary buttons side by side.
Do not create a second source of truth
If you already keep DTCG files in Git, do not hand-copy their values into DESIGN.md and let the two drift apart. Treat the DTCG files as the home of values, and let DESIGN.md focus on rationale and do's and don'ts. For a small project starting from scratch you can go the other way: write DESIGN.md first and use export to produce DTCG.
6.3. MCP: let agents look things up instead of guessing
Storybook MCP (@storybook/addon-mcp, currently in preview) exposes your component library as tools the agent can call: docs-list lists components, docs-show returns props and examples, get-storybook-story-instructions explains how to write stories following your project's conventions, and test-run runs component tests so the agent can check its own work. The server runs inside the Storybook dev server at http://localhost:6006/mcp.
The Figma MCP server (remote at https://mcp.figma.com/mcp) provides design context: the structure of the selected frame and the variables and components it uses; combined with Code Connect, the agent reuses the right components from your codebase instead of writing new ones. Newer versions also let agents write back to the canvas — building components, variables and screens from an existing design system library.
sequenceDiagram participant Dev as Developer participant AG as AI coding agent participant FM as Figma MCP participant SB as Storybook MCP participant CI as verify:ui Dev->>AG: Build the Billing Settings screen from the Figma frame AG->>AG: Read AGENTS.md, learn the token rules AG->>FM: Get design context and variables for the frame FM-->>AG: Layout structure, variables in use AG->>SB: docs-list, docs-show for Button and FormField SB-->>AG: Props, examples, existing stories AG->>AG: Write code using only real components and tokens AG->>CI: npm run verify:ui CI-->>AG: Token color-focus-ring does not exist AG->>AG: Switch to color-border-focus AG->>CI: npm run verify:ui CI-->>AG: Pass AG-->>Dev: Pull request with story screenshots
Figure 4: The agent looks things up through MCP before writing, then lets automated checks give feedback until everything passes.
7. Guardrails: let the codebase block drift by itself
Instructions are advice; checks are law. Teams that have brought agents into frontend work tend to land on the same lesson: instead of writing longer rules, make wrong code unable to pass CI. Here is a typical piece of CSS an agent writes when there are no guardrails:
.btn-primary {
background-color: var(--color-action-primary);
color: #fff;
border-radius: 6px;
padding: var(--space-2) var(--space-4);
outline-color: var(--color-focus-ring);
}
.btn-primary:hover { background-color: var(--color-action-primary-hover); }
There are four bugs: a hex code, an ad-hoc px value, and two "invented" tokens — names that sound perfectly plausible but do not exist. The last kind is the most dangerous because CSS does not complain: an undefined variable just makes the property fall back to its initial value, so the hover state never changes color and the focus outline silently disappears.
7.1. Stylelint: ban raw values
Stylelint's built-in color-no-hex rule blocks hex codes, and the stylelint-declaration-strict-value plugin requires selected properties to use variables, functions or keywords. Start with color and radius, and extend to spacing and typography only once your tokens cover them — otherwise agents will invent token names to get around the rule.
export default {
plugins: ["stylelint-declaration-strict-value"],
rules: {
"color-no-hex": true,
"color-named": "never",
"scale-unlimited/declaration-strict-value": [
["/color$/", "fill", "stroke", "border-radius"],
{ ignoreValues: ["transparent", "inherit", "currentColor", "0"] },
],
},
};
$ npx stylelint "src/**/*.css"
src/button.css
3:3 ✖ Expected variable, function or keyword for "#fff" of "color" scale-unlimited/declaration-strict-value
3:10 ✖ Unexpected hex color "#fff" color-no-hex
4:3 ✖ Expected variable, function or keyword for "6px" of "border-radius" scale-unlimited/declaration-strict-value
✖ 3 problems (3 errors, 0 warnings)
7.2. Catch invented tokens
Stylelint does not know which tokens exist. A small script checks every var(--color-…) or var(--space-…) in your source against the variables defined in the built tokens.css (Node 22 or later for globSync):
// Catch "invented" tokens: var(--color-...) that the generated token CSS does not define
import { readFileSync, globSync } from "node:fs";
const defined = new Set(readFileSync("dist/tokens.css", "utf8").match(/--[\w-]+(?=\s*:)/g));
const USE = /var\((--(?:color|space|radius|font|shadow)-[\w-]+)/g;
let bad = 0;
for (const file of globSync("src/**/*.{css,vue,tsx}")) {
for (const [, name] of readFileSync(file, "utf8").matchAll(USE)) {
if (!defined.has(name)) {
bad++;
console.error(`${file}: ${name} does not exist in the design tokens`);
}
}
}
process.exit(bad ? 1 : 0);
$ node scripts/check-unknown-tokens.mjs
src/button.css: --color-focus-ring does not exist in the design tokens
src/button.css: --color-action-primary-hover does not exist in the design tokens
7.3. One verification command
Bundle everything into one command the agent runs after every change. The errors it returns are the feedback the agent uses to fix itself, just like the loop in Figure 4:
{
"scripts": {
"tokens:build": "tz build",
"tokens:check": "node scripts/check-contrast.mjs",
"lint:css": "stylelint \"src/**/*.css\"",
"lint:tokens": "node scripts/check-unknown-tokens.mjs",
"verify:ui": "npm run tokens:build && npm run tokens:check && tsc --noEmit && eslint . && npm run lint:css && npm run lint:tokens && vitest run --project storybook"
}
}
- Make invalid states unrepresentable: use TypeScript discriminated unions so invalid prop combinations (an informational card with an
onClick, say) become compile errors. - Reference screens: a few runnable example screens (a settings form, a data table, a detail page) that live in CI; rename a prop and the build breaks, so examples never go stale.
- Block gaming: agents have been seen adding
eslint-disable, casting toas any, even deleting failing tests. Ban rule-disabling comments in changed files and flag every deleted or weakened test for human review.
8. Accessibility lives in the tokens
WCAG 2.2 requires a contrast ratio of at least 4.5:1 for body text (success criterion 1.4.3; 3:1 for large text) and 3:1 for UI components such as input borders and focus indicators (1.4.11). When colors live in tokens, you can check contrast before a single line of UI exists — and, more importantly, check every permutation the resolver can produce, not just the default theme designers usually look at.
The script below re-implements the four resolver stages in a minimal form (enough for this example: $ref to files only, oklch/srgb colors only), generates every brand × theme permutation, and measures the important color pairs with the culori library:
// Check WCAG contrast for EVERY brand × theme permutation the resolver can produce
import { readFileSync } from "node:fs";
import { wcagContrast } from "culori";
const load = (p) => JSON.parse(readFileSync(new URL(`../tokens/${p}`, import.meta.url), "utf8"));
const resolver = load("ds.resolver.json");
// [foreground, background, minimum] — 4.5:1 for body text, 3:1 for UI components (WCAG 2.2: 1.4.3, 1.4.11)
const PAIRS = [
["color.text.default", "color.surface.default", 4.5],
["color.text.muted", "color.surface.default", 4.5],
["color.action.on-primary", "color.action.primary", 4.5],
["color.border.focus", "color.surface.default", 3],
];
const isGroup = (x) => x && typeof x === "object" && !("$value" in x);
const merge = (a, b) => {
for (const [k, v] of Object.entries(b)) a[k] = isGroup(v) && isGroup(a[k]) ? merge(a[k], v) : v;
return a;
};
// Resolver steps 1–2: walk resolutionOrder, merging sets and the selected contexts
function build(input) {
const tree = {};
for (const { $ref } of resolver.resolutionOrder) {
const [, kind, name] = $ref.split("/"); // "#/modifiers/theme" -> modifiers, theme
const sources = kind === "sets" ? resolver.sets[name].sources : resolver.modifiers[name].contexts[input[name]];
for (const src of sources) merge(tree, structuredClone(src.$ref ? load(src.$ref) : src));
}
return tree;
}
// Step 3: resolve {a.b.c} aliases after merging — that is how a theme can point at the brand palette
function color(tree, path, seen = new Set()) {
if (seen.has(path)) throw new Error(`Circular alias at ${path}`);
seen.add(path);
const v = path.split(".").reduce((n, k) => n?.[k], tree)?.$value;
if (v === undefined) throw new Error(`Missing token ${path}`);
if (typeof v === "string") return color(tree, v.slice(1, -1), seen);
const [a, b, c] = v.components;
return v.colorSpace === "oklch" ? { mode: "oklch", l: a, c: b, h: c } : { mode: "rgb", r: a, g: b, b: c };
}
// Every context permutation: brand(acme, nova) × theme(light, dark) = 4
const inputs = Object.entries(resolver.modifiers).reduce(
(acc, [name, m]) => acc.flatMap((i) => Object.keys(m.contexts).map((ctx) => ({ ...i, [name]: ctx }))),
[{}],
);
let failed = 0;
for (const input of inputs) {
const tree = build(input);
const label = Object.entries(input).map(([k, v]) => `${k}=${v}`).join(" ");
for (const [fg, bg, min] of PAIRS) {
const ratio = wcagContrast(color(tree, fg), color(tree, bg));
if (ratio < min) {
failed++;
console.error(`✗ ${label.padEnd(22)} ${fg} / ${bg} = ${ratio.toFixed(2)} < ${min}`);
}
}
}
console.log(failed ? `${failed} color pairs failed` : `OK: ${inputs.length} permutations × ${PAIRS.length} pairs pass`);
process.exit(failed ? 1 : 0);
Running it against the tokens from section 4:
$ node scripts/check-contrast.mjs
✗ brand=acme theme=dark color.text.muted / color.surface.default = 4.23 < 4.5
✗ brand=nova theme=light color.action.on-primary / color.action.primary = 3.61 < 4.5
✗ brand=nova theme=light color.border.focus / color.surface.default = 2.83 < 3
✗ brand=nova theme=dark color.text.muted / color.surface.default = 4.23 < 4.5
4 color pairs failed
Four failures, three of them in the nova brand — a permutation nobody opens during review because acme is the default. The orange brand.600 looks great but only reaches 3.61:1 with white text; the muted neutral.500 text on the dark surface only reaches 4.23:1 for both brands. The right fix is not an "if nova then use 700" exception in the theme file, but to let each brand declare the semantics: every brand provides a brand.strong token, the shade dark enough for white text, and the theme asks for "the accessible strong shade" instead of guessing the number 600.
brand/acme.tokens.json (inside group color.brand)
+ "strong": { "$value": "{color.brand.600}", "$description": "Shade that reaches 4.5:1 with white text" }
brand/nova.tokens.json
+ "strong": { "$value": "{color.brand.700}", "$description": "Shade that reaches 4.5:1 with white text" }
theme/light.tokens.json
- "primary": { "$value": "{color.brand.600}" }
+ "primary": { "$value": "{color.brand.strong}" }
- "focus": { "$value": "{color.brand.500}" }
+ "focus": { "$value": "{color.brand.strong}" }
theme/dark.tokens.json
- "muted": { "$value": "{color.neutral.500}" }
+ "muted": { "$value": "{color.neutral.400}" }
$ node scripts/check-contrast.mjs
OK: 4 permutations × 4 pairs pass
color.brand.strong is an alias pointing at another alias (brand.700), and the resolver follows the chain to the concrete value. Add a third brand next year and CI will check both of its themes before anything merges.
Measure the colors users actually see
The script measures contrast on the original OKLCH values. For colors outside the sRGB gamut, regular screens display the gamut-mapped version, so lightness can shift slightly. If you want to be strict, map colors to sRGB first (for example with culori's toGamut) and keep a safety margin instead of sitting right at 4.5.
9. A four-week adoption roadmap
$description for each, and set up a resolver for light/dark themes and brands. Sync with Figma Variables or Penpot.color-no-hex and strict values for color. Gradually replace legacy hex codes with tokens, starting with the most-used components.verify:ui command.$deprecated with a replacement before removing them, version the token package, and review token diffs in every PR the way you review an API.10. Common mistakes
- Naming semantic tokens after values:
color.blue-buttonortext.graybecome lies at the first rebrand. Semantic names describe roles, not colors. - A token for every pixel: component tokens for every property of every component bloat the system until nobody dares to touch it. Create component tokens only when a component genuinely needs to diverge from the semantic tier.
- Uncontrolled two-way sync: editing both in Figma and in Git creates silent conflicts. Pick one primary direction and route the other through pull requests.
- Dark mode by inversion: flipping the lightness scale breaks contrast and brand colors. A dark theme needs its own semantic set, checked on its own.
- Trusting agents without lint: agents are very good at inventing plausible token names. Without a check against tokens.css, the bug only surfaces when users notice a missing focus ring.
- Testing only the default theme: as section 8 showed, bugs hide in the permutations nobody opens. Let the machine walk all of them.
Conclusion
AI agents make writing UI cheaper than ever, and precisely because it is cheap, consistency becomes the scarce resource. Design tokens move design decisions out of Figma files and designers' heads and turn them into typed data every tool can read. With DTCG 2025.10, that format is stable and shared across the industry for the first time: OKLCH colors instead of hex, aliases and group inheritance, and resolvers for multi-dimensional theming.
The 2026 recipe is clear: tokens in Git are the source of truth, CI checks contrast across every permutation, AGENTS.md points the way, DESIGN.md explains the why, MCP lets agents look things up, and lint plus tests are the final guardrail. A small first step for this week: run a grep to count the distinct hex codes in your src/ folder — that number is the reason you need design tokens.
References
- W3C DTCG — Design Tokens Format Module 2025.10 (Final Community Group Report)
- W3C DTCG — Design Tokens Color Module 2025.10
- W3C DTCG — Design Tokens Resolver Module 2025.10
- W3C DTCG — Design Tokens specification reaches first stable version
- designtokens.org — official site and adopters list
- Terrazzo — Resolvers guide
- Style Dictionary — DTCG format support
- Smashing Magazine — Integrating Design And Code With Native Design Tokens In Penpot
- Tokens Studio — Bringing native open-standard design tokens to Penpot
- Figma — Figma MCP server
- Storybook — AI and the MCP server
- Google Labs — DESIGN.md (GitHub)
- Builder.io — How to make AI agents follow your design system
- W3C — Web Content Accessibility Guidelines (WCAG) 2.2
- culori — JavaScript color library
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.