ADR-0100: A window has a layer above its application
- Status: Accepted
- Date: 2026-08-18
- Relates to:
docs/core-widgets.md§7,docs/ARCHITECTURE.md§4 and §11, extends ADR-0093, applies ADR-0062 to the toolkit’s own state
Context
docs/core-widgets.md §7 opens by naming two places an overlay can be drawn:
“all overlays render in the in-window overlay layer or backend popup windows
as appropriate”. The second half is M3’s — a menu that escapes the window needs a
platform window, and Backend says outright that popups are absent “not dropped”
until something needs one (ADR-0019).
The first half had nothing behind it at all, and three of §7’s five widgets want
it rather than a popup: a toast stacks in a corner of the window, a dialog’s
scrim covers the window, and hud — the frame-rate readout this record’s
sibling (ADR-0101)
adds — lies in a corner of it. None of them wants a second platform window, and
on Wayland none of them could reliably have one anyway.
Nothing in the tree can float itself. Yoga places an absolute box against its own parent, which means the furthest a widget can pin itself is the panel it happens to be in. A toast raised from a form’s submit handler would appear in the corner of the form. The thing being pinned to is the window, and only something that sits above the application’s root can name it.
And there is exactly one place that sits there: ADR-0093’s
launcher, which owns the window, the three trees and the frame loop, and hands
an application a Host instead of any of them.
Decision
Every window’s element tree is rooted at a WindowRoot, always, and an overlay
is one of its children.
tree = new ElementTree(new WindowRoot(application.root(), overlays));
WindowRoot renders one box: the application’s root in flow with flex-grow: 1,
so it fills the window, and every overlay after it as an absolute box inset to a
Corner.
Three consequences follow from that one shape, and each is the point of it:
- An overlay takes no space. An absolute box takes no part in its parent’s flex layout, so the application’s box is the same box it was. Adding a HUD cannot move a pixel of what is under it.
- An overlay is painted last. A box tree has no z-order beyond document order (ADR-0053), so “on top” is “listed after” and needs no new concept.
- Two of the four insets are
UNDEFINED, not zero. An inset of zero on all four edges pins a box to all four and stretches it across the window — which is a scrim, and a perfectly legal box.Corner.insetssets the two edges its corner touches and leaves the others undefined, so the overlay keeps its own size.
The root node is there from the first frame
Whether or not anything is floating. A layer that appeared with the first overlay would re-parent the entire application to show a toast, and re-parenting is precisely what throws away element state, focus and every animation in flight (ADR-0052). The cost of the node when nothing is floating is one box in the tree and one Yoga node; the cost of adding it later is the application’s state.
The layer arrives as a binding, not as a constructor argument
Host.overlay(...) is called at any time — from Application#start, from a
handler, from a key. An ElementTree’s root widget cannot be swapped, so the
list cannot be a value the root was built with.
It is a Property<List<Overlay>> the launcher owns and WindowRoot watches,
through the binding() every widget already has. The root element subscribes for
as long as it lives, and a change marks it for rebuild — the same route an
application’s model takes to the screen (ADR-0062).
No setState, no second invalidation path, and no mutable list read behind the
framework’s back.
The list is replaced rather than mutated, which is not a style preference: the subscription is to the value, and a list changed in place is the same value.
Overlay is a handle with identity
var hud = host.overlay(new Hud(), Corner.BOTTOM_END);
hud.remove();
Adding is one call and removing is the same object. Two identical HUDs in two
corners are equal values and two different things on screen, so removal is by
identity — which is why Overlay is a class and not a record. remove() is
idempotent, because removing twice is what shutdown looks like when two things
both think they own it.
window-root is selectable and not constructible
A stated exception to §11’s parity invariant, on the grounds a part is one
(ADR-0065): a document cannot
write the node it is the document of. It is CSS-selectable because it is the
element :root matches and the one place a stylesheet could put the window’s own
background.
Nothing visible changed by its arrival, and that is checkable rather than hoped
for: the :root blocks the two themes ship declare custom properties and nothing
else, so moving which element they match moves nothing that is drawn.
Alternatives considered
- A
stackwidget the application wraps its own root in.docs/core-widgets.md§1 specifiesstackand calls it “the basis for badges-over-things and custom overlays”, so this is not a wrong tool — but it puts the layer in the application’s tree, which means an application that forgot to add one has no overlay layer, and a library widget that raises a toast cannot know whether there is one above it.stackis still owed; it is a layout widget and this is a window facility, and building one does not build the other. Overlay.of(context)reached throughBuildContext, Flutter’s shape: any descendant finds the layer and pushes an entry into it. That is whattoastandtooltipwill want, because the thing raising them is deep in the tree. It is not built here, for ADR-0019’s reason: there is one consumer, it is the application itself, and an interface designed against one caller is designed twice.Host.overlayis the half that is certainly needed either way — aBuildContext-reachable form would be implemented in terms of it.- An overlay window per overlay, the popup path, used for everything. Wrong for the three widgets that want this: a toast is inside the window by specification, and a scrim over the window is of the window. It is also the more expensive answer everywhere and the less portable one — see §4 on Wayland.
- Placement in CSS rather than in Java. There is no
positionin §8’s subset, deliberately: it is the same reasonaffixis a widget rather thanposition: sticky. A corner and a margin are Java’s, and--gb-window-marginis the name the number will take when a floating button needs it in a rule.
Consequences
Hostgrows two methods —overlay(...)andframes()— and an application that uses neither is unchanged. §7’s toast, tooltip, dialog and popover all now have somewhere to be drawn that does not wait on the backend.- The application’s root is one level deeper, which is visible in a test that
walks from
tree.root(). Selectors are unaffected: nothing in the toolkit’s stylesheets is anchored to the root, and:rootmatches custom-property blocks only. - Nothing hit-tests an overlay yet. The pointer router tests against the
painted frame (ADR-0054),
and an overlay is in that frame, so a button in one is reachable today by
accident of the ordering rather than by a rule anyone wrote. A modal scrim
needs the rule written — “the topmost overlay takes the pointer first, and a
modal one takes it exclusively” — and that belongs with
dialog. - An overlay is not a focus scope. §7 says each overlay “wraps a
focus-scopeand restores focus on close”, which is true of the ones that take focus and is not true of a HUD. The wrapping belongs to those widgets rather than to the layer, andfocus-scopeexists already (ADR-0078). - Overlays do not animate in or out. A toast that appears and disappears
wants §1.7’s overlay curve, and the machinery is transitions on a node that is
there — so it is the widget’s, not the layer’s, and it is the same problem
collapsehas with a body that is unmounted while closed.