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 {/if}