Skip to content

Design tokens

Every value a theme draws with — a colour, a rounding, a spacing unit, a type size, a font, a shadow, a duration — is a design token: a CSS custom property with a name the views use and a value the theme chooses. This chapter is about where those values live, how they become a stylesheet, and how you prove that a change to them moved nothing it was not meant to. Theming is the wider picture; SPECS.md §20 is the terse reference.

Piece Where
A theme’s tokens themes/<name>/tokens.json, in the W3C Design Tokens Format 2025.10
The sheet Tailwind builds from themes/<name>/css/tokens.css for a theme with a build, themes/<name>/public/tokens.css for one without — generated, committed, never edited — and tokens.preview.css beside it, the same tokens on a block preview’s wrapper alone
The model app-modules/theme/src/Tokens: TokenType, TokenTier, Values/* (StepsValue and Equality among them), Token, TokenFile, TokenSet, TokenDocument, Bounds, Namespaces, ThemeTokens, GeneratedSheet
The contract Hilms\Theme\Contract\FoundationTokens: the 62 foundation tokens a theme with no parent answers for, and ComponentTokens: the 95 component tokens, each with its type and meaning; TokenContract::problems() is the verdict on both
The commands hilms:theme:tokens {theme?} {--check}, and the token lines of hilms:theme:check and hilms:theme:a11y
The pictures bin/visual.mjs (npm run visual) over hilms:design:export --split --at=…
Tests TokenModelTest, TokenDocumentTest, ThemeTokensTest, ThemeTokensCommandTest, TokenSheetTest, TokenContrastTest, MakeThemeCommandTest in app-modules/theme/tests/Feature

Tailwind 4 compiles every utility to a variable — bg-accent is background-color: var(--color-accent), p-4 is calc(var(--spacing) * 4) — so a site can already be restyled without a build by anything that redefines those variables. What was missing was a place where the values are data: typed, validated, readable by a program and by a design tool — and an administrator’s form. That form is Styles, which lays values over these without touching the file. The Design Tokens Format is the standard for exactly that, shared by Figma through Tokens Studio, Penpot, Style Dictionary and Claude Design, so a design can arrive as a file rather than as a list of numbers to copy by hand.

tokens.css stays, because Tailwind reads its @theme block to know which utilities exist. It is simply no longer where anyone writes.

{
"color": {
"$type": "color",
"$description": "Colour, light and dark.",
"surface": {
"$value": {"colorSpace": "oklch", "components": [0.98, 0.004, 85]},
"$extensions": {
"hilms": {"dark": {"colorSpace": "oklch", "components": [0.24, 0.006, 75]}}
}
}
},
"focus-ring": {
"$type": "dimension",
"width": {
"$value": {"value": 3, "unit": "px"},
"$extensions": {"hilms": {"min": {"value": 2, "unit": "px"}}}
}
},
"spacing": {
"$type": "dimension",
"$root": {"$value": {"value": 0.25, "unit": "rem"}},
"block": {"$value": {"value": 1.5, "unit": "rem"}}
},
"text": {
"$type": "dimension",
"xs": {"$value": {"value": 0.75, "unit": "rem"}},
"xs--line-height": {"$type": "number", "$value": 1.5}
},
"font": {
"$type": "fontFamily",
"sans": {"$value": ["Instrument Sans", "ui-sans-serif", "system-ui", "sans-serif"]},
"display": {"$value": "{font.sans}"}
}
}
  • Groups nest, and a group’s $type passes down to everything inside it; a token may state its own.
  • A token is whatever has a $value. The custom property is -- and the token’s path joined with dashes: color.surface is --color-surface, text.xs--line-height is --text-xs--line-height. A group’s $root token takes the group’s own name, so spacing.$root is --spacing.
  • Names are lowercase letters, digits and dashes; $root exists only inside a group.
  • An alias is {group.token}. It stays var(--…) all the way to the browser, so --font-display follows whatever later changes --font-sans.
  • Ours sits under $extensions.hilms: dark (a colour’s value in the dark scheme — only a colour has one), editable (whether a style may change the token; true unless said otherwise — the Styles editor offers only editable tokens and the compiler leaves any other out), min / max, which a value must stay within, steps (a length counted in steps of the spacing unit, below) and tier — foundation or component, the one thing a group may say there, passed down to everything it holds. Any other tool’s $extensions are left alone.
  • A length in steps keeps under $value the length it comes to where the file was written, which is what any other tool reads, and says its count under steps: {"$value": {"value": 1, "unit": "rem"}, "$extensions": {"hilms": {"steps": 4}}}. HiLMS reads the count (Values\StepsValue, a whole or a half step from 0 to 64, never on an alias) and writes calc(var(--spacing) * 4), so the length follows the density of whatever style it is drawn in. Values\Equality::same() says two counts are the same whatever unit each was written against, which is how the import and the generators compare them.

The value shapes are the format’s own: a colour is {colorSpace, components, alpha?} in oklch (lightness 0–1, chroma, hue) or srgb; a dimension is {value, unit} in px or rem, and in em too, which is ours, for what belongs to the text around it, such as the underline offset; a duration is {value, unit} in ms or s; a font family is a name or a list of names; a font weight is 1–1000 or one of the format’s names; a shadow is one layer {color, offsetX, offsetY, blur, spread, inset?} or a list of them.

TokenFile::read($path) reads one file and checks it token by token. Each value goes through the value object of its type, which takes it apart and writes it again from its parts: a font name is letters, digits, spaces, dashes and underscores, a colour is three numbers in range, so no stylesheet built from a token file can hold a quote, a brace or a url() that nobody meant. Keep that property when you add a type: it is what makes it safe to let an administrator type a value into a form. The same value objects read typed text too — TokenType::fromCss('0.75rem'), ColorValue::fromCss('#1f6feb') (oklch with an alpha, #hex of 3, 4, 6 or 8 digits, rgb()), and ->asOklch(), which the Styles editor keeps every saved colour in; a font stack and a shadow have no text reader, because the editor offers them as a choice and as layers.

A file is refused with InvalidTokenFile, which names the file and the token’s path, for: an unknown $type or unit, a value its type cannot read, a property the format does not have, a key under $extensions.hilms nobody reads, a dark value on anything but a colour, a default outside its min or max (compared in pixels for px and rem, and never across kinds — an em is not a pixel), and a token in one of Tailwind’s namespaces that holds another type than Tailwind reads it as. That table is Tokens\Namespaces: --color-* is a colour; --radius-*, --spacing*, --container-*, --breakpoint-*, --tracking-* and --text-<size> are dimensions; --text-*--line-height and --leading-* are numbers; --font-weight-* are weights and every other --font-* a family; --shadow-* a shadow. A --color-gap that is a length would give Tailwind utilities no browser can draw.

TokenSet::of($tokens) lays a list of tokens over each other — a later token takes an earlier one’s place, where the earlier one stood — and checks every alias against the whole set: an alias that names no token, goes in circles or reaches a token of another type is refused. It answers:

$tokens = theme()->tokens(); // the active chain, parent first
$tokens->find('--color-accent')?->type; // TokenType::Color
$tokens->literal('--font-display', Scheme::Dark); // "'Instrument Sans', ui-sans-serif, …" — aliases followed
$tokens->declarations(Scheme::Light); // ['--font-display' => 'var(--font-sans)', …] — as a sheet writes them
$tokens->resolved('--font-display', Scheme::Light); // the value object behind literal()

TokenDocument::write($tokens) writes tokens back as a document in the format, every token stating its own $type and every component token its tier, so a document cut down to a few tokens still says what each is; TokenFile::parse() reads it again unchanged, and it is what a style stores and an export hands over. Bounds::breach($value, $min, $max) is the sentence for a value beyond its bounds, or null: a theme’s file is refused with it, a style is advised with it.

ThemeTokens::for($theme) reads a theme’s chain parent first, so a child’s file needs only what it changes; own($theme) is one theme’s file alone. Nothing reads the model while a page is served — the stylesheet already holds the values, and a style is compiled when it is saved — so nothing is cached.

bin/artisan hilms:theme:tokens every theme that keeps a tokens.json
bin/artisan hilms:theme:tokens acme one theme
bin/artisan hilms:theme:tokens --check write nothing; exit 1 on a missing or stale sheet

Tokens\GeneratedSheet writes it, under a header saying where it came from:

  • A theme with a build gets css/tokens.css with its tokens in a plain @theme static block. Plain — never @theme inline — so every utility emits var(--…); static, so a token no utility happens to use is still emitted for whatever is laid over the theme later. A theme with no parent also gets the color-scheme rules, the dark set written out twice — once under [data-theme='dark'] for the visitor who chose it, once under @media (prefers-color-scheme: dark) { [data-theme='system'] } for the one who left the choice to their system, because neither covers the other — and @custom-variant dark, for the rare place that needs a variant rather than a token.
  • A theme without a build has no Tailwind to read that block, so it gets public/tokens.css with :root instead, and its manifest serves it first. Unlayered, :root beats the parent’s values, which Tailwind put in a layer.
  • A child writes only what its own file declares.
  • An alias that ends at a colour with a dark value is written again in both dark blocks, though it says the same thing there. A custom property is resolved where it is declared, so a declaration at the root alone would hand a scope that is dark inside a light page — a block preview in a dark panel, the dark half of /design/tokens — the light colour.
  • Beside it, tokens.preview.css (GeneratedSheet::renderPreview()) declares the same tokens on .hilms-block-preview and the dark ones on .hilms-block-preview:where([data-theme='dark']), both at (0,1,0), with no system block, because a preview is light or dark. It exists for the panel. A preview build imports tokens.css as a reference — @import './tokens.css' reference;, which names every token to Tailwind without writing it out, so its utilities read var(--color-accent, <value>) — and imports the scoped sheet as itself. The panel’s :root therefore keeps Filament’s own --spacing, --text-*, --radius-* and --font-*, which share Tailwind’s names, and the theme’s values reach the previews and nothing else. A theme without a build hands the panel its scoped sheet instead of its :root one (ThemeAssets::preview()).

Both sheets are committed, because the image’s Node stage has no PHP to generate them with, and a test runs --check — which holds both to the token file — over every theme in themes/. hilms:theme:check and the ops ThemeCheck report a token file that cannot be read and a sheet that is out of date through the same verdict, GeneratedSheet::problems().

What is not a token does not live in the sheet, and keeps its place in the cascade: the typography plugin’s --tw-prose-* bindings are css/prose.css, imported right after tokens.css by app.css and preview.css; the zeroing of the two motion tokens under prefers-reduced-motion is in a11y.css, beside the prefers-contrast: more promotions.

  1. Edit themes/hilms/tokens.json.
  2. bin/artisan hilms:theme:tokens, then npm run build.
  3. If the token is a colour, bin/artisan hilms:theme:a11y and TokenContrastTest say whether every pair it meets still holds its ratio.
  4. Look at it: /design/tokens lists every token with its value in each scheme, and the catalogue draws every view with it.
  1. Add it to the file, in the group it belongs to and with the type its namespace asks for.
  2. If every theme with no parent must have it, add it to Contract\FoundationTokens with its type and a one-line meaning; ThemeTokensTest holds the default theme to the contract. A component token — one part’s decision, following a foundation — has its own recipe in chapter 26.
  3. If it is a colour drawn on top of another, add the pair to Contract\ContrastPairs, or nothing checks it.
  4. Generate, build, and use it — bg-(--my-token), or the utility Tailwind makes from its namespace.

hilms:make-theme writes the file for you. A child’s declares nothing and inherits every token of its parent, and its generated sheet is empty until it changes something. A theme with no parent answers for both contracts — the foundations and the component tokens — and hilms:theme:check names any token it lacks; it starts from a copy of the default theme’s tokens.json, its own description in place of the original’s, so a page drawn from it is a whole page from the first render — the accessibility floors and the layout tokens included, without which min(var(--sidebar-width), 30vw) is an invalid declaration and the column beside the page does not exist. A standalone build compiles its parent’s sheet and its own over it; one with no parent compiles its own and a prose.css of its own.

A theme may add tokens of its own. A child with a build gets them in its own @theme static, so Tailwind makes utilities of them; a child without one gets them in :root, where p-(--brand-stripe) reads them. A child also states what it changes: a colour whose dark value its parent changes needs a dark value of its own, and a token keeps its parent’s kind, or ThemeTokens refuses the file (chapter 26).

A change to the tokens, the sheet or the stylesheets around them that promises no visual difference is proven with pictures, not by eye:

bin/artisan hilms:design:export --split --at="2026-01-01 10:00:00"
node bin/visual.mjs --baseline before the change
…the change, npm run build, the same export…
npm run visual after: "Identical." or a list

bin/visual.mjs draws every document of the split export at 390, 768 and 1280 px, full page, with animations off, and compares each picture with the baseline byte for byte. When the bytes differ it counts the differing pixels in the browser’s own canvas — no image library — and writes actual/ and diff/ beside storage/app/design/visual/baseline/. --at pins the clock for the export, because the samples date themselves from now() and a date that moves every day would be a difference nobody made. Nothing reaches the network: documents, the build’s fonts and the samples’ files are answered from disk and every other request is refused. The browser’s own media controls are switched off, because they spin a loading wheel and fade their buttons in whenever they like; everything of ours around a player stays in the picture.

It is sensitive: moving the accent’s lightness by one point changes a third of the pictures. A document the baseline does not have — a new view, or a new value of an option or a variant, which the split export draws as a document of its own — is listed as having no baseline, and the run then exits with a list rather than Identical.: it is a pass when nothing else is on it. Like npm run a11y, it runs on the host and never in CI.

  • Order is part of the meaning. The dark blocks come after the @theme layer at the same specificity as the light one, so anything moved between files keeps that order. a11y.css’s prefers-contrast promotions and reduced-motion zeroing do not depend on it: they are !important, so they win over the dark blocks and over any style laid on top (Styles); npm run cascade proves it.
  • Filament shares Tailwind’s names. The panel’s own --spacing, --text-*, --radius-* and --font-weight-* are the same variables, so nothing of a theme may be declared on the panel’s :root: a preview build imports tokens.css as a reference and its values from tokens.preview.css, scoped to the wrapper, as a style’s sheet is with .hilms-block-preview[data-theme]. The cascade check holds the panel’s root to a spacing of its own outside a preview.
  • Floats lie. 0.935 * 100 is 93.50000000000001; Values\CssNumber::format() writes the shortest exact form, which is what keeps a generated sheet byte-for-byte stable.
  • Breakpoints are build-time. CSS variables do not work inside media queries, so no token can move a breakpoint or a container-query size at runtime.

HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.