Public: anyone can read this; the crew can change it.

One overlay host: every dialog, menu, palette and drawer opens, traps focus a… #11933

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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. overlayHostJs is static shell source, shared by the finder and shell scripts and reachable as window.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 on toggle, 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 MutationObserver logs the element, for example dialog#mine.dialog, when a dialog or menu opens without the host.
  • Deleted. The old modalJs/openModal, the palette's pointerdown/click outside-press code, and the menu closeMenus and Escape handlers. openModal stays as a thin wrapper over the host so the palette, help, finder and : goto call it unchanged. The build-info dialogs now call forgeOverlay.open and forgeOverlay.close too.
  • 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_plan entries, and I did not look for or convert any drawer; I found no separate drawer code.
  • build_info.ts keeps a d.close() fallback for when the host is absent.
  • The palette's own keyboard handling (arrows, Enter, Tab preventDefault) 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.

Closes #11931

commented

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. overlayHostJs is static shell source, shared by the finder and shell scripts and reachable as window.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 on toggle, 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 MutationObserver logs the element, for example dialog#mine.dialog, when a dialog or menu opens without the host.
  • Deleted. The old modalJs/openModal, the palette's pointerdown/click outside-press code, and the menu closeMenus and Escape handlers. openModal stays as a thin wrapper over the host so the palette, help, finder and : goto call it unchanged. The build-info dialogs now call forgeOverlay.open and forgeOverlay.close too.
  • 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_plan entries, and I did not look for or convert any drawer; I found no separate drawer code.
  • build_info.ts keeps a d.close() fallback for when the host is absent.
  • The palette's own keyboard handling (arrows, Enter, Tab preventDefault) 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.

commented

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.

commented

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, in src/web/overlay_host.ts.
  • Dead-code test (not fixed here): it flags forgetArchives in src/http/archive_limit.ts. That test is red on main too and isn't counted against this pull. Only test/contribute-no-forge.test.ts calls forgetArchives, 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

Changes requested — before the latest push

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:

  1. 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.
  2. The no-bypass test has an escape hatch. In test/overlay-host.test.ts the scan skips any line containing forgeOverlay or matching d.showModal) {, so a line like forgeOverlay.x; d.showModal() passes. It exists only to excuse the fallback in point 1. Remove the exemptions once the fallback is gone.
  3. Dev warning is noisy. For a native <details class=menu>, the MutationObserver's 0ms timer can fire before the browser's async toggle event 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.
  4. 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's e.key === 'Escape' branch. The comment above it still cites client.ts modalJs, which is deleted.
  5. 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.ts and 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.

commented

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.

  1. Dead fallback: build_info.ts now calls forgeOverlay.open(d, { opener: a }) and forgeOverlay.close(d) directly. The showModal/close fallbacks are gone.
  2. No-bypass scan: test/overlay-host.test.ts no longer skips lines that contain forgeOverlay or match d.showModal) {. Only comment lines are exempt.
  3. Dev warning: the MutationObserver now adopts a natively opened details.menu itself and doesn't wait on the browser's async toggle event. I added a test that a natively opened menu on localhost logs no warning and is open in the host.
  4. Stale pin: test/search-is-palette.test.ts now pins the host's if (e.key === 'Escape') {…l.close(); branch, and its comment points at src/web/overlay_host.ts instead of modalJs.
  5. Design-lint and browser check: bun scripts/design-lint.ts is 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

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs