Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7dbf3d6
fix(ui): harden Sheet focus handling
cursoragent Jul 25, 2026
d3f9f67
Merge branch 'main' into cursor/sheet-focus-hardening-c6d3
github-actions[bot] Jul 25, 2026
a3f60f6
Merge branch 'main' into cursor/sheet-focus-hardening-c6d3
github-actions[bot] Jul 25, 2026
a05dd39
Merge branch 'main' into cursor/sheet-focus-hardening-c6d3
github-actions[bot] Jul 25, 2026
ee7665f
fix(ui): restore focus into the sheet below a closing stacked sheet
cursoragent Jul 25, 2026
dfbb2c6
Merge remote-tracking branch 'origin/cursor/sheet-focus-hardening-c6d…
cursoragent Jul 25, 2026
01a4daa
Merge branch 'main' into cursor/sheet-focus-hardening-c6d3
github-actions[bot] Jul 25, 2026
91d6602
test(ui): prove sheet background deactivation in a real browser
cursoragent Jul 25, 2026
d9a7dc7
Merge branch 'main' into cursor/sheet-focus-hardening-c6d3
github-actions[bot] Jul 25, 2026
659d5c3
Merge remote-tracking branch 'origin/cursor/sheet-focus-hardening-c6d…
cursoragent Jul 25, 2026
0d68401
style: format sheet inert browser guard
cursoragent Jul 25, 2026
3cfa44c
Merge branch 'main' into cursor/sheet-focus-hardening-c6d3
cursoragent Jul 25, 2026
684ce77
Merge remote-tracking branch 'origin/main' into cursor/sheet-focus-ha…
cursoragent Jul 25, 2026
4c22805
Merge branch 'main' into cursor/sheet-focus-hardening-c6d3
github-actions[bot] Jul 25, 2026
f7208d5
Merge branch 'main' into cursor/sheet-focus-hardening-c6d3
github-actions[bot] Jul 25, 2026
df42124
Merge branch 'main' into cursor/sheet-focus-hardening-c6d3
github-actions[bot] Jul 25, 2026
7231a1f
docs(ledger): record #1217 sheet-focus stress pass and CI retrigger
cursoragent Jul 25, 2026
f3f0a75
Merge remote-tracking branch 'origin/cursor/sheet-focus-hardening-c6d…
cursoragent Jul 25, 2026
65f7580
Merge remote-tracking branch 'origin/main' into cursor/sheet-focus-ha…
cursoragent Jul 25, 2026
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
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,7 @@ This file is append-only. Never rewrite or delete an existing review record; app
| 2026-07-26 | cursor/global-header-scroll-hide-4fd7 (PR #1222) | `a7f6d81f8b1dd5613dda94a3ddef78d480de876e` | Cross-breakpoint header hide/reveal + tablet/desktop scroll coverage | Header now hides on scroll down and returns on scroll up at every breakpoint; bottom search dock stays phone-only. Two root causes fixed: GlobalSearchShell had no scroll source above phones (`#main-content` onScroll never fires there) and its sticky rule sat on `header#search`, which has zero travel inside two header-height parents; ClinicalDashboard's collapse row was `max-sm`-gated so it never hid. Red/green proof captured: with the four source files reverted to base `1aa64e94`, all 12 new Playwright tests and 8/10 static contract assertions fail. | `npm run verify:cheap` pass except pre-existing local `tests/pdf-extractor.test.ts` Python-OCR failure (reproduced identically at base `1aa64e94`); `npm run verify:ui` 284/284 Chromium on the main-synced tree; `check:migration-role`, `check:function-grants`, `check:branch-review-ledger` pass after the #1197 SQL sync; no provider-backed checks. |
| 2026-07-25 | PR #1213 / `cursor/pr1188-fix-build-breakers-6ee0` | `c45189c76502050002e579cf2038157416a65f2d` | Deduplicate ledger rows before land | Removed 2 exact duplicate table row(s) introduced by union-merge churn (no unique review content lost). Production UI green after Sheet autofocus upgrade. | check:branch-review-ledger; hosted Production UI success on tip. |
| 2026-07-25 | PR #1213 / `cursor/pr1188-fix-build-breakers-6ee0` (supersedes #1188) | merge `8e3a49d0449ec2c1b8e4e10f6cd500c0c8c9550b` | prlanded after safe close+#1213 land | LANDED. #1188 CLOSED (not merged). #1213 squash-merged to main after PR required green (Production UI/Static/Unit/Build/Migration). Content verified on main: dashboard-notices extract, indexing-v3 utils `export async function sha256Hex`, Sheet autofocus defense. Remote feature branch deleted by merge. | Hosted CI run 30165248633 success; gh pr merge squash; git cat-file content checks on origin/main. No provider calls. |
| 2026-07-25 | PR #1217 / `cursor/sheet-focus-hardening-c6d3` | `f7208d52eca6d9059f15908367cebada568b0124` | Sheet focus stress test + CI unblock before land | READY. Stress pass found and fixed two defects in this branch's own work before land: (1) the open-sheet stack guard skipped every close-restore while any sheet was open, so closing a stacked sheet dropped focus to body instead of the sheet below (now `canRestoreFocusTo`: restore proceeds when the target is inside the top-most sheet); (2) a background surface re-focusing itself from its own focus handler could trade `focus()` calls synchronously with the controller (reclaims that do not stick are now bounded; a reclaim that holds resets the count). Resolved the #1213 conflict in favour of the event-driven controller and widened the settle window to cover the lazily imported `data-sheet-autofocus` child main's 200-attempt poll was defending. Repeated `pr-branch-sync` bot merges left hosted CI `action_required`; applying `skip-branch-sync` + this agent commit to re-trigger non-bot CI before land. | `verify:pr-local` green (389 files / 3458 tests, build, client-bundle scan, RAG fixtures); `test:e2e:pr` 285/285 Chromium; `ui-smoke.spec.ts --repeat-each=3` 273/273 no flake; 120-cycle browser soak of the phone mode-menu sheet (zero inert/scroll-lock leaks, focus restored every cycle, median 0 ms / max 10 ms); new `tests/sheet-focus-stress.dom.test.tsx` 13 cases + real-browser inert guard in `ui-accessibility.spec.ts`. No provider-backed checks. |
| 2026-07-25 | cursor/eval-matching-distinct-identity-6273 (PR #1204) | `c0db5f5810a37dbe7df9efa3d5479db134c98e10` | prlanded — #030 coverage hardening | LANDED. Squash `eb443dfda0fcfca8181e6839d156d9986dfc52db`. `expectedFileCoverage` now dedupes the citation window by document identity and assigns sources by maximum bipartite matching, closing two re-entry routes into #030 (repeat chunk citations of one document; order-dependent greedy assignment). Content verified by tree comparison against the squash commit (identical). Remote branch deleted at merge; local branch pruned 2026-07-25 after that verification. | `npm run verify:cheap` green pre-merge; hosted `PR required` green; tree-identity check `git diff eb443dfd c0db5f58` empty. No provider-backed checks. |
| 2026-07-25 | cursor/alias-slot-disjointness-guard-6273 (PR #1215) | `406cf21eb730809fb06df00b1a9299e3462c728a` | prlanded — generalized #030 contracts + ledger #081 | LANDED. Squash `b2d794c532ea8b7e259751005165f69906fcd784`. Adds two table-independent guards to `tests/eval-document-matching.test.ts`: pairwise alias disjointness across every multi-slot eval case, and the structural rule that one document can never satisfy every slot of a multi-slot case. Also opened ledger item #081 for the then-open PR #1196 alias conflict. Content verified by tree comparison against the squash commit (identical); remote branch deleted at merge, local pruned. | `npm run verify:cheap` green; hosted `PR required` green; tree-identity check `git diff b2d794c5 406cf21e` empty. No provider-backed checks. |
| 2026-07-25 | cursor/ledger-081-closeout-6273 (PR #1220) | `84e91194ecca7f74c0d70b9e30e1dbd05ab7853f` | prlanded — archive outstanding item #081 | LANDED. Squash `e7e60c6d02a37c1f5958cb97936bd3533c7a2f46`. #081 moved from Open items to Resolved/archive after PR #1196 was closed 2026-07-25 as superseded by #913 / current main; successor #1198 does not touch `src/lib/eval-document-matching.ts`, and the #1215 contracts fail closed on any re-added dual-listed alias. Merge friction worth recording: a `github-actions[bot]` branch-sync merge landed every 10-20 minutes and every bot-authored head produced `action_required` workflow runs, so the three required checks never reported and both normal and `--admin` merges were refused; runs on agent-pushed heads execute normally, so the resolution was to push an own-authored head and merge on green. Content verified by tree comparison against the squash commit (identical); remote branch deleted at merge, local pruned. | `npm run verify:cheap` on merged main: 387 files / 3431 tests pass; hosted CI, SAST, Secret Scan and PR Policy green on `84e91194`; `npm run docs:check-links` pass. No provider-backed checks. |
Expand Down
307 changes: 307 additions & 0 deletions src/components/ui/sheet-focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
"use client";

/**
* Stacked-overlay coordination for `Sheet`: open-sheet stack, body scroll lock,
* background inerting, and the open-focus settle controller.
*
* Split out of `sheet.tsx` so the focus rules can be unit-tested directly
* instead of only through a rendered Sheet, and so the stack is unambiguously a
* single module-level singleton shared by every Sheet instance.
*
* Every open Sheet registers a window keydown listener and locks body scroll.
* Without coordination two open Sheets (e.g. an image lightbox or table dialog
* opened over the mobile Evidence sheet) both react to a single Escape —
* closing both — and their independent per-instance scroll-lock save/restore is
* order-dependent (an out-of-order close unlocks the page behind a still-open
* sheet or leaks `overflow:hidden`). The stack lets only the top-most Sheet
* handle Escape/Tab, and its length doubles as a scroll-lock ref count so body
* scroll stays locked until the last Sheet closes and the original overflow is
* restored exactly once.
*/

/** Marks an element this module deactivated behind the top-most sheet. */
export const SHEET_INERT_MARKER = "data-sheet-inert";
/** Opt-in marker for a portaled surface that is logically inside a sheet. */
export const SHEET_PORTAL_ATTRIBUTE = "data-sheet-portal";
/** Opt-out marker for background elements that must stay interactive. */
export const SHEET_INERT_SKIP_ATTRIBUTE = "data-sheet-inert-skip";

/**
* How long a newly opened sheet keeps listening for the conditions that steal
* its initial focus: a sibling sheet finishing teardown, `focus=1` hydration
* reclaiming the composer, or a `data-sheet-autofocus` child arriving behind a
* slow dynamic import (the DocumentDrawer Find field). Listening is idle, so
* the window matches the widest case rather than the common one; it ends on the
* first user interaction, when this sheet stops being top-most, or here.
*/
export const SHEET_FOCUS_SETTLE_MS = 10_000;

/**
* Fallback re-attempt schedule, in milliseconds after the first attempt. Covers
* focus changes that emit neither a `focusin`, a panel mutation, nor a stack
* change. Backoff converges in the common case (target present next frame)
* without the fixed 50ms polling this replaced.
*/
const FALLBACK_ATTEMPT_DELAYS_MS = [16, 32, 64, 128, 256];

/**
* Ceiling on reclaims that do not stick. A component that re-focuses itself
* from its own focus handler would otherwise trade `focus()` calls with this
* controller synchronously until the stack blows; giving up leaves focus where
* that surface put it. A reclaim that holds resets the count, so defending the
* panel repeatedly over the settle window is unaffected.
*/
const MAX_CONSECUTIVE_RECLAIMS = 12;

/**
* Elements that must keep working behind a modal: stylesheets and scripts carry
* no focus, live regions must keep announcing (route changes, status), and the
* Next.js dev overlay/route announcer would otherwise be unreachable.
*/
const NEVER_INERT_TAGS = new Set(["SCRIPT", "STYLE", "LINK", "TEMPLATE", "NOSCRIPT", "NEXT-ROUTE-ANNOUNCER"]);

type SheetEntry = { id: string; root: HTMLElement | null };

const openSheets: SheetEntry[] = [];
const inertedElements = new Set<HTMLElement>();
const stackListeners = new Set<() => void>();
let bodyScrollLockPreviousOverflow = "";

export function isTopmostSheet(id: string) {
return openSheets[openSheets.length - 1]?.id === id;
}

export function hasOpenSheet() {
return openSheets.length > 0;
}

/**
* Whether a closing sheet may still return focus to `target`.
*
* With nothing open, always. With a sheet still open, only when the target
* lives inside it — that is a stacked sheet handing focus back down to the
* sheet below. A target out in the (now inert) page background belongs to a
* sheet that was replaced rather than stacked on, and restoring it would drag
* focus out of the sheet that took over.
*/
export function canRestoreFocusTo(target: HTMLElement | null) {
if (target == null) return false;
const topRoot = openSheets[openSheets.length - 1]?.root ?? null;
if (topRoot == null) return true;
return topRoot.contains(target);
}

/**
* Notifies on every push/pop so the top-most sheet can re-take focus the moment
* a sibling sheet finishes tearing down, instead of polling for it.
*/
export function subscribeToSheetStack(listener: () => void) {
stackListeners.add(listener);
return () => {
stackListeners.delete(listener);
};
}

function notifyStackListeners() {
for (const listener of Array.from(stackListeners)) listener();
}

export function pushSheet(id: string, root: HTMLElement | null = null) {
if (openSheets.length === 0) {
bodyScrollLockPreviousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
}
openSheets.push({ id, root });
syncBackgroundInert();
notifyStackListeners();
}

export function popSheet(id: string) {
for (let index = openSheets.length - 1; index >= 0; index -= 1) {
if (openSheets[index].id === id) {
openSheets.splice(index, 1);
break;
}
}
if (openSheets.length === 0) {
document.body.style.overflow = bodyScrollLockPreviousOverflow;
}
syncBackgroundInert();
notifyStackListeners();
}

function canInert(element: Element): element is HTMLElement {
if (!(element instanceof HTMLElement)) return false;
if (NEVER_INERT_TAGS.has(element.tagName) || element.tagName.startsWith("NEXTJS-")) return false;
if (element.hasAttribute(SHEET_INERT_SKIP_ATTRIBUTE)) return false;
if (element.hasAttribute("aria-live")) return false;
// Never clobber an `inert` another owner set: on release we could not tell
// whether removing it was ours to do.
return !element.hasAttribute("inert") || inertedElements.has(element);
}

/**
* Everything outside the sheet's own subtree: walking to `document.body` and
* taking the siblings at each level covers both portaled sheets (a direct body
* child) and inline sheets nested deep in the page tree.
*/
function collectBackgroundElements(root: HTMLElement) {
const background: HTMLElement[] = [];
let node: HTMLElement = root;
while (node !== document.body) {
const parent: HTMLElement | null = node.parentElement;
if (parent == null) break;
for (const sibling of Array.from(parent.children)) {
if (sibling === node) continue;
if (canInert(sibling)) background.push(sibling);
}
node = parent;
}
return background;
}

/**
* Containment beats reclamation: while a sheet is open the rest of the page is
* `inert`, so the browser itself refuses to move focus there. Only elements
* present when the sheet opened are marked — a popover portaled out of the
* sheet after it opened stays interactive.
*/
function syncBackgroundInert() {
if (typeof document === "undefined") return;
const topRoot = openSheets[openSheets.length - 1]?.root ?? null;
const next =
topRoot?.isConnected && document.body.contains(topRoot)
? new Set(collectBackgroundElements(topRoot))
: new Set<HTMLElement>();

for (const element of Array.from(inertedElements)) {
if (next.has(element)) continue;
element.removeAttribute("inert");
element.removeAttribute(SHEET_INERT_MARKER);
inertedElements.delete(element);
}
for (const element of next) {
if (inertedElements.has(element)) continue;
element.setAttribute("inert", "");
element.setAttribute(SHEET_INERT_MARKER, "true");
inertedElements.add(element);
}
}

/**
* A sheet may only take focus back from nothing (body) or from background it
* deactivated itself. Anything else — a control inside the panel, a popover
* portaled out of the sheet, a surface that legitimately opened on top — keeps
* the focus the user or the app gave it.
*/
export function shouldReclaimFocus(panel: HTMLElement | null, active: Element | null) {
if (active == null || active === document.body) return true;
if (panel?.contains(active)) return false;
if (active.closest(`[${SHEET_PORTAL_ATTRIBUTE}]`)) return false;
return active.closest(`[${SHEET_INERT_MARKER}="true"]`) != null;
}

export type SheetFocusController = { cancel: () => void };

/**
* Drives a newly opened sheet's initial focus from events rather than a poll:
* a panel `MutationObserver` catches a late-mounted autofocus child, `focusin`
* catches focus landing elsewhere, and the stack subscription catches a sibling
* sheet's teardown. A short backoff chain covers anything none of those emit.
* The window closes on success, on the first user interaction, when this sheet
* is no longer top-most, or at `settleMs`.
*/
export function startSheetOpenFocus({
sheetId,
getPanel,
resolveTarget,
settleMs = SHEET_FOCUS_SETTLE_MS,
}: {
sheetId: string;
getPanel: () => HTMLElement | null;
resolveTarget: () => HTMLElement | null;
settleMs?: number;
}): SheetFocusController {
let settled = false;
// The element this controller last focused. Focus sitting there is still the
// sheet's own opening focus, so it may be upgraded when a better target (a
// late-mounted autofocus child) appears — unlike focus the user moved.
let lastAppliedTarget: HTMLElement | null = null;
let reclaims = 0;
const timers: number[] = [];
const teardowns: Array<() => void> = [];

function finish() {
if (settled) return;
settled = true;
for (const timer of timers) window.clearTimeout(timer);
timers.length = 0;
for (const teardown of teardowns.splice(0)) teardown();
}

function attempt() {
if (settled) return;
if (!isTopmostSheet(sheetId)) {
finish();
return;
}
const target = resolveTarget();
// No target yet: the autofocus child may still be mounting, so stay armed
// rather than burning attempts against a node that does not exist.
if (!target?.isConnected) return;
const active = document.activeElement;
if (active === target) return;
if (active !== lastAppliedTarget && !shouldReclaimFocus(getPanel(), active)) return;
reclaims += 1;
if (reclaims > MAX_CONSECUTIVE_RECLAIMS) {
finish();
return;
}
target.focus({ preventScroll: true });
if (document.activeElement === target) {
lastAppliedTarget = target;
// The reclaim held, so this was ordinary defence rather than a fight.
// Only focus that is taken straight back accumulates against the budget.
reclaims = 0;
}
}

const frame = window.requestAnimationFrame(() => {
if (settled) return;
attempt();
if (settled) return;
// Stay armed after a successful first focus: the resolved target can still
// improve (a deferred autofocus input mounting into the panel).

const panel = getPanel();
if (panel != null && typeof MutationObserver === "function") {
const observer = new MutationObserver(attempt);
observer.observe(panel, { childList: true, subtree: true });
teardowns.push(() => observer.disconnect());
}

document.addEventListener("focusin", attempt);
teardowns.push(() => document.removeEventListener("focusin", attempt));

teardowns.push(subscribeToSheetStack(attempt));

// A deliberate interaction outranks the sheet's opening focus.
document.addEventListener("pointerdown", finish, true);
document.addEventListener("keydown", finish, true);
teardowns.push(() => {
document.removeEventListener("pointerdown", finish, true);
document.removeEventListener("keydown", finish, true);
});

for (const delay of FALLBACK_ATTEMPT_DELAYS_MS) {
timers.push(window.setTimeout(attempt, delay));
}
timers.push(window.setTimeout(finish, settleMs));
});

teardowns.push(() => window.cancelAnimationFrame(frame));

return {
cancel: finish,
};
}
Loading
Loading