Skip to content

AI & Engineering / Guide

Design Tokens Explained: A Design System an LLM Can't Break

By ·

Design tokens are names for design decisions: --ui-accent-brand instead of #b63a2f. They let one set of components wear many looks. They also answer a newer question: how do you let a language model restyle an application without letting it write CSS? This guide explains tokens with the system behind the four Semitexa sites, including the part where the model is only allowed to choose.

The short answer

A design token is a named value that components use instead of the value itself. A button asks for "the brand accent", not for a hex code. Change the value behind the name and every button changes, with no component edited.

A design system becomes hard to break when the list of names is a contract. Components may only use names from the list, every skin must fill every name, and the stylesheet is generated from one source file instead of being edited by hand.

That contract is also what makes AI theming safe. When the model can only pick a seed colour, an algorithm and a few settings from a fixed list, deterministic code turns the choice into tokens and a contrast check decides whether the result ships. The model never touches a stylesheet.

What a design token is

Without tokens, a design lives in the components. A card has background: #fffaf2, a link has color: #b63a2f, and a dark mode means a second copy of every rule. Rebranding is a search for hex codes, and a search always misses one.

With tokens, the component states its intent and the skin states the value:

.card   { background: var(--ui-surface-raised); border-color: var(--ui-border-subtle); }
.button { background: var(--ui-accent-brand);   color: var(--ui-text-on-accent); }

The names are semantic: they say what the colour is for (a raised surface, text on the accent), not what it looks like. "Blue-500" is a palette entry; "accent-brand" is a token. The difference matters the day the brand stops being blue. In the browser, tokens are usually CSS custom properties, which is what Semitexa uses.

The contract: 45 names

Most design systems have tokens. Fewer have a closed list. In Semitexa the list is a PHP enum, TokenContract, with 45 cases, and nothing outside it is a token:

GroupTokens
Colourfour surfaces (page, panel, raised, sunken), two borders, primary and muted text, the brand accent and the text on it
Statesuccess, warning, danger, info, and the text on each of them; the focus ring
Chartan eight-step categorical palette
Form and deptha radius scale and a shadow scale
Motion and effectdurations, easings, and the blur and saturation that glass surfaces use

The list works in both directions. Components promise to use only these names, so a skin knows exactly what it has to provide. A skin must fill all 45 for both light and dark mode, so a component can rely on every name existing. A skin that leaves one out does not load. Components still write var(--ui-radius-md, 0.5rem) with a fallback for the names added in later versions, so an older skin keeps rendering.

One source of truth

Each skin is a folder with two files. skin.json holds the values for both modes, the algorithm and settings that produced them (or "manual" when a person chose them) and a history of every change. tokens.css is what the browser loads. Only the first one is written by anyone:

  • bin/semitexa skins:rebuild <skin> emits tokens.css from skin.json. Its header says it is generated and must not be edited by hand.
  • bin/semitexa lint:skin-tokens enforces it. For every skin it checks that skin.json fills the whole contract, that tokens.css matches a fresh rebuild byte for byte, and that its sections come in the canonical order. A hand edit to the CSS fails the lint, so the edit has to go into skin.json, where the history records it.

That rule sounds pedantic until you have two sources. A colour fixed in the CSS and not in the source comes back on the next rebuild, and nobody remembers why it was changed.

Dark mode without a second stylesheet

Every colour token holds both modes in one declaration, with the CSS light-dark() function:

:root {
    color-scheme: light dark;
    --ui-surface-page:  light-dark(#f7eee4, #0f171e);
    --ui-accent-brand:  light-dark(#b63a2f, #d96858);
    /* ... */
}

:root[data-skin-mode="light"] { color-scheme: light; }
:root[data-skin-mode="dark"]  { color-scheme: dark; }

light-dark() returns its first value when the element's color-scheme is light and its second when it is dark. With color-scheme: light dark the page follows the visitor's system setting before any script runs. The theme toggle does one thing: it sets data-skin-mode on the root element, the last two rules pin color-scheme, and every token flips at once. There is no .dark copy of any rule, no media query per component, and nothing for a component author to forget.

One lesson from our own toggle: let exactly one script own the click. We once had two handlers bound to the same button; the first flipped light to dark, the second flipped it straight back, and the button looked dead with no error in the console.

Inside Semitexa: from request to tokens

The pieces above are not a convention the team agrees to follow. Each one is a class or a command in two packages: semitexa/theme, which every project gets and which owns the contract, the build and the per-request choice, and semitexa/platform-ui, which adds the generator and the model. Both are open source under MIT.

PartWhere it livesWhat it does
ContractTokenContract (enum)The 45 names. Everything else is checked against it.
Source fileSkinManifestReads and writes skin.json at schema 3.0; any other version throws instead of loading half a skin.
Both modesDualSkinPaletteRefuses to exist unless light and dark have identical keys and together cover the contract.
StylesheetTokenEmitter, SkinBuilderTurn the palette into tokens.css: fixed sections, light-dark() pairs, the two pinning rules.
AlgorithmsSkinAlgorithmInterface, SkinAlgorithmRegistryid(), description(), knobSchema() and generate(). The registry is what the CLI, the prompt and the validator all read, so a new algorithm appears in all three at once.
Colour mathsOklch\Converter, ContrastScore, OnColorOKLCH conversion, WCAG and APCA scores, and the choice of text colour on a filled background.
Readability floorEnforcesContrast (trait)Walks a colour's lightness in small steps until it clears its contrast floor, shared verbatim by every algorithm.
AuditSkinContrastAudit, skins:contrastThe table of pairs and the sixteen that gate.
Model sidePromptResolver, OutputValidator, RetryPolicy, RefinementResolverPrompt to JSON, JSON to checked parameters, three attempts, and the same for refinement.
Guardlint:skin-tokensFails when a tokens.css is not exactly what its skin.json produces.

Which skin a request gets

A skin belongs to a theme, and a theme is chosen per request. Each theme has a small manifest; this is the one behind the site you are reading:

{
    "id": "semitexa-site",
    "extends": "theme-base",
    "active_when": { "domain": "semitexa.com" },
    "skin": { "default": "semitexa-site" }
}

ManifestThemeResolver matches every manifest against the request in two phases: first the theme, then the skin inside it. Both phases use the same specificity order, from most to least specific: tenant with domain and locale, tenant with domain, tenant with locale, tenant, domain with locale, domain, locale, and finally the fallback. A tenant that wants its own colours gets a manifest with its id in active_when, and nothing else in the application changes. The extends chain doubles as the template fallback chain, so a theme only contains what it overrides. bin/semitexa theme:resolve --tenant=… --domain=… --locale=… prints which theme and skin a given context would get.

The rest happens at runtime. At worker start a listener registers src/skins/ and the framework's default skins under one URL prefix, /assets/skins/<skin>/tokens.css, with the project's folder taking priority. During each request the theme assignment is stored for the template and asset lookups that follow. In the <head>, before any CSS is applied, a few lines of script read the saved choice (or the system setting) and set data-skin-mode, so a visitor who picked dark mode never sees a light frame first.

The generator as an agent tool

skins:generate is an ordinary console command with one extra attribute, #[AsAiSkill], which describes it to Semitexa's own AI layer: when to use it ("a new theme, skin, palette, or colour scheme"), when not to (changing a single token value), a low risk level, confirmation required before anything is written, dry-run support, and an allow-list of the arguments a model may fill. So the same command a developer types can be offered to an agent, with the same validator, the same three attempts and the same preview standing between the request and the files.

The model picks, it does not write

A skin can be written by hand, generated from one colour, or generated from a sentence:

bin/semitexa skins:generate balanced "#2f6fed"
bin/semitexa skins:generate --prompt "calm, editorial, a little warm"

The first form is deterministic: an algorithm takes the seed colour and computes all 45 tokens for both modes in OKLCH, a colour space where equal steps in lightness look equal. There are three algorithms (balanced, glass, brutalist), and each has three settings with a fixed set of values, such as radius_scale: compact, default or rounded.

The second form adds a language model, and this is where the contract earns its keep. The model does not return CSS. It returns a small JSON object: a seed colour, an algorithm, values for that algorithm's settings, a mood and a sentence of rationale. Then deterministic code takes over:

  • A validator checks every field. The seed must be a six-digit hex colour, with OKLCH lightness between 0.2 and 0.7, chroma no higher than 0.3, and enough contrast against white. The algorithm must exist. Every setting must belong to that algorithm and take one of its allowed values.
  • A failed check goes back to the model as an instruction, for example: "Your seed's contrast against white is 3.1:1, but WCAG AA requires 4.5:1. Darken the seed significantly and retry." The model gets three attempts.
  • The algorithm writes the tokens. The model's whole influence is the choice it made inside the fixed list. Whatever it writes in its rationale, it cannot invent a token, a setting or a value.
  • Nothing is written by default. Without --write the command shows a preview. With it, skin.json records the algorithm, the seed, the settings, and the prompt and mood behind them, and tokens.css is rebuilt from that.

Refining works the same way. skins:refine accepts "softer corners, less shadow" and lets the model move only the settings of the skin's current algorithm; the seed and the algorithm stay fixed. A request the settings cannot express ("make it look like 1998") returns no change and a rationale saying so, rather than a guess.

This is harness engineering applied to design. The guide is the closed list the model chooses from; the sensors are the validator, the contrast check and the lint. The same rule as in an instruction file applies: whatever can be a check should be a check, not a sentence in the prompt.

Contrast is a check, not a guideline

All three algorithms share one piece of code, a trait called EnforcesContrast: if a colour they chose is not readable on its background, they adjust its lightness until it is. Its doc comment says why it is shared rather than left to each algorithm: a skin that renders unreadable text is broken no matter how good it looks.

Then bin/semitexa skins:contrast audits the result. For every skin and both modes it prints the WCAG 2 ratio and the APCA value of each foreground and background pair, and sixteen of those pairs gate: body and muted text on all four surfaces, the accent as link text, the text on the accent and on each state colour (all at 4.5:1), and the focus ring and the strong border against the page (3:1). One gating pair under AA fails the command, which means it can fail a build.

What it caught on our own sites

The four Semitexa sites (this one, the framework demo, the platform site and the OS site) share one set of markup and differ only by skin. Their skins were chosen by hand, not generated, and moved into the contract in April. When we ran the contrast audit against them in October, two of them failed:

  • On the OS site in light mode the coral accent, which is also the link colour, reached 3.20:1 against the page instead of 4.5:1. It moved from #c96d49 to #ad532f.
  • On this site in dark mode the accent #d96858 carried white text below 4.5:1. The accent stayed; the text on it became near-black.

Both fixes changed OKLCH lightness only, so the hues kept their character. Both went into skin.json, with a contrast-repair entry in the history, and tokens.css was rebuilt from it. No component changed. That is the practical value of the contract: an accessibility fix across a site is a two-line change in one file, and the history says why it happened.

Skin--ui-accent-brand lightdarkCharacter
semitexa-site#b63a2f#d96858terracotta on warm cream
semitexa-demo#b63a2f#ef8c68terracotta on warm cream
semitexa-platform-site#2f5e52#8cc3a8moss on cream paper
semitexa-os-site#ad532f#f0a47dcoral on cream paper

What this does not do

Tokens fix values, not layout. A contract of colours, radii and shadows cannot stop a component from being badly spaced or from hiding the primary action. Contrast ratios are a floor, not taste: a skin can pass every gate and still look wrong for the brand. And the generator is new. Our own sites were styled by hand and then brought under the contract; the value we have measured so far is in the contract, the lint and the audit, more than in generated palettes.

Common questions

What are design tokens?

Named values for design decisions, such as colours, spacing, radii, shadows and motion, that components reference instead of hard-coded values. On the web they are usually CSS custom properties; design tools and native apps get them from the same source through generated files.

What is the difference between a design token and a CSS variable?

A CSS variable is a mechanism; a token is a decision with a name and a role in the system. --blue-500 is a variable. --ui-accent-brand is a token, because components use it for a purpose and a skin is required to provide it.

Do I need design tokens if I use Tailwind?

A Tailwind theme is a token set. Since Tailwind v4 the theme is defined as CSS variables, so the same ideas apply: name colours by role rather than by hue if you expect more than one look, and generate the values from one source.

How do I build dark mode with design tokens?

Give each colour token both values. With light-dark() and color-scheme: light dark the page follows the system setting with no script, and a toggle only needs to pin color-scheme on the root element.

Can AI generate a design system?

It can choose one. Letting a model write stylesheets gives you output you have to review line by line. Letting it choose inputs from a closed list, and having code produce and check the tokens, gives you a result that is valid by construction and fails loudly when it is not readable.

← Back to the Semitexa Blog

The same idea for coding agents rather than colours: Harness Engineering for PHP Developers

Have a product idea?One free MVP every month