diff --git a/web/e2e/scenarios/04-member-context-menu.spec.ts b/web/e2e/scenarios/04-member-context-menu.spec.ts index 1a86924..0681f25 100644 --- a/web/e2e/scenarios/04-member-context-menu.spec.ts +++ b/web/e2e/scenarios/04-member-context-menu.spec.ts @@ -430,17 +430,16 @@ test.describe('Scenario 04 v0.4.4 enhancements: W-8 + W-12 coverage', () => { assertNoConsoleErrors(consoleErrors); }); - test('source-level pin: MemberContextMenu portals to (W-8)', () => { - // P-1 + W-8 source side: the v0.4.4 fix relocates the menu DOM under - // via Svelte 5 `{@attach portal()}`. If the attachment is - // removed, the menu falls back to its native location (inside the - // backdrop-filter stacking context) and Phil's Bug 1 returns. + test('source-level pin: MemberContextMenu uses the top-layer primitive (W-8)', () => { + // Overlay overhaul Phase 2 supersedes the v0.4.4 portal+z9999 fix: the + // menu now promotes into the browser native top layer via use:topLayer + // (Popover API), which escapes the backdrop-filter stacking contexts with + // NO portal and NO z-index. If this regresses to a portal or a hardcoded + // index, Phil's Bug 1 risk returns - so pin the new primitive instead. const src = readFileSync(MEMBER_CTX_PATH, 'utf-8'); - // Pin the attach directive + the portal import. - expect(src).toMatch(/\{@attach\s+portal\(\)\}/); - expect(src).toMatch(/from\s+['"][^'"]*portal/); - // Pin the z-index bump (post-v0.4.4 = 9999, pre-v0.4.4 = 250). - expect(src).toMatch(/z-index:\s*9999/); + expect(src).toMatch(/use:topLayer/); + expect(src).not.toMatch(/\{@attach\s+portal\(\)\}/); + expect(src).not.toMatch(/z-index:\s*\d/); }); test('source-level pin: portal helper exists at lib/portal.js (W-8)', () => { diff --git a/web/e2e/scenarios/05-invite-participant.spec.ts b/web/e2e/scenarios/05-invite-participant.spec.ts index 35b0db5..afdfbda 100644 --- a/web/e2e/scenarios/05-invite-participant.spec.ts +++ b/web/e2e/scenarios/05-invite-participant.spec.ts @@ -538,8 +538,8 @@ test.describe('Scenario 05 v0.4.4 enhancements: W-8 top-layer coverage', () => { await row.click({ button: 'right' }); const menu = appPage.locator('[data-testid="channel-ctx-menu"]'); await expect(menu).toBeVisible(); - // W-8: hit-test the menu center to confirm it is on top. v0.4.4 fix - // portals + bumps z-index to 9999. + // W-8: hit-test the menu center to confirm it is on top. Phase 2 puts it + // in the browser native top layer via use:topLayer (no portal/z-index). await expectLocatorOnTop(appPage, menu); await appPage.keyboard.press('Escape'); assertNoConsoleErrors(consoleErrors); @@ -554,10 +554,13 @@ test.describe('Scenario 05 v0.4.4 enhancements: W-8 top-layer coverage', () => { assertNoConsoleErrors(consoleErrors); }); - test('source-level pin: ChannelContextMenu portals + z-index 9999 (W-8)', () => { + test('source-level pin: ChannelContextMenu uses the top-layer primitive (W-8)', () => { const src = readFileSync(CHANNEL_CTX_MENU_PATH, 'utf-8'); - // The v0.4.4 fix applied portal to both the main menu AND the submenu. - expect(src).toMatch(/\{@attach\s+portal\(\)\}/); - expect(src).toMatch(/z-index:\s*9999/); + // Phase 2: the menu AND its submenu each promote into the native top + // layer via use:topLayer (design §F.8) - no portal, no hardcoded z-index. + const matches = src.match(/use:topLayer/g) ?? []; + expect(matches.length).toBeGreaterThanOrEqual(2); + expect(src).not.toMatch(/\{@attach\s+portal\(\)\}/); + expect(src).not.toMatch(/z-index:\s*\d/); }); }); diff --git a/web/e2e/scenarios/11-profile-card.spec.ts b/web/e2e/scenarios/11-profile-card.spec.ts index bb18a06..3d6f15d 100644 --- a/web/e2e/scenarios/11-profile-card.spec.ts +++ b/web/e2e/scenarios/11-profile-card.spec.ts @@ -36,10 +36,12 @@ test.describe('Scenario 11: profile card', () => { assertNoConsoleErrors(consoleErrors); }); - test('clicking the backdrop dismisses the card', async ({ appPage, consoleErrors }) => { + test('clicking outside dismisses the card', async ({ appPage, consoleErrors }) => { await openOwnProfile(appPage); - // Click the backdrop corner (away from the centered card) to fire onClose. - await appPage.locator('[data-testid="profile-card-close"]').click({ position: { x: 5, y: 5 } }); + // Overlay overhaul Phase 2: the card is a top-layer popover with no + // backdrop element; a click well outside it fires the component's + // window-level outside-click handler -> onClose. + await appPage.mouse.click(5, 5); await expect(appPage.locator('[data-testid="profile-card"]')).not.toBeVisible(); assertNoConsoleErrors(consoleErrors); }); diff --git a/web/e2e/scenarios/14-overlay-top-layer.spec.ts b/web/e2e/scenarios/14-overlay-top-layer.spec.ts index c02e489..6b390bc 100644 --- a/web/e2e/scenarios/14-overlay-top-layer.spec.ts +++ b/web/e2e/scenarios/14-overlay-top-layer.spec.ts @@ -130,3 +130,102 @@ test.describe('Scenario 14: StatusEditor in the native top layer', () => { assertNoConsoleErrors(consoleErrors); }); }); + +// ── Phase 2: the newly-migrated overlays over deliberately-occluding +// surfaces (design §E Tier-2 / §F worst-case trap pairs). Each asserts the +// real "painted on top" hit-test PLUS the native top-layer pseudo-class +// (:popover-open for popovers, :modal for the dialog) that only Chromium can +// evaluate. +async function openGeneral(appPage: import('@playwright/test').Page) { + await appPage.waitForSelector('[data-testid="sidebar-sections"]'); + await appPage.locator('[data-testid="sidebar-channel-row-general"]').click(); + await appPage.waitForSelector('[data-testid="chat-view"]'); +} + +test.describe('Scenario 14: anchored popovers + native modal in the top layer', () => { + test('MentionDropdown paints ON TOP over the message list AND is :popover-open', async ({ + appPage, + consoleErrors, + }) => { + await openGeneral(appPage); + const input = appPage.locator('[data-testid="message-input"]'); + await input.click(); + // Typing "@" at a word boundary opens the mention autocomplete over the + // message list (the composer's stacking/overflow context used to clip it). + await input.pressSequentially('@'); + + const dropdown = appPage.locator('[data-testid="mention-dropdown"]'); + await expect(dropdown).toBeVisible(); + await expectLocatorOnTop(appPage, dropdown); + const isPopoverOpen = await dropdown.evaluate((el) => + el.matches(':popover-open'), + ); + expect(isPopoverOpen, 'mention dropdown must match :popover-open').toBe(true); + + // Keyboard focus is NOT captured by the dropdown - it stays in the + // textarea (the dropdown is presentational). + const focusTestId = await appPage.evaluate( + () => document.activeElement?.getAttribute('data-testid') ?? null, + ); + expect(focusTestId).toBe('message-input'); + + // Removing the trigger char closes it. + await input.press('Backspace'); + await expect(dropdown).not.toBeVisible({ timeout: 5000 }); + assertNoConsoleErrors(consoleErrors); + }); + + test('ChannelContextMenu paints ON TOP over an open backdrop-filter panel AND is :popover-open', async ({ + appPage, + consoleErrors, + }) => { + // Open the artifact panel (backdrop-filter -> its own stacking context), + // the exact trap that used to paint the menu behind the panel. + await openArtifactPanel(appPage); + await appPage + .locator('[data-testid="sidebar-channel-row-general"]') + .click({ button: 'right' }); + + const menu = appPage.locator('[data-testid="channel-ctx-menu"]'); + await expect(menu).toBeVisible(); + await expectLocatorOnTop(appPage, menu); + const isPopoverOpen = await menu.evaluate((el) => + el.matches(':popover-open'), + ); + expect(isPopoverOpen, 'channel context menu must match :popover-open').toBe( + true, + ); + + await appPage.keyboard.press('Escape'); + await expect(menu).not.toBeVisible({ timeout: 5000 }); + assertNoConsoleErrors(consoleErrors); + }); + + test('quick-join is a native :modal dialog (inert background) and Esc-dismisses', async ({ + appPage, + consoleErrors, + }) => { + await openGeneral(appPage); + await appPage.keyboard.press('Control+j'); + + const dialog = appPage.locator('[data-testid="quick-join-dialog"]'); + await expect(dialog).toBeVisible(); + await expectLocatorOnTop(appPage, dialog); + const isModal = await dialog.evaluate((el) => el.matches(':modal')); + expect(isModal, 'quick-join must be a native :modal ').toBe(true); + + // Inert background: a sidebar channel row sits behind the modal scrim and + // is NOT the topmost element at its own centre (the ::backdrop is). + const bgRow = appPage.locator('[data-testid="sidebar-channel-row-dev-chat"]'); + const bgInert = await bgRow.evaluate((el) => { + const r = el.getBoundingClientRect(); + const top = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); + return top === null || !el.contains(top); + }); + expect(bgInert, 'background row must be inert behind the modal').toBe(true); + + await appPage.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + assertNoConsoleErrors(consoleErrors); + }); +}); diff --git a/web/src/App.svelte b/web/src/App.svelte index cb83b6d..9eae2be 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -28,6 +28,7 @@ import MemberContextMenu from './components/MemberContextMenu.svelte'; import InviteParticipantDialog from './components/InviteParticipantDialog.svelte'; import NotificationPolicyMenu from './components/NotificationPolicyMenu.svelte'; + import { topLayer } from './lib/top-layer.svelte.js'; import { getKeyboardRegistry } from './lib/keyboard.svelte.js'; import * as api from './lib/api.js'; import { applyToast } from './lib/toast-coalesce.js'; @@ -1240,23 +1241,22 @@ {/if} {#if showQuickJoin} - - + -
+ { if (e.target === e.currentTarget) cancelQuickJoin(); }} > - -
+
{/if} in the browser top layer (showModal via use:topLayer). + * The scrim is the native ::backdrop - no manual backdrop element and no + * z-index. Centering is the default (margin:auto). */ .quick-join-dialog { + margin: auto; + padding: 0; background: var(--bg-elevated, var(--surface-elevated, #1f1c19)); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5); - width: min(420px, 100%); + width: min(420px, 92vw); + max-width: 92vw; + } + .quick-join-dialog::backdrop { + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(2px); + } + .quick-join-inner { padding: 20px; display: flex; flex-direction: column; diff --git a/web/src/app.css b/web/src/app.css index 72bfc83..89f0dfb 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -95,16 +95,26 @@ * / Popover API `showPopover()`) - the top layer always wins, ordered * by promotion time. * - * Nothing above --z-toast (60) should exist in CSS once the migration - * completes. Phase 1 only ADDS these tokens; the app-wide collapse of - * the existing z-index jumble onto this scale is Phase 2. + * Phase 2 collapsed the whole app onto this scale: every bare z-index + * is now a token. The low `--z-raised/content/elevated` rungs cover + * LOCAL intra-component stacking (a child above a decorative + * pseudo-element, layered inputs, hover accents) - they are + * value-preserving so no stacking order changed. Floating overlays + * (modals, menus, dropdowns, popovers, context menus, tooltips) carry + * NO z-index: they live in the browser native top layer, which always + * wins regardless of these tokens. */ --z-base: 0; /* in-flow content */ - --z-sticky: 10; /* sticky headers, date separators, scroll-to-bottom */ + --z-raised: 1; /* local: content above a decorative ::before/::after */ + --z-content: 2; /* local: layered content / inputs */ + --z-elevated: 5; /* local: hover actions, accents, scroll-to-bottom */ + --z-sticky: 10; /* sticky headers, date separators, message actions */ --z-banner: 20; /* connection status, remote-update banner */ --z-sidebar: 30; /* sidebar / docked rails */ --z-panel: 40; /* right-side sheets: thread/artifact/search/settings */ --z-toast: 60; /* notification + undo toasts (passive, NOT top layer) */ + --z-noise: 999; /* decorative full-screen noise; topmost PASSIVE layer + (still below the native top layer, which has no index) */ } /* @@ -279,7 +289,7 @@ body::after { position: fixed; inset: 0; pointer-events: none; - z-index: 999; + z-index: var(--z-noise); opacity: 0.015; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); background-repeat: repeat; diff --git a/web/src/components/ArtifactDetailHeader.svelte b/web/src/components/ArtifactDetailHeader.svelte index 1b7515f..90e230f 100644 --- a/web/src/components/ArtifactDetailHeader.svelte +++ b/web/src/components/ArtifactDetailHeader.svelte @@ -36,6 +36,7 @@ Check, } from 'lucide-svelte'; import { formatTime, getParticipantColor } from '../lib/utils.js'; + import { topLayer } from '../lib/top-layer.svelte.js'; let { artifact, @@ -387,6 +388,14 @@ aria-label="Compare-from version" aria-activedescendant={compareActiveId} onkeydown={handleCompareListboxKeydown} + use:topLayer={{ + anchor: compareTriggerEl, + placement: 'bottom-start', + offset: 4, + dismiss: 'manual', + trapInitialFocus: false, + restoreFocus: false, + }} > {#each compareOptions as v, idx (v.version)} {@const pc = getParticipantColor(v.author?.key ?? '')} @@ -441,6 +450,14 @@ aria-label="Artifact version" aria-activedescendant={primaryActiveId} onkeydown={handlePrimaryListboxKeydown} + use:topLayer={{ + anchor: primaryTriggerEl, + placement: 'bottom-start', + offset: 4, + dismiss: 'manual', + trapInitialFocus: false, + restoreFocus: false, + }} > {#each versions as v, idx (v.version)} {@const pc = getParticipantColor(v.author?.key ?? '')} @@ -674,10 +691,8 @@ } .artifact-version-dropdown { - position: absolute; - top: 100%; - left: 0; - margin-top: 4px; + /* Position + top-layer promotion owned by use:topLayer (sets position: + fixed + left/top inline, anchored to the trigger button); no z-index. */ width: 320px; max-height: 240px; overflow-y: auto; @@ -685,7 +700,6 @@ border: 1px solid var(--border); border-radius: var(--radius-sm); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); - z-index: 10; animation: panelIn 0.15s cubic-bezier(0.16, 1, 0.3, 1) both; } diff --git a/web/src/components/ArtifactList.svelte b/web/src/components/ArtifactList.svelte index 80d3738..bdafb1c 100644 --- a/web/src/components/ArtifactList.svelte +++ b/web/src/components/ArtifactList.svelte @@ -311,7 +311,7 @@ .artifact-item-top { position: relative; - z-index: 1; + z-index: var(--z-raised); display: flex; align-items: center; gap: 8px; @@ -341,7 +341,7 @@ .artifact-item-bottom { position: relative; - z-index: 1; + z-index: var(--z-raised); display: flex; align-items: center; gap: 10px; diff --git a/web/src/components/ArtifactPanel.svelte b/web/src/components/ArtifactPanel.svelte index 3c5053d..1878c0c 100644 --- a/web/src/components/ArtifactPanel.svelte +++ b/web/src/components/ArtifactPanel.svelte @@ -730,7 +730,7 @@ /* width comes from the inline style bound to `panelWidth` */ min-width: 320px; max-width: 900px; - z-index: 104; + z-index: var(--z-panel); background: rgba(20, 20, 22, 0.96); backdrop-filter: blur(16px); border-left: 1px solid var(--border); @@ -758,7 +758,7 @@ width: 6px; height: 100%; cursor: ew-resize; - z-index: 110; + z-index: var(--z-panel); background: transparent; transition: background 120ms ease; /* Keep the hit-target easy to grab without shifting layout. */ @@ -894,7 +894,7 @@ font-weight: 600; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); animation: toastIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both; - z-index: 20; + z-index: var(--z-banner); } @keyframes toastIn { diff --git a/web/src/components/ChannelContextMenu.svelte b/web/src/components/ChannelContextMenu.svelte index 3cafffb..5c6c756 100644 --- a/web/src/components/ChannelContextMenu.svelte +++ b/web/src/components/ChannelContextMenu.svelte @@ -65,7 +65,7 @@ --> {#if open} -
via use:topLayer (showModal, + ::backdrop, focus-trap, inert) - no portal, no position:fixed, no + z-index. The action capability-guards for jsdom; the component keeps its + own focus + Tab trap + overlay-click. Escape -> action onClose. + --> +
{/if}
-
+
{/if}