Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/web-ui/src/component-library/styles/tokens.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<body>` 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;
}
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down