Alpha — breaking changes ship without a deprecation cycle. What is built vs. planned
The Transtyle language
Machine translation between many languages doesn’t build a translator per pair — it translates through a pivot language, an interlingua. Transtyle’s semantic catalog is exactly that. Your design system’s semantics map into the catalog (manually via aliases, or automatically via derivation); each target library’s semantics map out of it (via each exporter’s mapping table). N design systems × M libraries, through one vocabulary.
This page is the full pivot vocabulary as implemented today — compiled unless marked specced (exists in the IR specification, not yet compiled). Swatches show real derived values from the Acme example’s single blue brand color.
The same vocabulary as data — every slot with its type, the rule that fills it when you don’t, that rule’s inputs, and the anchor it needs — is one command away: transtyle catalog, or transtyle catalog --json for tools (CLI reference). The tables below are checked against it on every build, so a slot can’t be added to the engine without a row here.
Color roles: the role grid#
Eight roles; each is a grid, not a flat scale, because every mature design system independently arrives at the same two axes: how prominent a color is (solid fill → tint wash → outline → text) crossed with interaction state (rest → hover → active → selected), plus the paired foregrounds for the two surface-like columns. Radix’s 12 steps, Ant Design’s map tokens, Bootstrap’s subtle triad, Chakra’s colorPalette, and Material 3’s container/on-* pairs are all differently-named samples of this same grid.
prominence → solid tint outline text
rest solid tint outline text
hover solid-hover tint-hover outline-hover text-hover
active solid-active tint-active — text-active
selected solid-selected tint-selected — —
on-colors on-solid on-tint — —
strong — — — text-strong
| Grid cell | Meaning | If unauthored, derived by |
|---|---|---|
<role>.solid |
The role’s principal value | per-role rule below |
<role>.solid-hover / -active / -selected |
Interaction states on the solid fill | lightness deltas from solid, direction flips in dark mode; -selected aliases -active |
<role>.tint / -hover / -active / -selected |
Tinted background wash | mix toward elevation.1.surface (92% / 88% / 84%) |
<role>.outline / -hover |
Border-only wash, one step below solid |
mix toward elevation.1.surface (70% / 55%) |
<role>.on-solid |
Readable foreground on solid |
contrast-pick white/near-black, checked against check.contrast.standard, warning if it fails |
<role>.on-tint |
Readable foreground on tint |
on-brand walk from solid-active until AA clears |
<role>.text / -hover / -active |
A role-colored, AA-safe text/link color against the page | on-brand walk against elevation.0.surface |
<role>.text-strong |
Max-contrast counterpart | contrast-anchor(text) |
| Role | Meaning | solid derivation when unauthored |
e.g. (from a blue brand) |
|---|---|---|---|
primary |
The action/brand color | must be authored — the one non-negotiable input | |
secondary |
Second brand color | desaturated primary | |
accent |
Emphasis/highlight | alias of primary | |
success / warning / danger / info |
Status colors | fixed hue anchors (150/85/25/230), brand-matched chroma | |
neutral |
The gray family | brand-hued near-gray |
Which targets read each cell of the grid, role by role: slot matrix.
Elevation, content, and the rest#
Surfaces are an elevation ladder, not four separate named slots — each level projects a surface color, and levels 1–4 pair with a shadow:
| Slot | Meaning | Derivation |
|---|---|---|
elevation.0.surface |
The page | author it |
elevation.1.surface |
Cards, panels | author it (falls back to level 0) |
elevation.2.surface |
Raised layer | raise(level 1) |
elevation.3.surface |
Floating layers: popover, menu, dialog | raise(level 2) — deliberately one step further than a merely-raised card |
elevation.4.surface / elevation.5.surface |
Deeper stacking contexts | raise(previous level) |
elevation.1..4.shadow |
Paired shadow per level | composed from scrim at fixed alpha ramps |
scrim |
Dimming veil behind modals | near-black at fixed alpha — a veil, not an elevation level |
text.base / text.muted / text.subtle / text.disabled / text.strong / text.inverse |
Content hierarchy | author text.base; the rest derive from it (.strong aliases neutral.text-strong; .inverse reads the other mode’s text.base) |
link.base / .hover / .visited |
Link colors | alias of primary.text and its states, hue-shifted for visited |
border.subtle / border.base / border.strong |
Content borders by strength: separators and cards, the default border, a boundary that meets 3:1 (WCAG 2.2 SC 1.4.11) | author border.base (or let it derive from text.base mixed toward the surface); border.subtle mixes it halfway to the surface, border.strong walks it toward text.base until 3:1 against elevation.0 and .1 |
border.field |
The neutral border of a form field at rest (shadcn --input, PrimeNG formField.borderColor) |
alias of border.base; author it (often {…border.strong}) for accessible fields |
inverse.surface / inverse.text |
The inverted bubble: tooltips, contrast toasts | alias of neutral.text-strong / elevation.0.surface, the pair Bootstrap’s tooltip already paints; not text.inverse, which is the other mode’s body text |
ring |
Focus | ring ← primary, lightened in dark |
ring.width / ring.offset |
The focus ring’s outline width and its gap from the element | {border-width.medium} (2px) and 2px unless you author them; a target writes them only when you do |
palette.categorical.1–8 |
Data-viz series colors | hue rotation from primary, distinguishability-banded; entries 1–5 frozen (cross-target contract) |
radius.md, font.sans, font.mono |
Shape and type | author them |
radius.none / .sm / .lg / .xl / .full |
The radius scale | radius.md × 0 / × 0.5 / × 1.5 / × 2; full is 9999px |
radius.control / .field / .container |
Radius per family of element | alias of radius.md unless you author them |
font.display |
Display face | optional: when authored, type.role.display.* use it instead of font.sans |
space.*, size.control.*, border-width.*, opacity.disabled, breakpoint.*, z.*, type.* (+ composite type.role.*), duration.*, easing.* |
Scales every target can share | catalog-default constants unless you author them |
Which targets read each of these slots: slot matrix.
Anything else you define under semantic.* is a custom semantic token — legal, carried, mode-aware, and the recommended home for your own vocabulary (adoption guide, step 2). A custom role can also declare an archetype (brand/status/neutral) via $extensions.transtyle.role to get the full grid derived like a built-in — compiled. Roles with an open set (daisyUI, css-variables) emit it; closed-set targets (Bootstrap, shadcn, ECharts, Storybook, Radix) don’t have a slot for it and skip it. Cathode’s crt-amber demonstrates it end to end.
The component tier#
Everything above is the semantic tier — meanings that hold regardless of what you build with them. The component.* tier is the third and last tier: decisions that are about a specific kind of UI element and cannot be stated any other way.
It is deliberately tiny, and stays tiny by rule (below):
| Slot | Meaning | Defaults from |
|---|---|---|
component.control.radius |
shared shape of form controls | semantic.radius.control |
component.control.padding-x / -y |
padding shared by controls | semantic.space.4 / space.2 |
component.button.radius |
button shape | component:control.radius |
component.button.padding-x / -y |
button padding | component:control.padding-* |
component.tooltip.max-width |
how wide a tooltip may grow | nothing — authored only |
Which targets read each component slot: slot matrix.
The component: prefix makes the tier layered, and that layering carries an authoring intent no flat vocabulary can express:
author component.control.radius → buttons AND inputs move ("controls are rounder")
author component.button.radius → only buttons move ("buttons are pills")
One authored line, and the exporters reproduce the distinction on targets that model it in incompatible ways: Bootstrap chains buttons and inputs through a shared $input-btn-* root, PrimeNG keeps button.* and formField.* entirely separate. Authoring component.button.radius: "{semantic.radius.full}" produces a 9999px pill in both, without moving inputs in either.
Why it isn’t bigger#
Both reference targets expose enormous component surfaces — 657 themable Bootstrap variables, 2759 PrimeNG slots — and it would be easy to mint a catalog slot for each. That would produce a vocabulary shaped like whichever target was read last, which is the failure mode this whole design exists to avoid.
So nothing enters the component tier without two independent exporters needing the identical thing, for architectural rather than nominal reasons. Two examples of the rule doing work, both recorded in docs/proposals/0003-component-catalog-generalization.md:
- Accepted: control padding/radius. Bootstrap and PrimeNG both treat “a control’s box” as one shared decision, arrived at independently. That’s architectural correspondence.
- Rejected: the
sm/lgsize ladder. Both targets have one — and they disagree about which rungs it has. The disagreement is the finding: a shared slot would have to pick a winner, so exporters keep deriving their own.
The rule has since been run once more, over the whole of component geometry — 25 Bootstrap sizing variables against PrimeNG’s 243 width/height/size slots. It rejected nine of the ten concepts and accepted one:
- Accepted:
tooltip.max-width. Both libraries constrain how wide a tooltip may grow, both with amax-widthon the tooltip root, both at the same measure — Bootstrap200px, PrimeNG12.5rem, which is 200px. And both are selective about it: PrimeNG has exactly twomaxWidthslots in 2759. Two libraries agreeing that this specific element is the one needing a width ceiling. - Rejected — false friend:
$toast-max-width: 350pxagainst PrimeNG’stoast.root.width: 22rem(352px). Nearly the same number, opposite box semantics: one grows to its content up to a ceiling, the other is fixed. The near-coincidence is exactly what a name-and-number comparison would have promoted. - Rejected — one-sided: spinner size, popover width, the modal size ladder, offcanvas dimensions. PrimeNG hard-codes every one of them; there is nothing to correspond with.
The full ledger is in docs/proposals/0004-component-geometry.md. The point of writing down the rejections is that the next probe doesn’t re-litigate them.
Everything a target needs beyond the catalog stays inside that exporter, where it belongs. The measured result is that Bootstrap variables not bound to a catalog slot are overwhelmingly reached anyway — through the target’s own !default chains and CSS custom properties — rather than left untouched.
False friends#
The reason a pivot language must exist: the same word means different things across ecosystems, and Transtyle’s job is to translate meanings, never names.
| Word | In the catalog | In shadcn | In Bootstrap | In Radix | In Chakra UI | In Material UI |
|---|---|---|---|---|---|---|
| secondary | second brand color | subtle gray button surface (--secondary ← our neutral.tint) |
a theme color (≈ ours) | — | — (no secondary palette) | a palette colour (≈ ours, same name) |
| accent | brand emphasis color | hover-highlight tint (--accent ← our accent.tint) |
not a concept | the only brand color (their one accent ≈ our primary) |
— | not a palette colour (an extra key) |
| muted | not a slot (see text.muted, neutral.tint) |
a surface and a foreground pair | text utility | — | colorPalette.muted: a tint depth, the hover of subtle (our <role>.tint-hover) |
— |
| outline | <role>.outline — a border-only wash, one prominence step below solid |
not a slot (Tailwind border utility on --border) |
— | steps 7/8 | a button variant whose border is colorPalette.border (our <role>.outline) |
variant="outlined", bordered by alpha(main, 0.5) (here our <role>.outline) |
| subtle | <role>.tint (one wash, all states) |
muted = a surface+foreground pair |
-bg-subtle/-border-subtle (now bound to tint/outline) |
steps 3–5 (a range, not one value) | colorPalette.subtle: the lightest tint (our <role>.tint) |
— |
| selected | <role>.solid-selected / tint-selected (aliases of -active unless authored) |
not a concept | not a concept | not a concept | not a concept | action.selected: one translucent veil for every list |
| emphasized | not a slot (the deepest tint is <role>.tint-active) |
— | -text-emphasis: text on a subtle background (our <role>.on-tint) |
— | colorPalette.emphasized: the deepest tint (our <role>.tint-active) |
— |
| dark / light | the two values of the color-scheme mode |
— | — | — | — | palette.<color>.dark / .light: shades of main, not schemes (here solid-hover / outline) |
Exporter mapping tables encode these translations once, reviewed by people who know both languages — that’s why exporters bind to the catalog and never to your names, and why you should bind your names by meaning, not spelling. Two rows are checked: transtyle check notes (TST1124, info) a secondary.solid bound to a token named secondary whose value is within ΔEOK 0.05 of neutral.tint, and an accent.solid bound to an accent token within 0.05 of primary.tint or neutral.tint, the tint accent would derive or shadcn’s stock gray wash. It takes both signals, the word and the value: Bootstrap’s $secondary gray and a real second brand colour stay silent. The other rows have no value to tell them apart, so they stay documentation. The comparative study behind the grid — 14 ecosystems, their tier architectures, and per-ecosystem conversion tables — lives in docs/proposals/0001-universal-token-ir.md.
The table above already shows this isn’t just “different word, same slot”: <role>.outline alone shows up as a Tailwind border utility in shadcn, nothing in Bootstrap, and a numbered step range in Radix — one catalog concept, three unrelated target shapes. The same holds in the other direction: nothing stops one catalog token from feeding several differently-named places inside a single target’s own structure, when that target’s internal organization (a shared token group, a flat per-context naming scheme, whatever it is) simply isn’t shaped like the catalog. Translating by meaning means the mapping is exporter-owned and can be as many-to-many as the target actually needs — it never obligates the catalog to grow a matching concept for every target’s internal grouping.
How the language grows#
The catalog is deliberately fixed per version — it’s the compiler’s instruction set. Growth is evidence-driven, and the evidence has a specific shape: two independent exporters must need the identical thing for architectural, not nominal, reasons. One target wanting something is a feature request for that exporter; two targets arriving at the same structure independently is a fact about design systems, which is the only kind of fact this vocabulary should encode.
That is why the reports matter. An unsupported row is a claim on the record that a target themes something the IR can’t express — the raw material for the next catalog decision. Only an exporter that inventories its target’s whole surface can make that claim exhaustively (Bootstrap, PrimeNG, Mantine, Chakra and Material UI do; ECharts names its one known gap). Each row can say what it is missing as a meaning key, and the catalog-signals report groups those keys across every exporter and example, compiled rather than collected by hand and kept current by check:catalog-signals. What it shows today, all measured rather than intuited: icon size is the one concept two exporters report — PrimeNG systematically, Bootstrap ad hoc — and stays on watch until a third target brings a systematic model; icon colour is watched too, because the systems that name it (Carbon, Spectrum) give it their text colours, so both targets’ icons read the text ladder for now (proposal 0005); per-component opacity, compositional opacity (a catalog factor applied to a target’s own resting value), an inset shadow, a default gradient and heading text-wrap are open, each waiting on a second target; icon assets are watched too, with one source so far; component geometry, breakpoints and the display ladder are settled, each with the proposal or worklog that settled it; the focus ring’s width and offset went the other way, promoted to ring.{width, offset} on 2026-10-18 once Chakra UI and Material UI had joined PrimeNG in reading them (proposal 0002, row 8). Through the alpha, the catalog can still change in place — the role grid above landed exactly that way, as a breaking revision rather than a version bump (see docs/adr/0010-pre-release-breaking-changes.md; publishing the alpha deliberately did not end that, because a prerelease identifier is not a stability promise). At the first release without one, growth becomes additive-only — nothing removed or re-typed within a major. Your token files outlive our versions.