Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

121. A tour is a veil and a sequence

Date: 2026-08-19

Status

Accepted. The last of the scroll group, after ADR-0119 and ADR-0120. Reuses ADR-0100’s overlay layer and ADR-0108’s “a target is a name”.

Context

docs/core-widgets.md §5’s tour: a guided sequence of popovers over real widgets, each stop naming a target by id with a title, a body and Back/Next/Skip. The window dims outside the target with a veil cut to its rect. It scrolls the target into view before positioning. Esc skips the whole tour, and a target that is not in the tree is skipped with a warning rather than throwing.

Two of those had no mechanism. A veil “cut to a rect” wants a mask, and §8’s subset has no path, no mask and no clip-path. And an overlay in the layer is pinned to a corner and sized to its content, which is right for a hud and exactly wrong for something that has to cover everything.

Decision

The veil is four rectangles

Above the target, below it, and the two beside it spanning the gap between those two. They tile the window exactly and leave the target uncovered. No mask, no path, no new property in the subset — four absolutely positioned boxes, which is what the subset already does well.

The workaround is better than the thing it replaced. Nothing is drawn over the target, so it stays live: it takes the pointer and the keyboard normally, and a stop that says “click Save to continue” can be obeyed. A single masked rectangle would have had to arrange an exception to itself to allow that, and the exception would have had to be described in terms of hit testing rather than in terms of paint.

The bands consume the pointer. That is what makes a tour modal without anything declaring it so: everything the veil covers is unreachable because the veil is over it, and the one thing it does not cover is the thing the stop is about.

They tile without overlapping, which matters because they are translucent — a doubled band would be visibly darker, and the seam would trace a rectangle around nothing.

Host.fill is one flag, not a second placement path

An overlay’s insets already decide where it goes: two sides is a corner, and four is a fill. So Overlay.filling sets a flag and WindowRoot chooses Insets.all(0) instead of the corner’s. No new placement code, no new concept in the layer.

The card is not a popover

§5 calls a tour “a guided sequence of popovers”, and the word is doing less work than it looks. popover is the panel half of an anchored floating thing; its other half — measure, flip, shift, open a platform window, light-dismiss — is precisely what a tour must not do (ADR-0104). A tour’s card lives inside the window, over a veil that is also inside it, and dismisses on its own buttons rather than on an outside click.

Placement is flip-and-shift done in six lines rather than reused, because Placement positions a window against a display’s work area and this positions a box inside another box. Same idea, different coordinate space, and sharing it would mean teaching it about both.

The target is resolved every frame

A stop names a widget and the anchor is read from the painted frame on every build, not once when the stop opens. A window that resizes, a list that scrolls, a panel that reflows — all of them move the thing being described, and a veil cut where the widget used to be is worse than no veil at all.

That also makes “skipped with a warning” fall out rather than being handled: a target that is not on screen is simply one anchor does not answer, and the build walks on to the next stop. A tour is documentation, and documentation going stale must not take the window down.

Starting one takes a Host

Tours.start(host, stops), exactly as Menus.open(host, …) does and for the same reason (ADR-0106): resolving an id against the painted frame and putting something on the window are both things a widget tree cannot do to itself.

It needed a picture, and the picture found the bug

TourTest drives fifteen cases against a stub host and every one of them passed while the card was drawn down the entire left edge of the window, one pixel from the top, stretched to the full height.

Insets is in CSS order — top, right, bottom, left — and the placement passed left and top. Anchoring a box by its top and its bottom stretches it; anchoring it by nothing horizontal puts it at the origin. Every assertion about what the tree contained was true throughout, because the defect was entirely in two numbers’ positions in an argument list.

So TourGoldenTest exists, and it is the right kind of test for this widget rather than a belt-and-braces one: a veil is a fact about pixels, and which region is dimmed and which is not is not a question the widget tree can be asked.

Consequences

tour is the first widget to use the in-window overlay layer for something that is not a corner badge, which is what Host.fill exists for and the only reason it does.

A tour cannot find the viewport its target is in. §5 asks it to scroll the target into view, and Stop takes an optional ScrollController for the application to supply. Discovering it automatically means walking from an element to its nearest scrolling ancestor, which is a :core-to-:widgets dependency the toolkit does not have — the same wall ADR-0120 turned around to avoid, and here there is nothing to turn around because the tour is not the thing being revealed.

The card’s height is estimated when deciding whether it fits below its target. Measuring it would need the measure-then-place machinery ADR-0104 built for popup windows, which works on windows rather than on boxes. Being wrong puts a card above its target when it would have fitted below, which is a placement nobody will notice and not a defect anybody can see.

tour-band’s opacity is a fixed 0.55 rather than a token. §1.2 has a scrim and this is one; a --gb-scrim would be the right name and there is one consumer, which is ADR-0019’s argument for waiting.