Problem
client/src/hooks/useKeyCapture.js and client/src/hooks/useKeyboardShortcuts.js both answer the same question — "should this window key event reach an app-level handler?" — but they filter different things:
| guard |
useKeyboardShortcuts |
useKeyCapture |
isEditableTarget |
✅ |
✅ |
aria-modal dialog open |
✅ enabledInDialog |
✅ enabledInDialog (added in #4731) |
| ⌘/Ctrl/Alt chord |
✅ |
❌ |
e.repeat |
✅ ignoreRepeat |
❌ |
isButtonActivation |
✅ |
❌ |
isButtonActivation (client/src/lib/a11yKeyboard.js) is the sharp one. VoiceWidget deliberately stands down for Space on a focused button (client/src/components/voice/VoiceWidget.jsx, added because the POST drills' Start/Match buttons broke), and useKeyboardShortcuts mirrors that. useKeyCapture does not: it preventDefaults Space unconditionally, so every claiming surface re-breaks the native button activation the other two paths specifically protect.
Each new option added at one hook also has to be re-remembered at the other, and only one of the five has been kept in sync so far.
Approach
Extract the shared predicate into client/src/lib/a11yKeyboard.js (which already hosts isButtonActivation) — something like shouldIgnoreGlobalKey(event, { enabledInDialog, ignoreRepeat, allowChords }) — and have both hooks call it. Then a guard lands in one place for both.
Sequence the isButtonActivation part carefully: it changes behavior at all useKeyCapture call sites (POST cognitive drills, Morse keyer, RapidReader, OpenWorld playback transport, exploration-mode jump). The drills' Start-button interaction was tuned recently, so that surface needs its suite re-read rather than a blind flip.
Acceptance criteria
- One predicate module answers "should this global key handler ignore this event", consumed by both hooks.
- Space on a focused
<button> activates the button rather than being claimed, at every useKeyCapture call site.
- A test proves that, using the stand-in-listener pattern in
client/src/test/voiceHotkeySpy.js.
- Existing drill / Morse / RapidReader / OpenWorld keyboard behavior is otherwise unchanged.
Also worth folding in (small)
client/src/pages/OpenWorld.transport.test.jsx and client/src/pages/OpenWorld.fastTravel.test.jsx carry ~40 lines of identical mock preamble (scene/HUD/overlay stubs, the useOpenWorldData payload, the 8-endpoint services/api stub). A third OpenWorld page suite makes it three copies, and each new endpoint the page polls has to be added to every copy. Export the fixture payloads from a shared client/src/test/openWorldPageMocks.js and dynamic-import them inside each vi.mock factory (the vi.mock calls themselves must stay per-file for hoisting).
Related to #4731.
Problem
client/src/hooks/useKeyCapture.jsandclient/src/hooks/useKeyboardShortcuts.jsboth answer the same question — "should this window key event reach an app-level handler?" — but they filter different things:useKeyboardShortcutsuseKeyCaptureisEditableTargetaria-modaldialog openenabledInDialogenabledInDialog(added in #4731)e.repeatignoreRepeatisButtonActivationisButtonActivation(client/src/lib/a11yKeyboard.js) is the sharp one.VoiceWidgetdeliberately stands down for Space on a focused button (client/src/components/voice/VoiceWidget.jsx, added because the POST drills' Start/Match buttons broke), anduseKeyboardShortcutsmirrors that.useKeyCapturedoes not: itpreventDefaults Space unconditionally, so every claiming surface re-breaks the native button activation the other two paths specifically protect.Each new option added at one hook also has to be re-remembered at the other, and only one of the five has been kept in sync so far.
Approach
Extract the shared predicate into
client/src/lib/a11yKeyboard.js(which already hostsisButtonActivation) — something likeshouldIgnoreGlobalKey(event, { enabledInDialog, ignoreRepeat, allowChords })— and have both hooks call it. Then a guard lands in one place for both.Sequence the
isButtonActivationpart carefully: it changes behavior at alluseKeyCapturecall sites (POST cognitive drills, Morse keyer, RapidReader, OpenWorld playback transport, exploration-mode jump). The drills' Start-button interaction was tuned recently, so that surface needs its suite re-read rather than a blind flip.Acceptance criteria
<button>activates the button rather than being claimed, at everyuseKeyCapturecall site.client/src/test/voiceHotkeySpy.js.Also worth folding in (small)
client/src/pages/OpenWorld.transport.test.jsxandclient/src/pages/OpenWorld.fastTravel.test.jsxcarry ~40 lines of identical mock preamble (scene/HUD/overlay stubs, theuseOpenWorldDatapayload, the 8-endpointservices/apistub). A third OpenWorld page suite makes it three copies, and each new endpoint the page polls has to be added to every copy. Export the fixture payloads from a sharedclient/src/test/openWorldPageMocks.jsand dynamic-import them inside eachvi.mockfactory (thevi.mockcalls themselves must stay per-file for hoisting).Related to #4731.