fix(shortcuts): keep board keys behind an open modal and tighten the shell key guards - #2635
Conversation
Adds the failing behaviour specs for #2621 and the #1968 keyboard-guard LOWs before the fix: board f and n reaching the board from behind the open help dialog, the command palette and quick capture opening over a modal they do not own, ? toggling over another modal, Shift+H navigating Home, and ? being unreachable when AltGr sets altKey.
…shell guards AppShell now stops a keydown that no app-shell binding consumed while a keyboard-owning surface is active, so board bare-letter bindings can no longer run behind the help dialog (#2621). Escape and any target inside the surface are exempt: the escape stack and BoardView.closeOpenUi still close what they own, and a capture-phase stop would otherwise break typing inside modals. Each non-navigate action now says which surface owns its key, marked with data-shell-surface: ? toggles the help dialog it owns and does nothing over another modal, mod+k still closes the palette but no longer opens over one, and quick capture never stacks a second modal. strokeMatches moves into the shared ledger module and gains two rules: an undeclared shift now requires Shift to be up over a letter, so Shift+H stops navigating Home, and a layout-produced character ignores Alt plus the AltGr Ctrl, so ? stays reachable where the layout needs AltGr. The surface scan is now lazy per event, so a keystroke typed into a field no longer pays for the querySelectorAll and getComputedStyle sweep. Closes #2621. Refs #1968.
…l source text The Paper overlay spec asserted that the AppShell source contains APP_SHELL_SHORTCUT_BINDINGS.find. What that reached for is that an app-shell row on the surface is a key the shell really dispatches, which a grep cannot see. Each displayed app-shell row now runs the shipped matcher over the stroke it advertises, and the footer help key is checked on the Shift and AltGr layouts that have to reach it. keyboardShortcuts.spec.ts gains direct coverage of the matcher: Shift over a bare letter, an exact declared shift, exact mod and alt over letters, and the ? stroke under Shift, AltGr and a real Ctrl or Command chord. Refs #1968.
Asserts that an ordinary keystroke into a field never reaches the querySelectorAll plus getComputedStyle sweep, and that mod+k, the one binding allowed inside text entry, still gets the scan because the surface state is what decides whether it may open. Red on the base implementation, which scanned twice for two typed keys. Refs #1968.
|
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, review-and-ship round 1 at head 07ca649). Reviewer: one fresh-context independent reviewer subagent (read-only), input = merge-base..head diff plus the worktree at the head. Verdict: FIX-FIRST, one HIGH.
Confirmed clean by the reviewer: per-event memoisation is a per-call let with ??=, no cross-event staleness; the chord rewrite is behaviour-preserving; Escape is exempted before anything else so useEscapeStack and the board's close paths are untouched; text-entry targets outside a surface are never stopped and stopPropagation cancels no default action (Tab, native button activation, browser chords survive); every data-shell-surface marker sits on the element that carries the dialog role; the Shift rule covers exactly the bare-letter set and leaves quick capture's declared shift and the US-layout Shift+/ for ? intact; the AltGr rule cannot widen letter bindings (Ctrl+Alt+H does not navigate) and mod+k still requires exactly one of Ctrl or Meta; the diff is exactly the nine claimed files. Merge gate: round-2 push, one verification pass scoped to the fix diff (the selector and the spec harness change the risk boundary), ci-required green at the round-2 head, aged three minutes, then merge commit. |
Round 2 review fixes for #2635. HIGH. KEYBOARD_OWNING_SURFACE_SELECTOR listed a bare [role="dialog"], and CardModal keeps that role in both presentations while setting aria-modal only outside the inspector. The Paper desktop card inspector is a sticky side panel that traps nothing, so it counted as a keyboard-owning surface: with the new ownership gate, ?, mod+k and mod+shift+c went dead for as long as a card was open for reading, and every non-Escape key pressed outside the panel was stopped. The selector now requires modality: dialog[open], [role="alertdialog"], [aria-modal="true"]. This is the 2026-08-29 #1968 MEDIUM on the same selector. LOW. Ownership asked that EVERY active surface be this action's surface, so two open shell surfaces left neither ? nor mod+k able to close its own surface. It now asks that this action's surface is present and that no surface outside the shell is. Stack order is deliberately not used: a Teleport places its anchor when the shell mounts, not when the surface opens, so document order is template order whatever the user opened first. MEDIUM. The two matcher loops built their event from the stroke's own fields and could not fail. The util spec now drives the matcher from the DESCRIPTOR, the string the help surfaces print, and asserts descriptor and sequence agree. The overlay spec couples each rendered chip to formatShortcut of its ledger row. AppShell.spec adds a behavioural table over APP_SHELL_SHORTCUT_BINDINGS that presses each advertised stroke and asserts the ledger action runs. MEDIUM. The escape-stack spec built its stack with keys the gate now refuses and passed only because an unattached mount hides every surface from document.querySelectorAll. It stacks by mouse and mounts attached, and the two other specs that press keys over an open surface now attach too, with the reason named on mountShell. Refs #1968, #2636.
|
Round-2 verification record (scoped to the fix diff 07ca649..8eb4010; read-only pass). Base updated afterwards onto 9eb6f40 (#2631 touched AppShell.vue): the merged head 019f932 was re-proved locally (14 files / 348 across the shell, shortcut, board, card and capture-store specs) and hosted CI runs at that head. Verdict: SHIP for the fix diff, no CRITICAL, HIGH or MEDIUM. Confirmed: the final selector is dialog[open], [role="alertdialog"], [aria-modal="true"]; every role="dialog" in src was enumerated with its aria-modal state and only the Paper desktop card inspector (aria-modal absent when presentation is inspector, passed only by PaperBoardView on desktop widths) falls outside it; tablet and phone Paper and every Legacy CardModal use stay guarded; no other non-modal dialog and no native dialog element exist; the newly un-gated navigation keys over a dirty inspector hit PaperBoardView's route-leave guard, so no unsaved-work path opens; single-surface semantics are byte-identical to round 1; with help and palette both open each key closes its own surface, a foreign modal over help blocks ? and Escape is untouched; the replaced matcher assertions are falsifiable (rendered chip text versus formatShortcut of the ledger descriptor; descriptor-derived strokes versus the declared sequence) and the ledger-to-shell coupling survives a removed row; every spec that opens a surface and then presses a key mounts attached, teardown is the file's explicit unmount, and the escape-stack ordering spec still proves its original property; the diff is four files at the stated seams and keyboardShortcuts.ts is untouched. Three LOWs recorded, no fix cycle: dialog[open] would also match a non-modal native dialog shown with show() (none exists in src; dialog:modal is the precise form if one ever does); the ownership rule is order-independent rather than topmost-wins, so with the palette clicked open over the help dialog ? closes the background help (cosmetic; DOM order cannot express stack order because the Teleport anchors sit in template order, and useEscapeStack would be the readable source if topmost is ever wanted); the new ledger walk has no default arm for a future action type (mitigated by the truth guard requiring a case arm per row). Merge gate unchanged: ci-required green at 019f932, aged, merge commit. |
Summary
With the shell keyboard-help dialog open over a Legacy board, a plain
fornstill reached theboard.
AppShell.vuereturned without stopping propagation when a keyboard-owning surface wasactive and no app-shell binding matched, so the keydown bubbled to
BoardView'suseKeyboardShortcutswindow listener:ftoggled the filter panel behind the modal andnclicked the column's
[data-action="toggle-add-card"]button and pulled focus out of the opendialog.
This takes the shell-side fix rather than widening
boardShortcutsEnabled. The board gate wouldhave to learn about the shell's help state through the
useShellKeyboardHelpseam and would stillleave every other page-level window listener exposed to the same leak; the shell already computes
the surface state on the keydown path, so one guard there covers the whole class. It also stays out
of
PaperBoardView, which the board-side gate would have had to reach.The same handler carried four keyboard-guard LOWs and a MEDIUM recorded on #1968 on 2026-08-29, and
they are fixed here because they live in the lines this change already rewrites.
Closes #2621
Refs #1968, #2636
Changes
test(shortcuts): pin the shell keyboard guard defects red- the failing specs first, inAppShell.spec.ts. A probe stands in for the routed board: it installs the realuseKeyboardShortcutswith BoardView's ownfandnbindings and the same add-card DOM contractcreateCardInSelectedColumndrives, so the routing under test is the shipped mechanism. Keys aredispatched from
document.bodywithbubbles: true, because awindow-dispatched event isAT_TARGET, where capture and bubble listeners both run whatever propagation says.
fix(shortcuts): keep board keys behind an open modal and tighten the shell guardsactive. Two carve-outs: Escape is never stopped, because board dialogs the escape stack does not
carry are closed by
BoardView.closeOpenUion the bubble and stopping it would strand them open;and a target inside the surface is left alone, because this listener runs in the capture phase
ahead of every handler the surface owns.
data-shell-surfaceonthe two help twins and the two command palettes.
?toggles the help dialog it owns and doesnothing over another modal,
mod+kstill closes the palette but no longer opens over one, andquick capture never stacks a second modal.
navigateandquick-capturename no surface, so anactive surface always wins over them.
strokeMatchesmoves into the shared ledger module and gains two rules. An undeclaredshiftnow requires Shift to be up over a letter, so
Shift+Hstops navigating Home. A layout-producedcharacter ignores Alt, and ignores Ctrl while Alt is also down, so
?stays reachable on layoutsthat need AltGr without loosening any ordinary Ctrl or Alt combination.
querySelectorAllplusgetComputedStylesweep.test(shortcuts): assert the advertised strokes instead of the AppShell source textandtest(shortcuts): pin the surface scan out of the typing path- replace thetoContain('APP_SHELL_SHORTCUT_BINDINGS.find')scan, and assert the sweep is not reached for anordinary keystroke in a field while
mod+k, the one binding allowed inside text entry, still getsit.
fix(shortcuts): require modality before a surface owns the keyboard(round 2)[role="dialog"], andCardModalkeeps that role in both presentations while settingaria-modalonly outside theinspector. The Paper desktop card inspector is a sticky side panel that traps nothing, so it
counted as a keyboard-owning surface: with the new gate,
?,mod+kandmod+shift+cwent deadfor as long as a card was open for reading, and every non-Escape key pressed outside the 420 px
panel was stopped. The selector is now
dialog[open], [role="alertdialog"], [aria-modal="true"]. This is the 2026-08-29 [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 MEDIUM onthe same selector. Narrowing also un-gates
navigateover a non-modal dialog, which is the samecorrection;
PaperBoardViewguards dirty navigation withonBeforeRouteLeave, so an unsavedinspector edit still raises its discard dialog rather than being lost.
neither
?normod+kable to close its own surface. It now asks that this action's surface ispresent and that no surface outside the shell is. Stack order is deliberately not used: a
<Teleport>places its anchor when the shell mounts, not when the surface opens, so documentorder is AppShell's template order whatever the user opened first.
they were true for any stroke. The util spec now drives the matcher from the DESCRIPTOR, the
string the help surfaces print, and asserts descriptor and sequence agree. The overlay spec
couples each rendered chip to
formatShortcutof its ledger row.AppShell.spec.tsadds abehavioural table over
APP_SHELL_SHORTCUT_BINDINGSthat presses each advertised stroke throughthe mounted shell and asserts the ledger action runs.
an unattached mount hides every surface from
document.querySelectorAll(Vue Test Utils onlyputs the component in the document when
attachTois given). It now stacks by mouse and mountsattached; the two other specs that press keys over an open surface attach too, with the reason
named on
mountShell.The keystroke ledger is untouched: no row added or removed, and no change to the review keymap,
BoardToolbar or PaperReviewView.
#1968 items from the 2026-08-29 comments
[role=dialog]?and the command palette fire over modals?unreachablePaperShortcutsOverlay.spec.tsLeft open: the notation guard still misses
Ctrl +(spaced) andControl+.HARDCODED_MODIFIERinshortcutNotation.spec.tsis still⌘|Ctrl\/Cmd|Ctrl\+. Different file, different defect, nottouched here.
Test plan
Verified, from
frontend/taskdeck-web:npx vitest --run --maxWorkers=2 src/tests/components/AppShell.spec.tsgave6 failed, 41 passed (47) -
expected 1 to be +0for the filter toggle running behind the opendialog; the command palette and quick capture opening over an active modal;
?toggling overanother modal;
expected "vi.fn()" to not be called at all, but actually been called 2 times(
/workspace/home,/workspace/today) forShift+HandShift+T; and?withaltKeyneveropening the map. The scan pin was proved separately against
origin/main'sAppShell.vue:expected [ …(2) ] to have a length of +0 but got 2.(
07ca649502bbae0f61c5cbdb817b1a47bfbc8052): 2 failed, 57 passed (59).keeps every shell key live while a non-modal card inspector is openfailed withAssertionError: expected null not to be null-?was dead with the inspector open.lets each surface of a shell stack close itself with its own keyfailed withAssertionError: expected true to be false-mod+kcould not close the palette.the card inspector:
npx vitest --run --maxWorkers=2overAppShell.spec.ts,keyboardShortcuts.spec.ts,PaperShortcutsOverlay.spec.ts,ShellKeyboardHelp.spec.ts,shortcutLedgerTruth.spec.ts,shortcutNotation.spec.ts,BoardView.spec.ts,BoardView.keyboardRouting.spec.ts,BoardView.coverage.spec.ts,useKeyboardShortcuts.spec.ts,AppShell.paperVariant.spec.ts,ShellCommandPalette.spec.ts,PaperCommandPalette.spec.ts,useShellKeyboardHelp.spec.ts,paper/PaperBoardView.spec.ts,CardModal.spec.ts,useCardModal.spec.tsandpaper/PaperBoardManagement.spec.tsgave18 files passed, 391 tests passed.
npx vitest --run --maxWorkers=2gave 371 files passed, 5753 passed and 3 skipped (5756),exit 0.
npm run typecheckclean.npm run buildsucceeded.npx eslintclean on every changed file.git diff --checkclean.NOT verified:
tests/e2e/keyboard-navigation.spec.tsneeds a running stack and was not run, so noreal-browser evidence that a physical AltGr
?orShift+Hbehaves as the matcher now says. TheAltGr and Shift rules are proved only against synthesized
KeyboardEventflags.role="dialog"probe withoutaria-modal, matchingCardModal.vue's attributes, not by mountingPaperBoardViewat desktop width inside AppShell.BoardViewinside AppShell. Theprobe installs the real
useKeyboardShortcutswith BoardView's own bindings, but it omits theenabledpredicates BoardView passes. Both predicates are true in the state exercised here.Boundaries and risks
not touch an event whose target is already inside the active surface. Concretely: open quick
capture with
mod+shift+c, Tab to a button inside the CaptureModal, pressn- the key bubblespast the shell to the board's window listener and clicks the add-card button on the column behind
the modal. Both real paths into the help dialog leave focus outside it, and neither help twin
traps or moves focus, so this needs a deliberate Tab. Closing it would need a second listener on
documentin the bubble phase, which would also silencePaperHomeViewandPaperInboxViewwindow listeners under any modal - wider than this issue authorizes.
isLayoutProducedCharactertreats any single non-alphanumericcharacter as layout-produced, which includes Space. A Space stroke would therefore ignore Alt and
the AltGr Ctrl. No binding in the ledger uses Space, so nothing exercises it; a future Space
binding should declare its modifiers or the predicate should exclude Space.
BoardView.closeOpenUikeeps closing the label manager, boardsettings, filter panel and column form.
background element's own handlers. Text-entry targets return before the guard, so typing into a
background field is untouched.
data-shell-surfaceis now load-bearing: a new shell surface that toggles from a shortcut mustcarry it, or its key will be blocked while the surface is open. A surface WITHOUT the marker is
treated as foreign and takes every shell key, which is the intended default.
declared
modoralt.?is the only such stroke in the ledger today. Ctrl-only andCommand-only chords over
?are still refused, and every letter stroke keeps exact Alt and modmatching.
git status --porcelain --ignoredshows onlyfrontend/taskdeck-web/node_modules/anddist/from the build, both reproducible withnpm ciandnpm run build.