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
19 changes: 9 additions & 10 deletions web/e2e/scenarios/04-member-context-menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body> (W-8)', () => {
// P-1 + W-8 source side: the v0.4.4 fix relocates the menu DOM under
// <body> 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)', () => {
Expand Down
15 changes: 9 additions & 6 deletions web/e2e/scenarios/05-invite-participant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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/);
});
});
8 changes: 5 additions & 3 deletions web/e2e/scenarios/11-profile-card.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
99 changes: 99 additions & 0 deletions web/e2e/scenarios/14-overlay-top-layer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dialog>').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);
});
});
67 changes: 33 additions & 34 deletions web/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1240,23 +1241,22 @@
{/if}

{#if showQuickJoin}
<!-- v0.4.0 Step 2.17 — Ctrl+J quick-join prompt. Minimal inline dialog
(single text input + submit). Escape closes via the global priority
cascade; submit calls store.joinChannel(value). -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- v0.4.0 Step 2.17 — Ctrl+J quick-join prompt. Overlay overhaul Phase 2:
now a native <dialog> driven by use:topLayer (showModal), so it lives in
the browser top layer with a free native ::backdrop, focus-trap, and an
inert background - no manual backdrop element, no portal, no z-index.
Backdrop click (e.target === the dialog box) closes; Esc fires the
native `cancel` event which the action forwards to cancelQuickJoin. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="quick-join-backdrop"
data-testid="quick-join-backdrop"
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<dialog
class="quick-join-dialog"
aria-label="Quick join channel"
data-testid="quick-join-dialog"
use:topLayer={{ modal: true, onClose: cancelQuickJoin }}
onclick={(e) => { if (e.target === e.currentTarget) cancelQuickJoin(); }}
>
<div
class="quick-join-dialog"
role="dialog"
aria-modal="true"
aria-label="Quick join channel"
data-testid="quick-join-dialog"
>
<div class="quick-join-inner">
<label class="quick-join-label" for="quick-join-input">Channel name or ID:</label>
<!-- svelte-ignore a11y_autofocus -->
<input
Expand Down Expand Up @@ -1294,7 +1294,7 @@
>Join</button>
</div>
</div>
</div>
</dialog>
{/if}

<KeyboardShortcutsHelp
Expand Down Expand Up @@ -1481,7 +1481,7 @@
flex-direction: column;
min-width: 0;
position: relative;
z-index: 1;
z-index: var(--z-raised);
overflow: hidden;
}

Expand Down Expand Up @@ -1515,7 +1515,7 @@
left: 0;
width: 0;
height: 100%;
z-index: 200;
z-index: var(--z-sidebar);
pointer-events: none;
}

Expand All @@ -1532,7 +1532,7 @@
height: 100%;
width: 268px;
min-width: 268px;
z-index: 202;
z-index: var(--z-content);
transform: translateX(-100%);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: none;
Expand All @@ -1548,31 +1548,30 @@
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 201;
z-index: var(--z-raised);
animation: overlayIn 0.2s ease;
}
}

/* v0.4.0 Step 2.17 — Ctrl+J quick-join prompt. Matches the heavier
* ChannelDirectoryModal visual treatment but compressed for a single
* text input + two action buttons. */
.quick-join-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 9000;
padding: 24px;
backdrop-filter: blur(2px);
}
/* v0.4.0 Step 2.17 — Ctrl+J quick-join prompt. Overlay overhaul Phase 2:
* a native <dialog> 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 <dialog> 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;
Expand Down
20 changes: 15 additions & 5 deletions web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
}

/*
Expand Down Expand Up @@ -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;
Expand Down
Loading