Design Tokens: Teaching AI Agents to Build On-Brand UI

Posted on: 10/26/2026 3:00:28 PM

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.

Oct 28, 2025DTCG publishes the first stable Design Tokens specification (2025.10)
13standard token types: 7 primitive and 6 composite
14color spaces in the Color module, including oklch and display-p3
23tools and organizations listed by the DTCG as participants or adopters

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.

TierExampleAnswersChanges when
Primitivecolor.brand.600, space.4Which values exist?The palette or spacing scale changes
Semanticcolor.action.primary, color.text.mutedWhat is this value for?Theme, brand, or high-contrast mode changes
Componentbutton.primary.backgroundWhat 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

GroupTypesNotes
Primitivecolor, dimension, fontFamily, fontWeight, duration, cubicBezier, numberdimension and duration are { value, unit } objects: px/rem and ms/s; the unit is required even when the value is 0
CompositestrokeStyle, border, transition, shadow, gradient, typographyEach sub-property can be a value or an alias, like shadow.color above
Color (Color module)colorSpace + components14 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 a sources array of $ref file pointers or inline tokens.
  • modifiers: "switches" such as brand or theme, each with contexts (the options) and an optional default.
  • 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:

ToolRoleDTCG 2025.10 supportGood fit when
Terrazzo (2.x)Parser, linter, multi-platform buildFull since 2.0, including ResolversStarting fresh, multi-dimensional theming, OKLCH colors
Style Dictionary (5.x)Multi-platform build, largest ecosystemDTCG since v4; its docs say full 2025.10 support is still a work in progress in v5Existing pipeline, many custom transforms, Android
Tokens StudioFigma plugin, Git syncW3C DTCG format optionDesigners work in Figma
PenpotOpen-source design toolNative DTCG tokensYou 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 layerProvidesWhen the agent reads it
AGENTS.md / CLAUDE.mdA short map: hard rules, where tokens live, reference screens, the verify commandAt the start of every session
Built tokens (tokens.css)The list of valid token names, with $description as commentsWhen writing styles
DESIGN.mdDesign rationale, do's and don'ts, in proseWhen building a new screen
Storybook MCPComponent list, props, examples, test runsBefore creating or changing a component
Figma MCPFrame structure and the variables in useWhen 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 to as 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

Week 1: Inventory
Count the colors, font sizes and spacing values your CSS actually contains (CSS analyzers such as Project Wallace do this quickly). Collapse near-duplicates into primitive scales; the 47 blues from the introduction are usually variations of a handful of shades you really need.
Week 2: Semantic tier and themes
Name tokens by meaning (surface, text, action, border, feedback), write a $description for each, and set up a resolver for light/dark themes and brands. Sync with Figma Variables or Penpot.
Week 3: Pipeline and CI
Build with Terrazzo or Style Dictionary, check contrast across every permutation, turn on color-no-hex and strict values for color. Gradually replace legacy hex codes with tokens, starting with the most-used components.
Week 4: Agent context
Write AGENTS.md as a map, add DESIGN.md for rationale, enable Storybook MCP and Figma MCP, and create the reference screens and the verify:ui command.
Ongoing: Token lifecycle
Mark tokens $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-button or text.gray become 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.