254. A build may ask the cascade for a number
Date: 2026-09-05
Status
Accepted. Closes the TODO.md entry
ADR-0251 left
open, and with it the last of
ADR-0213’s.
Context
ADR-0251 gave a widget Paints.Context.length, and said plainly what it did not
close:
listis unchanged, and its entry stays open.ListView.virtualized(h)takes the row height as an argument, which is an API choice rather than a missing reader: the number decides which rows to build inchildren(), and a value banked fromrenderwould be a frame late in the one place a frame late means building the wrong rows. The door this opens is the onescrollneeded;listneeds a different one.
The stakes are higher than “a number is stated twice.” density-compact.css
sets --gb-list-row-height: 26px. A list written virtualized(32) against the
regular density therefore virtualizes on the wrong pitch the moment an
application switches density — the spacers and the window disagree with the rows.
The number was not merely inconvenient to repeat; repeating it was a bug waiting
for a setting to be changed.
Decision
BuildContext.token(name, fallback) — Paints.Context.length’s build-time
twin, for the numbers wanted before there is a box to paint.
It is three lines, because the pieces were already there and had not been put
together: Element already implements both BuildContext and
StyleElement, and ElementTree has held a StyleResolver since ADR-0149 so a
node whose state changed could ask what the sheets say. What was missing was the
method.
WidgetRenderer.prepare(tree) is new and is about ordering. render
already hands the tree its resolver on the way in, and that is a frame too late
for a reader in build — a build runs before the frame it produces. Launcher
and Popup now call prepare before the flush.
ListView.virtualized(), with no argument, is the form to prefer.
virtualized(h) is unchanged.
rowHeightFromToken is a component, and the first attempt was a sentinel
rowHeight is already a tagged number — 0 means “do not virtualize” — so a
second tag, -1 for “ask the token”, looked consistent and cost nothing.
It cost a guard. ListVirtualTest asserts that virtualized(-1) throws, and
the test’s name says why: “a negative row height is refused where it is
written”. -1 is what a typo looks like, and the refusal catches it at the call
site. Making it meaningful would have deleted a check that exists to catch a real
mistake, in exchange for not adding a field.
So it is a boolean beside the number. Seven constructor sites, and none of them
could be got wrong silently: double, boolean, Attributes in a row is a
transposition the compiler refuses.
The first build of a tree has no cascade, and that is worth writing down
A Stateful widget builds once inside the ElementTree constructor — before any
renderer has taken the tree on, and therefore before any cascade exists. So a
token asked for there answers its default, and the second build is the first
that can see the stylesheet.
This was found by measuring rather than assumed: the value resolved correctly on
every build except the first, which is the one prepare cannot reach.
It is acceptable because everything that reads a token is expected to settle, and a virtualized list settles by construction — its window is recomputed from the geometry every frame, so the frame after the first is already right. Closing it means handing the resolver to the tree at construction, which nothing has needed enough to widen the constructor for.
The token must be declared at or above the list
ListView is a composition node whose state builds the list element, so the
build that decides the row count runs one level above the node a
list { … } rule would match. Custom properties inherit downward, so the token
has to be set on an ancestor.
That is where it ships — :root, in both controls.css and
density-compact.css — so the case this exists for works. It is stated here
because list { --gb-list-row-height: 26px } looks like it should work and will
not.
Alternatives considered
- Bank it from
render, asscrolldoes. ADR-0251 already gave the reason it does not transfer: the number decides which rows to build, so a frame-late value builds the wrong rows rather than moving the right ones. - Read the token in
ListBox, which is thelistelement and would honourlist { … }. It is built by the state that needs the answer, so the question would be asked after it was needed. - Make
virtualized()meanvirtualized(32). It is the same repeated number with the repetition moved into the toolkit, and it is wrong under compact density in exactly the same way. - A general
token(String)returning tokens rather than a number. The argumentPaints.Context.colormade and this inherits: a widget would parse them, and there would be two parsers.
Consequences
- Four tests, and the one that says what this is for asserts that a compact density changes the pitch with no Java change at all.
ListVirtualTest’s harness callspreparebefore its flush, matchingLauncher. Without it the tests would pass for the wrong reason — the harness would be measuring the fallback and so would production.- The token tests settle over two extra frames, and the comment says which property that is rather than treating it as flakiness.
--gb-list-row-heightfinally has a consumer, which is what itsTODO.mdentry has wanted since it shipped with the density tokens: “it shipped before any of them existed, because the density they would have to honour was decided here rather than there”.