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.
your semantics the catalog (pivot) each library's semantics
────────────── ─────────────────── ────────────────────────
"brand-action" ─alias→ primary.solid ─table→ --primary (shadcn)
"flame-soft" ─alias→ primary.tint ─table→ color[0] (ECharts)
(nothing) ─rule──→ primary.on-solid ─table→ --primary-foreground
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.
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, AA-checked, warning if impossible |
<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 |
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 / ring |
Lines and focus | author border; ring ← primary, lightened in dark |
palette.categorical.1–8 |
Data-viz series colors | hue rotation from primary, distinguishability-banded; entries 1–5 frozen (cross-target contract) |
radius.md (+ control/field/container aliases), font.sans, font.mono |
Shape and type | author them |
space.*, size.control.*, border-width.*, breakpoint.*, z.*, type.* (+ composite type.role.*), duration.*, easing.* |
Scales every target can share | catalog-default constants unless you author them |
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 |
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 |
|---|---|---|---|---|
| secondary | second brand color | subtle gray button surface (--secondary ← our neutral.tint) |
a theme color (≈ ours) | — |
| accent | brand emphasis color | hover-highlight tint (--accent ← our accent.tint) |
not a concept | the only brand color (their one accent ≈ our primary) |
| muted | not a slot (see text.muted, neutral.tint) |
a surface and a foreground pair | text utility | — |
| outline | <role>.outline — a border-only wash, one prominence step below solid |
not a slot (Tailwind border utility on --border) |
— | steps 7/8 |
| 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) |
| selected | <role>.solid-selected / tint-selected (aliases of -active unless authored) |
not a concept | not a concept | not a concept |
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. 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. Every exporter classifies every slot of its target’s documented surface, and an unsupported row is a claim on the record that the IR can’t express something — the raw material for the next catalog decision. The current named growth signals, all from measured coverage rather than intuition: component icon/asset slots (Bootstrap embeds 16 SVG data-URIs the IR has no vocabulary for), component geometry and sizing (25 more), breakpoints, and compositional opacity (a catalog factor applied to a target’s own resting value). Each is waiting on a second target needing the same thing. 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.