Skip to contentWolf-Rayet

Decision records

ADR-0039 Two roles on one lightness is the ladder; a state told apart by hue alone is the defect

Accepted2026-09-02Phase 4, reopened

#Context

The form Emitter audit reported that status-critical and emitter-marked resolve to the same lightness in field-night, so an invalid field is not told from a resting one by its edge, and that in interior-dark and field-day the two separate by hue alone at identical lightness. Read against the token source rather than taken on trust, the report is right and understates its own worst case. In field-night the two roles do not merely share a lightness: they resolve to the same hex, #8d4931, at L 0.484, C 0.0996, H 40. Same lightness, same chroma, same hue. Nothing separates them at all.

Widening the question before deciding it changes what the finding is. Taking every pair of semantic roles that can be substituted into one element — the mark slot, the one slot a variant swaps a role into; surface and text slots take a fixed role each and so pair with nothing — there are 15 such pairs, and they collide on lightness in 25 (pair, theme) combinations. Of those, 19 are separated by hue alone, 5 by chroma alone, and 1 by nothing. That is not one loose value. It is the shape of the generator: generate.js builds one base lightness array per theme and every ramp starts from base.slice(), so only chroma and hue vary per ramp and two roles assigned the same step index resolve to the same L by construction.

Which means the collision cannot be read as a defect on its own, and the number 25 is two different facts added together.

Twenty-one of those collisions are between roles declared at the *same* level. Those are the system working. ADR-0027 makes emphasis the level, and the level is the lightness ladder; status-positive, status-caution, status-critical and emitter-marked are all declared level 2, so they are *supposed* to sit at one lightness. Separating them by lightness would say a caution is louder than an ok, which is the one thing the ladder exists to prevent.

Four are between roles at *different* levels, and those are the ladder failing at the token layer. In interior-light, emitter-directed (level 3, accent/2) sat at L 0.3787 with all three status roles (level 2, */2) — and interior-light was the only theme that split its own level-2 roles across two steps, putting emitter-marked at step 3 and the status roles at step 2, which is what put the status roles on top of level 3. In field-day, emitter-demanded (level 4, critical/1) and emitter-directed (level 3, accent/1) both sat at L 0.1725: the ladder's top two rungs at one height, separated by hue 25 against 250, in the theme built for high ambient light where hue is least reliable.

The second fact is at the component layer and the audit found the right site. input-field, text-area, select and number-input declare geometry keyed on level, not variant; surface.control-color is surface-inset for resting, filled and invalid alike, as that component's own $surfaceNote records; and outline-color is a fixed structural border. The four stylesheets declare exactly one variant-scoped rule between them — --focused, setting the control's ground — and none for --invalid. So at one level a resting field and an invalid field differ in the mark role and in nothing else, and in field-night that role resolves to the same hex. They render identically. input-field.css said so in a comment: what tells invalid apart is "this bar's colour". In three of the four themes that colour carried nothing.

#Decision

Two roles resolving to one lightness is not, by itself, a defect, and the check this ADR adds does not report it as one: roles declared at one level are required to share a lightness, because the level is the lightness. What is forbidden is narrower and is enforced in two rules, both machine-checked in packages/tokens/tools/verify-output.js §9, which reads the configs and the committed primitives and never calls the generator.

Rule A — the ladder must have rungs. Two roles that can occupy the same slot and are declared at different levels must not resolve to the same lightness in any theme. Where they do, the level itself is being carried by hue, and §4 makes hue reinforcement and never the carrier. The fix is a step assignment in semantic.config.json, not a nudged value.

Rule B — a state may not be told apart by hue alone. Where two of a component's variants resolve, at one level, to different mark roles whose lightness is equal in any theme, that component's stylesheet must give those variants non-hue declarations that differ — shape, weight, position or pattern, §4's own list. Declaration *values* are compared, not property names, because ok and idle both declare border-radius and attention and critical both declare clip-path; and transparent or none on a colour property counts as non-hue, because it encodes fill against no-fill, which is a reading that survives greyscale. A component may be exempted only by a $stateEncodingExemption naming the channel that already carries the state. The exemption list is printed by the check on every run and is part of the reviewed surface.

A mark against its own ground is not this check's business: a mark that vanishes on the surface it sits on is a contrast failure, and §7's non-text APCA floors already fail it.

#Rejected options

Separate the colliding roles by lightness. Its merit is that it is the direct answer to the literal finding, and lightness is the channel that survives greyscale, protanopia and field-night's single locked hue, so a pair separated by it is separated for every reader. It lost on the 21 same-level collisions, which are most of them. status-positive, status-caution and status-critical are one level by declaration, and giving them three lightnesses would make a caution read as louder or quieter than a critical for no reason but that it needed a distinct L — ADR-0027's rule inverted, emphasis set by identity rather than by level. The option is right for exactly the four cross-level collisions, where it is not an alternative to this decision but is Rule A, and it is wrong everywhere else.

Give the invalid state a second channel such as edge weight or a mark, and leave the lightness shared. Its merit is large and it is half of what was done: it is what §4 actually prescribes, it needs no ramp change and no baseline movement, and it is local to the components that are broken. It lost as a *decision* because it is a repair and not a rule. It fixes four stylesheets and writes nothing down, so the next component built on the same collision arrives with the same defect and nothing fails; it leaves the four cross-level ladder collisions untouched, and no second channel can fix those, because there the colliding thing is the level itself rather than a state; and it would have left the meter — whose encoding is length and is the strongest in the set — indistinguishable in the record from the four components that had no encoding at all. Rule B is this option generalised and made to fail.

Declare the collision acceptable because no component renders both roles on one element simultaneously. Its merit is that the premise is true, and provably so rather than by assertion: input-field-contract.ts states that a field "is in exactly one of these at a time", the four states are a closed mutually exclusive set, and the component tier keys the mark token on variant, so one element resolves one role per render. It lost because the premise is true and irrelevant. Discrimination does not require one element to hold two roles at once; it requires a reader to tell two elements apart, or one element now from the same element a moment ago. The same contract supplies the counter-example in its own justification for excluding level 4: "a required- fields check on submit fails every empty field in the same instant, in the same Scope," a state "a real form produces in twos and threes as a matter of routine." That is two fields side by side in one Scope at one instant, one resting and one invalid, at the same level, on the same ground, with the same edge weight — and in field-night, the same hex. The contract's argument for its own level ceiling is the proof that this option is wrong.

#Consequences

Four role assignments move in semantic.config.json, and no ramp does — zero primitive steps change, because the fix is which step a role points at. interior-light status roles move from step 2 to step 3, joining emitter-marked so that theme's level-2 roles share one lightness as every other theme's do; field-day's emitter-demanded moves from critical/1 to critical/0, putting a rung below Directed. Cross-level collisions go from 4 to

  1. Total collisions go from 25 to 24 and hue-only from 19 to 18 — the count

barely moves, and that is the finding rather than a disappointment: the collisions that remain are the ladder, and Rule B is what makes them safe.

The status-critical/emitter-marked pair the audit opened with now collides in all four themes rather than three, because interior-light was corrected *into* the collision. That is the intended direction. The two roles are one level and belong at one lightness; what changed is that their difference is no longer asked to ride on colour.

The four form components gain a variant-scoped --invalid rule: the leading edge is masked into segments, with the period taken from the edge's own width token so the pattern sits on the ladder's axis. A mask carries no colour, so the distinction holds in greyscale, under protanopia, and in field-night where the two roles are one hex. The meter gains a $stateEncodingExemption recording that its variants are told apart by the fill's length — product state, which no CSS scan can see — which converts a $shapeNote sentence into a field the check reads and prints.

What this makes harder is adding a component that leans on hue: Rule B fails it at build time unless it declares a channel or earns an exemption, and the exemption is visible in the check's own output. What it makes easier is the review that could not previously be done at all — "is this state legible in field-night" is now a question the build answers rather than one a reviewer remembers to ask.

Token values move, so baselines move with them under ADR-0032, per platform, recorded by the platform that measured.