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 {