ADR 0014. The material rung names are the token names
Three documents name the six material rungs three ways. The names in tokens/material.json win, because a stylesheet resolves them and a drift gate checks them.
Status
Accepted. 2026-09-03.
Context
The material ladder has six rungs and three sets of names for them, in three documents that all read as authoritative.
| Source | The six rungs, in order |
|---|---|
tokens/material.json | canvas, card, raised, sheet, overlay, scrim |
The MaterialRung union on Surface | base, raised, panel, overlay, sheet, chrome |
| The rung table on The ladder | ground, panel, raised, bar, overlay, scrim |
Only two names appear in all three, and one of them is a trap. raised is rung
two in the token source and rung two on the ladder page, but rung two in the
component union sits where the token source has card. A component written
against one vocabulary and styled against another therefore compiles, renders,
and sits at the wrong depth. sheet and overlay appear in two of the three
sets in opposite order. bar and chrome exist in one set each and in neither
of the others.
Only one of the three sets has consequences. scripts/build-tokens.mts reads
tokens/material.json and emits --opsin-material-<rung>-<property> into
app/tokens.generated.css; pnpm check:generated regenerates that file and
fails on a byte difference; and the token source states the naming rule itself,
in a comment saying that rungs are addressed by name and never by number,
"because a number invites arithmetic … and the ladder is not arithmetic". The
other two sets are prose. Nothing resolves them, nothing checks them, and neither
has ever been true of a rendered element, because no component exists yet.
Amendment, after the component layer shipped. Two clauses above are stale and
are left standing rather than rewritten: "because no component exists yet" here,
and the aside under Alternatives considered placing
this decision "at a moment when none of the components that would use the names
exists". Sixty components are now implemented and every catalogue row reads
shipped, and two of them resolve these names in code. surface.tsx imports
the MaterialRung union from the shared substrate, keys its translucency table off it so that adding a rung
breaks the build rather than defaulting, and carries a table of the three retired
names that can be told apart. That table maps base to canvas, panel to
raised and chrome to overlay, by job and not by position, so that a caller
who passes one gets a development warning naming the rung that now does that job,
and gets their content rendered with no material rather than dropped. card.tsx
defaults its rung to card, which is the correction the first Consequence
below asks for. The clause "neither has ever been true of a rendered element"
still holds: a rendered element resolves the token names and nothing else. What
the shipped code changes is the cost of the outstanding half. The rung table,
description and alias on The ladder
still carry the losing vocabulary, and a reader who translates bar or overlay
positionally against a component that now exists lands a surface at the wrong
depth.
Decision
The token names win. The material ladder is canvas, card, raised,
sheet, overlay, scrim, ordinal zero to five, and the MaterialRung union
is that list and nothing else, declared once in the substrate module of
ADR 0010.
The reason is not that the token source is older or more official. It is that the
token names are the ones a stylesheet resolves and a gate checks, and the other
two are the ones a reader has to be told about. Choosing either prose set would
mean renaming six generated custom properties, regenerating
app/tokens.generated.css, and re-measuring every published figure keyed to
those names. All of that buys one word.
Consequences
Card's documented default rung is wrong and must be corrected. The specification defaultsrungto"raised", and in the losing vocabulary that reads as the rung a card sits on. In the token vocabularyraisedis one rung higher. The token source describes rung one,card, as the default home for a health value and rung two,raised, as a thing lifted off the page. The default almost certainly means the token layer'scard. Fixing it belongs to whoever owns that page; this record only settles which vocabulary the word is read in.- Two documentation pages are now wrong, and must be corrected rather than
reinterpreted: the
MaterialRungunion on Surface and the rung table on The ladder. The ladder page also carries the losing names in itsdescriptionand in one of itsaliases, which makes part of that correction an edit to the global alias namespace rather than to prose. barandchromeare renaming casualties, not gaps. Both losing sets reserve a rung for persistent edge chrome that content scrolls under, and so does the token ladder:overlayasks "Is this chrome floating over scrolling content?" and its stated use names "a pinned toolbar, a tab bar, a floating action bar" as chrome that content scrolls beneath. A tab bar has a rung and it isoverlay. What the losing sets calloverlayis the token ladder'ssheet. That rung is the one "covering the page while leaving it recognisable underneath", a dismissible bottom sheet or side panel. The vocabularies must be mapped by job, never by ordinal, and this pair is exactly where an ordinal mapping goes wrong:overlaymeans different depths in the two schemes, so a reader translating positionally lands a dismissible sheet on the chrome rung and a tab bar on the sheet rung.- The ladder is uniform, and it was not when this record was first written.
The version of this consequence written on 2026-09-03 said that
card,raised,sheetandoverlayhad no border token at all. That was true of the emitted CSS on the day it was written and false a few hours later:emitMaterial()readborderandtintAlphawith a string helper whiletokens/material.jsonstores several of them as{light, dark}objects, so seven properties were silently never emitted. With the emitter fixed, all six rungs carry all eight properties, and the literalnoneis the border oncanvasandscrimrather than on the other four. That is correct: the page itself has no edge, and neither does a dimming layer. A component reads--opsin-material-<rung>-borderand gets a value;noneis a legitimate one and is invalid insidebox-shadow, so a rung's edge is aborder-colorand not a ring. - No token is renamed and no generated file changes. The entire cost of this decision is prose, which is why it is cheap enough to take now and would not have been in six months.
Alternatives considered
The component specification's names win. That set is base, raised,
panel, overlay, sheet and chrome. Rejected. It renames six generated
custom properties, invalidates app/tokens.generated.css and every measured
figure keyed to it, and buys a word. It also orders sheet above overlay, the
opposite of the token ladder, which is the one difference in either losing set
that would silently place a surface at the wrong depth rather than merely under
the wrong name.
The ladder page's names win. Those six are ground, panel, raised,
bar, overlay and scrim. Rejected on the same ground. bar reads as the
more descriptive word for the rung the token layer calls overlay, and if the
ladder were being named from scratch it would be the better choice; it is not
worth renaming six emitted custom properties and invalidating every measured
figure keyed to them. Note what this alternative would cost beyond the rename: it
uses overlay for the rung the token layer calls sheet, so adopting it would
move the meaning of a word that is already in use, which is the one kind of
change that breaks a component silently rather than loudly.
Keep all three and map between them. Rejected outright. A mapping table is a fourth vocabulary; it has to be maintained in both directions; and its first stale row puts a health value on a translucent rung, which the token source forbids in a single sentence for a reason that has nothing to do with naming.
Rename the tokens and the prose to a fourth set that everybody likes. Rejected as scope. It is the cost of the first option plus a naming debate, at a moment when none of the components that would use the names exists.
Revisiting this
Revisit when a component needs a rung the ladder does not have; bar is the
known candidate and the tab bar is the component that will ask for it. That is a
change to tokens/material.json followed by a regeneration, and it is a design
decision about what should remain visible behind persistent chrome rather than a
renaming. Do not revisit in order to make one of the prose sets win: the prose is
the part that changes.
Last read through against the system on 2026-09-20. Due for review every 6 months; expiry is reported by pnpm run check:freshness.
ADR 0013. A component preview is an iframe, not an inline render
Every component preview embeds a /view route in a frame, because the two palettes overlap and an inline preview shows a component that is not the component.
ADR 0015. Error codes are flat, not banded
OPSIN-0001 upwards, allocated in sequence with no meaningful ranges. tokens/errors.json is the only place a code is authored, and the rest is generated from it.