fix(shell): stop keys pressed inside a shell surface from reaching page-level listeners - #2709
fix(shell): stop keys pressed inside a shell surface from reaching page-level listeners#2709Chris0Jeky wants to merge 3 commits into
Conversation
The capture-phase window guard from PR #2635 stands aside when the keydown target is inside the keyboard-owning surface, because it runs ahead of every handler the surface owns and stopping there would break typing and arrow navigation. Keys pressed with focus INSIDE the surface therefore ran the surface's handlers and then kept bubbling to page-level window listeners: on a Legacy board, Tab into the open help dialog and press f or n and the board's useKeyboardShortcuts listener toggled the filter panel and pulled focus into the add-card composer. Add the other half on document in the bubble phase, which is the one seam between the two. All four surfaces bind their own keys on their own elements, so those handlers have already run by the time the event reaches document, while every page-level listener binds on window, one hop further out. Stopping there cannot take a key away from the surface that owns it, so no surface component needed changing. Escape is never stopped, so useEscapeStack (capture phase), BoardView.closeOpenUi and PaperShortcutsOverlay all keep theirs. Text-entry targets are left alone: useKeyboardShortcuts ignores them anyway, and the early-out keeps the #1968 promise that an ordinary keystroke in a field never pays for the surface scan. The two guards now share one per-event scan so the pair costs no more than the capture half did alone. Refs #2636
Six cases for the #2636 residual, all dispatching from a node INSIDE the surface with bubbles: true. Dispatching on window would put the event AT_TARGET, where capture and bubble listeners both run whatever propagation says, and every one of these would have passed against the unguarded source. Four failed red against the unmodified guard: f and n from the help dialog's close button and from a focused palette option and from a button in a capture modal over the board (filterToggles 1, expected 0), and the bare-letter navigation set plus the g-chord reaching a page-level window listener (['h','t','b','i','r','g'], expected []). Two are regression guards that already passed: Escape still leaves the surface for the page close paths, and each surface keeps its own keys from inside it (? closes the help dialog, arrows move the palette selection, mod+k closes the palette). The last of those re-queries the palette input after the selection re-render: that render replaces the input element, and a key dispatched on the detached node never reaches the window listener at all, which would have passed the assertion for the wrong reason. Refs #2636
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Review record (alpha product-trust lane; one fresh-context read-only reviewer subagent since Codex credits are exhausted, SC-9; round 1 at head Verdict at round 1: SHIP; no CRITICAL or HIGH, and the reviewer could not construct a path from the changed lines to a surface key dying or a page key that must fire and does not. Triage:
Clean lenses corroborated by the reviewer against the tree at the head: document bubble runs before window bubble and Unverified by the read-only reviewer, run by the worker at the head (AppShell spec 66; shell surfaces and keyboard composables 11 files / 189; page-listener views 9 files / 228; review keymap seam 3 files / 68; typecheck, eslint, diff-check; keyboard-navigation and workspace-help E2E 6 passed locally) and by CI's frontend unit job. Round count: 2 (comments and one spec, no logic, so no further pass is owed). Merge gate remaining: the fix head's |
…er help overlay
Review round 2. No mechanism change; comment truth on a safety seam plus one
coverage gap.
The guard docblock claimed it was safe because "all four surfaces bind their own
keys on their own elements". Two things were false. PaperShortcutsOverlay binds
its Escape handler on window in the bubble phase, one hop OUTSIDE the document
guard, so it survives on the Escape carve-out and not on that premise. And the
guard's reach is not four surfaces: it fires for every dialog[open],
[role="alertdialog"] or [aria-modal="true"] in the app, which is 16 components
today (CardModal, TdDialog and the review dialogs on it, ProvenanceDrawer,
PaperBoardDialogShell, the board modals, WorkspaceSetupModal, MfaChallengeModal).
Restate the actual invariant: a surface keeps a key only if it handles it at or
below document, or if the key is Escape. A window-level non-Escape handler
belonging to a modal would be silenced, so that shape is forbidden here and is
now pinned by a spec.
Also:
- The unmount reset claimed "nothing should outlive the shell holding a
reference to a detached surface", which describes a module-level hazard. The
memo lives in the per-instance setup() closure and dies with the instance, so
the reset is belt-and-braces and now says so.
- The palette re-query comment asserted a mechanism ("the selection re-render
replaces the input element"). Replaced with what was measured under this mount
-- the captured node reports isConnected false and no longer matches the
selector after the ArrowDown -- plus why the re-query cannot mask a defect: a
detached input reaches no listener, so the palette would stay open and the
exists() assertion would go red.
- New Paper-skin spec on the help twin whose Escape lives on window: a bare f
and n from inside it reach no window-bubble probe and no board action, and
Escape still closes the overlay. Red on the unmodified mechanism
(expected [ 'f', 'n' ] to deeply equal []), verified by disabling the document
listener and restoring it.
- Stub versionApi in this suite, as AppShell.paperVariant.spec.ts already does:
the Paper sidebar reads the product version on mount, so the new spec was
logging a real ECONNREFUSED per run.
Refs #2636
|
Round 2 at |
Summary
PR #2635 guarded the page behind a modal with a capture-phase
windowlistener inAppShell.vue, but it has to stand aside when the keydown target is inside the surface — it runs ahead of every handler the surface owns, so stopping there would break typing and arrow navigation. That carve-out was the leak this issue records: keys pressed with focus inside the surface ran the surface's handlers and then kept bubbling to page-levelwindowlisteners. On a Legacy board, Tab into the open help dialog and pressfornand the board'suseKeyboardShortcutslistener toggled the filter panel and pulled focus into the add-card composer. Same class with Ctrl+Shift+C openingCaptureModalover a board and Tab off the textarea onto a button.This adds the other half of the same guard: a
document-level bubble-phase listener, kept beside the capture-phase one.Why
documentbubble, and why it needs only one file. That phase is the single seam between the surface and the page. All four surfaces bind their own keys on their own elements (ShellCommandPalette/PaperCommandPalette@keydown.down/up/enter/escape,CaptureModal@keydown,PaperShortcutsOverlay's backdrop), so those handlers have already run by the time the event reachesdocument; every page-level listener binds onwindow, one hop further out. Stopping there cannot take a key away from the surface that owns it — which is why no surface component needed changing and the whole mechanism lands inAppShell.vue. The two guards now share one per-event surface scan, so the pair costs no more than the capture half did alone (#1968's laziness is preserved, and its no-scan-while-typing spec still passes untouched).Listener audit
Every page-level keydown listener outside tests. The guard silences page-level
windowlisteners under any modal, so all of them were checked. Grep:addEventListenerfiltered to key events acrosssrc, excludingsrc/tests.handleKeydownAppShell.vuewindow, capture?,mod+k,mod+shift+c,h/t/b/i/r,gchord) plus the #2635 guardwindowis the first thing to run; it now shares its surface scan with the new guard.handleEscapeKeydownuseEscapeStack.tswindow, capturedocumentbubble, and the guard never stops Escape.handleKeyDownuseKeyboardShortcuts.tswindow, bubblef,n,j,k,l, Enter, arrows, and Escape toBoardView.closeOpenUicloseOpenUi.handleGlobalKeydownPaperShortcutsOverlay.vuewindow, bubbleonCaptureShortcutPaperHomeView.vuewindow, bubblemod+;to focus the quick-capture rowhandleGlobalKeydownPaperInboxView.vuewindow, bubblemod+;to toggle capture varianthandleKeyDownuseShortcutContext.tsdocument, bubbleuseContextualShortcutshas no caller outside its own spec — andstopPropagationwould not stop it anyway (same node, same phase). Recorded, not changed.handleKeyDownuseReviewKeymap.ts, viaPaperReviewView.vuewindow, bubbletargetoption defaults towindow. Itsenabledpredicate already gates its own dialogs (#1818, GH-1969) but not the shell surfaces, so the guard adds that. Audited read-only —PaperReviewViewis leased to the #2214 residual pass; nothing there was changed and no spec was added to its spec files.Not page-level, so out of range by construction:
ApplyToBoardDialog.vueandBatchExecuteDialog.vuebindkeydownon their own dialog elements, andLegacyReviewView.vueuses a template@keydown— all of which run beforedocument.BatchExecuteDialog'swindowlistener iskeyup, a different event.LegacyReviewView.vuecarries nowindowkeydown listener.Changes
AppShell.vue— newguardPageListenersFromSurfaceKeys, registered ondocumentin the bubble phase inonMountedand removed inonUnmounted. Two carve-outs, matching the capture half's reasoning: Escape is never stopped, and text-entry targets are left alone (useKeyboardShortcutsignores them anyway, and the early-out is what keeps [Frontend][UX] Documented bare-letter shortcuts (H/T/B/I/R, G T, board C/R) have no handler, and Settings has no Keyboard page #1968's promise that an ordinary keystroke in a field never pays for the surface scan).AppShell.vue— the per-event surface scan is lifted out ofhandleKeydowninto a sharedkeyboardOwningSurfacesFor(event)memo so the two guards scan once between them, not twice. The answer is pinned to the event rather than re-read on the bubble: a surface handler may have closed its own surface on the way up, and the key still belonged to the surface that was open when it was pressed.Test plan
Specs dispatch from a node inside the surface with
bubbles: true. Awindow-dispatched event is AT_TARGET, where capture and bubble listeners both run whatever propagation says, and every assertion below would have passed against the unguarded source.Red-first, against the unmodified source (4 of the 6 new cases failed; the other 2 are regression guards that already held):
keeps board keys off the board when focus is inside the help dialog—AssertionError: expected 1 to be +0(boardProbe.filterToggles)keeps the bare-letter navigation set and the g-chord off page listeners from inside a surface—AssertionError: expected [ 'h', 't', 'b', 'i', 'r', 'g' ] to deeply equal []keeps board keys off the board with focus inside a capture modal over the board—AssertionError: expected 1 to be +0keeps board keys off the board from a focused option inside the command palette—AssertionError: expected 1 to be +0Already green before the fix, kept as regression guards:
still lets Escape out of a surface to the page close pathsandleaves each surface its own keys with focus inside it(?closes the help dialog from a control inside it; arrows move the palette selection;mod+kcloses the palette from its own input).Commands, each with the file count the summary reported:
npx vitest --run --maxWorkers=2 src/tests/components/AppShell.spec.ts— Test Files 1 passed (1), 66 tests. Includes the untouched [Frontend][UX] Documented bare-letter shortcuts (H/T/B/I/R, G T, board C/R) have no handler, and Settings has no Keyboard page #1968 specdoes not scan the DOM for modal surfaces on an ordinary keystroke in a field.npx vitest --run --maxWorkers=2over the shell surfaces and keyboard composables —AppShell.spec.ts,AppShell.paperVariant.spec.ts,ShellKeyboardHelp.spec.ts,ShellCommandPalette.spec.ts,CaptureModal.spec.ts,paper/PaperCommandPalette.spec.ts,paper/PaperShortcutsOverlay.spec.ts,useKeyboardShortcuts.spec.ts,useEscapeStack.spec.ts,useEscapeToClose.spec.ts,useShortcutContext.spec.ts— 11 named, Test Files 11 passed (11), 189 tests.npx vitest --run --maxWorkers=2over the boards and Paper views that own page-level listeners —BoardView.spec.ts,BoardView.keyboardRouting.spec.ts,BoardView.coverage.spec.ts,paper/PaperBoardView.spec.ts,paper/PaperBoardCard.spec.ts,paper/PaperHomeView.spec.ts,paper/PaperHomeView.escape.spec.ts,paper/PaperHomeCaptureRecovery.spec.ts,paper/PaperInboxView.spec.ts— 9 named, Test Files 9 passed (9), 228 tests.npx vitest --run --maxWorkers=2over the review keymap seam the audit turned up —review/ApplyToBoardDialog.spec.ts,useReviewKeymap.spec.ts,paper/review/ReviewKeymap.spec.ts— 3 named, Test Files 3 passed (3), 68 tests.npm run typecheck(vue-tsc -b) — clean.npx eslint src/components/shell/AppShell.vue src/tests/components/AppShell.spec.ts— clean.git diff --check— clean.Llm__Gemini__*variables unset:npx playwright test tests/e2e/keyboard-navigation.spec.ts tests/e2e/workspace-help.spec.ts --reporter=line— 6 passed (33.1s).Not verified
PaperHomeView/PaperInboxViewmod+;typed inside a modal textarea still reaches them. That is outside this issue's acceptance, which namesf,n, the bare-letter set and the g-chord (all non-text-entry), and closing it would mean scanning on every keystroke typed anywhere, breaking [Frontend][UX] Documented bare-letter shortcuts (H/T/B/I/R, G T, board C/R) have no handler, and Settings has no Keyboard page #1968's guarantee. Recorded here rather than fixed.Boundaries
AppShell.vueandAppShell.spec.tsonly — both owned by this claim. No surface component needed changing, for the reason given above.PaperReviewView.vue/LegacyReviewView.vueand their specs are leased to the Review queue refresh residuals: deep-link re-auth, poll restart after recovery, stale indicator (from PR #2208) #2214 residual pass: audited read-only and reported in the table, nothing changed there, no spec added to their files.Refs #2636
Round 2 (head
8da74c1b5)Fresh-context review returned SHIP with no logic defect. One MEDIUM and three LOWs, all comment truth on a safety seam plus one coverage gap, fixed in a single commit — no mechanism change, so no E2E rerun.
MEDIUM — the guard docblock stated a false premise. It justified the guard as "all four surfaces bind their own keys on their own elements". Two things were wrong.
PaperShortcutsOverlay— a help twin,aria-modal,data-shell-surface="keyboard-help"— binds its Escape handler onwindowin the bubble phase, one hop outside the document guard; it survives on the Escape carve-out, not on that premise. And the guard's reach is not four surfaces: it fires for everydialog[open],[role="alertdialog"]or[aria-modal="true"]in the app, which a grep puts at 16 components today (CardModal,TdDialogand the review dialogs built on it,ProvenanceDrawer,PaperBoardDialogShell, the four board modals,WorkspaceSetupModal,MfaChallengeModal, both palettes, both help twins,CaptureModal).The docblock now states the real invariant: a surface keeps a key only if it handles that key at or below
document— an element-level handler anywhere from the target up to and includingdocument— or if the key is Escape. Anything a surface binds onwindowis outside this guard and, unless it is Escape, gets silenced. That makes awindow-level non-Escape handler belonging to a modal a forbidden shape here, and item 4 pins it.LOW — false lifetime claim on the memo reset. The unmount comment ("nothing should outlive the shell holding a reference to a detached surface") described a module-level hazard.
scannedEvent/scannedSurfaceslive in the per-instancesetup()closure and die with the instance. The reset is kept but now reads as belt-and-braces, and the declaration says the memo is per-instance so two mounted shells never share one.LOW — unverified mechanism in a spec comment. The comment asserted "the selection re-render replaces the input element". Rather than swap one unverified claim for another, I re-measured with a throwaway probe: after the ArrowDown the captured node reports
isConnected === falseand no longer matches the selector, so the element the test holds really is detached under this mount (jsdom, Teleport stubbed). Why Vue drops it is not pinned down, so the comment now states the measurement and the consequence rather than a mechanism — and adds why the re-query cannot mask a defect: a key dispatched on a detached node never enters the tree, so no listener runs and no state changes, meaning the palette would stay open and theexists()assertion would go red.LOW — coverage gap. All six from-inside specs ran with the Paper theme off. Added one Paper-skin spec (
mockPaperTheme.isOn = true) onPaperShortcutsOverlay, the one surface whose Escape lives onwindow: focusing its close button, a barefandnreach neither a window-bubble probe nor the board keymap, and Escape still closes the overlay. Red-first was not required (the mechanism is skin-independent) but I checked it anyway by disabling the document listener: red withexpected [ 'f', 'n' ] to deeply equal [], then restored.Also folded in: this suite now stubs
versionApithe wayAppShell.paperVariant.spec.tsalready does. The Paper sidebar reads the product version on mount, so the new spec was logging a realECONNREFUSEDon every run; that noise is now zero.Proof at
8da74c1b5—npx vitest --run --maxWorkers=2 src/tests/components/AppShell.spec.ts src/tests/components/AppShell.paperVariant.spec.ts src/tests/components/paper/PaperShortcutsOverlay.spec.ts→ 3 named, Test Files 3 passed (3), 94 tests (AppShell alone is now 67, up one).npm run typecheckclean;npx eslinton both changed files clean;git diff --checkclean. Still the same two files changed overall, and no doc edits.