Alpha — breaking changes ship without a deprecation cycle. What is built vs. planned
Derivation engine
Derivation fills every semantic slot you didn’t author. Concretely, on the Acme example: 42 authored DTCG tokens produce 281 resolved slots per mode, which the shadcn exporter alone turns into 131 CSS custom properties. The other 239 slots are the engine’s work.
It’s built on three promises, because a tool that silently invents brand values deserves rejection:
- Deterministic. Pure functions over OKLCH color math. Same inputs, same outputs, forever, on every machine. No ML, nothing environment-dependent.
- Explainable. Every derived value records the rule that made it and the inputs it used, visible in
report.jsonand in the generated CSS comments (· derived). - Governable. Authored values always win.
derivation.requirelets you forbid derivation for specific tokens, or for a whole completeness level. Rule packs are version-pinned in config.
Rules only fill holes#
Derivation behaves like water finding its level: it fills what you left empty and flows around everything you placed. The engine walks the semantic catalog; any slot with an authored or aliased value is untouched. Overriding a derived value is authoring that token. It’s one line, visible in your token files and versioned with your design system.
Worked example: one value, traced#
transtyle explain prints the actual rule and the actual inputs, recursively, for any slot:
$ transtyle explain semantic.color.primary.tint --cwd examples/acme
semantic.color.primary.tint = oklch(0.95 0.017 255) [#e7effa]
└─ derived by rule mix-toward-surface(0.92)@standard@1
inputs: semantic.color.primary.solid = oklch(0.55 0.18 255) [#026fd7]
└─ aliased → option.color.blue.600
inputs: semantic.color.elevation.1.surface = oklch(0.985 0.003 255) [#f9fafc]
└─ aliased → option.color.gray.50
Read bottom-up, that’s the whole story of a value nobody wrote. Acme authored a blue and a near-white gray, aliased them to primary.solid and elevation.1.surface, and the tint is those two mixed 92% toward the surface. Change the blue and this recomputes. Author semantic.color.primary.tint yourself and the rule stops running for that slot. Nothing else in the chain changes either way.
The standard@1 rule pack: the role grid#
Every color role fills a grid, prominence × interaction state, not a flat list. See the language reference for the full shape and why.
| Slot filled | Rule |
|---|---|
accent.solid |
alias of primary.solid |
secondary.solid |
desaturated primary (chroma × 0.35, lightness toward mid) |
danger.solid |
fixed red hue (25), chroma/lightness matched to your brand |
success.solid / warning.solid / info.solid |
fixed hue anchors (150 / 85 / 230). Hue is conventional, temperature follows the brand |
neutral.solid |
neutral gray carrying your brand’s hue cast |
<role>.solid-hover / <role>.solid-active |
lightness deltas from solid; direction flips in dark mode (darken on light, lighten on dark) |
<role>.tint / -hover / -active |
solid mixed 92%/88%/84% toward elevation.1.surface in cartesian OKLab. This is the tinted-background family (shadcn’s muted/secondary/accent surfaces). Hue follows the surface as chroma collapses, so heavy tints sit ambiently instead of glowing |
<role>.outline / -hover |
solid mixed 70%/55% toward elevation.1.surface. This is the border-subtle family (it was a private Bootstrap-only formula; now a first-class grid cell) |
<role>.text-strong |
the role re-anchored at your text lightness, with its hue/chroma pushed to text-level contrast (Bootstrap’s $dark/$light, emphasis text) |
<role>.on-solid |
contrast-pick white vs. near-black against solid; hard rule: below the configured standard emits a warning (TST2101), never silence, and an authored one is checked the same way |
<role>.on-tint |
on-brand walk: starts at solid-active and steps lightness away from tint until AA (or APCA Lc 60) clears; falls back to the max-contrast neutral only if the ramp runs out |
<role>.text / -hover / -active |
the same on-brand walk against elevation.0.surface (the page). It’s a role-colored, AA-safe text/link color |
elevation.0.surface |
white in light mode, near-black in dark mode: the default page (defaulted, rule default-canvas) |
text.base |
near-black or white, whichever contrasts more with the mode’s page (defaulted, rule default-text): near-black on white, white on near-black when the page is the default too. Bind it to a role cell and it waits for that alias instead (TST1205) |
elevation.0.surface + text.base in dark mode, when you author the text with no dark value and no page |
the light pair swapped. The dark page takes your text color and the dark text takes the light page (derived, rule swap-neutrals, TST1206). Same contrast ratio as in light. Works the other way for a dark-native system’s light mode |
elevation.1..5.surface |
each level raised from the one below: toward white in light mode, lightened in dark mode |
elevation.1..4.shadow |
composed from scrim at fixed geometry/alpha ramps, one per level |
text.muted / text.subtle / text.disabled |
text.base mixed toward elevation.1.surface, or given reduced alpha |
text.strong |
alias of neutral.text-strong, the neutral role’s own grid, reused for content emphasis |
text.inverse |
text.base of the other color-scheme mode. It’s a cross-mode read, like the swap above |
border.base |
text.base mixed 88% toward elevation.1.surface: where hand-authored borders sit (the four examples measure 79–92%) |
border.subtle / border.strong |
border.base mixed halfway to elevation.1.surface / walked toward text.base in 1% steps until it reaches 3:1 against elevation.0 and .1 (WCAG 2.2 SC 1.4.11). On Carbon’s colours the walk lands on #8b8b8b, and Carbon’s own border-strong-01 is #8d8d8d |
border.field |
alias of border.base, the form-field border at rest; alias it to border.strong for 3:1 fields |
inverse.surface / inverse.text |
alias of neutral.text-strong / elevation.0.surface: the dark-on-light bubble Bootstrap’s tooltip and PrimeNG’s contrast severity already paint |
link.base / -hover / -visited |
primary.text and its state cells, hue-shifted for visited |
scrim |
near-black veil at fixed alpha |
ring |
primary, lightened in dark mode for visibility |
space.*, size.control.*, border-width.*, breakpoint.*, z.*, type.*, duration.*, easing.* |
catalog-default constants (defaulted) unless you author them. Every exporter that needs a spacing/type/motion scale now shares the same values |
ring.width / ring.offset |
the focus ring’s outline width, {border-width.medium} (2px) unless you author it, and its 2px gap from the element. The targets that draw a ring from their theme (PrimeNG, Chakra UI, Material UI, Bootstrap’s halo) write them only when you author them, either the width itself or through border-width.medium, so each keeps its own ring otherwise |
palette.categorical.1–8 |
data-viz palette: hues rotated from your brand anchor, lightness/chroma banded for adjacent distinguishability; feeds shadcn’s --chart-1…5 (first five, frozen) and ECharts’ color[] (all eight) |
Approximate OKLCH values in this table are produced by real color math in packages/core/src/color.js, including proper OKLab↔sRGB conversion and WCAG 2.1 contrast ratios.
The on-color picks (on-solid, on-tint, a role’s text) aim at WCAG 2.1 AA by default. With derivation.contrast: "apca" (the default when check.contrast.standard is apca) they pick by APCA instead: the candidate with the larger |Lc|, and walks that stop at Lc 60. The rule names then carry it (contrast-pick(apca), contrast-pick(subtle, apca)), so transtyle explain shows which contrast picked a value. On a mid-tone fill the two often disagree: see WCAG 2.1 or APCA.
More contrast, reduced motion#
Two rules read the reserved dimensions of the combination they run in, and like every rule they only fill holes:
contrast: more: the on-brand walks (<role>.on-tint,<role>.text) step until 7:1 (WCAG AAA) instead of 4.5:1, andtext.muted/text.subtlemix toward the surface only as far as 7:1 / 4.5:1 allow. Provenance readscontrast-more(<rule>), for examplecontrast-more(mix-toward-surface(0.12)).on-solidis still the better of white and near-black; when that falls short of 7:1, the warning asks for a darker or lightersolidforcontrast: more.motion: reduced: every unauthoredduration.*is0ms(rulemotion-reduced,derived). Easings are unchanged.
An authored value, including one carried over from the default mode, is never re-derived for either: check measures every contrast: more combination at 7:1 and names the authored value to write. transtyle explain primary.on-tint --mode light+more shows which rule ran (the mode is the combination’s key, one value per declared dimension in declaration order; an unknown one lists them all).
The component tier: defaults that layer#
component.* slots derive too, but by a different mechanism: each one declares a defaultFrom, the semantic slot it falls back to when you say nothing.
| Slot | Defaults from | Meaning |
|---|---|---|
component.control.radius |
semantic.radius.control |
the shared shape of form controls |
component.control.padding-x |
semantic.space.4 |
horizontal padding shared by controls |
component.control.padding-y |
semantic.space.2 |
vertical padding shared by controls |
component.button.radius |
component:control.radius |
a button is a control, until you say otherwise |
component.button.padding-x |
component:control.padding-x |
″ |
component.button.padding-y |
component:control.padding-y |
″ |
component.tooltip.max-width |
none (authored only) | how wide a tooltip may grow |
The component: prefix is what makes the tier layered rather than flat, and it is the difference between two authoring intentions that would otherwise be indistinguishable:
- Author
component.control.radius→ buttons and inputs both move. You changed the shared control shape. - Author
component.button.radius→ only buttons move. You made a button-specific decision, and inputs keep following the control default.
That asymmetry is the whole point. One line expresses either intent. The exporters reproduce it on targets that model the relationship completely differently. Bootstrap chains buttons and inputs through a shared $input-btn-* root. PrimeNG keeps button.* and formField.* fully separate. The same authored token lands correctly in both.
A slot can have no default at all. component.tooltip.max-width declares none, because the IR has no rung meaning “a readable measure” and synthesizing one would mean adopting a number on one library’s authority. So it exists only when you author it. When you don’t, Bootstrap and PrimeNG each keep their own default, which happens to be the same 200px. That agreement is why the slot exists: both libraries independently constrain the same element the same way at the same measure, and PrimeNG carries only two maxWidth slots in its entire 2759-slot surface (proposal 0004).
If the source doesn’t exist, neither does the slot. A design system that authors no radius scale has no semantic.radius.control, so component.control.radius is absent rather than present-and-empty. An exporter with a binding for it emits nothing and reports a dropped coverage row that names the slot to author. See a three-token design system is valid.
Provenance classes#
derived values track your brand: change primary and every derived value follows (hover tints, on-colors, the whole chart palette). See it live by editing the Acme example and rebuilding. defaulted values are catalog constants with no user input. Some are picked per mode and stay defaulted: the default page is white or near-black by color scheme, and the default body text is whichever of the two contrasts more with the page. Your tokens decide which constant applies, never what it is. For audits, the report can tell you “on this target, your theme is 42% native, 53% derived, 3% approximated, 3% dropped”.
What derivation refuses to do#
- Invent your dark theme. An unauthored dark value for a role’s
.solidanchor just carries the light one over (TST1204),autoDarkor not.autoDark: trueclassifies that carry-overderivedin coverage instead ofauthored, so audits see it as it is. It doesn’t yet compute a different dark color. That transform (darkBrandAdjust) is a still-open, deliberately deferred question (roadmap), not something invented here without more cross-target evidence. Dark brand adjustment is a design decision either way, and the engine won’t take it from you silently. The page and body text are the exception, because they aren’t brand colors. Leave the page out and author the text with no dark value, and dark mode gets the light pair swapped rather than your dark text on a dark default page (TST1206says so). An authored page is never touched. - Have taste. Derivation guarantees coherence, not art direction. On the Cathode terminal theme, derived
infocomes out conventionally blue. That’s coherent, aesthetically wrong for a monochrome CRT, and fixed by one authored line. That boundary is by design. - Run your code. Rules are declarative and shipped in versioned packs; there is deliberately no JS escape hatch in token processing. If the rule language is insufficient, that’s a spec conversation, not a plugin.
What to author next: completeness levels#
Three levels say which slots to author, in the order they pay off. Each one extends the one before. build and check print where you stand on one line (authored 1/14 recommended), and transtyle check --completeness <level> lists what is left, in this order:
| Level | Slots, in order |
|---|---|
minimal |
color.primary.solid: your brand, the one input the engine cannot invent |
recommended |
the neutrals color.elevation.0.surface, color.elevation.1.surface, color.text.base, color.text.muted, color.border; the same five again for each non-default color scheme (your dark neutrals); radius.md, font.sans, font.mono |
complete |
color.secondary.solid; the status colors success, warning, danger, info (each .solid); color.ring, color.scrim; the space.* and type.* scales; component.control.* |
Every path is under semantic. except component.control.*. Authoring means a value of your own or an alias to one: binding a slot to your vocabulary counts. An item still to author is missing (nothing fills it), derived, defaulted, or carried over: a neutral whose dark value is its light one (the same test as TST1204). A scale is one item, done once you author one of its steps: the engine fills the others.
npx transtyle check --completeness recommended
#
# Completeness recommended: 1/14 authored. To author next:
# 1. semantic.color.elevation.0.surface defaulted by default-canvas
# the page background: text, on-colors and contrast checks are measured against it
# 2. semantic.color.elevation.1.surface derived by alias(elevation.0.surface)
# cards and panels
# …
# 13. semantic.font.mono missing
# the code font: without it every target keeps its own
That is a design system with only primary.solid, in light and dark. Right after transtyle init (the recommended preset), recommended is done and complete is next. The levels are advice: the exit code doesn’t change. To make one a rule for your team, require it: "derivation": { "require": ["completeness:recommended"] } fails the build with TST1202 for each item left. check.completeness picks the level the summary line reports on (configuration).
No example reaches complete: each one leaves scrim, type.* and component.control.* to the engine. Acme stops at 15 of 24 on purpose, leaving its second brand color, status colors and focus ring to derivation (its README says why).
Practical workflow#
- Author the minimum you have opinions about: the
recommendedlevel above (brand color, neutrals, dark neutrals, radius, fonts). - Build; read
report.jsonand the· derivedcomments in the output.transtyle check --completeness completelists what else needs a decision. - Where a derived value is wrong for you, author that token. Repeat. Your token file grows exactly as fast as your opinions do.