Files
pad/web/e2e/pane-controller.spec.ts
T
xarmian 2521e3e1c7 fix(web): portal the pane action-bar menus — anchored panels clipped against the pane's scroll container (BUG-2610) (#1132)
* fix(web): portal the pane action-bar menus — anchored panels clipped against the pane's scroll container (BUG-2610)

In split view the item pane is an overflow-y:auto scroll container,
which computes overflow-x:auto too — the quick-actions () and ⋯
menus were ANCHORED panels inside it, and a right-aligned panel
opening from the pane's action bar extends left past the pane's edge,
so the container clipped it mid-text (Dave's screenshots: 'age
actions' for 'Manage actions', truncated tagline).

Both menus now use the Menu component's portal mode — built precisely
to escape overflow containment (fixed coords portaled to <body>,
viewport clamping, flip-when-cramped, scroll dismissal), and already
the mode of every board-card menu. Widths cover each menu's content
(QA: the 230px qa-body min-width + chrome; ⋯: the longest row).

Regression e2e uses a PAINT-level oracle — clipping doesn't shrink
getBoundingClientRect, so elementFromPoint just inside the panel's
left edge must resolve to the panel; verified failing on the anchored
control build with the exact reported symptom, plus a geometry
precondition so the probe can't pass vacuously. Existing e2e + unit
lookups that scoped menu rows under .item-pane / the master column
are page-scoped now (the portaled panel lives in <body>; only one
menu is ever open, and the scoped trigger click is what ties it to
its column).

* fixup: scope portal scroll-dismiss to anchor-moving containers — any-scroll dismissal closed pane menus under live SSE churn (found via parallel e2e instability vs a clean control build)

* fixup: codex round 1 — route nav-key bail includes portaled [role=menu] (pre-existing leak for board menus too), stopPropagation on handled menu keys, exempt-aware scroll dismiss, per-menu e2e geometry preconditions

* fixup: page-scope the two graph-drawer menu lookups in pane-content-link-anchors (codex round 2)
2026-08-17 03:24:24 -04:00

1226 lines
54 KiB
TypeScript

import { test, expect } from './fixtures';
import { browserLogin, seedDoc } from './lib/collab-helpers';
import type { APIRequestContext, Page } from '@playwright/test';
import type { SuiteFixture } from './fixtures';
/**
* Pane-navigation controller — depth/ownership state machine
* (PLAN-2154 Architecture A / TASK-2157).
*
* The collection page's split pane (PLAN-2105) becomes a navigable
* mini-browser: `openItemPane` handles lateral/list opens; `navigatePaneTo`
* handles in-pane DRILLS; `closeItemPane` does a three-way, ownership-aware,
* staged unwind. Depth + ownership are stamped in SvelteKit `page.state` so
* they follow opaque Back/Forward, survive `history.go`, and reconstruct on
* cold-load.
*
* `navigatePaneTo` has NO in-pane content-link caller yet (those land in
* TASK-2158/2159/2160). We exercise it — and read back the depth/ownership
* stamp — through the localStorage-gated `window.__padPaneController` test hook
* this task ships, so the drill / reset / three-way-close arithmetic is
* verified end-to-end in a REAL browser before the UI callers exist. jsdom has
* no history/navigation model, so the pure arithmetic is unit-tested in
* paneController.test.ts and the runtime wiring is verified here.
*
* Viewport is driven explicitly (desktop split), so one project is enough.
*/
const DESKTOP = { width: 1200, height: 900 };
const MOBILE = { width: 768, height: 1024 }; // == breakpoint (inclusive) → overlay + BottomSheet
function docsUrl(fixture: SuiteFixture, query = ''): string {
return `/${fixture.adminUsername}/${fixture.workspaceSlug}/docs${query}`;
}
function openItemParam(page: Page): string | null {
return new URL(page.url()).searchParams.get('item');
}
function pathname(page: Page): string {
return new URL(page.url()).pathname;
}
interface HookState {
paneDepth: number;
paneOwned: boolean;
}
/** Read the controller's live {paneDepth, paneOwned} via the test hook. */
function paneState(page: Page): Promise<HookState | null> {
return page.evaluate(() => {
const c = (window as unknown as { __padPaneController?: { getPaneState(): HookState } })
.__padPaneController;
return c ? c.getPaneState() : null;
});
}
/** Drive an in-pane DRILL (`navigatePaneTo`) via the test hook. */
async function drillTo(page: Page, ref: string): Promise<void> {
await page.evaluate((r) => {
(
window as unknown as { __padPaneController?: { navigatePaneTo(ref: string): void } }
).__padPaneController?.navigatePaneTo(r);
}, ref);
}
/** Imperative close via the test hook (exercises the three-way close). */
async function hookClose(page: Page): Promise<void> {
await page.evaluate(() => {
(
window as unknown as { __padPaneController?: { closeItemPane(): void } }
).__padPaneController?.closeItemPane();
});
}
function historyLength(page: Page): Promise<number> {
return page.evaluate(() => history.length);
}
/**
* Whether keyboard focus currently sits INSIDE the detail pane (`.item-pane`).
* This is the actual "focus per hop" acceptance criterion (PLAN-2154 R1 /
* TASK-2162): each in-pane hop moves focus into the STABLE aria-labeled
* `<aside>` region so the next `j`/`k` can't steal to list-nav — the exact
* element (the region itself vs. a control within it) is not load-bearing, so
* this asserts containment rather than a specific button.
*
* A `page.evaluate` read of `document.activeElement`, NOT
* `expect(locator).toBeFocused()` — this file's other focus checks
* (pane-a11y-focus.spec.ts) use the same `evaluate`-based pattern, which is
* the reliable one under heavy parallel load: a bare locator-based
* `toBeFocused()` polls independently of the app's own microtask queue and
* can observe a still-mid-flush DOM after a header swap, where an
* `evaluate` round-trip naturally lets pending Svelte effects settle first.
*/
function paneHasFocus(page: Page): Promise<boolean> {
return page.evaluate(() => {
const active = document.activeElement;
const pane = document.querySelector('.item-pane');
return !!active && !!pane && pane.contains(active);
});
}
/** Enable the controller test hook for all navigations in this context. */
async function enableHook(page: Page): Promise<void> {
await page.addInitScript(() => {
try {
localStorage.setItem('pad:pane-test-hook', '1');
} catch {
/* private mode / disabled storage — hook simply won't install */
}
});
}
function authHeaders(fixture: SuiteFixture) {
return { Authorization: `Bearer ${fixture.apiToken}`, 'Content-Type': 'application/json' };
}
/** Create a fresh, test-scoped renamable collection (letters-only prefix, name
* unique — see pane-collection-migration-race.spec.ts for the slug/prefix
* gotchas). */
async function seedCollection(
fixture: SuiteFixture,
request: APIRequestContext,
namePrefix: string,
itemPrefix: string,
): Promise<{ id: string; slug: string; name: string }> {
const name = `${namePrefix} ${Date.now()}`;
const schema = JSON.stringify({ fields: [{ key: 'note', label: 'Note', type: 'text' }] });
const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections`, {
headers: authHeaders(fixture),
data: { name, prefix: itemPrefix, schema },
});
if (!resp.ok()) throw new Error(`collection create failed (${resp.status()}): ${await resp.text()}`);
return (await resp.json()) as { id: string; slug: string; name: string };
}
async function seedItemIn(
fixture: SuiteFixture,
request: APIRequestContext,
collSlug: string,
title: string,
): Promise<{ id: string; slug: string }> {
const resp = await request.post(
`/api/v1/workspaces/${fixture.workspaceSlug}/collections/${collSlug}/items`,
{ headers: authHeaders(fixture), data: { title, fields: JSON.stringify({}), content: '' } },
);
if (!resp.ok()) throw new Error(`item create failed (${resp.status()}): ${await resp.text()}`);
return (await resp.json()) as { id: string; slug: string };
}
test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK-2157)', () => {
// The controller is viewport-agnostic; the desktop split project is enough.
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== 'desktop-chromium',
'controller is viewport-agnostic; the desktop split project is enough',
);
});
test('no PLAN-2105 regression: click-open (owned depth 0), j/k re-target (still depth 0), close returns to pre-pane URL', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl regress alpha');
await seedDoc(fixture, request, 'Ctrl regress bravo');
// Pin list view: this test exercises LIST j/k re-target. The workspace
// default is now Board (IDEA-2274), so request list explicitly rather
// than lean on board's single-lane collapse matching flat stepping.
await page.goto(docsUrl(fixture, '?view=list'));
const prePaneUrl = page.url();
// Open the FIRST rendered row (not a NAMED one) so the `j` (down) below
// always has a row beneath it to re-target to. The two seeds share a
// same-second `created_at`, so their list order is a non-deterministic
// tie-break (BUG-2270); a named row could land LAST, where `j` clamps at
// the final index and the pane-follow correctly finds nothing new to
// target — a test-ordering artifact, not a follow-logic bug (BUG-2279).
const row = page.locator('.item-card').first();
await expect(row).toBeVisible();
await row.click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
const refA = openItemParam(page);
expect(refA).not.toBeNull();
// First-open MINTS ownership at depth 0.
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
// j moves the list cursor to the row below; the pane FOLLOWS (re-target
// replace) — still depth 0, still owned, and NO new history entry (the
// PLAN-2105 fix).
const lenAfterOpen = await historyLength(page);
await page.keyboard.press('j');
await expect.poll(() => openItemParam(page)).not.toBe(refA);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
expect(await historyLength(page)).toBe(lenAfterOpen); // replace, not push
await expect(pane).toBeVisible();
// Close via the pane's real ✕ (aria-label is unique across the loaded and
// minimal headers, which are mutually exclusive) — OWNED depth 0 →
// history.go(-1) back to the exact pre-pane URL (pane gone, `?item=` gone).
await pane.locator('button[aria-label="Close pane"]').click();
await expect.poll(() => openItemParam(page)).toBeNull();
await expect(pane).toBeHidden();
await expect.poll(() => page.url()).toBe(prePaneUrl);
});
test('a duplicate close gesture cannot stack a second history.go and overshoot the pre-pane URL', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl dblclose alpha');
await page.goto(docsUrl(fixture));
const prePaneUrl = page.url();
await page.locator('.item-card', { hasText: 'Ctrl dblclose alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
// Fire closeItemPane TWICE synchronously (a double-click ✕ / ESC+click
// race). The owned close is a one-phase history.go(-1); without the
// in-flight fence the second call would read the still-stale state and
// stack a SECOND go(-1), overshooting PAST the pre-pane entry (here, back
// to /login). The fence must make the second call a no-op.
await page.evaluate(() => {
const c = (
window as unknown as { __padPaneController?: { closeItemPane(): void } }
).__padPaneController;
c?.closeItemPane();
c?.closeItemPane();
});
await expect.poll(() => openItemParam(page)).toBeNull();
await expect(pane).toBeHidden();
// Landed EXACTLY on the pre-pane URL — not overshot to /login.
await expect.poll(() => page.url()).toBe(prePaneUrl);
expect(page.url()).not.toContain('/login');
});
test('drill A→B pushes a depth-1 owned entry; browser Back returns to A at depth 0', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl drill alpha');
const b = await seedDoc(fixture, request, 'Ctrl drill bravo');
await page.goto(docsUrl(fixture));
await page.locator('.item-card', { hasText: 'Ctrl drill alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
const refA = openItemParam(page);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
const lenBeforeDrill = await historyLength(page);
// DRILL A→B (no UI caller yet → test hook). Pushes depth 1, INHERITING
// ownership (owned base → owned drill).
await drillTo(page, b.slug);
await expect.poll(() => openItemParam(page)).toBe(b.slug);
await expect(pane.locator('.title', { hasText: /Ctrl drill bravo/ })).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
expect(await historyLength(page)).toBe(lenBeforeDrill + 1); // push
// Same-ref guard (D4): re-drilling to the CURRENT item is a no-op — no
// new history entry, depth unchanged.
const lenAtB = await historyLength(page);
await drillTo(page, b.slug);
await page.waitForTimeout(50);
expect(await historyLength(page)).toBe(lenAtB);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
// Browser Back unwinds ONE hop → back to A at depth 0.
await page.goBack();
await expect.poll(() => openItemParam(page)).toBe(refA);
await expect(pane.locator('.title', { hasText: /Ctrl drill alpha/ })).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
});
test('detach: j/k is INERT at depth>0 (a list follow cannot re-target a drilled stack)', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl detach alpha');
const b = await seedDoc(fixture, request, 'Ctrl detach bravo');
await seedDoc(fixture, request, 'Ctrl detach charlie');
await page.goto(docsUrl(fixture));
await page.locator('.item-card', { hasText: 'Ctrl detach alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
// Drill to B → depth 1 (detached).
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
const refAtB = openItemParam(page);
// Move focus to the list, then press j. schedulePaneFollow must BAIL at
// depth>0 (both at schedule time and in the fired callback), so the pane
// stays on B — j/k does NOT laterally re-target a drilled stack.
await page.evaluate(() => document.querySelector<HTMLElement>('.list-column')?.focus());
await page.keyboard.press('j');
await page.waitForTimeout(250); // > PANE_FOLLOW_DEBOUNCE_MS (140ms)
expect(openItemParam(page)).toBe(refAtB);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
});
test('cold-load close: a cold `?item=` then drill closes back to the cold base — never go(-2) off it', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
const a = await seedDoc(fixture, request, 'Ctrl cold alpha');
const b = await seedDoc(fixture, request, 'Ctrl cold bravo');
// COLD LOAD: deep-link straight into an open pane. No history stamp →
// UNOWNED base.
await page.goto(docsUrl(fixture, `?item=${a.slug}`));
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: false });
const coldBasePath = pathname(page);
// Drill A→B: INHERITS the cold base's unowned stamp (depth 1, UNOWNED) —
// this is what keeps the cold-base close branch reachable.
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: false });
// Imperative close: UNOWNED depth>0 → history.go(-1) to the cold base,
// THEN a latched replaceState-delete of `?item=`. Must NOT go(-2) off the
// base into whatever preceded the cold load (the /login page here).
await hookClose(page);
await expect.poll(() => openItemParam(page)).toBeNull();
await expect(pane).toBeHidden();
// Landed on the SAME collection page (the cold base), not an earlier page.
expect(pathname(page)).toBe(coldBasePath);
expect(page.url()).not.toContain('/login');
});
test('detached row click RESETS the stack: a new top-level open, closing cleanly to the pre-pane URL', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl reset alpha');
const b = await seedDoc(fixture, request, 'Ctrl reset bravo');
await seedDoc(fixture, request, 'Ctrl reset charlie');
await page.goto(docsUrl(fixture));
const prePaneUrl = page.url();
await page.locator('.item-card', { hasText: 'Ctrl reset alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
// Drill A→B (depth 1).
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
// Now a DIRECT list-row click on a THIRD item (charlie) while detached:
// resets the stack (go(-depth) to base, then a latched re-target),
// landing at depth 0 (the base ownership preserved → owned).
await page.locator('.item-card', { hasText: 'Ctrl reset charlie' }).first().click();
await expect(pane.locator('.title', { hasText: /Ctrl reset charlie/ })).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
// The reset collapsed the stack, so closing now returns straight to the
// pre-pane URL in a single unwind (owned go(-1)).
await pane.locator('button[aria-label="Close pane"]').click();
await expect.poll(() => openItemParam(page)).toBeNull();
await expect.poll(() => page.url()).toBe(prePaneUrl);
});
test('collection rename keeps the pane on the NEW slug and closes cleanly (ownership rebased, not 404)', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
const coll = await seedCollection(fixture, request, 'Ctrl rename', 'CTRN');
await seedItemIn(fixture, request, coll.slug, 'Rename target item');
await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/${coll.slug}`);
await page.locator('.item-card', { hasText: 'Rename target item' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
// Owned first-open at depth 0.
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
// The `?item=` value (canonical ref or slug) that must survive the rename.
const itemParam = openItemParam(page);
expect(itemParam).not.toBeNull();
// Rename the collection via the pane's Quick Actions → Manage actions →
// Edit Collection modal (General tab). The rename changes the pathname
// (old→new slug) while preserving `?item=` — routed through
// onNavigateAway (must replaceState + rebase ownership).
await pane.locator('button.trigger-btn[title="Quick actions"]').click();
// Menu panels are PORTALED to <body> since BUG-2610 (the pane's
// overflow container clipped anchored panels), so menuitem lookups
// are page-scoped; the pane-scoped trigger click above is what ties
// the open menu to this pane.
await page.getByRole('menuitem', { name: 'Manage actions' }).click();
await expect(page.locator('#edit-collection-title')).toBeVisible();
// "Manage actions" opens the modal on the Actions tab — switch to General
// to reach the collection-name field.
await page.locator('button.tab', { hasText: 'General' }).click();
const newName = `Ctrl renamed ${Date.now()}`;
await page.locator('input.name-input').fill(newName);
await page.locator('button.btn-save', { hasText: 'Save Changes' }).click();
// The pane survives on the NEW slug with `?item=` intact and the pane not
// remounted away.
await expect.poll(() => new URL(page.url()).pathname).not.toContain(`/${coll.slug}`);
const newPath = new URL(page.url()).pathname;
expect(newPath).toMatch(new RegExp(`/${fixture.workspaceSlug}/[^/]+$`));
expect(openItemParam(page)).toBe(itemParam);
await expect(pane).toBeVisible();
// Ownership REBASED to a fresh unowned depth-0 base on the new slug (the
// pre-pane entry now points at the dead old slug).
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: false });
// Closing must drop `?item=` IN PLACE (staying on the valid new slug),
// never `history.go` back onto the now-404 old slug.
await pane.locator('button[aria-label="Close pane"]').click();
await expect.poll(() => openItemParam(page)).toBeNull();
await expect(pane).toBeHidden();
expect(new URL(page.url()).pathname).toBe(newPath);
// Still a LIVE collection page — the renamed collection renders its own
// heading, not a "Collection not found" error. (The item list re-hydrates
// the renamed collection asynchronously via the local index, so we assert
// page liveness via the heading rather than a specific row.)
await expect(page.getByRole('heading', { level: 1, name: new RegExp(newName) })).toBeVisible();
});
// ── Detach at depth>0 (PLAN-2154 Architecture C / D-detach / TASK-2161) ──
// Once the pane DRILLS past its base (depth>0) it becomes an independent
// viewer: the list row-highlight is cleared, the pane-snap effect stops
// snapping, and j/k goes INERT (schedulePaneFollow bails at schedule time and
// re-checks depth in its fired callback, and any pending follow is cancelled
// on drill). A direct row-click, by contrast, RESETS the stack (covered by
// the 'detached row click RESETS' test above). Only row-clicks reset; j/k
// never does.
test('detach: at depth>0 the list highlight is CLEARED, j/k cannot re-introduce it or move the pane, and unwind restores it', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl hl alpha');
const b = await seedDoc(fixture, request, 'Ctrl hl bravo');
await seedDoc(fixture, request, 'Ctrl hl charlie');
await page.goto(docsUrl(fixture));
// The list row-highlight marker (`focusedItemId === item.id`) renders as
// `.item-card.focused` in the list column. Scoped to `.list-column` so the
// pane's own ItemDetail can never match.
const focusedRows = page.locator('.list-column .item-card.focused');
// Open A at depth 0 → the pane-snap effect highlights A's row.
await page.locator('.item-card', { hasText: 'Ctrl hl alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
await expect(focusedRows).toHaveCount(1);
await expect(focusedRows).toHaveText(/Ctrl hl alpha/);
// Drill A→B → depth 1 (detached). The highlight is CLEARED (focusedItemId
// gated to null at depth>0) and the pane-snap effect no longer snaps to B.
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
const refAtB = openItemParam(page);
await expect(focusedRows).toHaveCount(0);
// j/k is INERT at depth>0: pressing j moves no highlight and does NOT
// re-target (schedulePaneFollow bails; focusedItemId stays null).
await page.evaluate(() => document.querySelector<HTMLElement>('.list-column')?.focus());
await page.keyboard.press('j');
await page.waitForTimeout(250); // > PANE_FOLLOW_DEBOUNCE_MS (140ms)
await expect(focusedRows).toHaveCount(0);
expect(openItemParam(page)).toBe(refAtB);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
// Browser Back unwinds to depth 0 → the base row's highlight is restored
// (both openItemRef and page.state change, so the gated effect re-runs).
await page.goBack();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
await expect(focusedRows).toHaveCount(1);
await expect(focusedRows).toHaveText(/Ctrl hl alpha/);
});
test('R3 late-timer: a j/k pane-follow scheduled at depth 0 does NOT clobber a drill to depth>0', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl late alpha');
const b = await seedDoc(fixture, request, 'Ctrl late bravo');
await seedDoc(fixture, request, 'Ctrl late charlie');
await page.goto(docsUrl(fixture));
// Open A at depth 0.
await page.locator('.item-card', { hasText: 'Ctrl late alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
// SCHEDULE a pane-follow at depth 0 (press j while focused on the list) —
// the 140ms debounce timer is now pending, armed to re-target the pane to
// the newly focused row.
await page.evaluate(() => document.querySelector<HTMLElement>('.list-column')?.focus());
await page.keyboard.press('j');
// DRILL to B BEFORE the follow fires (well within the 140ms window). The
// drill cancels the pending follow AND pushes depth 1; even if a timer
// somehow survived, its fired callback re-checks the CURRENT depth and
// bails. Either way the drilled `?item=` must NOT be clobbered.
await drillTo(page, b.slug);
await expect.poll(() => openItemParam(page)).toBe(b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
// Wait PAST the debounce window: the stale follow must never fire. Had it
// clobbered, it would have `openItemPane`-RESET the stack (depth→0) and
// re-targeted `?item=` to the j-focused row.
await page.waitForTimeout(250); // > PANE_FOLLOW_DEBOUNCE_MS (140ms)
expect(openItemParam(page)).toBe(b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
});
// ── Depth-aware ESC (PLAN-2154 Architecture C / R2, TASK-2163) ──────────
// Once the pane has drilled past its base (depth>0), ESC pops exactly ONE
// level via `history.back()` and consumes the key — it must NOT route
// through the two-level list-focus step or the list-row helpers
// (`returnFocusToList` / `resolvePaneReturnTarget`), which are meaningless
// once detached. Only at depth 0 does ESC fall through to the existing
// two-level return-focus-to-list-then-close behavior (TASK-2122,
// exhaustively covered by pane-a11y-focus.spec.ts — unaffected by this
// change, since the new depth>0 branch returns before reaching it).
test('depth-aware ESC: pops one level per press at depth>0 (pane stays open, ?item= stays truthy)', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl esc alpha');
const b = await seedDoc(fixture, request, 'Ctrl esc bravo');
const c = await seedDoc(fixture, request, 'Ctrl esc charlie');
const d = await seedDoc(fixture, request, 'Ctrl esc delta');
await page.goto(docsUrl(fixture));
const prePaneUrl = page.url();
await page.locator('.item-card', { hasText: 'Ctrl esc alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
// Drill to depth 3: A(0) → B(1) → C(2) → D(3).
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
await drillTo(page, c.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 2, paneOwned: true });
await drillTo(page, d.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 3, paneOwned: true });
expect(openItemParam(page)).toBe(d.slug);
// ESC at depth 3 pops exactly ONE level — the pane stays open, `?item=`
// stays truthy (now C), and no list row is even focused, so the
// two-level list-focus step never fires.
await page.keyboard.press('Escape');
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 2, paneOwned: true });
await expect.poll(() => openItemParam(page)).toBe(c.slug);
await expect(pane).toBeVisible();
// A second press pops again, to depth 1 (B) — one level per press, never
// a jump straight to the base or a close.
await page.keyboard.press('Escape');
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
await expect.poll(() => openItemParam(page)).toBe(b.slug);
await expect(pane).toBeVisible();
// A third press reaches the base (depth 0, back on A) — still open.
await page.keyboard.press('Escape');
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
await expect.poll(() => openItemParam(page)).not.toBeNull();
await expect(pane).toBeVisible();
// At depth 0, ESC falls through to today's two-level desktop behavior
// (TASK-2118). Focus-per-hop (TASK-2162) keeps focus INSIDE the pane
// across the drills+pops, and the paned row A carries `.focused` again
// once the stack unwinds to depth 0, so the FIRST depth-0 ESC returns
// focus to the list (level one) — the pane stays open, `?item=` truthy —
// rather than closing outright. (Before focus-per-hop, drilling had
// dropped focus to <body>, so this branch was skipped and the pane
// closed on the first press; keeping focus in the pane is the correct
// fix, and it simply restores the standard two-level ESC here.)
await page.keyboard.press('Escape');
await expect(pane).toBeVisible();
await expect.poll(() => openItemParam(page)).not.toBeNull();
await expect
.poll(() => page.evaluate(() => !!document.activeElement?.closest('.item-card')))
.toBe(true);
// The SECOND depth-0 ESC — now with focus on the list row, outside the
// pane — closes it, returning to the pre-pane URL.
await page.keyboard.press('Escape');
await expect.poll(() => openItemParam(page)).toBeNull();
await expect(pane).toBeHidden();
await expect.poll(() => page.url()).toBe(prePaneUrl);
});
test('depth-aware ESC: at depth 0 the two-level return-focus-to-list-then-close behavior is unchanged', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl esc0 alpha');
await seedDoc(fixture, request, 'Ctrl esc0 bravo');
await page.goto(docsUrl(fixture));
await page.locator('.item-card', { hasText: 'Ctrl esc0 alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
// Bridge focus into the pane (as a real user would via Tab, TASK-2122).
await page.keyboard.press('Tab');
await expect
.poll(() => page.evaluate(() => !!document.activeElement?.closest('.item-pane')))
.toBe(true);
// First ESC at depth 0, focused inside the pane: returns focus to the
// list — the pane STAYS open, depth stays 0.
await page.keyboard.press('Escape');
await expect
.poll(() => page.evaluate(() => !!document.activeElement?.closest('.item-pane')))
.toBe(false);
await expect(pane).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
// Second ESC, now from the list: closes the pane.
await page.keyboard.press('Escape');
await expect.poll(() => openItemParam(page)).toBeNull();
await expect(pane).toBeHidden();
});
test('depth-aware ESC: a HELD key (auto-repeat keydowns) still pops only one level', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl esc-repeat alpha');
const b = await seedDoc(fixture, request, 'Ctrl esc-repeat bravo');
const c = await seedDoc(fixture, request, 'Ctrl esc-repeat charlie');
await page.goto(docsUrl(fixture));
await page.locator('.item-card', { hasText: 'Ctrl esc-repeat alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
await drillTo(page, c.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 2, paneOwned: true });
// The initial (non-repeat) physical keydown pops one level, to depth 1.
await page.keyboard.press('Escape');
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
await expect.poll(() => openItemParam(page)).toBe(b.slug);
// A held key then fires a burst of AUTO-REPEAT keydowns
// (`KeyboardEvent.repeat === true`) — dispatched here after the first
// pop's traversal has already settled, so `paneNavInFlight()` alone
// would no longer block them; only the `!e.repeat` guard does. None of
// them may pop a further level — "one level per press", not per
// keydown.
await page.evaluate(() => {
for (let i = 0; i < 5; i++) {
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
repeat: true,
}),
);
}
});
await page.waitForTimeout(100);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
await expect.poll(() => openItemParam(page)).toBe(b.slug);
await expect(pane).toBeVisible();
});
test('depth-aware ESC: a HELD key that pops depth 1→0 does not also close the pane in the same press', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl esc-boundary alpha');
const b = await seedDoc(fixture, request, 'Ctrl esc-boundary bravo');
await page.goto(docsUrl(fixture));
await page.locator('.item-card', { hasText: 'Ctrl esc-boundary alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
// The initial (non-repeat) physical keydown pops depth 1 → 0, back at
// the base (A). This is the exact boundary Codex round 3 flagged: a
// naive `!e.repeat` guard scoped only to the depth>0 branch would let
// the FOLLOWING repeat events (now depth 0, no `.item-pane` focus) fall
// through to `runTopEscape()` and close the pane — all within the same
// physical key hold.
await page.keyboard.press('Escape');
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
await expect.poll(() => openItemParam(page)).not.toBeNull();
await expect(pane).toBeVisible();
// A burst of auto-repeat keydowns from the same continued hold must be
// fully inert — no fall-through to the two-level step or a close.
await page.evaluate(() => {
for (let i = 0; i < 5; i++) {
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
repeat: true,
}),
);
}
});
await page.waitForTimeout(100);
await expect(pane).toBeVisible();
await expect.poll(() => openItemParam(page)).not.toBeNull();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
});
test('depth-aware ESC: an open BottomSheet owns ESC — the pane underneath must not also pop', async ({
page,
fixture,
request,
}, testInfo) => {
test.skip(
testInfo.project.name !== 'desktop-chromium',
'mobile viewport is driven explicitly; one project is enough',
);
await page.setViewportSize(MOBILE);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl esc-sheet alpha');
const b = await seedDoc(fixture, request, 'Ctrl esc-sheet bravo');
await page.goto(docsUrl(fixture));
await page.locator('.item-card', { hasText: 'Ctrl esc-sheet alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
// Open the mobile Quick Actions BottomSheet from within the pane.
// `BottomSheet.svelte` has no focus trap — it never moves focus into
// itself on open — so `document.activeElement` stays on the trigger
// button back inside `.item-pane` while the sheet is showing.
await pane.locator('button.trigger-btn[title="Quick actions"]').click();
// Target the sheet by ACCESSIBLE NAME, not `[role="dialog"]` + text: on the
// mobile overlay the pane itself is now `role="dialog"` (TASK-2131) and it
// CONTAINS the sheet, so a text filter matches both the pane and the sheet
// (strict-mode violation). The pane's accessible name is "Item detail"; the
// BottomSheet's is "Quick actions" (its `aria-labelledby` heading) — unique.
const sheet = page.getByRole('dialog', { name: 'Quick actions' });
await expect(sheet).toBeVisible();
// ESC must be owned entirely by the sheet (it has its own window
// keydown listener, outside the shared escape stack): the sheet
// closes, and the pane beneath it must NOT also pop a drill level —
// a target-based dialog guard would miss this because focus never
// moved into the sheet (Codex review, TASK-2163).
await page.keyboard.press('Escape');
await expect(sheet).toBeHidden();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
await expect.poll(() => openItemParam(page)).toBe(b.slug);
await expect(pane).toBeVisible();
});
// ── In-pane Back chevron (PLAN-2154 Architecture C, TASK-2164) ──────────
// The pane chrome (⤢/↗/✕ today) grows a fourth control — a Back chevron —
// shown ONLY once the pane has drilled past its base (depth>0), wired to
// the same fenced `paneHistoryGo(-1)`/`paneNavInFlight()` traversal the
// depth-aware ESC handler above uses (R14). Depth is read reactively off
// SvelteKit `page.state` inside `ItemDetail` itself (never raw
// `history.state`), so the chevron's visibility always matches the
// controller's own `paneState()` read back through the test hook.
test('Back chevron: hidden at depth 0, visible once drilled, one press pops exactly one level', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl back alpha');
const b = await seedDoc(fixture, request, 'Ctrl back bravo');
await page.goto(docsUrl(fixture));
await page.locator('.item-card', { hasText: 'Ctrl back alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
const refA = openItemParam(page);
const backBtn = pane.locator('button[aria-label="Back"]');
// Depth 0 (first-open) — the chevron is not rendered at all.
await expect(backBtn).toHaveCount(0);
// Drill A→B → depth 1 — the chevron appears.
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
await expect(backBtn).toBeVisible();
// One press pops exactly one level: back to A at depth 0, and the pane
// stays open (this is a drill-stack pop, not a close).
await backBtn.click();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
await expect.poll(() => openItemParam(page)).toBe(refA);
await expect(pane).toBeVisible();
// Back at the base — the chevron is gone again.
await expect(pane.locator('button[aria-label="Back"]')).toHaveCount(0);
});
test('Back chevron: pops one level per press across a multi-hop drill', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl back-multi alpha');
const b = await seedDoc(fixture, request, 'Ctrl back-multi bravo');
const c = await seedDoc(fixture, request, 'Ctrl back-multi charlie');
const d = await seedDoc(fixture, request, 'Ctrl back-multi delta');
await page.goto(docsUrl(fixture));
await page.locator('.item-card', { hasText: 'Ctrl back-multi alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
// Drill to depth 3: A(0) → B(1) → C(2) → D(3). Each `drillTo` only
// awaits `navigatePaneTo`'s SYNCHRONOUS portion — the `goto()` it
// fires is fire-and-forget — so poll `page.state` to settle after
// each hop before firing the next; otherwise a later drill can read
// a still-stale depth off an in-flight earlier one (Codex review
// round 2, P1).
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
await drillTo(page, c.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 2, paneOwned: true });
await drillTo(page, d.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 3, paneOwned: true });
expect(openItemParam(page)).toBe(d.slug);
const backBtn = pane.locator('button[aria-label="Back"]');
// Press 1: D(3) → C(2). The click triggers a reload (`loading` briefly
// true), which swaps the loaded header for the minimal one and
// unmounts the just-clicked, focused Back button. Focus-per-hop
// (TASK-2162) moves focus onto the STABLE pane region synchronously at
// the pop, so it stays inside `.item-pane` across that remount — a
// following `j`/`k` can't steal to list-nav, and further ESC/Back pops
// still land on the detached pane.
await expect(backBtn).toBeVisible();
await backBtn.focus();
await backBtn.click();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 2, paneOwned: true });
await expect.poll(() => openItemParam(page)).toBe(c.slug);
await expect.poll(() => paneHasFocus(page)).toBe(true);
// Press 2: C(2) → B(1). Focus stays in the pane again.
await backBtn.click();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
await expect.poll(() => openItemParam(page)).toBe(b.slug);
await expect.poll(() => paneHasFocus(page)).toBe(true);
// Press 3: B(1) → A(0), the base — chevron disappears, pane stays open.
// The terminal pop has no Back button left to land focus on, but the
// pane region itself is always present, so focus stays inside the pane
// rather than stranding on <body>.
await backBtn.click();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
await expect(pane).toBeVisible();
await expect(pane.locator('button[aria-label="Back"]')).toHaveCount(0);
await expect.poll(() => paneHasFocus(page)).toBe(true);
});
test('Back chevron: a second press while the first pop is still loading still ends with focus restored (Codex review round 3)', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl back-race alpha');
const b = await seedDoc(fixture, request, 'Ctrl back-race bravo');
const c = await seedDoc(fixture, request, 'Ctrl back-race charlie');
const d = await seedDoc(fixture, request, 'Ctrl back-race delta');
await page.goto(docsUrl(fixture));
await page.locator('.item-card', { hasText: 'Ctrl back-race alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
await drillTo(page, c.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 2, paneOwned: true });
await drillTo(page, d.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 3, paneOwned: true });
// Gate C's item fetch — the FIRST Back press (D→C) lands on it and
// stalls mid-load, so the SECOND Back press (fired while that fetch is
// still outstanding) exercises the exact race Codex round 3 flagged:
// a click while `loading` is already true from an EARLIER, unrelated
// load must not let the focus-restore effect latch onto that stale
// cycle instead of its own.
let releaseGate: () => void = () => {};
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
await page.route(`**/api/v1/workspaces/*/items/${c.slug}`, async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await gate;
await route.continue();
});
const backBtn = pane.locator('button[aria-label="Back"]');
await expect(backBtn).toBeVisible();
await backBtn.focus();
// Press 1: D(3) → C(2). C's fetch is gated — this pop never actually
// finishes loading until the gate is released below.
await backBtn.click();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 2, paneOwned: true });
// Wait for the DOM to actually reach the minimal (loading) header —
// not just for `page.state` to have updated — so press 2 below
// deterministically lands mid-fetch regardless of system load
// (rather than merely racing the depth-poll's own timing).
await expect(pane.locator('.pane-header--minimal')).toBeVisible();
// Press 2, fired while `loading` is STILL true from the stalled C
// fetch (the history-level `paneNavInFlight()` fence has already
// cleared by now, since that only guards the `history.go` traversal
// itself, not the data fetch it triggers). Targets B, whose fetch is
// NOT gated.
await backBtn.click();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
await expect.poll(() => openItemParam(page)).toBe(b.slug);
// The pane settled on B — focus stayed inside the pane throughout (each
// pop moves it onto the stable region synchronously), not consumed early
// or lost to <body> by C's stale, still-outstanding load.
await expect.poll(() => paneHasFocus(page)).toBe(true);
// Release the gate so C's now-superseded fetch can drain (generation-
// fenced server-side by `loadData`'s own `myGen === loadGeneration`
// checks — it must not clobber the now-current B state).
releaseGate();
await page.waitForTimeout(100);
await expect.poll(() => openItemParam(page)).toBe(b.slug);
await expect.poll(() => paneHasFocus(page)).toBe(true);
});
test('Back chevron: a superseding row-click while the pop is still loading does NOT steal focus back into the pane (Codex review round 6)', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
const a = await seedDoc(fixture, request, 'Ctrl back-supersede alpha');
const b = await seedDoc(fixture, request, 'Ctrl back-supersede bravo');
const c = await seedDoc(fixture, request, 'Ctrl back-supersede charlie');
await page.goto(docsUrl(fixture));
await page.locator('.item-card', { hasText: 'Ctrl back-supersede alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
// A real row click puts the item's REF (e.g. `DOC-16`), not its slug,
// in `?item=` (`itemUrlId` prefers `formatItemRef` — +page.svelte),
// and that's what the fetch path segment uses too. Capture it here
// rather than assuming `a.slug` so the route gate below actually
// matches.
const aRef = openItemParam(page);
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
// Gate A's item fetch — the Back press (B→A) lands on it and stalls
// mid-load, giving room to fire an UNRELATED navigation (a direct
// row click on C) before the Back pop ever settles.
let releaseGate: () => void = () => {};
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
await page.route(`**/api/v1/workspaces/*/items/${aRef}`, async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await gate;
await route.continue();
});
const backBtn = pane.locator('button[aria-label="Back"]');
await expect(backBtn).toBeVisible();
await backBtn.focus();
await backBtn.click();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
// Confirm the pop is genuinely stalled mid-fetch before superseding it.
await expect(pane.locator('.pane-header--minimal')).toBeVisible();
// SUPERSEDE: a direct row click on a THIRD item while A is still
// loading. `paneDepth` is already 0 (the Back pop's own destination
// settled at the history level), so this is a normal depth-0
// re-target (`openItemPane` replace) — a completely different, later
// navigation than the stalled Back pop, landing on C.
await page.locator('.item-card', { hasText: 'Ctrl back-supersede charlie' }).first().click();
await expect(pane.locator('.title', { hasText: /Ctrl back-supersede charlie/ })).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
// A row click lands the item's ref (not necessarily its slug — same
// `itemUrlId` caveat as `aRef` above) in `?item=`; just confirm it
// changed to something other than A's ref.
const cRef = openItemParam(page);
expect(cRef).not.toBe(aRef);
// The stalled Back pop must NOT reach back and steal focus onto C's pane
// CHROME once A eventually settles. Under host-owned focus-per-hop there
// is no armed restore intent to mis-fire (TASK-2162 dissolved the exact
// TASK-2164 deferred edge this test guarded). We assert the Close button
// is never focused — not that focus stays wholly outside the pane, since
// C's editor legitimately autofocuses on load (ItemDetail), which is
// benign in-pane focus, not the mis-fire onto the Close/Back control.
const closeIsFocused = () =>
page.evaluate(() => document.activeElement?.getAttribute('aria-label') === 'Close pane');
await expect.poll(closeIsFocused).toBe(false);
// Release the gate — A's now-superseded fetch drains (generation-
// fenced by `loadData`'s own checks), and no stale focus pull may fire
// afterward.
releaseGate();
await page.waitForTimeout(150);
await expect.poll(() => openItemParam(page)).toBe(cRef);
await expect.poll(closeIsFocused).toBe(false);
});
test('Back chevron: a genuinely slow (but legitimate) destination load keeps focus in the pane, however long it takes', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl back-slow alpha');
const b = await seedDoc(fixture, request, 'Ctrl back-slow bravo');
await page.goto(docsUrl(fixture));
await page.locator('.item-card', { hasText: 'Ctrl back-slow alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
// A real row click puts the item's REF (e.g. `DOC-16`), not its slug,
// in `?item=` (`itemUrlId` prefers `formatItemRef` — +page.svelte),
// and that's what the fetch path segment uses too.
const aRef = openItemParam(page);
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
// Gate A's item fetch — the Back press (B→A) LANDS on it. Focus-per-hop
// moves focus onto the stable pane region SYNCHRONOUSLY at the pop, so
// it never depends on the destination load at all: no matter how long
// the fetch takes, focus can't drift out of the pane while it loads
// (TASK-2162 replaced TASK-2164's load-settle-timed restore, which is
// why there is no wall-clock deadline to race here).
let releaseGate: () => void = () => {};
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
await page.route(`**/api/v1/workspaces/*/items/${aRef}`, async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await gate;
await route.continue();
});
const backBtn = pane.locator('button[aria-label="Back"]');
await expect(backBtn).toBeVisible();
await backBtn.focus();
await backBtn.click();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
await expect(pane.locator('.pane-header--minimal')).toBeVisible();
// Focus is already inside the pane (moved there synchronously at the
// pop) even while the destination is still loading behind the minimal
// header.
await expect.poll(() => paneHasFocus(page)).toBe(true);
// Hold well past the mint-settle window before releasing — the pop's
// slow load must not knock focus back out of the pane.
await page.waitForTimeout(400);
releaseGate();
await expect.poll(() => openItemParam(page)).toBe(aRef);
await expect(pane.locator('.pane-header--minimal')).toBeHidden();
await expect.poll(() => paneHasFocus(page)).toBe(true);
});
test('Back chevron: a cold-loaded shared ?item= starts at depth 0 and stays hidden; browser Back still exits the pane', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
const a = await seedDoc(fixture, request, 'Ctrl back-cold alpha');
// COLD LOAD straight into an open pane — no history stamp, so the
// controller normalizes to depth 0 / unowned (readPaneState's base
// default). The chevron must NOT render here.
await page.goto(docsUrl(fixture, `?item=${a.slug}`));
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: false });
await expect(pane.locator('button[aria-label="Back"]')).toHaveCount(0);
// Browser Back at the cold base behaves exactly as before this task —
// it leaves the page entirely (there was no pre-pane entry to unwind
// to within this tab's history), unaffected by the chevron addition.
await page.goBack();
await expect.poll(() => page.url()).not.toContain(`item=${a.slug}`);
});
// Also the regression guard for BUG-2284: the mobile overlay's `paneOverlay`
// ref-count effect must not self-loop (`effect_update_depth_exceeded`) and
// strand `paneMintForRoute` — if it does, the pane renders EMPTY and the Back
// chevron below never mounts.
test('Back chevron: works from the mobile full-screen overlay too', async ({
page,
fixture,
request,
}, testInfo) => {
test.skip(
testInfo.project.name !== 'desktop-chromium',
'mobile viewport is driven explicitly; one project is enough',
);
await page.setViewportSize(MOBILE);
await enableHook(page);
await browserLogin(page);
await seedDoc(fixture, request, 'Ctrl back-mobile alpha');
const b = await seedDoc(fixture, request, 'Ctrl back-mobile bravo');
await page.goto(docsUrl(fixture));
await page.locator('.item-card', { hasText: 'Ctrl back-mobile alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
const backBtn = pane.locator('button[aria-label="Back"]');
await expect(backBtn).toHaveCount(0);
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
await expect(backBtn).toBeVisible();
await backBtn.click();
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
await expect(pane).toBeVisible();
await expect(pane.locator('button[aria-label="Back"]')).toHaveCount(0);
});
});