416. rem is the root element’s size, and the walk is what knows it
Date: 2026-09-19
Status
Accepted. Closes the TODO.md entry left open by
ADR-0242, which is the record that
created it.
Context
ADR-0242 fixed em and wrote down what it had not fixed:
remcontinues to useContext.rootFontSize(). In CSSremis the root element’s computed font size, so these agree unless the root element itself declares one — and recovering that insideComputedStyle.ofis not possible, because a node is handed its parent’s style and not the root’s. Nothing in the catalog styles a root’sfont-size, so this is exact today and is written down rather than fixed.
And named the two shapes a fix could take:
It needs a third thing passed down beside the parent style, or a mutable field on the renderer that is correct only after the root has resolved.
The diagnosis is exactly right and the pessimism is half misplaced. The problem
splits in two, and only one half is out of ComputedStyle.of’s reach.
Decision
The root’s own half was already built, for em
ADR-0242 gave ComputedStyle.of two passes: font-size first against the
parent’s size, then everything else against the size that produced. The reason
was CSS’s one exception — 1.2em on font-size means “a fifth larger than my
parent”, because the value being computed cannot be its own input.
rem has the same exception, from the other end. CSS: when specified on the
root element’s font-size, rem refers to the property’s initial value. So on
the root:
font-size: 2remresolves against the configured size, because the root has not computed one yet;- everything else on the root resolves against the size it just computed.
That is one line in the second pass, in a method that was already shaped for it:
var rootSize = parent == null ? (float) style.typography().size() : context.rootFontSize();
var own = new CssLength.Context((float) style.typography().size(), rootSize);
parent == null is the root — the method’s own javadoc has said so since it
was written. No plumbing, and the “correct only after the root has resolved”
caveat turns out to be the specification rather than a wart.
The descendants’ half is the renderer’s, and it is the second shape
A node with a parent takes rootFontSize as given, because by then nothing in
the method can do better: a node is handed its parent’s style and never the
root’s, which is precisely ADR-0242’s point. What can is the thing that walks
the tree, at the one moment it holds the root’s resolved style and has not yet
descended.
WidgetRenderer keeps one field, lengthsBelowRoot, reset to the configured
context at the top of every frame and set once:
if (element.parent() == null && self != null) {
lengthsBelowRoot = lengths.withRootFontSize((float) self.typography().size());
}
It is the only mutable style state in the walk, so “is it ever stale” is the question this shape has to answer. It is not, for two separate reasons and both matter:
- Within a frame, it is set before any descendant resolves and read nowhere else. A frame has either not reached the root — in which case the configured value is the right answer, because there is no root size yet — or has.
- Across frames, a root whose
font-sizechanged resolves a different style and therefore hands its children a different instance, and their cache is keyed on that by identity ([ADR-0070]). The subtree re-resolves without anything telling it to, which is the invalidation scheme already in place doing the work a “root size changed” signal would otherwise need.
Why the field rather than the third argument. Threading the root’s size into
ComputedStyle.of means a fourth parameter on a method with two public overloads
and callers in the renderer, the keyframe track and every style test — to carry a
value that is constant for the whole tree, which is what CssLength.Context
already is. The context is the third thing, and it is already threaded; the only
change is that one of its two fields now means what it says.
Context’s two fields both narrow, and that is a pattern rather than a coincidence
ADR-0242 left Context.fontSize meaning “what the root’s em resolves
against” rather than “what every em resolves against”. Context.rootFontSize
takes the same step here: it is what the root’s own font-size declaration
resolves rem against, and from the root’s computed size onward the renderer
replaces it. A Context is now, precisely, the two numbers the root starts
from.
Alternatives considered
- A fourth parameter on
ComputedStyle.of. Above. Every caller would carry a tree-wide constant through a per-node call, beside a parameter that already carries tree-wide constants. - Compute the root’s size in
render(ElementTree)before the walk. One extra resolve of one node, and then the whole walk — root included — uses the final context. Simpler, and it gets the root’s ownfont-size: 2remwrong by making it its own input. It also has to mirrorStyled.restyle, or the pre-pass and the walk disagree about the root’s style. - Keep the configured value when the root declares nothing. This is the only
alternative with a real argument behind it: it changes no existing answer.
Rejected because it makes
remmean two different things depending on whether some other rule exists — the configured number for a silent root, the computed size for a declared one — and a unit whose meaning turns on a declaration elsewhere is worse than one that is merely different from what you expected. - Leave it, since nothing in the catalog styles a root’s
font-size. That was the state, and it is the same argument ADR-0242 rejected forem: a unit that silently means something else is worse than an unimplemented one, because it looks like it works. The typography scale is what makes it reachable, and §1.4’s whole point is that an application moves the scale.
Consequences
rembelow a root that declaresfont-sizechanges, which is the entry’s case and the thing that was broken.window { font-size: 20px } button { padding: 2rem }is 40 and was 32.rembelow a root that declares nothing changes too, and this one is worth stating plainly: a silent root computesTypography.INITIAL’s 13, so2remis 26 where it was 2 × the configured 16. The configured number now reaches only the root’s ownfont-sizedeclaration. That is the cost of the decision above, paid once, and nothing shipped is affected — ADR-0242 checked that not oneemorremappears innord-dark.css,nord-light.css,controls.cssor the showcase’s sheets, and that is still true.- One existing test changed meaning and was rewritten, exactly as ADR-0242’s
emtest did.ComputedStyleTest’s “rem multiplies the root font size, not the local one” built an element with no parent, passedContext(20, 16), and asserted 32. An element with no parent is a root, and this one declares no size, so its computed size is 13 and2remis 26. On a root,1remand1emcoincide — which reads like the test losing its point and is CSS’s rule: the root has nothing above it for the two to differ about. The point moves toRootFontSizeTest, where there is a descendant to tell them apart. RootFontSizeTestneeds a real renderer, and that is the ADR in one sentence. The missing half was never arithmetic, it was reach, so a test built onComputedStyle.ofalone cannot fail against the old code —computeChild, the existing stand-in for the renderer, hands both contexts down unchanged and is itself an instance of the bug. Six tests: the entry’s case, a root and a descendant declaring different sizes soremandemcannot be satisfied by one number, the silent-root case above, the root agreeing with its own descendants, and two renders in a row.Paints.Context.length’sremis exact now. That seam runs withcurrentElementset, so the walk has passed the root. Itsemnarrowing — against the root’s size rather than the node’s — stands, documented as before, because the element’s resolved style still is not in hand there.CssLength.Context.withRootFontSizeis the only wither on the record. There is deliberately none forfontSize: the element’s own size is derived per node where it is used, and a second way to say it would be a worse way.