DevKeys
PURPOSE
Global keyboard shortcut handler for dev/automation navigation. Mounts a keydown listener on window that intercepts Ctrl+Shift+<key> chords and either navigates the React Router to a metagame screen, toggles the debug overlay, or programmatically clicks shop/collection buttons. Pure side-effect component — renders null. Must be mounted inside BrowserRouter because it consumes useNavigate and useLocation.
The Ctrl+Shift prefix is chosen specifically to avoid collisions with in-game input (which uses bare keys / WASD / arrows). Enables Claude and any other automation to drive the entire game UI from the browser console or DevTools snippets without clicking.
OWNS
- The keyboard event listener (
window.addEventListener('keydown', ...)) registered inuseEffectand torn down on unmount. - The dispatch
switchstatement mapping uppercasede.keyto navigation / debug / button-click actions. - A module-local helper
_clickButton(selector, textMatch, nthMatch)that performs DOM lookup-and-click for context-sensitive shop pulls. Tries the CSS selector first, falls back to scanning all<button>elements for matching text content (case-insensitiveincludes), and clicks the nth match (0-indexed).
READS FROM
react-router-dom—useNavigate(),useLocation()for current path (location.pathname).@starship-survivors/services/assembleRunService—assembleRunDef({ shipId })to build aRunDeffor the quick-launch shortcut.@starship-survivors/stores/sessionStore—useSessionStore.getState()readsselectedShipIdand writessetRunDef(runDef)during quick-launch.@starship-survivors/engine/core/state— readsdebugOverlayflag, writes viasetDebugOverlay(!debugOverlay).- DOM —
document.querySelectorAll(selector)anddocument.querySelectorAll('button')for the_clickButtonfallback. - Live
KeyboardEventfields:e.ctrlKey,e.shiftKey,e.key.
PUSHES TO
- React Router history via
navigate('/<route>')— pushes to/,/shop,/collection,/collection?tab=ships,/profile,/admin,/games/starship-survivors/levels,/games/starship-survivors/play,/games/starship-survivors/reveal,/ship-playground. sessionStore—setRunDef(runDef)on theL(quick-launch) shortcut.- Engine debug state —
setDebugOverlay(boolean)toggles the overlay flag onD. - DOM — synthetic
.click()calls on shop/collection buttons via_clickButton. - Calls
e.preventDefault()on every matchedCtrl+Shiftchord so the browser does not consume the chord for its own bindings.
DOES NOT
- Render any UI — returns
null. - Listen for plain key presses, single-modifier chords (Ctrl-only or Shift-only), or key-up events. The very first guard
if (!e.ctrlKey || !e.shiftKey) return;short-circuits everything else. - Stop propagation (
stopPropagationis never called) — other listeners on the page still see the event afterpreventDefault. - Handle
Ctrl+Shift+E— explicitly noted as removed because the settings screen was deleted (settings now live in the in-game cog menu). - Handle
Ctrl+Shift+7— referenced in the file header comment (Collection: ACHIEVEMENTS tab) but nocase '7'exists in the switch. - Guard the quick-launch shortcut against missing
selectedShipId— passes whatever value the store currently holds toassembleRunDef. - Persist any state of its own; the shortcuts are stateless dispatch on each keystroke.
Signals
- Trigger: any
keydownevent onwindowwhere bothctrlKeyandshiftKeyare true. - Key normalization:
e.key.toUpperCase()— so letter cases match, and the number-row keys are matched both as digits ('1'–'6') and as their shifted symbol forms ('!','@','#','$','%','^') sinceShift+1produces!on US layouts. - Path-gated branches: number-key shortcuts only fire when
location.pathnamematches:'1'/'2'/'3'/'4'only act whenpath === '/shop'.'5'/'6'only act whenpath.startsWith('/collection').
Entry points
- Default export: none. Named export
DevKeys(React function component). - Module-internal:
_clickButton(selector: string, textMatch: string, nthMatch: number): void. - Listener lifecycle is keyed on
[navigate, location.pathname]— the effect re-registers on every route change, so thepathcaptured in the closure stays current.
Shortcut table
| Chord | Action | Path gate |
|---|---|---|
Ctrl+Shift+H | navigate('/') (hub) | any |
Ctrl+Shift+L | Quick-launch — assembleRunDef({ shipId: selectedShipId }), setRunDef, navigate('/games/starship-survivors/play') | any |
Ctrl+Shift+S | navigate('/shop') | any |
Ctrl+Shift+C | navigate('/collection') | any |
Ctrl+Shift+P | navigate('/profile') | any |
Ctrl+Shift+A | navigate('/admin') | any |
Ctrl+Shift+V | navigate('/games/starship-survivors/levels') | any |
Ctrl+Shift+T | navigate('/ship-playground') | any |
Ctrl+Shift+R | navigate('/games/starship-survivors/reveal') | any |
Ctrl+Shift+D | setDebugOverlay(!debugOverlay) | any |
Ctrl+Shift+1 | Solar Legion x1 pull — click [data-testid="pull-1-solar"] (or button-text "x1" match 0) | /shop |
Ctrl+Shift+2 | Solar Legion x10 pull — [data-testid="pull-10-solar"] (text "x10" match 0) | /shop |
Ctrl+Shift+3 | Freebooters x1 pull — [data-testid="pull-1-free"] (text "x1" match 1) | /shop |
Ctrl+Shift+4 | Freebooters x10 pull — [data-testid="pull-10-free"] (text "x10" match 1) | /shop |
Ctrl+Shift+5 | navigate('/collection?tab=ships') | /collection* |
Ctrl+Shift+6 | navigate('/collection') | /collection* |
Pattern notes
- Side-effect-only component returning
null. Same shape as the React idiom for global listeners (e.g., scroll restoration, analytics). Mount once at the app shell, never inside a route. - Effect re-runs on
location.pathnamechange. This meanspathin the closure is always fresh and the listener is rebound on every navigation — cheap because the handler body is small, but it does mean a removed-and-re-added listener on every route hop. - Selector-first, text-fallback button finder.
_clickButtonis deliberately tolerant: shop UI authors don’t have to adddata-testidfor the dev shortcut to keep working — the text-content fallback catches buttons labeled"x1"/"x10".nthMatch(0 for Solar Legion row, 1 for Freebooters row) assumes a stable visual order of shop banners. - Shifted-symbol cases:
case '1': case '!':etc. Thee.key.toUpperCase()does not transform'!'into'1', so both forms are listed explicitly. This is what makes the shortcuts work regardless of whether Shift normalizes the digit on the user’s keyboard layout. - No collision avoidance with browser chords.
Ctrl+Shift+T(reopen closed tab),Ctrl+Shift+P(private window / command palette),Ctrl+Shift+R(hard reload),Ctrl+Shift+D(bookmark all tabs),Ctrl+Shift+H(history) are all browser-reserved.e.preventDefault()blocks the browser’s default action when the chord matches a case in the switch — but only after the React handler has fired, so DevTools / extensions that intercept at a higher level still win. - No feature flag. Shortcuts are live in production; this is intentional — Claude needs them on the deployed Vercel URL, not just locally.
EXTRACT-CANDIDATE
_clickButton(selector, textMatch, nthMatch)is a generic DOM-click utility (CSS selector first, button-text fallback). If a second component grows similar “drive the UI by clicking a button” needs (e.g., a test harness or another automation surface), lift it tosrc/metagame/utils/clickButton.ts. Not worth extracting yet — single caller.- The
Ctrl+Shift+<key>chord-dispatch pattern (modifier guard → uppercased key → switch withpreventDefault) could become a genericuseKeyboardShortcuts({ 'Ctrl+Shift+H': () => navigate('/'), ... })hook if a second shortcut surface appears (in-game hotkeys, admin keybinds). Today it’s the only consumer. - The shortcut → route mapping is currently inline in the switch. If the metagame screen list ever moves to a data table (
screens.tswith{ path, key, label }), this file should consume that table instead of duplicating the route strings.