import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; async function seedLongThread(page: import("@playwright/test").Page) { await expect .poll(() => page.evaluate( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ), ) .toBe(true); return page.evaluate(() => { const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: "Focus mode integration thread", createdAt: 1_700_900_000, }); if (!root) throw new Error("Failed to seed focus thread root"); for (let index = 0; index < 48; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `Focus reply ${index}: this deliberately wraps across several lines so changing the thread measure causes real layout reflow.`, parentEventId: root.id, createdAt: 1_700_900_001 + index, }); } return root.id; }); } async function scrollToMiddleVisibleMessage( body: import("@playwright/test").Locator, threadRootId: string, ): Promise { let anchorId: string | null = null; await expect .poll(async () => { anchorId = await body.evaluate((element) => { const maxScrollTop = element.scrollHeight - element.clientHeight; if (maxScrollTop <= 0) return null; const targetScrollTop = Math.floor(maxScrollTop * 0.4); element.scrollTop = targetScrollTop; element.dispatchEvent(new Event("scroll", { bubbles: true })); if (Math.abs(element.scrollTop - targetScrollTop) > 1) return null; const bounds = element.getBoundingClientRect(); const row = Array.from( element.querySelectorAll("[data-message-id]"), ).find((candidate) => { const rect = candidate.getBoundingClientRect(); return rect.bottom > bounds.top && rect.top < bounds.bottom; }); return row?.dataset.messageId ?? null; }); return anchorId !== null && anchorId !== threadRootId; }) .toBe(true); if (!anchorId) throw new Error("No visible middle-thread anchor"); return anchorId; } /** * The channel header must own its pixels, not merely be "visible". * * Regression guard for the focus-mode launch: a `z-0` on the channel section * created a stacking context that flattened the header's `z-30` beneath the * sibling shared header backdrop (also `z-30`), painting the backdrop over the * name and actions. Neither `toBeVisible()` nor `elementFromPoint` can catch * this — the backdrop is `pointer-events-none`, so hit-testing skips it. We * compare CSS paint order directly: walk each element's stacking-context * chain, find the branches under their common stacking context, and check the * header's branch wins (higher z-index, or later in DOM order on a tie). */ async function expectChannelHeaderUnobscured( page: import("@playwright/test").Page, ) { const title = page.getByTestId("chat-title"); await expect(title).toBeVisible(); await expect .poll(() => page.evaluate(() => { const titleEl = document.querySelector('[data-testid="chat-title"]'); const backdropEl = document.querySelector( '[data-testid="channel-shared-header-backdrop"]', ); if (!titleEl) return "missing title"; if (!backdropEl) return "missing backdrop"; const createsStackingContext = (el: Element): boolean => { const style = getComputedStyle(el); if (style.position !== "static" && style.zIndex !== "auto") return true; if (parseFloat(style.opacity) < 1) return true; if (style.transform !== "none") return true; if (style.filter !== "none") return true; const backdropFilter = style.backdropFilter ?? (style as unknown as { webkitBackdropFilter?: string }) .webkitBackdropFilter; if (backdropFilter && backdropFilter !== "none") return true; if (style.isolation === "isolate") return true; if ( style.contain.includes("paint") || style.contain.includes("strict") ) return true; return false; }; // Chain of stacking-context roots from the element up to . const stackingChain = (el: Element): Element[] => { const chain: Element[] = [el]; let current: Element | null = el.parentElement; while (current) { if ( createsStackingContext(current) || current === document.documentElement ) { chain.push(current); } current = current.parentElement; } return chain; }; const titleChain = stackingChain(titleEl); const backdropChain = stackingChain(backdropEl); const common = titleChain.find((el) => backdropChain.includes(el)); if (!common) return "no common stacking context"; // Branch = the child-of-common entry each element paints through. const titleBranch = titleChain[titleChain.indexOf(common) - 1]; const backdropBranch = backdropChain[backdropChain.indexOf(common) - 1]; if (!titleBranch || !backdropBranch) return "degenerate chain"; const effectiveZ = (el: Element): number => { const z = getComputedStyle(el).zIndex; return z === "auto" ? 0 : parseInt(z, 10); }; const titleZ = effectiveZ(titleBranch); const backdropZ = effectiveZ(backdropBranch); if (titleZ !== backdropZ) { return titleZ > backdropZ ? true : "backdrop paints above header"; } // Tie: later in DOM order paints on top. const order = backdropBranch.compareDocumentPosition(titleBranch); return (order & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 ? true : "backdrop paints above header"; }), ) .toBe(true); } test("focus and split preserve reading context and interaction ownership", async ({ page, }) => { await page.setViewportSize({ width: 1280, height: 720 }); await page.addInitScript(() => { localStorage.setItem("buzz.channels.threadViewMode", "focus"); }); await installMockBridge(page, { managedAgents: [ { pubkey: "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f", name: "alice", status: "stopped", }, ], }); await page.goto("/"); const rootId = await seedLongThread(page); await page.getByTestId("channel-general").click(); await expectChannelHeaderUnobscured(page); const summary = page.locator( `[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`, ); await expect(summary).toBeVisible(); await summary.click(); const channel = page.getByTestId("channel-drop-zone"); const drawer = page.getByTestId("focus-thread-drawer"); const body = page.getByTestId("message-thread-body"); await expect(drawer).toBeVisible(); await expect .poll(() => page.evaluate(() => Boolean( document .querySelector('[data-testid="focus-thread-drawer"]') ?.contains(document.activeElement), ), ), ) .toBe(true); await expect(channel).toHaveAttribute("inert", ""); const anchorId = await scrollToMiddleVisibleMessage(body, rootId); const focusModeToggle = page.getByRole("button", { name: "Show thread beside channel", }); await focusModeToggle.hover(); await expect( page.getByRole("tooltip", { name: "Show thread beside channel" }), ).toBeVisible(); await focusModeToggle.click(); await expect(drawer).toHaveCount(0); await expect(channel).not.toHaveAttribute("inert", ""); await expectChannelHeaderUnobscured(page); await expect(page.getByRole("tooltip")).toHaveCount(0); await expect(page.getByTestId("message-thread-body")).toBeFocused(); await expect(summary).not.toBeFocused(); await expect( body.locator(`[data-message-id="${anchorId}"]`), ).toBeInViewport(); await expect( body.locator(`[data-message-id="${anchorId}"]`), ).not.toHaveAttribute("data-highlighted", "true"); // Sidebar background dismissal belongs to the overlay presentation only. await page .getByTestId("app-sidebar-scroll-anchor") .evaluate((element) => (element as HTMLElement).click()); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); const splitModeToggle = page.getByRole("button", { name: "Expand thread" }); await splitModeToggle.focus(); await splitModeToggle.press("Enter"); await expect(drawer).toBeVisible(); await expect(channel).toHaveAttribute("inert", ""); await expect(page.getByTestId("thread-view-mode-toggle")).toBeFocused(); await expect( body.locator(`[data-message-id="${anchorId}"]`), ).toBeInViewport(); // Focus mode owns Escape even while the rich-text composer and one of its // nested controls has focus: one press exits the focused thread. const threadInput = page .getByTestId("message-thread-panel") .getByTestId("message-input"); await threadInput.click(); await threadInput.pressSequentially("@al"); await expect( page .getByTestId("message-thread-panel") .getByTestId("mention-autocomplete"), ).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0); await expect(channel).not.toHaveAttribute("inert", ""); await summary.click(); await expect(drawer).toBeVisible(); const profileCard = page.getByTestId("sidebar-profile-card"); await profileCard.click({ position: { x: 8, y: 8 } }); await expect(page.getByTestId("profile-popover")).toBeVisible(); await expect(drawer).toBeVisible(); await profileCard.click({ position: { x: 8, y: 8 } }); await expect(page.getByTestId("profile-popover")).toHaveCount(0); await page .getByTestId("app-sidebar-scroll-anchor") .evaluate((element) => (element as HTMLElement).click()); await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0); await summary.click(); await expect(drawer).toBeVisible(); await page.getByTestId("focus-thread-drawer-scrim").click({ position: { x: 24, y: 200 }, }); await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0); await expect(channel).not.toHaveAttribute("inert", ""); }); test("narrow threads do not offer an unavailable layout switch", async ({ page, }) => { await page.setViewportSize({ width: 860, height: 720 }); await installMockBridge(page); await page.goto("/"); const rootId = await seedLongThread(page); await page.getByTestId("channel-general").click(); const summary = page.locator( `[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`, ); await expect(summary).toBeVisible(); await summary.click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); await expect(page.getByTestId("thread-view-mode-toggle")).toHaveCount(0); });