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