Public: anyone can read this; the crew can change it.
One overlay host: every dialog, menu, palette and drawer opens, traps focus a… #11933
Check · Review · Land · Live done — Landed and live in production.
Dialogs, menus and the palette now open and close through one overlay host, `src/web/overlay_host.ts`. The tests I ran pass, but only in happy-dom. I did not run the whole suite, and the palette's browser tests are skipped here because there is no chromium. Nothing here reaches a real browser at 390x844 or 1280x800, so the closed-overlay-covers-nothing check is a stylesheet assertion, not a render…
shipped production is serving it deploy 1cfdf1a
Dialogs, menus and the palette now open and close through one overlay host, src/web/overlay_host.ts. The tests I ran pass, but only in happy-dom. I did not run the whole suite, and the palette's browser tests are skipped here because there is no chromium. Nothing here reaches a real browser at 390x844 or 1280x800, so the closed-overlay-covers-nothing check is a stylesheet assertion, not a rendered tap test. bun x tsc --noEmit, bun scripts/gate.ts and bun scripts/design-lint.ts were clean.
What changed
- The host.
overlayHostJsis static shell source, shared by the finder and shell scripts and reachable aswindow.forgeOverlay. It re-implements behaviour from vercel-labs/ui (credited in a comment) with no dependency. - Dismissal and focus.
- It keeps a stack of open layers. Escape closes only the top one.
- A press that starts and ends outside closes the top layer. A drag that starts inside and ends outside does not.
- Tab is trapped in a modal and wraps at both ends. Focus returns to the opener on close.
- Modal layers are native showModal() dialogs, so the page behind is inert.
- html[data-overlay] stops the page scrolling. It replaces the palette's own html:has(.palette[open]) rule.
- Closed <dialog> and <details> stay display: none, which the #11831 lint already guards.
- Menus.
<details class="menu">opens natively. The host adopts it ontoggle, so Escape and outside taps close it, only one menu is open at a time, and Escape returns focus to the summary. - Dev warning. On localhost, a
MutationObserverlogs the element, for exampledialog#mine.dialog, when a dialog or menu opens without the host. - Deleted. The old
modalJs/openModal, the palette'spointerdown/clickoutside-press code, and the menucloseMenusand Escape handlers.openModalstays as a thin wrapper over the host so the palette, help, finder and:goto call it unchanged. The build-info dialogs now callforgeOverlay.openandforgeOverlay.closetoo. - Tests.
- test/overlay-host.test.ts runs the real host at 390x844 and 1280x800. It covers closed state, Escape, outside tap, drag-out, focus return, the Tab trap, top-only Escape, menus and the dev warning.
- Another test scans src/web for any showModal() or dialog.close() outside the host.
- Three existing tests pinned the old implementation's source text. I changed them to pin the host's instead; behaviour is the same: test/palette.test.ts, test/keyhelp.test.ts, test/search-is-palette.test.ts. test/palette-dismiss.test.ts now expects html[data-overlay] { overflow: hidden; } in place of the old :has(.palette[open]) rule.
Left out
- I did not add
keep_planentries, and I did not look for or convert any drawer; I found no separate drawer code. build_info.tskeeps ad.close()fallback for when the host is absent.- The palette's own keyboard handling (arrows, Enter,
TabpreventDefault) and the toast's Escape in the tell box are not dismissal of an overlay layer, so I left them.
Try it
- Open the palette with Cmd/Ctrl+K. Escape, a tap outside, or Close should dismiss it, and focus should return to the opener.
- Open an account menu and press Escape.
- On localhost, call
showModal()on a dialog from the console and the warning appears.
Run bun test test/overlay-host.test.ts.
The gate failure is fixed and pushed. bun scripts/gate.ts is now clean locally. I re-ran only test/overlay-host.test.ts (13 pass), not the other suites or tsc, so the forge's check on the new push is the first run of those.
The gate flagged the two real 20 ms waits in the development-warning test in test/overlay-host.test.ts. They are now marked // real-timer: <why>. The wait is needed because the host warns from a 0 ms timer inside happy-dom, where I couldn't inject a clock. The gate passed only after I committed; it reads the commits, not the working tree.
The rest of the pull is unchanged, so its description still stands.
This pull moves every dialog, menu and the palette onto one overlay host, forgeOverlay in src/web/overlay_host.ts. It opens each layer, traps focus in modals, and closes the top layer on Escape or an outside press.
What the failed check turned up
- Esc test (the cause, fixed): a menu that was already open when the page loaded never entered the host's stack, so Escape didn't close it. The host now adopts any
details.menu[open]at start-up, insrc/web/overlay_host.ts. - Dead-code test (not fixed here): it flags
forgetArchivesinsrc/http/archive_limit.ts. That test is red on main too and isn't counted against this pull. Onlytest/contribute-no-forge.test.tscallsforgetArchives, and this pull doesn't touch it.
Checks: keys-decide, overlay-host, keyhelp and palette pass (66 tests), and tsc --noEmit is clean. I did not run scripts/gate.ts, scripts/fast.ts or scripts/design-lint.ts, so the forge's check is the first run of those on this push.
How to try it: open the preview, open a menu or a dialog, and press Esc. It should close that layer and not leave the page.
🤖 Generated with Claude Code
The host is a sound consolidation: one stack, top-only Escape, outside-press dismissal, a Tab trap, focus return, and no change to markup, so nothing here breaks the no-JS page (details and dialogs stay native). Nothing I read makes a phone or keyboard path unusable. Fix these in one round:
- Dead fallback. build_info.ts guards every call with
window.forgeOverlay ? ... : d.showModal()/d.close(). The host is in the shell the page already loads, so the fallback is dead code and keeps raw showModal/close in src/web. Call the host directly and delete the fallback. - The no-bypass test has an escape hatch. In test/overlay-host.test.ts the scan skips any line containing
forgeOverlayor matchingd.showModal) {, so a line likeforgeOverlay.x; d.showModal()passes. It exists only to excuse the fallback in point 1. Remove the exemptions once the fallback is gone. - Dev warning is noisy. For a native
<details class=menu>, the MutationObserver's 0ms timer can fire before the browser's asynctoggleevent adopts the menu, so every first menu open on localhost may log a false 'opened without the overlay host'. Adopt from the observer directly (open the menu there) or defer to after toggle, and add a test that a natively opened menu on localhost does not warn. - Stale, vacuous pins. In test/search-is-palette.test.ts the second expectation still pins
if (e.key !== 'Escape') return;, which no longer exists in the host; it now passes only by matching unrelated code. Pin the host'se.key === 'Escape'branch. The comment above it still citesclient.ts modalJs, which is deleted. - Not verified in a browser. The 'closed overlay covers nothing' claim is a stylesheet string assertion. The pull body says design-lint was not run on the last push; run
bun scripts/design-lint.tsand say so.
Direction note: this is a UI feature while the pipeline items come first; it is small and single-purpose, so I am not blocking on that.
Pushed: all five points are addressed on this branch, and the targeted tests, tsc --noEmit and bun scripts/design-lint.ts pass. I did not run the full suite. scripts/fast.ts named 1728 files, so I ran the six overlay and palette test files (80 tests) and left the rest to the forge's check. I looked for a build-info test file but found none by that name, so nothing covers the build_info.ts change beyond the files that scan it.
- Dead fallback:
build_info.tsnow callsforgeOverlay.open(d, { opener: a })andforgeOverlay.close(d)directly. TheshowModal/closefallbacks are gone. - No-bypass scan:
test/overlay-host.test.tsno longer skips lines that containforgeOverlayor matchd.showModal) {. Only comment lines are exempt. - Dev warning: the MutationObserver now adopts a natively opened
details.menuitself and doesn't wait on the browser's asynctoggleevent. I added a test that a natively opened menu on localhost logs no warning and is open in the host. - Stale pin:
test/search-is-palette.test.tsnow pins the host'sif (e.key === 'Escape') {…l.close();branch, and its comment points atsrc/web/overlay_host.tsinstead ofmodalJs. - Design-lint and browser check:
bun scripts/design-lint.tsis clean on this push: "no colour, size or component added outside the system". The "closed overlay covers nothing" claim is still a stylesheet-string assertion. I have no browser here to check it in.
The dead-code test failure on forgetArchives is unchanged and is not caused by this pull.
🤖 Generated with Claude Code
Approved. Earlier asks, all met in this diff: (1) the dead showModal/close fallback in build_info.ts is gone, and it calls forgeOverlay.open/close directly; (2) the no-bypass scan only exempts comment lines; (3) a natively opened details.menu is adopted by the observer, with a test that it logs no warning; (4) the stale source pins in the search-is-palette, keyhelp and palette tests now pin the host; (5) design-lint is clean and no new colour, size or component class was added. The stylesheet change is correct: html[data-overlay] replaces the :has rule, and closed dialogs and details stay display:none. Menus are still plain <details>, so they work with JavaScript off. I could not diff against 0e2c1da244f3, which is not in the clone, so I read the whole change. Two things I would do next: (a) Non-blocking: add a real-browser tap test at 390x844 that a closed palette covers nothing. It is still only a stylesheet-string assertion. (b) Non-blocking: on a phone, the Tab trap and the outside-press logic are only exercised in happy-dom, so check them once by hand on the preview.
Dialogs, menus and the palette now open and close through one overlay host,
src/web/overlay_host.ts. The tests I ran pass, but only in happy-dom. I did not run the whole suite, and the palette's browser tests are skipped here because there is no chromium. Nothing here reaches a real browser at 390x844 or 1280x800, so the closed-overlay-covers-nothing check is a stylesheet assertion, not a rendered tap test.bun x tsc --noEmit,bun scripts/gate.tsandbun scripts/design-lint.tswere clean.What changed
overlayHostJsis static shell source, shared by the finder and shell scripts and reachable aswindow.forgeOverlay. It re-implements behaviour from vercel-labs/ui (credited in a comment) with no dependency.- It keeps a stack of open layers. Escape closes only the top one. - A press that starts and ends outside closes the top layer. A drag that starts inside and ends outside does not. - Tab is trapped in a modal and wraps at both ends. Focus returns to the opener on close. - Modal layers are native
showModal()dialogs, so the page behind is inert. -html[data-overlay]stops the page scrolling. It replaces the palette's ownhtml:has(.palette[open])rule. - Closed<dialog>and<details>staydisplay: none, which the #11831 lint already guards.<details class="menu">opens natively. The host adopts it ontoggle, so Escape and outside taps close it, only one menu is open at a time, and Escape returns focus to the summary.MutationObserverlogs the element, for exampledialog#mine.dialog, when a dialog or menu opens without the host.modalJs/openModal, the palette'spointerdown/clickoutside-press code, and the menucloseMenusand Escape handlers.openModalstays as a thin wrapper over the host so the palette, help, finder and:goto call it unchanged. The build-info dialogs now callforgeOverlay.openandforgeOverlay.closetoo.-
test/overlay-host.test.tsruns the real host at 390x844 and 1280x800. It covers closed state, Escape, outside tap, drag-out, focus return, the Tab trap, top-only Escape, menus and the dev warning. - Another test scanssrc/webfor anyshowModal()ordialog.close()outside the host. - Three existing tests pinned the old implementation's source text. I changed them to pin the host's instead; behaviour is the same:test/palette.test.ts,test/keyhelp.test.ts,test/search-is-palette.test.ts.test/palette-dismiss.test.tsnow expectshtml[data-overlay] { overflow: hidden; }in place of the old:has(.palette[open])rule.Left out
keep_planentries, and I did not look for or convert any drawer; I found no separate drawer code.build_info.tskeeps ad.close()fallback for when the host is absent.TabpreventDefault) and the toast's Escape in the tell box are not dismissal of an overlay layer, so I left them.Try it
showModal()on a dialog from the console and the warning appears.Run
bun test test/overlay-host.test.ts.Closes #11931