// Keyboard controller — side-effect-free at import time. // The consumer wires document.addEventListener('keydown', controller.onKeyDown) // and pairs the cleanup with teardown(). export type RouteName = 'home' | 'projects' | 'stats' | 'health' export interface KeyboardOpts { go: (r: RouteName) => void openPalette: () => void closePalette: () => void isPaletteOpen: () => boolean cursor: { move(d: 1 | -1): void; activate(): void } } const G_TIMEOUT_MS = 800 function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof Element)) return false const tag = target.tagName.toLowerCase() if (tag === 'input' || tag === 'textarea') return true if (target.hasAttribute('contenteditable')) return true return false } export function createKeyboardController(opts: KeyboardOpts): { onKeyDown(e: KeyboardEvent): void teardown(): void } { let gPending = false let gTimer: ReturnType | null = null function clearG(): void { gPending = false if (gTimer !== null) { clearTimeout(gTimer) gTimer = null } } function onKeyDown(e: KeyboardEvent): void { // ⌘K / Ctrl+K → open palette if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault() opts.openPalette() return } // Esc → close palette if open if (e.key === 'Escape') { if (opts.isPaletteOpen()) { opts.closePalette() } return } // After ⌘K and Esc, all remaining shortcuts (g-leader, j/k, Enter) // are reserved for the global navigation surface. They must not fire // while the palette has focus or while the user is typing into any // editable element — typing "gh" into a search input would otherwise // navigate home mid-query. if (opts.isPaletteOpen() || isEditableTarget(e.target)) { return } // g-prefix navigation: consume 'g' then route letter if (gPending) { let route: RouteName | null = null if (e.key === 'h') route = 'home' else if (e.key === 'p') route = 'projects' else if (e.key === 's') route = 'stats' else if (e.key === 'i') route = 'health' if (route !== null) { clearG() opts.go(route) return } // unrecognised key after g — clear pending and fall through clearG() } if (e.key === 'g') { gPending = true gTimer = setTimeout(clearG, G_TIMEOUT_MS) return } // j / k — cursor movement if (e.key === 'j') { opts.cursor.move(1); return } if (e.key === 'k') { opts.cursor.move(-1); return } // Enter — activate cursor item only when target is the document body // (not, e.g., a tab button that handles its own Enter). if (e.key === 'Enter' && e.target === document.body) { opts.cursor.activate() return } } function teardown(): void { clearG() } return { onKeyDown, teardown } }