A Design System an AI Agent Can Follow
An AI coding agent has no taste. It follows what is written down, and it fills every gap with a default. Give it design tokens it can read, rules it can check and a gate that fails the build when a page breaks them, and it stays inside your design system. Leave any of the three out and it drifts.
This guide is for theme builders and front-end developers. It describes four layers we use on our own sites and themes, shows a small worked example from palette to theme.json to a component, gives you four starter files, and is honest about where the approach still breaks.
In this guide
- Why an agent drifts from a design system, and how to see it happen
- Layer one: tokens in a single palette file, mirrored into
theme.json - Layer two: written rules, phrased so a script can check them
- Layer three:
theme.jsonas the contract an agent reads before it writes - Layer four: a page specification used as a gate on the built page
- A starter kit of four files
- Where it still goes wrong
- Questions people ask
We keep the detail of two neighbouring topics in their own posts and link to them rather than repeat them. For why tokens should outlive a CSS framework, read Your Design Tokens Should Outlive Your CSS Framework. For the full page checklist, read The Page Specification We Gate Every Page With. For how theme.json relates to the newer admin design system, read WordPress Now Has Two Design Systems, and Only One Is theme.json. For what automation does to the people who used to do this work by hand, read AI Took the Mechanical Half of Design Work, and That Half Was the Training Ladder.
Why does an AI agent drift away from your design system?
Because a prompt is not a system. Asked for “a pricing section that matches the site”, an agent has two sources of truth: what it can read in the repository, and what it learned from millions of other pages. Where the repository is silent, the second wins.
The drift is predictable. In our experience it shows up in four places:
- Generic colours. A new component gets a blue or purple that was never in the palette, because a blue button is the most common button there is.
- Arbitrary spacing. You find
padding: 22pxandmargin-top: 37pxnext to a spacing scale built on multiples of four. - One more font. A heading gets a display typeface the site never loaded, or a serif for “editorial feel”.
- Physical instead of logical properties.
margin-leftappears where the system saysmargin-inline-start, which breaks right-to-left layouts quietly.
How do you check whether your project is drifting?
Do not read the diff by eye. Count. Run a search for raw colour values and physical direction properties outside the files that are allowed to contain them. If the count grows with each agent-made change, the agent is filling gaps. The fix is not a longer prompt. The fix is to remove the gaps: write the palette down in one place, write the rules as checkable sentences, and fail the build when a rule is broken. The next four sections do exactly that.
What goes in the token layer, and what stays out?
A design token is a named value that stands for a design decision, such as an accent colour or a step on the spacing scale. The Design Tokens Community Group describes a token file as a JSON file where each token has a required $value, an optional $type, and optional references to other tokens written like {colors.primary}. The group has a published version of the format, labelled 2025.10. The newer working draft we read for this post is marked as a preview and says not to implement it, so we use the format here as a description of what a token file is, and you should check the published version before adopting it.
For a WordPress theme, a practical token layer has one file that holds every decision and every other file reads from it. We use CSS custom properties for this. MDN describes a custom property as a name starting with two dashes, read back with var(), with an optional fallback as the second argument, and notes that defining them on :root is the common way to make them available everywhere.
Here is the palette file from the worked example. It follows the pattern our own sites use.
/* src/palette.css - the only file allowed to contain colour values */
:root {
--color-accent: #0369a1;
--color-accent-hover: #0c4a6e;
--color-surface: #fafafa;
--color-text: #09090b;
--color-text-muted: #52525b;
--color-border: #e4e4e7;
--color-on-accent: #ffffff;
}
:root.dark {
--color-accent: #7dd3fc;
--color-surface: #131113;
--color-text: #fafafa;
--color-on-accent: #131113;
}
Which decisions belong in tokens?
Anything a person could reasonably want to change in one place, and anything an agent could otherwise invent:
- Colours, split by role rather than by name: accent, surface, text, text-muted, border, and the colour that sits on top of the accent.
- The type scale and the font families.
- The spacing scale, container widths, radii and shadows.
The token file in one of our own sites states its scale in the header comment: type steps at a 1.2 ratio, spacing on a four pixel base, and the line “Every component value comes from these. No raw px/rem in components.” That sentence is the whole design of the layer. A component may use a step; it may not invent one.
What stays out of tokens?
One-off values that carry no design decision: the width of an icon, a fixed aspect ratio, a z-index. They bloat the palette file and train everyone, including the agent, to skim it.
How does the palette reach theme.json?
WordPress documents theme.json presets as generating custom properties named --wp--preset--{preset-category}--{preset-slug}, so a colour with the slug black becomes --wp--preset--color--black. That means the block editor works from the theme.json palette, while your component CSS works from palette.css. Two places can disagree. We remove the chance by generating one from the other. This short script reads the light block of the palette file and writes the theme.json palette.
# palette-to-themejson.py
import re, json, sys
css = open(sys.argv[1]).read()
light = css.split(":root.dark")[0]
palette = [{"slug": s, "name": s.replace("-", " ").title(), "color": v}
for s, v in re.findall(r"--color-([a-z-]+):\s*(#[0-9a-fA-F]{6})", light)]
print(json.dumps({"$schema": "https://schemas.wp.org/trunk/theme.json", "version": 3,
"settings": {"color": {"custom": False, "defaultPalette": False,
"palette": palette}}}, indent=2))
Run as python3 palette-to-themejson.py src/palette.css > theme.json, it produced valid JSON with seven palette entries from the file above. Dark values are not mirrored; the dark overrides live in CSS.
How does a component use it?
.price-card {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
padding-inline: var(--space-5);
padding-block: var(--space-4);
}
.price-card .button {
background: var(--color-accent);
color: var(--color-on-accent);
}
.price-card .button:hover { background: var(--color-accent-hover); }
Nothing in that block names a colour or a pixel. Change the accent in the palette file and every component follows, in light and dark mode, with no edit to the component. That property is also what makes the layer safe for an agent: the shortest way to write the component is to use the tokens.
Should dark mode use light-dark()?
It can. MDN describes light-dark() as taking two colours and returning one based on the active colour scheme, requires color-scheme: light dark on :root, and lists it as Baseline 2024, available across current browsers since May 2024. Our sites use a class on the root element, hence the .dark block above. Pick one approach and write it in the rules.
How do you write design rules an agent can follow?
Write each rule as a sentence that a script, or a person with a browser, can answer with yes or no. A rule that cannot fail cannot be enforced, and an agent cannot tell whether it has met it. This is the second layer, and it is where most written guidelines fall short: they describe a mood where they should state a test.
Our global instructions for coding agents contain rules in exactly this shape. The big-site checklist asks for “token-driven var(--*-space-*), margin-inline-* instead of margin-left/right“, and for dark mode: “token-driven colors only, never raw hex.” Another section says every new CSS layout block must include a mobile breakpoint, and that a UI change is not shipped without verifying it at a 390 pixel viewport. Each of those can be checked by looking at the code or the browser. None of them says “make it feel consistent”.
What does a good rule look like next to a bad one?
| Vague rule (cannot fail) | Checkable rule | How to check it |
|---|---|---|
| Use our brand colours | Never a raw hex value outside the palette file. | Search CSS and templates for hex values, exclude the palette file, expect zero hits. |
| Keep the colour palette tight | One accent colour per page, used identically in every section. | List the distinct accent values computed on the built page, expect one. |
| Support right-to-left languages | Logical properties for spacing: margin-inline and padding-block, not left and right. | Search for margin-left, margin-right, padding-left and padding-right outside an allowed list. |
| Make it responsive | No horizontal scroll at 390 pixels wide. | Compare scrollWidth with the viewport width in a browser at 390. |
| Keep type readable | Body text contrast at least 4.5 to 1 in light and dark mode. | Compute the ratio for each text and surface token pair. |
| Don’t add random fonts | Only the two families declared in the token file may appear in font-family. | Search for font-family declarations, compare with the token file. |
Where do written rules come from in practice?
We keep a design rules file that we give our agents for marketing pages. It is long, so we will not reproduce it, but its structure is worth copying. It starts with a brief-inference step: before any code, the agent states in one line what it is building, for whom, and in which visual language. It sets three numeric dials, for layout variance, motion and density, with a stated baseline, so “make it bold” becomes a number rather than an adjective. It contains a colour consistency lock: once an accent is chosen for a page it is used on the whole page, with a maximum of one accent colour. It names defaults to avoid, such as AI-purple gradients, and says when an override is acceptable. It ends with a pre-flight checklist where every box must pass.
The useful lesson is the last item. A rule file that ends in a checklist gives the agent a way to review its own output against the same sentences that guided it. A short addendum at the top of the file also does something subtle: it states that the correctness gate, one title, one language attribute, a single h1, alt text, visible focus rings and AA contrast, is not negotiable, and that no dial value buys an exemption. Aesthetic freedom is allowed only inside correctness.
How does theme.json act as the contract?
In a block theme, theme.json is the file an agent should read before it writes any markup or CSS, because WordPress itself treats it as the source for what the editor offers. The block editor handbook separates the two halves clearly: settings define what customisation options are available or hidden, and styles apply the actual CSS. The agent needs both halves, for different reasons.
Why do settings matter more than styles for an agent?
Styles tell you what exists. Settings tell you what is allowed. In the generated example above, settings.color.custom is set to false and defaultPalette to false. The theme.json reference describes those two switches as allowing users to select custom colours and to choose from the default palette. Switching them off means the editor offers only your palette, so the same constraint that guides a human editor guides generated content that goes through the editor: there is no off-palette colour to reach for. The same idea applies to spacingSizes and fontFamilies, which the reference describes as the presets behind the spacing and font family selectors. Define the presets, and the selectors offer only those.
How do style variations stop the agent inventing new looks?
WordPress documents style variations as JSON files in the theme’s /styles folder, where “any setting or style that you can add to theme.json can also be added to your style variation JSON file”. A variation is a named alternative, such as a second colour scheme, that a site owner can switch on. When someone selects one, WordPress stores the result as a user customisation.
This turns “add a dark blue version” into a bounded task. The agent adds a file in /styles that overrides tokens. It does not edit component CSS, and it does not add a one-off colour to a template. The handbook adds a second guard on block style variations: styles defined for an unregistered variation are ignored. So a rule that says “a new look is a registered variation or a new /styles file, never an inline style” is both checkable and supported by how WordPress behaves.
What should the contract say in words?
Put three sentences next to the file, in the repository instructions the agent reads first:
- “
theme.jsonis generated frompalette.css. Edit the palette, never the generated palette block.” - “Custom colours and the default palette are off. If you need a colour, it is a token first.”
- “A new visual option is a file in
/stylesor a registered block style. Nothing else may add a look.”
Our own themes follow the first pattern. One site’s palette file begins with a comment saying it is site-owned while the shared stylesheet is not, and that the values were copied from the live site’s exported variables. Brndle itself ships twelve named colour schemes, each selectable from one setting, and our site’s palette uses the first, Sapphire, as its single anchor. The structure is the point: one small file per site decides identity, and one shared file consumes it. For the broader story of building a design system inside theme.json, see How to Create a Design System in theme.json: From Tokens to Production.
Why is a gate more important than the prompt?
A prompt is a request. A gate is a check that runs whatever the prompt said. Agents are good at looking confident, and a page can read as finished while failing basic requirements, so the only reliable answer to “is this done” is a test run on the built page. This is layer four, and if you can only build one layer, build this one.
Our gate is a written page specification. The checks the spec turns into yes or no questions include:
- Exactly one
h1, and a heading outline that does not skip levels. - Every
imghas analtattribute, descriptive for meaningful images and empty for decorative ones. - Text contrast of at least 4.5 to 1, in both light and dark mode.
- A visible focus indicator on every control.
- No horizontal scroll at phone width, checked in a browser at 390, 768 and 1280 pixels.
Part of that runs as a script against the deployed URL. Our site-spec gate document says a required failure from the audit script means exit code 1 and no cutover. Items a script cannot see are walked by eye at the three widths, in light and dark. The same document is candid that “code passing lint is not done”: a page is done when it passes in the browser. The full checklist is in The Page Specification We Gate Every Page With.
What does a gate catch that a rule file misses?
Things a rule assumed. The audit table in our gate document is the best evidence, because it lists failures in our own starters. One example: a skip link was marked a gap on both starters, and the document notes that the shared stylesheet already styled a .skip-link class that no template actually output. The rule existed, the CSS existed, and the element did not. No amount of prompting finds that. A check on the built page does, and it did.
How do you run a small gate yourself?
Start with two checks that need only a shell. Accessibility checks like these are engineering checks, not legal advice; if you have a legal obligation, get qualified advice. The first is the raw hex test. This script lists hex values outside the palette file and exits 1 if it finds any:
#!/bin/sh
# rawhex-check.sh src/palette.css src
palette="$1"; dir="$2"
grep -rnE '#[0-9a-fA-F]{3,8}\b' --include='*.css' --include='*.astro' --include='*.html' "$dir" \
| grep -v "^$palette:" && { echo "FAIL: raw hex outside $palette"; exit 1; }
echo "PASS"
The second is contrast, calculated rather than eyeballed. With the standard relative luminance formula, the example palette gives: body text on surface 19.06 to 1, muted text on surface 7.41 to 1, white on the accent button 5.93 to 1, and the dark mode accent on the dark surface 11.27 to 1. All pass 4.5 to 1. Run the same arithmetic over every text and surface pair and fail the build on any miss.
What is the minimum starter kit?
Four files. None needs a framework, and each is short enough to read in a minute.
1. The palette and scale file
The palette.css above, plus type and spacing tokens. Copy our habit of stating the scale in a header comment:
/* tokens.css
Type: 12 / 14 / 16 / 18 / 20 / 24 / 30 / 36 / 48 (1.2x)
Spacing: 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 / 96
Every component value comes from these. No raw px/rem in components. */
:root {
--space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; --space-4: 1rem;
--space-5: 1.5rem; --space-6: 2rem; --space-7: 3rem; --space-8: 4rem;
--text-sm: 0.875rem; --text-base: 1rem; --text-lg: 1.25rem; --text-xl: 1.5rem;
}
2. The rules file
One page of checkable sentences, each with a reason, in the file your agent reads at the start of a session (CLAUDE.md, AGENTS.md or the equivalent in your tool). A starting set:
## Design rules (each one is checked by gate.sh)
1. Colours: never a raw hex value outside src/palette.css. Reason: one place to change.
2. One accent colour per page, used identically in every section.
3. Spacing: values come from --space-*. Logical properties only (margin-inline,
padding-block), never margin-left or margin-right.
4. Fonts: only the two families in tokens.css.
5. theme.json is generated from palette.css. Never edit its palette by hand.
6. A new look is a file in /styles or a registered block style.
3. The generator
The palette-to-themejson.py script above.
4. The gate
A gate.sh that runs the hex check, a search for physical direction properties, the contrast arithmetic, and a browser step for the page specification. Start with the first two and add one check each time a bug slips through. The gate becomes a list of past mistakes that cannot recur.
Where does this still go wrong?
It does, and we would rather show you our own failures than describe a system that works perfectly. We ran the hex check against two of our own repositories while writing this post.
Our own files break our own rules
Run against the site source that hosts this blog, the raw hex check reported hits in several categories. Some are legitimate exceptions that the rule needs to name: the theme-color meta tags and an inline SVG logo fill cannot use a CSS variable, a list of colour swatches in a page is data, and HTML entities such as ' look like hex to a crude search. But two are real violations: dark-mode code block backgrounds and text colours written as raw hex in page-level styles. We had a rule, and our own pages had not been brought in line with it.
A shared stylesheet in one of our other sites showed the same pattern at larger scale: more than sixty hex values still appear below its default token block, and eleven physical margin and padding declarations sit beside three logical ones. We have not triaged them. Some may be fallbacks and some may be real drift. The honest summary is that a rule written after the code exists is a to-do list, not a guarantee. The fix is to put the check in the gate and let the gate list the work.
Rules conflict
Our design rules file discourages the Inter typeface as a default and says to pick something else unless the brief asks for a neutral, standard feel. Our own token file declares Inter. Both are defensible, because the override condition exists, but an agent reading both has to decide. Without a precedence line it will choose arbitrarily, and different sessions will choose differently. Whenever you add a rule, search for the rules it could contradict, and write which one wins.
Tokens nobody maintains
A token layer decays when a new colour is cheaper to paste than to add. Watch for tokens named after a one-off use and for two tokens with nearly identical values. Review the token file on a schedule, and remove a token’s references in the same change that removes the token.
Judgement a rule cannot capture
Whether a hero feels balanced, whether two sections have too similar a rhythm, whether a layout suits this audience: a rule cannot answer these, and pretending it can produces checklist-shaped pages that pass every gate and still look generic. Keep a person in the loop for that half. This is the same point we make in the training ladder post: the mechanical work moves to the agent and the gate, and the judgement stays with someone who has seen enough pages to tell.
Questions people ask
Do agents do better with plain CSS or with a utility framework like Tailwind?
We have not run a controlled comparison, so we will not claim a winner. What we can say is that the choice matters less than whether the values are named in one place. Both approaches work when the tokens are the source: our site source uses a utility framework with its theme variables defined from the token files. The failure we see comes from missing tokens, not from the syntax.
Is theme.json enough on its own?
No. It controls what the editor offers and what block styles apply, but it does not constrain the CSS in your templates, patterns or custom blocks, and it cannot check a built page. Treat it as the contract for the editor and pair it with the rules and the gate.
Should the tokens live in theme.json or in a CSS file?
Pick one as the source and generate the other. We keep the source in the palette file because the same values are needed outside WordPress, on Astro sites for example, and CSS custom properties work in every runtime. If your theme is WordPress only, theme.json as the source is a reasonable choice. What fails is two hand-edited copies.
Do you need the Design Tokens Community Group format?
You do not need it to follow this guide. The group has a published 2025.10 version, and the newer draft we read is a preview that says not to implement it. Either way it is useful as a model of what a token file contains: names, values, types and references between tokens. If you plan to feed tokens to design tools later, keep your names role-based so a future conversion is mechanical.
How many rules should the file contain?
As few as you will check. Seven rules with a script behind each are worth more than forty that depend on goodwill. Add a rule, with its check, when something slips through.
If you want to see this applied rather than described, the Brndle WordPress theme is the reference implementation of the same ideas: a small palette that decides identity, twelve selectable colour schemes, and a published readability and accessibility benchmark. Start from the theme page at brndle.com, read the design system section, and open the source on GitHub to see how the palette, scale and rules fit together.