From 0069201f0c425eabb484fff840b51d52701347c4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 20 Aug 2026 00:37:09 -0700 Subject: [PATCH] fix(web-ui): give portaled overlays their own stacking layer The appearance overlay host was an unstyled div on the body, so an overlay portaled into it kept competing, in the root stacking context, with the container it had escaped from. Session UI lives inside .bitfun-app-main-workspace (a stacking context at z-index 1), so its menus cleared it at $z-dropdown. The floating mini chat panel instead sits in the root context at $z-overlay + 1, which buried every menu its composer opened: the composer's add-boost menu and slash-command picker rendered behind the panel, and the panel's full-screen backdrop took their clicks, so the "+" button read as dead. Make the host the layer it was always meant to be: one stacking context above every container that hosts app UI, and below the chrome that must outrank overlays (notifications, context menus, splash), which therefore needs no change. Overlay z-indexes now only order overlays against each other. The host box mirrors the initial containing block it used to sit in so absolutely positioned overlays keep their reference rect, and hit testing is handed back to the overlays through a zero-specificity rule so a tooltip that opts out of pointer events still wins. --- .../src/component-library/styles/tokens.scss | 8 +++ .../runtime/AppearanceOverlayHost.scss | 39 ++++++++++ .../runtime/AppearanceOverlayHost.test.ts | 71 +++++++++++++++++++ .../runtime/AppearanceOverlayHost.ts | 11 +++ 4 files changed, 129 insertions(+) create mode 100644 src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.scss create mode 100644 src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts diff --git a/src/web-ui/src/component-library/styles/tokens.scss b/src/web-ui/src/component-library/styles/tokens.scss index ff7a5c5bd2..e90372d418 100644 --- a/src/web-ui/src/component-library/styles/tokens.scss +++ b/src/web-ui/src/component-library/styles/tokens.scss @@ -74,6 +74,14 @@ $z-overlay: 100; $z-modal: 200; $z-modal-active: 250; $z-fullscreen: 280; +// The appearance overlay host owns this single level in the document, and every +// portaled overlay stacks inside it. That is what lets detached UI clear the +// container it escaped from whatever z-index that container picked — so a +// container that hosts app UI (panels, canvases, the floating mini chat) has to +// stay below this line. The overlay levels below only order overlays against +// each other inside the host; chrome that must outrank every overlay keeps its +// own level in the document instead. +$z-overlay-host: 300; $z-tooltip: 350; $z-popover: 360; $z-notification: 400; diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.scss b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.scss new file mode 100644 index 0000000000..decebf852f --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.scss @@ -0,0 +1,39 @@ +/** + * The one layer every portaled overlay lands in. + * + * An overlay portals out of its component subtree precisely because that + * subtree clips or out-stacks it. Landing it on a bare `` child only + * moves the contest: the overlay's own z-index then competes, in the root + * stacking context, with whatever container it escaped from. A container that + * legitimately floats above app content — the floating mini chat panel at + * `$z-overlay + 1`, a maximized canvas — therefore paints over popovers that + * use the ordinary `$z-dropdown` level, which reads as a dead control: the menu + * opens behind the panel, and the panel's own backdrop takes the clicks. + * + * So the host itself takes one level, above every container that hosts app UI + * and below the chrome that must outrank overlays (notifications, context + * menus). Overlay z-indexes then only order overlays against each other, inside + * this stacking context. + * + * The box mirrors the initial containing block the host used to sit in, so + * absolutely positioned overlays resolve against the same rect as before. + * Nothing here may establish a containing block for fixed-position children — + * no transform, filter, backdrop-filter, will-change or contain — or every + * viewport-anchored overlay would shift. + */ + +@use '../../../component-library/styles/tokens' as *; + +#bitfun-appearance-overlay-host { + position: fixed; + inset: 0; + z-index: $z-overlay-host; + // The layer spans the viewport; only the overlays in it are hit-testable. + pointer-events: none; +} + +// Hand hit testing back to the overlays themselves. Zero specificity, so an +// overlay that deliberately opts out — a tooltip — keeps its own declaration. +:where(#bitfun-appearance-overlay-host) > :where(*) { + pointer-events: auto; +} diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts new file mode 100644 index 0000000000..453e4790e3 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment jsdom + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { getAppearanceOverlayHost } from './AppearanceOverlayHost'; + +// Read as text rather than imported: the bundler hands stylesheets to the test +// runner as empty modules, and these assertions are about the CSS itself. +const readSource = (fromProjectRoot: string): string => + readFileSync(resolve(process.cwd(), fromProjectRoot), 'utf8'); + +const layerStyles = readSource('src/infrastructure/appearance/runtime/AppearanceOverlayHost.scss'); +const tokens = readSource('src/component-library/styles/tokens.scss'); + +/** Numeric value of a `$z-*` token, so the ordering below is read, not assumed. */ +const zLevel = (token: string): number => { + const match = tokens.match(new RegExp(`^\\$${token}:\\s*(\\d+);`, 'm')); + if (!match) throw new Error(`missing z token: $${token}`); + return Number(match[1]); +}; + +/** The host's own rule block, without the child rule that follows it. */ +const hostRule = layerStyles.slice( + layerStyles.indexOf('#bitfun-appearance-overlay-host {'), + layerStyles.indexOf('}', layerStyles.indexOf('#bitfun-appearance-overlay-host {')), +); + +describe('appearance overlay host', () => { + afterEach(() => { + document.getElementById('bitfun-appearance-overlay-host')?.remove(); + }); + + it('mounts one reusable host on the body', () => { + const host = getAppearanceOverlayHost(); + + expect(host.parentElement).toBe(document.body); + expect(host.getAttribute('data-bf-overlay-host')).toBe('true'); + expect(getAppearanceOverlayHost()).toBe(host); + expect(document.querySelectorAll('[data-bf-overlay-host]')).toHaveLength(1); + }); + + // The host is a stacking layer, not just a mount point. Drop either half and + // an overlay portaled out of a container that floats above app content — the + // floating mini chat panel, a maximized canvas — paints behind that container + // and never receives its clicks. + it('gives portaled overlays their own stacking context', () => { + expect(hostRule).toMatch(/position:\s*fixed/); + expect(hostRule).toMatch(/z-index:\s*\$z-overlay-host/); + }); + + it('stacks above containers that host app UI and below always-on-top chrome', () => { + expect(zLevel('z-overlay-host')).toBeGreaterThan(zLevel('z-overlay')); + expect(zLevel('z-overlay-host')).toBeLessThan(zLevel('z-notification')); + expect(zLevel('z-overlay-host')).toBeLessThan(zLevel('z-context-menu')); + }); + + // A containing block on the host would re-anchor every `position: fixed` + // overlay to the host box instead of the viewport, moving all of them. + it('never becomes a containing block for fixed-position overlays', () => { + expect(hostRule).not.toMatch(/(^|[^-])(transform|filter|backdrop-filter|will-change|contain):/); + }); + + it('keeps the layer itself click-through and its overlays hit-testable', () => { + expect(hostRule).toMatch(/pointer-events:\s*none/); + expect(layerStyles).toMatch( + /:where\(#bitfun-appearance-overlay-host\)\s*>\s*:where\(\*\)\s*\{\s*pointer-events:\s*auto/, + ); + }); +}); diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.ts b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.ts index faff488f59..a274f048e1 100644 --- a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.ts +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.ts @@ -1,3 +1,14 @@ +/** + * Shared mount point for portaled overlays (menus, popovers, modals, tooltips). + * + * The host is a layer, not just a container: the stylesheet imported here gives + * it a stacking context above every container that hosts app UI, which is what + * keeps an overlay visible and clickable no matter how high the z-index of the + * subtree it portaled out of. See AppearanceOverlayHost.scss. + */ + +import './AppearanceOverlayHost.scss'; + const OVERLAY_HOST_ID = 'bitfun-appearance-overlay-host'; export function getAppearanceOverlayHost(): HTMLDivElement {