Skip to content

Migrate the remaining hand-rolled Space-capture handlers onto useKeyCapture (OpenWorld transport still opens the voice mic) #4731

Description

@atomantic

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:

  1. 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.
  2. client/src/components/RapidReader.jsx:106-123 — hand-rolled, multi-key, claims only handled keys. Fits the hook unchanged.
  3. client/src/pages/OpenWorld.jsx:327-338still 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    code-qualitymodel:mediumDispatch capability: routine workhorse modelplanTracked by /do:replan

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions