Problem
useKeyCapture (client/src/hooks/useKeyCapture.js) now provides the shared "this surface owns the key" pattern: a capture-phase window listener whose handler claims an event by returning true, which then gets preventDefault + stopImmediatePropagation so no app-global bubble handler sees it. It exists because the voice widget binds Space app-wide for push-to-talk (client/src/components/voice/VoiceWidget.jsx), so any surface that wants Space has to claim it first.
Three pre-existing surfaces still hand-roll the same pattern, and one still leaks Space to the voice hotkey:
client/src/components/openworld/PlayerController.jsx:317-340 — hand-rolled capture + claim. onKeyDown claims Space, onKeyUp does its work without claiming; that maps onto the hook's contract directly. The effect's keysRef.current.delete(' ') unmount cleanup has to move to its own effect.
client/src/components/RapidReader.jsx:106-123 — hand-rolled, multi-key, claims only handled keys. Fits the hook unchanged.
client/src/pages/OpenWorld.jsx:327-338 — still bubble-phase, so Space (play/pause transport) still opens the voice mic. This is a live bug, not just duplication.
Each hand-roll also carries its own isTypingTarget variant, and they disagree: PlayerController tests document.activeElement, RapidReader tests e.target, and none of them include SELECT (where Space natively opens the dropdown). The hook uses the shared isEditableTarget from client/src/hooks/useKeyboardShortcuts.js, which does.
Migrated already, as the reference shape: client/src/components/meatspace/post/PostCognitiveDrillRunner.jsx (n-back, go/no-go, simple reaction-time) and client/src/components/meatspace/post/MorseTrainer.jsx (keydown + keyup pair).
Approach
Migrate all three onto useKeyCapture, deleting the local capture/cleanup boilerplate and the local isTypingTarget copies. For OpenWorld.jsx this is a behavior fix (Space stops reaching the voice widget), not just a refactor — call that out in the commit.
Split any non-listener cleanup (PlayerController's keysRef reset) into its own [enabled]-keyed effect, the way MorseTrainer.jsx now does for its tone/flush-timer teardown.
Acceptance criteria
- No
addEventListener('key…', …, true) + stopImmediatePropagation hand-roll remains outside useKeyCapture.
client/src/hooks/useKeyCapture.js is the only definition of the typing-target guard used by these surfaces.
- A test proves Space on the OpenWorld playback transport does not reach a bubble-phase window listener (see the "Space-driven drills do not leak the key to the global voice hotkey" block in
PostCognitiveDrillRunner.test.jsx for the stand-in-listener pattern).
- Existing OpenWorld / RapidReader behavior is otherwise unchanged.
Problem
useKeyCapture(client/src/hooks/useKeyCapture.js) now provides the shared "this surface owns the key" pattern: a capture-phase window listener whose handler claims an event by returningtrue, which then getspreventDefault+stopImmediatePropagationso no app-global bubble handler sees it. It exists because the voice widget binds Space app-wide for push-to-talk (client/src/components/voice/VoiceWidget.jsx), so any surface that wants Space has to claim it first.Three pre-existing surfaces still hand-roll the same pattern, and one still leaks Space to the voice hotkey:
client/src/components/openworld/PlayerController.jsx:317-340— hand-rolled capture + claim.onKeyDownclaims Space,onKeyUpdoes its work without claiming; that maps onto the hook's contract directly. The effect'skeysRef.current.delete(' ')unmount cleanup has to move to its own effect.client/src/components/RapidReader.jsx:106-123— hand-rolled, multi-key, claims only handled keys. Fits the hook unchanged.client/src/pages/OpenWorld.jsx:327-338— still bubble-phase, so Space (play/pause transport) still opens the voice mic. This is a live bug, not just duplication.Each hand-roll also carries its own
isTypingTargetvariant, and they disagree: PlayerController testsdocument.activeElement, RapidReader testse.target, and none of them includeSELECT(where Space natively opens the dropdown). The hook uses the sharedisEditableTargetfromclient/src/hooks/useKeyboardShortcuts.js, which does.Migrated already, as the reference shape:
client/src/components/meatspace/post/PostCognitiveDrillRunner.jsx(n-back, go/no-go, simple reaction-time) andclient/src/components/meatspace/post/MorseTrainer.jsx(keydown + keyup pair).Approach
Migrate all three onto
useKeyCapture, deleting the local capture/cleanup boilerplate and the localisTypingTargetcopies. For OpenWorld.jsx this is a behavior fix (Space stops reaching the voice widget), not just a refactor — call that out in the commit.Split any non-listener cleanup (PlayerController's
keysRefreset) into its own[enabled]-keyed effect, the wayMorseTrainer.jsxnow does for its tone/flush-timer teardown.Acceptance criteria
addEventListener('key…', …, true)+stopImmediatePropagationhand-roll remains outsideuseKeyCapture.client/src/hooks/useKeyCapture.jsis the only definition of the typing-target guard used by these surfaces.PostCognitiveDrillRunner.test.jsxfor the stand-in-listener pattern).