// 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<typeof setTimeout> | 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 }
}