178. A stack closes its own hole
Date: 2026-08-23
Status
Accepted. Builds §3’s sibling reflow, the one thing
ADR-0177 left §7
owing, and the last of docs/core-widgets.md §7.
Context
docs/design-system.md §3, the toast row: “in: slide 16px from edge +
opacity, overlay · out: opacity base · siblings reflow via translate
base (explicit controller — the one sanctioned movement effect, transforms not
layout)”.
Everything else in that sentence shipped with the widget. The reflow did not,
because it is the one part that is not about a toast at all: it is about what
happens to the toasts that stay when one of them goes. Until ADR-0177 there
was nothing in the catalog that knew what a notification’s siblings were — a
message is written where it goes and has no owner
(ADR-0175) — so the effect had no
subject. A stack that holds a queue is a subject.
Without it, a toast dismissed from the middle of three is a jump: the column reflows on the frame the element leaves the tree, and everything on one side of it is suddenly somewhere else.
Decision
Only the older toasts move, and the corner decides which way
This is the part that is not obvious, and it falls out of where the column is pinned rather than out of anything the widget does.
A toaster is a corner overlay: content-sized, with two of its four insets
undefined, so it is anchored along the edge it is against and grows away from
it. controls.css then arranges the children so that the newest toast is the
one nearest that corner — column at the bottom corners, column-reverse at
the top. Put those together and the column is anchored by its newest member.
So taking one out of the middle leaves everything between it and the corner
exactly where it was, and moves everything on the far side — the older half —
toward the corner by the height of the hole plus the gap it was keeping. Which
direction “toward the corner” is, is the corner’s own, and ToastBox reads it
there rather than being handed a signed number; a signed distance would be the
one value in the widget that had to be recomputed when a stack changed corner.
A corollary worth writing down: the ordinary case moves nothing. A stack
whose toasts all have the same timeout loses its oldest first, and the oldest
has nothing older to move. The reflow is what a dismissal from the middle
looks like — an action button pressed, a clear(), a burst with uneven
timeouts.
The translate runs backwards
Nothing here moves a toast to a new place. The sibling is gone, so Yoga has already put the survivor where it belongs; what the translate does is put it back where it was for one frame and then let go. The offset shrinks to zero rather than growing from it.
This is the same shape as the arrival — (1 - visible) * TRAVEL — and it is
worth naming because the two are otherwise easy to confuse: the arrival is
about the toast, the reflow is about a hole, and they compose on two axes
rather than taking turns. A toast can still be sliding in when the one beside
it is dismissed, and either effect being dropped for the length of the other is
a gap that closes late.
The height comes from Measured, and the gap from the stylesheet
The distance is two numbers, and neither is one a widget may invent.
- The height of the hole is the departing toast’s, which nothing can ask for
afterwards — by the time it is wanted the toast is gone. So each
ToastBoximplementsMeasuredand the stack banks what every frame reports. That it is last frame’s is exactly right here: a toast has to have been on screen to be dismissed, so by the time the number is wanted it has been reported.Measured’s third rule holds too — a reflow is atransform, so the box it moves is laid out where it always was, which is why §3 asked for a transform in the first place. - The gap is
toaster { gap: 8px }, reported up fromrenderbeside the frame clock, through the channel ADR-0177 opened for the clock and for the same reason: it is a reading onlyrendercan take. Read rather than assumed, because a stylesheet that changed the gap and nothing else would otherwise leave the whole stack reflowing to somewhere it is not.
A toast dismissed before a frame ever painted it has no height, and the stack reads that as no hole rather than as a hole of nothing. The check is on the height and not on the total: the gap alone is a real number, and 8px in a direction nobody asked for is worse than the jump this replaces.
Not an AnimationController
§3 says “explicit controller”, and book/src/TODO.md has had the imperative
AnimationController down as a specification without a subject since
ADR-0081 took two of its three away —
the spinner and indeterminate progress ship as functions of the frame clock with
no state at all. Toast reflow was one of the two it had left, on the grounds
that it has “a start, an end, and an interruption to reverse from”.
Two of those three are Phase, which already ships and already runs on the
frame clock. The third — the interruption — turned out to be arithmetic rather
than a mechanism:
var left = entry.reflow == null ? 0
: entry.reflow.distance() * (1 - entry.reflow.phase().progressAt(now));
return new ToastBox.Reflow(left + distance, new Phase(ENTERING, REFLOW_MILLIS));
Read what is left, add the new distance, start again. That is ADR-0081’s finding one level up: a controller here would be a per-element copy of the time for a consumer that needs three lines of it. The overlay enter/exit sequence is the one subject the specification has left, and it stays on the list.
Phase.Kind.ENTERING for a movement that is neither an arrival nor a departure,
because what the kind actually selects is “runs once, then settles” — and
settling itself is what stops isAnimating asking for frames forever.
Consequences
- §7 is complete, and §3’s movement clause has its one implementation. A
column of
messagees still cannot have it, for ADR-0175’s unchanged reason: a banner has no owner to hold the list. isAnimatinghad to learn about the second clock, which is ADR-0176’s bug waiting to happen again: a toast that has settled but is still travelling would be a widget nobody repaints, and a widget nobody repaints does not move — it stands still for 160ms and is then somewhere else. A golden would photograph that happily, so the assertion is onisAnimatingdirectly.- The golden had to be taken in a real window, which is new for this widget
and is the direct consequence of the first decision above. Every other picture
in
ToastGoldenTestis of a column on its own, and that column is top-anchored — so it photographs the newer toast moving, which is the opposite of what a pinned stack does. Overlay placement is not assertable as a number, whichHudGoldenTestfound first. Measuredhas a fifth consumer, and the first whose reason is not its own geometry but a sibling’s.book/src/TODO.mdcalls it “a door every widget can now open and almost none should”; a stack that must move its survivors by an exact distance is one of the few that should.