Contributing tokens
Adding or changing a token, and the migration obligation it creates. That obligation exists because a CSS custom property is a public API covered by semver.
The short version
A token is a public API. Somebody has written
var(--opsin-status-urgent-surface) in a stylesheet you will never see, and
renaming it breaks their build with no error message. CSS custom properties
fail by resolving to nothing.
So: adding a token is cheap, changing one is a minor version, and removing one is a major version with a deprecation period. Versioning policy states this formally, and it is one of the few design systems that commits to it.
Tokens are authored in apps/www/tokens/*.json and compiled by
build-tokens.mts into a committed CSS layer. You edit the JSON; you never edit
the generated CSS.
How it works
The three tiers
- Primitive is a raw ramp step.
--opsin-red-60. Components never reference these. - Semantic is a role.
--opsin-status-urgent-surface. This is the tier components use and the tier a theme redefines. - Component is a part-specific hook.
--opsin-range-bar-track-height.
A new token almost always belongs at tier 2 or 3. Adding a primitive means adding a ramp, which is a colour-engine change rather than a token change. See Token architecture.
The two axes are closed
The status axis has exactly four levels and the category axis has a fixed membership. Adding a status level is not a token change; it is a change to Clinical status semantics and needs a clinical reviewer. Adding a category is a supported extension and has its own page at Category palettes.
What happens when you change one
- Add: a minor version. Document it on the relevant Foundations page, and the generated token tables pick it up automatically.
- Change a value: a minor version if it stays within the contrast floor, and
a change that must be regenerated and committed.
check-contrast.mtswill fail CI otherwise. If it moves a pair below the floor, it is not a token change, it is a bug. - Rename: a major version. Ship the old name as an alias resolving to the new one for at least one minor cycle, list it in Deprecations, and emit a development-mode warning with an error code.
- Remove: a major version, after a deprecation period during which the token still resolves.
Do this
- Edit the JSON, run
pnpm run generate, commit both. CI regenerates and fails on any diff, so an uncommitted regeneration is caught immediately. - Name from general to specific: axis, member, role. See Naming conventions.
- Define both themes. A token defined only in light is a bug that ships.
- Run
pnpm run contrastand read the output, not just its exit code. A pair that passes at Lc 62 today is one design tweak away from failing. - Say what the token controls, in the JSON. The generated token table has a third column headed "used by". That column is what turns a list into a decision aid, and it comes from what you write.
Not this
- Do not add a token for one component's one-off need. That is a
className. A token is for a value that more than one place should agree on. - Do not add a fifth status level. Not "unknown", not "stale", not "possibly". Staleness withdraws the status axis rather than extending it; see Offline and stale data.
- Do not create a category token that varies with a value. That is the status axis wearing a category name, and it is the failure the whole system is built to prevent.
- Do not hand-edit
app/tokens.generated.css. It is overwritten. - Do not change a token to fix one screen. Fix the screen; a token change moves every screen.
Gotchas
- A missing token resolves to nothing, silently. No console error, no fallback, just an unstyled property. This is the whole reason renames are a major version.
- Tailwind derives utility class names from token names, so renaming a token also renames a class somebody may have used.
build-tokens.mtsruns on Node 24 and uses native.mtstype stripping; on an older Node it fails with a syntax error that does not mention the version. Each script carries a version guard for that reason.- The generated CSS has a fixed position in
globals.css. It sits between the lyra blocks and@layer base. Moving the@importchanges which declarations win. - The published contrast numbers describe the shipped presets only. A consumer's custom theme is their responsibility; see Validating your theme.
Related
- Token architecture covers the three tiers and which one you may touch.
- Adding your own tokens covers extending the system as a consumer rather than a contributor.
- Category palettes covers adding a category without contaminating the status axis.
- Versioning policy says what semver covers.
Documentation templates
Twelve page kinds, each with a fixed set of headings the build enforces. The outlines are published verbatim, so a contributor fills in a template.
Code style
The conventions a reviewer will hold you to. The named-props-interface rule, what the formatter owns, and the handful of choices that are not negotiable.