// 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 } // 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 (only when palette closed and target not editable) if (!opts.isPaletteOpen() && !isEditableTarget(e.target)) { if (e.key === 'j') { opts.cursor.move(1); return } if (e.key === 'k') { opts.cursor.move(-1); return } } // Enter — activate cursor item when palette closed and target is body if (e.key === 'Enter' && !opts.isPaletteOpen() && e.target === document.body) { opts.cursor.activate() return } } function teardown(): void { clearG() } return { onKeyDown, teardown } }