A consumer of the shared chrome signal composes with it locally; mutating it is a global act
A consumer of the shared chrome signal composes with it locally; mutating it is a global act
Section titled “A consumer of the shared chrome signal composes with it locally; mutating it is a global act”Context
Section titled “Context”chromeScroll (web/src/lib/stores/chromeScroll.svelte.ts) owns a single scroll listener on
the app shell’s <main> and publishes one boolean, hidden, meaning “the chrome should be
off-screen.” It is deliberately one listener and one signal: the top navbar and the browse
control bar hide and return together, so the page does not appear to come apart while
scrolling. The store is ref-counted precisely because more than one component attaches to it.
Three components consume it today:
| consumer | how |
|---|---|
+layout.svelte:149 (navbar) | reads hidden raw |
ViewControls.svelte:56 (browse bar) | hidden && !expanded |
AssetPlaylist.svelte:322 | calls reveal() |
The store also exposes reveal(), which sets hidden = false and lets the next scroll-down
re-hide normally. It was added for #554: picking a view collapsed the switcher, and if the user
had scrolled down while it was open the bar vanished the instant they chose.
Nothing recorded which of these two mechanisms a new requirement should use, and the names
actively mislead. reveal() is discoverable, well-commented and reads exactly like “show this
thing” — so it is the first thing anyone reaches for. #1020 (reveal the browse bar when the
pointer approaches the bottom of the window) is the case that forced the question, and the
planning agent’s brief for it specified reveal(). That would have slid the navbar down
too, every time the user moved the mouse toward the bottom of the screen.
Decision
Section titled “Decision”A consumer that wants to stay visible for its own reason ANDs a local term into its own derivation. It does not touch the store.
// ViewControls.svelte — the browse bar's own visibilityconst hidden = $derived( chromeScroll.hidden && !expanded && !focusInside && !(pointerNear && !hoverDismissed),);expanded, focusInside and pointerNear are all local state. The navbar’s chromeHidden
is unaffected by any of them, which is the point.
reveal() stays, and stays legitimate — for global intent. The test is whose visibility
the triggering interaction is about:
- Global — the interaction concerns the chrome as a whole, and showing all of it is correct.
Closing a maximized playlist (
AssetPlaylist.svelte:322) returns the user to the page; the page’s chrome should be there. Picking a view (ViewControls.svelte:87) ends an interaction the user started from the chrome. →reveal(). - Local — only this component has a reason to be visible, and the reason is invisible to every other consumer. Hovering the bottom edge, holding focus inside the bar, keeping the switcher open mid-interaction. → a local term.
Amendment 2026-08-13 (#1061) — the auto-hiding layer is a hazard for AUTOMATION, and the naive signals lie
Section titled “Amendment 2026-08-13 (#1061) — the auto-hiding layer is a hazard for AUTOMATION, and the naive signals lie”This ADR settles who decides the chrome is visible. It says nothing about interacting with it, and a UI test that clicks something inside the layer hit two traps that cost a full CI failure and two rejected candidate fixes.
-
⭐ Scroll events COALESCE. A test (or any code) that issues two
scrollTocalls in quick succession may produce one scroll event carrying only the final offset. The store sees a single downward move past its threshold, hides the chrome, and never sees the upward move that would bring it back. The chrome then stays hidden indefinitely — the observed CI failure waselement is outside of the viewport, retried 55 times to timeout, not a transient. Each scroll must wait for its own scroll event before the next is issued. -
⛔ The target element’s own bounding rectangle is the WRONG readiness signal, and it fails in two different directions: it lies before the scroll event has dispatched (the store has not reacted yet) and again during the 200ms
transition-transform(the layer is mid-slide). Two candidate fixes built on it failed 8-in-10 and 9-in-10. Read the layer’s own rendered state instead. -
reveal()is not the tool for this, and the test in the Decision above is what says so: nothing in an automated scroll is a user acting on the chrome. Reaching forreveal()to make a test pass would assert global user intent that did not occur, which is exactly the misuse this ADR was written to prevent.
The generalisable point: an auto-hiding layer driven by a derived, event-timed signal is not addressable the way a static element is. Anything that must interact with it — a test, a programmatic focus, a scroll-into-view — has to wait on the signal, not on the element.
⚠️ Note that Playwright’s own click implementation scrolls an element into view first, so clicking an element inside a scroll-driven layer can perturb the very state that decides its visibility. That interaction is the reason this deserves a place in the ADR rather than a comment in one spec.
Stated as a rule: reveal() answers “the user just did something that should bring the chrome
back.” A local term answers “this component in particular should not be hidden right now.”
If the answer names one component, it is not a store call.
Consequences
Section titled “Consequences”- The browse bar can be revealed, focused, hovered and expanded without the navbar moving.
chromeScrollkeeps a small surface — one boolean and one verb — rather than growing a per-consumer pin registry. Consumers that need more nuance express it in their own scope, where the state already lives.- The invariant needs an explicit test, because it looks redundant.
ViewControls.test.tsassertschromeScroll.hiddenis still true after a local reveal. Reading that line, the natural reaction is “of course it is — we didn’t touch it,” which is exactly why it must be written down: it is the only thing standing between this design and thereveal()shortcut. It was mutation-tested when written (rewriting the component to callreveal()fails three tests), so it is known to bite rather than assumed to. - A fourth consumer inherits the question. The table above should grow with it.
⚠️ Related trap, recorded because it cost a browser run. Keyboard parity on a locally-revealed
element must use :focus-visible, not :focus-within. A mouse click focuses the control it
lands on, so focus-within keeps the element pinned open after any click until focus moves
elsewhere — a regression on the mouse path introduced by a line intended for keyboard users. The
#1020 brief specified focus-within; the coding agent’s own browser run caught it. Unit tests do
not: the defect lives in what a real click does to the focus tree.