Alpha — breaking changes ship without a deprecation cycle. What is built vs. planned
Configuration reference
One file: transtyle.config.json, in your project root (or another name you pass with --config; several products can share a base through extends). It’s the only Transtyle-specific file in a project. Everything else is standard DTCG. Config is data, so there’s no transtyle.config.ts, by design (introspectability, portability, determinism).
The $schema line at the top is a real, published JSON Schema. Editors that honor it give you autocomplete and inline validation as you type. The transtyle.dev URL below is the schema’s permanent identifier, not a download link. That domain is not registered yet, so an editor that fetches it literally will come up empty. The identical file is served from this site at the link above, and the compiler fetches neither: it validates against its own bundled copy.
The compiler validates the same schema at load time. An unknown or mistyped key is an error (TST1010), never silently ignored. Each target’s options are checked against the selected exporter’s own schema (TST1011), so a wrong era or a stray option fails the build with the exact path instead of being dropped without warning.
Full annotated example:
{
"$schema": "https://transtyle.dev/schemas/config/v0.json",
"name": "acme-design-system",
"tokens": [
"tokens/base.tokens.json",
{ "files": "tokens/dark.tokens.json", "mode": { "color-scheme": "dark" } },
"tokens/bindings.tokens.json"
],
"modes": {
"color-scheme": { "values": ["light", "dark"], "default": "light" }
},
"bindings": [{ "slot": "semantic.color.{role}.solid", "from": "{option.color.{role}.600}" }],
"derivation": {
"rules": "standard@1",
"autoDark": false,
"require": ["semantic.color.primary"]
},
"units": { "remBase": "16px" },
"targets": {
"shadcn": { "output": "dist/shadcn", "options": { "era": "tailwind-v4" } },
"shadcn-v3": {
"exporter": "shadcn",
"output": "dist/shadcn-v3",
"options": { "era": "tailwind-v3" }
}
},
"check": {
"failOn": "error",
"contrast": { "standard": "wcag21-aa" },
"hygiene": { "unusedOption": "info", "duplicateOption": "info" }
}
}
Binding rules#
bindings is optional. A binding is one alias from a catalog slot to a token of your own vocabulary. When that vocabulary is regular (a brand.50 to brand.950 ramp per role), a rule writes the whole grid in one line:
"bindings": [
{ "slot": "semantic.color.{role}.solid", "from": "{option.color.{role}.600}" },
{ "slot": "semantic.color.{role}.tint", "from": "{option.color.{role}.50}" },
{ "slot": "semantic.color.{role}.on-solid", "from": "{option.color.white}", "roles": ["primary", "danger"] },
{ "slot": "semantic.color.text.{rung}", "from": "{option.color.ink.{rung}}" },
{ "slot": "semantic.color.elevation.{level}.surface", "from": "{option.color.surface.{level}}" }
]
{role}is every built-in color role plus every custom role that joined the grid with$extensions.transtyle.role;rolesrestricts it.{rung}is the text rungs (strong,base,muted,subtle,disabled,inverse) and{level}the elevation levels0to5. Those three are the only placeholders; write ramp steps such as600literally. Two placeholders in oneslotiterate their cross product.fromis a single alias and may only use placeholders theslotuses.- Explicit bindings win. A slot you already bind in a token file (an authored value or a hand-written alias) is left alone by every rule, silently. Overriding one cell of the grid is just writing it. Between two rules, the first in the array wins and the second gets a
TST1119note. - A slot whose target token doesn’t exist is skipped silently, so one rule can cover the whole grid and bind only what your vocabulary has. Add
"required": trueand a missing target is an error instead (TST1118). Targets must be tokens written in a token file. - Rules expand when the config loads into ordinary aliases, so exporters and
diffsee nothing new, andexplainnames the rule behind an alias.npx transtyle bindings --expandprints the expansion as a token file when you prefer to freeze it, andnpx transtyle bind --suggest --rulesdrafts rules from your own names. - A malformed rule is
TST1117: an unknown placeholder, afromthat isn’t one alias,roleswithout{role}or naming a role that doesn’t exist.
Token files in your editor#
Token files have a published JSON Schema too, generated from the compiler’s own catalog. With it, typing inside semantic.color.primary. offers the sixteen grid cells, a $value can be completed with an alias such as {semantic.radius.full}, and a misspelled slot like primary.solidd is underlined. Scaffolded token files (transtyle init) carry the $schema line already. For an existing file, add it at the top:
{
"$schema": "https://transtyle.dev/schemas/tokens/v0.json",
"semantic": { "color": { "primary": { "solid": { "$value": "oklch(0.55 0.18 255)" } } } }
}
As with the config schema, the transtyle.dev URL is the identifier and an editor that fetches it will find nothing yet. Until the domain exists, associate the schema with your token files in VS Code’s .vscode/settings.json:
{
"json.schemas": [
{
"fileMatch": ["**/*.tokens.json"],
"url": "https://transtyle.github.io/transtyle/schemas/tokens/v0.json"
}
]
}
Two limits are by design. Your own tokens must stay valid, so semantic, semantic.color, semantic.font and component accept any group. A misspelled role (semantic.color.primry) looks like a custom role and isn’t flagged, while everything inside a built-in role or ladder is checked. And alias completion offers the catalog as a list of strings. Editors complete a string value from that list, but not in the middle of a string, and option.* aliases and literal values are never flagged.
name#
Used in generated file headers and usage docs. Pick something stable; it’s your design system’s identity in every artifact.
tokens — ordered layers#
Think of the layers as transparent sheets stacked on a lightbox: each one can only add to or paint over what’s below it, and you read the stack from the top. Later entries win.
Each entry is a glob string (base layer) or { "files": glob | [globs], "mode": { dimension: mode } } (mode-scoped layer, a pure DTCG file whose values apply to one mode). Globs support single * segments (tokens/*.tokens.json); matched files load in sorted order for determinism. A third form, { "tokensStudio": … }, loads a Tokens Studio export as it is (below).
Worked example. Three files, in this order:
"tokens": [
"tokens/option.tokens.json",
"tokens/semantic.tokens.json",
{ "files": "tokens/dark.tokens.json", "mode": { "color-scheme": "dark" } }
]
| File | Contains | Effect |
|---|---|---|
option.tokens.json |
option.color.blue.500 = #3b5bdb |
A raw value. Nothing binds to it yet. |
semantic.tokens.json |
semantic.color.primary.solid = {option.color.blue.500} |
Binds meaning to the raw value. This is the alias that makes it your brand color. |
dark.tokens.json |
semantic.color.text.base = #f8f9fa |
Applies only in dark mode. Light mode keeps whatever the base layers said. |
The mode-scoped layer never has to repeat anything: it lists only the tokens that differ in that mode, which on a real design system is usually a handful of neutrals. Everything else, including every derived value, recomputes per mode from what’s underneath.
A glob can cover the folder that holds the overlays. The overlay claims its file, so the glob skips it, whichever entry comes first:
"tokens": [
"tokens/*.tokens.json",
{ "files": "tokens/dark.tokens.json", "mode": { "color-scheme": "dark" } }
]
dark.tokens.json loads once, as the dark overlay; every other file in tokens/ is a base layer.
Override layers#
A layer that redefines tokens from earlier layers on purpose (core system, then business unit, then product) is marked with override:
{ "files": "tokens/product.tokens.json", "override": true }
true: redefinitions are silent. A token the layer defines that no earlier layer defined warnsTST1116(an override that overrides nothing is usually a typo). A first layer has nothing to override, and gets oneTST1116for the layer."extend": same, and the layer may also add new tokens.- Combinable with
mode: an override mode-scoped layer does not raiseTST1108when it replaces an earlier mode value. - An object entry needs
modeoroverride; a bare{ "files": … }is just a string glob and fails schema validation (TST1010), like any otheroverridevalue. transtyle explainand the report’s provenance record the winning layer and the files it shadowed.
| Rule | Diagnostic |
|---|---|
| Glob matches nothing | TST1001 warning |
| Token defined twice across base layers | TST1103 warning, last wins |
Same, in a layer marked "override" |
Silent (intended override) |
"override": true layer defines a new token |
TST1116 warning ("extend" allows it) |
| Mode value overrides an earlier one | TST1108 warning |
| Mode value for a token with no default value | TST1107 warning, skipped |
Mode not declared in modes |
TST1109 error |
| Mode-scoped layer naming no dimension | TST1110 error |
| File matched by a glob and a mode-scoped entry | Loaded once, as the overlay (no diagnostic) |
Tokens Studio exports#
A folder synced by Tokens Studio for Figma (the one holding $metadata.json), or a single-file export, loads without rewriting it:
{
"tokens": [
{
"tokensStudio": "tokens/figma",
"themes": {
"Mode": { "dimension": "color-scheme", "map": { "Light": "light", "Dark": "dark" } }
},
"sets": { "core": "option", "semantic/*": "semantic" }
},
"tokens/transtyle.bindings.tokens.json"
],
"modes": { "color-scheme": { "values": ["light", "dark"], "default": "light" } }
}
themes: one entry per theme group. A group is a mode dimension:mapgives each of its themes a mode value.{ "fixed": "<theme>" }compiles a group with one theme only. When the export’s themes have no group,themesis the mapping itself ({ "dimension": …, "map": … }); with no themes at all, every set loads in set order.sets: the tier each set’s tokens are placed under (set names,*for any characters, first match wins; defaultoption). References are rewritten to match, so{color.blue.600}becomes{option.color.blue.600}. A set already written underoption/semantic/componentis kept as is.- Tokens Studio’s types, unitless pixels, Figma weight names (
Semi Bold), percentages, math ({space.base} * 2,roundTo(…)),rgba({color.black}, 0.5)and the legacyvalue/typeformat are all read. A color modifier is refused (TST1007): the plugin would output a different color. - The theme mapped to each default is the base, each other mode value a mode-scoped layer of what differs. What one override per dimension can’t express is an error (
TST1008), never a silently different theme. transtyle explainnames the set file and the path designers know a token by, per mode, and shows a math token’s expression.
The full rules are in the configuration spec; the adoption recipe is in You already have a design system.
modes#
Declares the axes your design system varies along. Each dimension lists its values and names a default; the compiler resolves every combination of them.
default names your design system’s native mode, the one plain $values describe. It doesn’t reorder exporter output: exporters bind mode names, so a dark-native system still gets shadcn’s light-first structure. See Weird things for why.
Worked example: the Acme example declares four dimensions:
"modes": {
"color-scheme": { "values": ["light", "dark"], "default": "light" },
"density": { "values": ["comfortable", "compact"], "default": "comfortable" },
"contrast": { "values": ["standard", "more"], "default": "standard" },
"motion": { "values": ["full", "reduced"], "default": "full" }
}
which resolves to sixteen full token maps: light+comfortable+standard+full, light+comfortable+standard+reduced, and so on up to dark+compact+more+reduced. Every derived value is computed independently in each one. A dark-mode hover state darkens or lightens according to that combination, not by translating the light-mode answer.
Two constraints:
- The first dimension carries light/dark. Order matters. The first dimension listed is the polarity axis, the one exporters bind as light/dark. So if you use
color-scheme, list it first. Declaring it after another dimension (for exampledensityfirst) would make dark mode silently never reach any exporter. The dark values still resolve into their combinations, but no target reads them, so every exporter emits a dark block filled with light values. Because that output is guaranteed wrong, Transtyle makes it a build error (TST1112), not a warning. Reordermodesto fix it. - One combination per layer. A mode-scoped token file usually targets one value of one dimension,
{ "color-scheme": "dark" }. When a value belongs to a combination, the layer names every dimension of it: see Combo layers. - Exporters express what their target can express.
color-schememaps everywhere;densityhas no Bootstrap or PrimeNG counterpart, so it appears in those reports as adroppedrow naming the dimension, not silently flattened.
Combo layers#
A token that varies on two dimensions at once needs a value for the combination. A high-contrast palette is the usual case: the more text color written for light can’t be the dark one. Name both dimensions in the layer’s mode, and its values apply only to that combination, winning there over the one-dimension layers:
"tokens": [
"tokens/base.tokens.json",
{ "files": "tokens/contrast-more.tokens.json", "mode": { "contrast": "more" } },
{ "files": "tokens/dark-contrast-more.tokens.json", "mode": { "color-scheme": "dark", "contrast": "more" } }
]
Without the second layer, dark + more takes the more value written for light (the dimension declared later wins), and Transtyle says so with TST1125, naming the combinations and the layer to add. Two combo layers matching the same combination: the one naming more dimensions wins, then the later one. The same pattern authors a brand’s own dark value ({ "color-scheme": "dark", "brand": "globex" }).
Contrast, motion and brand#
Three reserved dimension names mean something to the exporters:
contrast(standard/more). CSS targets write themorevalues in a[data-contrast="more"]block and again inside@media (prefers-contrast: more), so the user’s system setting applies until the page sets the attribute. Unauthored on-colors and role text aim at 7:1 there, andcheckholds themorecombinations to 7:1 (WCAG AAA). Authored values are never re-derived: write themorevalue for every authored text or border color that falls short, and the warning names them.motion(full/reduced). Every unauthoredduration.*is0msunderreduced; CSS targets followprefers-reduced-motion: reducethe same way.brand(your brand names). CSS targets add a[data-brand="<name>"]block per brand. Targets with no runtime switch (Bootstrap’s Sass path, PrimeNG, Mantine, Chakra, MUI) emit their files once per brand (preset.transtyle.globex.ts). ECharts emits one theme per brand and scheme, and daisyUI one theme per combination. Brand values become file names and selectors, so they are lowercase letters, digits and hyphens (TST1010otherwise). This dimension is not Storybook’soptions.brand, which brands the Storybook manager itself.
"modes": {
"color-scheme": { "values": ["light", "dark"], "default": "light" },
"contrast": { "values": ["standard", "more"], "default": "standard" },
"motion": { "values": ["full", "reduced"], "default": "full" },
"brand": { "values": ["acme", "globex"], "default": "acme" }
}
Set the attributes on the same element as the scheme’s own class or attribute, usually <html>: <html class="dark" data-contrast="more" data-brand="globex">. Each exporter page says how it expresses each dimension, and every target that can’t reports a dropped row with the reason.
derivation#
rules: the rule pack, pinned with a version (standard@1). Pinning means upgrading Transtyle can never silently change your compiled theme.autoDark: defaultfalse. Regardless of this setting, a role’s.solidyou didn’t author for a non-defaultcolor-schemevalue falls back to the default-mode color (brand colors stay identical across modes). That’s deliberate, andTST1204surfaces it. WhatautoDark: trueadds today is that this carry-over is classifiedderivedin coverage instead ofauthored, soreport.jsonshows synthetic dark-theme coverage. Computing a different dark color is specced but not yet implemented. It’s an open, deliberately deferred research question (see the roadmap), not a missing wire-up. The page and body text aren’t covered by this setting: an unauthored page under atext.basewith no dark value gets the light pair swapped in dark mode either way (TST1206).require: tokens that must be authored (an alias to your own token counts), not derived, defaulted or left out. Otherwise the build fails withTST1202. Use this to encode team policy (“nobody ships a derived brand color”). A color role may be named at the role (semantic.color.primary) or the anchor cell (semantic.color.primary.solid); both check the.solidcell, which is the one the grid is built from. An entry"completeness:recommended"(orminimal,complete) requires a whole completeness level, dark neutrals included: oneTST1202per item left.overrides: per-slot derivation rules (specced, not yet implemented; today, author the token, since authored always wins).contrast:wcag21|apca, the contrast on-colors are picked with (on-solid,on-tint, a role’stext). Unless set, it followscheck.contrast.standard:apcawhen the check isapca,wcag21otherwise. Set it towcag21to keep the WCAG picks while checking under APCA (each pick that fails APCA then warns). The WCAG picks aim at 4.5:1 even when the check iswcag21-aaa.
require is a policy knob, not the engine’s own floor. semantic.color.primary.solid is required whether or not you list it, because nothing can invent your brand color. Its absence is TST1201, which fires even with no derivation block at all.
units#
remBase: what oneremis worth, as apxlength,"16px"by default. Exporters whose target wants pixels (ECharts, Storybook) convertremdimensions at this base and name it in theapproximatedrow of the report. Set it when your system’s root font size isn’t 16px: GOV.UK’s 62.5 % trick is"10px", some enterprise systems use"14px". A value that isn’t a positivepxlength ("abc","0px","100%") isTST1010. Storybook’s ownoptions.remBase(a number) still overrides it for that target.
targets — instances, not just names#
Each key is a target instance. The optional exporter field selects the plugin (defaults to the key), which is how one exporter runs twice with different options, for example shadcn in both Tailwind eras. output is the emit directory (relative to the config, or to the product’s config when a base declares the target: see extends). options are exporter-specific; see each exporter’s page.
transtyle build builds all instances; transtyle build shadcn-v3 selects by instance name.
exporter can also be a path to a mapping file next to your config, "exporter": "./ourlib.mapping.json": a JSON table of variable names and catalog slots that is an exporter without code. See Start declarative for the format.
Target versions#
Tell Transtyle which version of the framework your project uses with version (always major.minor.patch):
"targets": {
"bootstrap": { "output": "dist/bootstrap", "version": "5.3.8" },
"shadcn": { "output": "dist/shadcn", "version": "3.4.17" }
}
Each exporter supports version ranges of its framework, one mapping profile per range, and the compiler picks the range covering your version. Bootstrap has one profile, >=5.3 <6. shadcn follows your Tailwind version, >=3 <4 (the tailwind-v3 era) or >=4 <5 (tailwind-v4). Things to know:
- A version no range covers stops the build with
TST1313, which lists the ranges the exporter supports. Nothing is written for that target:"version": "4.6.2"on Bootstrap fails rather than producing variables Bootstrap 4 doesn’t have. - It’s recorded. That target’s
report.jsongets"version": { "requested": "5.3.8", "profile": ">=5.3 <6" }. - Leave it out and nothing changes. Without
versioneach exporter uses its newest profile, exactly as before. - shadcn’s
erastill works as an explicit choice; iferaandversiondisagree,erawins and the build warns (TST2105). - A partial version (
"5.3") is a config error (TST1010).
Per-target mode subsets#
Modes are declared once for the project, but not every target needs all of them: a marketing site on Bootstrap may be light-only while the app on shadcn ships light and dark. Set modes on a target to keep only some values; a dimension you don’t name keeps all of its values.
"targets": {
"bootstrap": { "output": "dist/bootstrap", "modes": { "color-scheme": ["light"] } },
"shadcn": { "output": "dist/shadcn" }
}
That Bootstrap build emits no data-bs-theme="dark" block, its usage.md lists the modes it contains, and the other targets are unchanged. Things to know:
- Keep the default value. A subset that leaves out the dimension’s
default, or names a dimension or value you didn’t declare inmodes, stops the build withTST1308before anything is written, for every target. - A narrowed dimension is not a loss. It gets no
droppedrow inreport.json; a dimension you didn’t narrow that the exporter can’t express still does. - Project-level checks still run on everything. A dark-mode contrast warning still fires even if every target leaves dark out, because modes are declared for the project, not per target.
- PrimeNG always carries a
darkscheme in its preset; without a dark mode it repeats the light values there.
check#
failOn:error(default) |warning|approximation, the diagnostic level that makes the build exit non-zero. CI teams typically tighten this over time.suppress: silence a known warning or info and say why, with an array of{ "code": "TST1305", "path": "scratch", "reason": "…" }.reasonis required and can’t be blank;pathis optional (an exact token path, or a prefix ending in.*). Suppressed diagnostics leave the printed output andfailOn, and are listed undersuppressedinreport.json. Errors can’t be suppressed. See Suppressing a diagnostic.completeness:minimal|recommended(default) |complete, the completeness levelbuildandchecksummarize on theirauthored n/m <level>line, andcheck --jsonreports on. It never changes the exit code;transtyle check --completeness <level>overrides it for one run. To fail on a level, put it inderivation.require.contrast.standard:wcag21-aa(4.5:1, the default),wcag21-aaa(7:1) orapca(Lc 75 for body text, Lc 60 for the rest; see WCAG 2.1 or APCA). Contrast is checked ontext.baseandtext.mutedagainst theelevation.0andelevation.1surfaces, and on every role’son-solidandon-tint, derived or authored; failures are warnings (TST2101), never silent. The same standard drivestranstyle diff’s contrast-regression flag, socheckanddiffalways agree on what “passing” means.hygiene.unusedOption,hygiene.duplicateOption:info(default) |warning|off, the severity ofTST1114(option tokens no alias resolves to) andTST1115(option tokens of the same type with the same value). Both are reported once per build, with the count and the first paths in the message and the full lists incheck --json. Setwarning(withfailOn: warning) to make CI refuse dead or duplicated palette entries.
WCAG 2.1 or APCA#
WCAG 2.1’s ratio is one number for every use, and it is known to misjudge light text on dark backgrounds, which is where dark modes and dark-native systems live. APCA (the Accessible Perceptual Contrast Algorithm, base algorithm 0.0.98G-4g) measures lightness contrast as a signed Lc: positive for dark text on a light background, negative for light on dark. Transtyle compares its magnitude to APCA’s Bronze levels by use, and prints the sign:
| Pair | wcag21-aa / wcag21-aaa |
apca |
|---|---|---|
text.base on elevation.{0,1}.surface |
4.5:1 / 7:1 | Lc 75 (body text) |
text.muted on elevation.{0,1}.surface |
4.5:1 / 7:1 | Lc 60 (secondary text) |
<role>.on-solid, <role>.on-tint |
4.5:1 / 7:1 | Lc 60 (labels, badges) |
APCA is not built in: its licence allows it only unmodified and kept current, so Transtyle uses the apca-w3 package, an optional peer dependency you install next to Transtyle when you select it (npm install --save-dev apca-w3). Without it, apca fails the build with TST1013 rather than check under another standard. The package’s own licence applies to it: free for web content in support of accessibility guidelines, AGPL v3 for other uses. Lc is measured on the hex Transtyle emits, so a printed value matches what the APCA reference implementation gives for the same two colors.
The two standards disagree on real colors. On Cathode’s green success.solid (#319751), WCAG picks near-black text: 5.3:1, while white would be 3.6:1 and fail. APCA rates that near-black at Lc 39.6 and white at Lc -69.7, so under apca the same slot derives white. Cathode’s dark muted text (#50a252 on #040904) goes the other way: 6.3:1, clean under AA, and Lc -43, below APCA’s Lc 60 for secondary text.
{ "check": { "contrast": { "standard": "apca" } } }
extends — several products, one design system#
A repository with several products that share one design system, or with several design systems, needs one config per product. Put what they share in a base config, and have each product extend it:
design-system/
transtyle.config.json tokens, modes, bindings, derivation, units, check
tokens/
apps/marketing/
transtyle.config.json "extends": "../../design-system/transtyle.config.json" + its targets
apps/admin/
transtyle.config.json "extends": "../../design-system/transtyle.config.json" + other targets
{
"$schema": "https://transtyle.dev/schemas/config/v0.json",
"extends": "../../design-system/transtyle.config.json",
"name": "marketing-site",
"targets": {
"bootstrap": { "output": "dist/bootstrap", "modes": { "color-scheme": ["light"] } }
}
}
npx transtyle build --cwd apps/marketing (or --config apps/marketing/transtyle.config.json from the root) builds the marketing site’s Bootstrap theme from the shared tokens, into apps/marketing/dist/bootstrap.
extendsis a file path relative to the config that declares it, starting with./or../. A base may extend another. Package names (@acme/ds/…) are not supported yet.- The nearer file wins.
tokensare concatenated, the base’s layers first, so a product’s own layer comes last; to redefine a base token, mark that layeroverride: true, as for any later layer.bindingsandcheck.suppressare concatenated with the product’s entries first (the first match wins in both).modesmerge by dimension,targetsby instance name (a redefined dimension or instance replaces the base’s whole entry), andderivation,unitsandcheckby key.namecomes from the nearest file that sets one;$schemais not inherited. The full table is in the configuration spec. - Paths. Token globs resolve against the file that declares them; target outputs against the product’s config, wherever the target is declared, so two products never write into the same folder. Messages name files relative to the product (
../../design-system/tokens/base.tokens.json:12:7). - Errors name the file. An unknown key in the base is
TST1010with the base’s path. A missing base or a cycle stops the run (exit 2) and prints the chain. - Where it shows.
report.jsonandcheck --jsonlist the files read asconfig, base first;explainprints the chain on stderr.transtyle diffin a product also reports a change made only in the base.transtyle addwrites into the product’s config only, and refuses a target the base already declares. - Keep targets in the products. A product can’t remove a target it inherits, so a base that declares targets builds them in every product.