ADR-0074: Density is a token swap, and regular is no stylesheet at all
- Status: Accepted
- Date: 2026-08-17
- Relates to:
docs/design-system.md§1.3, §3;docs/ARCHITECTURE.md§8, §10; uses the mechanism of ADR-0049; applies to every control ADR-0059 ships
Context
docs/design-system.md §1.3 specifies a density preference:
Density:
--gb-densityregular(default) |compact— control heights 32 / 28, list rows 32 / 26. A user preference applied app-wide; token-conformant apps adapt with zero code.
Nothing implemented it. Every control’s height was a literal 32px in
controls.css, so there was nothing for a density to swap — the tokens the
promise depends on did not exist, and “adapt with zero code” was a sentence
about a mechanism that was not there.
This is deliberately being done at four controls rather than at thirteen. It is per-control plumbing: every control written before the token exists is a control that has to be revisited, so the change costs three edits now and ten later. That is the whole reason it is scheduled ahead of the fifth control rather than after the catalog.
Decision
The height is a token; nothing else is
controls.css declares §1.3’s regular column at :root and every control sizes
itself from it:
:root {
--gb-density: regular;
--gb-control-height: 32px;
--gb-list-row-height: 32px;
}
button { height: var(--gb-control-height) }
checkbox { height: var(--gb-control-height) }
radio { height: var(--gb-control-height) }
Padding, gap and radius stay literal. The obvious next move is to tokenise
them too “for symmetry”, and it is wrong: §1.3’s density row names control
heights and list rows and nothing else, so a --gb-control-padding that a
density moved would be inventing a scale the design system does not define
(Principle 3, “token or extend” — and extending means editing the table first).
DensityTest.onlyHeightMoves asserts that padding, gap and radius are identical
at both densities, which is what keeps a later change honest.
Compact is a theme-layer stylesheet, and there is no fifth cascade layer
density-compact.css is a :root block of three custom properties, parsed into
CascadeLayer.THEME — the same slot nord-light and nord-dark go into.
The alternative was a fifth layer between TOOLKIT_BASE and THEME, and it was
rejected because the theme layer is defined by what it holds, not by what it is
called: custom properties that the toolkit-base rules read, swapped as a user
preference, meaning nothing until a base rule reads them. That is a description
of a density as exactly as it is a description of a theme. A fifth layer would
differ from the fourth in its name and in nothing else, and CascadeLayer says
in its own documentation that four layers everyone knows beats an open-ended
mechanism.
The layer is also what makes the override work, and this is worth stating
because it looks like list order and is not. Both blocks are :root, so they
carry identical specificity; the cascade compares important → specificity → layer → order, and layer is therefore the only term that separates them. A
compact sheet parsed into TOOLKIT_BASE by mistake would tie all the way down to
order, and the winner would be an accident of sort stability rather than a
decision. DensityTest.compactIsAThemeLayer asserts the layer for that reason,
rather than asserting the resolved height and calling it covered.
There is no conflict with the theme sharing the slot: no theme declares
--gb-control-height, and no density declares a colour. compactDeclaresOnlyTokens
holds the second half of that — a density that grew a rule would be styling
controls behind the theme’s back, and switching one would restyle rather than
resize.
Density.REGULAR ships no stylesheet
Density.stylesheets() returns a List<Stylesheet>, empty for REGULAR and one
sheet for COMPACT. There is no density-regular.css.
This asymmetry is the fact rather than an omission. §1.3 spells regular
“(default)”, and a default is the absence of an override — regular is not
something an application applies, it is what the toolkit already is. Writing 32
in controls.css and in a density-regular.css would be one number in two
files, which is the arrangement this repository has already been bitten by twice:
§10.1 carried a typography table that disagreed with §1.4’s, and the checkbox
carried a surface ramp beside the button’s that disagreed with it
(ADR-0073). One number, one place.
The return type is a list rather than a Stylesheet for the same reason. An
empty stylesheet returned to keep two shapes matching is a thing that parses,
sorts and cascades every frame in order to do nothing, and Stylesheet.empty was
available — it was not used, because the honest statement is “regular contributes
no stylesheets”, not “regular contributes an empty one”.
The consequence an application sees is the good one: an application that never mentions density gets regular, because regular is the base and there is nothing to remember to add.
--gb-density is a marker, not the mechanism
§1.3 names the property, so it is declared. Nothing in the toolkit reads it.
A keyword custom property cannot select a number in §8’s subset — there is no
@container style() here, no @media, and there is not going to be either, so
--gb-density: compact cannot by itself make anything 28 tall. The two length
tokens beside it are what switch; this one says which set is in force, and custom
properties inherit, so any element can be asked. It ships because an application
that needs to branch in Java — or a list that has to pick a row height — should
read the answer rather than be told it out of band.
Density lives in :widgets, and Theme stays in :core
A density sizes controls, and :core’s primitives have no height for one to
move: row, column, text, panel and spacer are sized by their content
and their application’s rules. A theme is in :core for the opposite reason —
text reads --gb-text and panel reads --gb-surface, so the colour tokens
have consumers on both sides of the module boundary and the height token has
consumers on one.
Controls.stylesheets(theme, density) assembles the three in order, for the
reason the rest of that class exists: the order matters, getting it wrong is
silent rather than loud, and an application should not have to know that a
density goes above a theme in a list.
Compact is below §1.3’s own hit-target floor, deliberately
§1.3 says two things that cannot both hold:
Hit targets ≥ 32×32 logical px even when the visual is smaller. Density: … control heights 32 / 28.
A compact control is 28 tall. The floor gives, and it gives because that is what the preference is: a user who asks for compact is asking to trade the comfort margin for more on screen, and a density that refused to go below 32 would be a density that does nothing. The ≥ 32 rule is therefore the regular default rather than an invariant, and this record is where that is written down.
Two things bound the trade:
- The glyph does not shrink. A checkbox’s tick and a radio’s dot stay 16px at
either density; only the row around them closes. Compact costs 4px of margin
around the target, not a smaller target — a density that scaled its contents
would look plausible in a screenshot and be a zoom rather than a density.
theGlyphHoldsStillasserts it. - Compact is never the default. It is reached only by an application setting it, on a user’s instruction. Nothing in the toolkit chooses it, and no OS setting is read to infer it.
Consequences
- §1.3’s density row is implemented and its “zero code” promise is real: the
showcase switches density on
Ctrl+Dand not one widget in that file mentions a height. There is deliberately no button for it in the tree — a density is an application-wide preference, so it belongs in a menu or a settings screen, neither of which exists yet. - Every existing golden image is byte-identical. The token swap changes
nothing at regular density, which is the check that it was a refactor — the
same check ADR-0073 used when the mark
became a node. Two new images,
controls-density-{regular,compact}.png, are the same scene at both, so the pair is the assertion: three controls four pixels shorter and nothing else moved. - The height assertions are written over the catalog rather than per control,
because a density that moved
buttonand notcheckboxwould pass three per-control tests and be exactly the divergence §3’s shared metrics row exists to prevent. A control added with a literal height failsDensityTeston the day it is added, which is the point of scheduling this at four controls. theTwoDifferexists because the two height tests cannot cover each other: if the token were dropped and both densities fell back to one literal, one of them would still pass in full.- Open:
--gb-list-row-heighthas no consumer.listis M3. It ships now because the density alistwill have to honour is decided here rather than there, and an application building its own rows today has the token it would otherwise hard-code. That is the same argument ADR-0037 made forParagraphCache, which shipped a year of frames before anything called it. - Open: nothing detects the user’s preference. An application that knows sets it, exactly as with reduced motion (ADR-0067) — SDL exposes no query for either. The difference is that reduced motion is an accessibility setting the OS really does hold, while density is usually the application’s own preference, so this one may never need detecting.
- Open: the typography does not move with the density. A 28px control still carries a 13/18 label, which fits (18 of 28, against 18 of 32) and is what §1.4 specifies unconditionally. Whether a compact density should also take a step down the type scale is a question §1.3 does not answer, and inventing an answer here would be the “improvise a third value” mistake ADR-0066 declined to make.