Skip to contentWolf-Rayet

Decision records

ADR-0017 One flat custom-property namespace, one owner per name

Accepted2026-08-28Phase 4

#Context

--wr-text-body shipped as two tokens. It is a semantic colour role in semantic.config.json, emitted by the four theme stylesheets as a colour, and a spatial size token in generator.config.json, emitted by the two density stylesheets as a length. The DTCG source keeps them apart — {color.text-body} and {size.text-body} are different addresses in different groups — but CSS has one namespace for custom properties, and both groups flatten into it.

The tiers resolve on different selectors: the semantic tier on [data-wr-theme], the spatial tier on [data-wr-density]. Markup that declares only a theme never meets the collision, which is why the Phase 1 scenes did not. Markup that declares both puts the two declarations in one cascade on one element, and inside that subtree color: var(--wr-text-body) resolves to 1.25rem. An invalid declaration is dropped at parse time with no diagnostic, the element inherits its parent's colour, and the result is a page that renders — slightly wrong, in a way no check reports. Under field-day it is not even a question of order: ADR-0007's clamp block is [data-wr-theme="field-day"] [data-wr-density="compact"], two attribute selectors against the theme block's one, so the length wins on specificity regardless of how the files are imported.

Every Phase 4 scene must declare a density, because the component tier reads spatial tokens. So the collision arrived with the component set and was routed around rather than fixed: packages/budget/fixtures/_scene-indicator.css avoids the name, and packages/budget/fixtures/_scene-tokens.css still consumed it. Storybook, which declares a density on every story, was dropping two color: declarations already.

Nothing in the build caught it, and nothing was going to. Theme completeness diffs roles across themes and passes; density completeness diffs spatial tokens across densities and passes; the component tier checks that every alias resolves and passes. Each tier is individually complete. The defect exists only in the space between them, and no check was looking there.

#Decision

**The --wr-* namespace is one namespace, and a name in it belongs to exactly one tier. text-body belongs to the spatial tier: "body" is a rung on a type ladder whose other rungs are caption, lead, title and display, and the spatial tier is the only one that can name that rung without inventing a word for it. The semantic colour role is renamed text-primary**, which is the rank it always described and which pairs with the text-secondary already shipping beside it. The two vocabularies are now separable by rule rather than by luck: colour roles are named for rank in the reading hierarchy, spatial tokens for position on the type ladder.

Disjointness stops being a naming habit and becomes a build gate. packages/tokens/tools/verify-output.js gains a namespace section that runs before every per-tier check, because the per-tier checks all pass. It fails in two ways for two reasons: a config pass, which fails when one name is declared both as a semantic role and as a spatial token; and a shipped-CSS pass, which reads every declaration in css/ and fails when one --wr-* name is declared by two tiers. The second generalises the rule to the stacking, motion and component stylesheets, and catches a generator that emits a colliding name the configs never asked for. A stylesheet the pass cannot classify by tier fails rather than being skipped — a file exempt from the check is the one way this defect returns.

#Rejected options

Rename the spatial token to text-size-body. It leaves the more widely referenced colour role alone — the role is named in four theme maps, four APCA floor tables and every scene stylesheet, against three lines for the spatial token — and -size- announces the owning tier at the call site, which is a real property the chosen name does not have. Rejected because it breaks the one structure the spatial names have. text-caption, text-size-body, text-lead, text-title, text-display is a ladder with one rung spelled by a different rule than its four siblings; making it consistent means renaming all five, which is five renames to avoid one and produces worse names (text-size-caption describes nothing text-caption did not). The colour tier borrowed a typographic word for a rank, and the tier that borrowed is the one that gives it back.

**Prefix the whole namespace by tier: --wr-color-* and --wr-size-*.** The strongest alternative, and the only one that ends the class rather than the instance. The DTCG source already separates the groups; prefixing simply stops throwing that information away at emission, after which no future token can collide by construction and this ADR's check becomes unnecessary. Rejected on cost and timing rather than on merit: it renames every token in the system in the middle of Phase 4 — every theme file, every density file, the component stylesheet, every fixture and every story — to fix a defect with one known instance, and it invalidates the fixture set that baseline.json and themed-baseline.json are calibrated against, spending the Phase 0 and Phase 1 cross-platform evidence to buy a guarantee the check below already gives. The check is what makes deferring it safe: a second collision cannot arrive quietly, it arrives as a build failure, and a superseding ADR can spend the rename then with a second instance as the argument for it.

Keep both names and rely on import order. No rename at all, and it appears to work: import the density stylesheets before the theme stylesheets and the colour wins. Rejected because it is not true. The field-day compact clamp is a two-attribute selector and outranks the single-attribute theme block on specificity, so under that one theme the length wins whatever the order — and a system whose correctness depends on the order in which a consuming product imports two stylesheets has no correctness to enforce.

Leave it and keep the workaround. The scene stylesheet already avoids the name; the cost is one comment explaining why. Rejected because the workaround is local to two fixtures and the defect is not. It never reached Storybook, where two color: declarations were being dropped on every story, and it reaches no consuming product at all. A workaround that has to be reapplied by every future consumer, in a failure mode that renders without complaining, is not a fix — it is a decision to keep paying.

#Consequences

Renaming a role is a rename and nothing else: text-primary resolves to the same primitive step in every theme, so the shipped hex is unchanged everywhere. That was checked rather than assumed. Regenerating the token set changes exactly two lines per theme stylesheet, one key per semantic file, and the config hash; no primitive moves. The budget engine re-run over all eight Phase 0/4 fixture scenes and all twenty-one themed renders is identical to six decimals against both baseline.json and themed-baseline.json, so neither baseline is re-recorded — this ADR moves no pixels.

What it makes easier: the two vocabularies can now be read side by side without checking which tier a name came from, and text-primary / text-secondary is a pair rather than a size word next to a rank word.

What it makes harder: a name is now a cross-tier decision. Adding a spatial token called border-strong, or a colour role called text-title, is a build failure rather than a shipped ambiguity — which is the intended cost.

Enforcement: adds a Token namespace row to playbook §10. It touches nothing else; theme completeness and density completeness keep their existing objects and their existing verdicts. docs/STATUS.md closes the open item recorded when Phase 4 met the collision.