{
  "name": "use-surface-keyboard",
  "type": "registry:hook",
  "title": "useSurfaceKeyboard",
  "description": "One window keydown listener per surface, declared as a binding map. Bindings are ignored while typing unless whileTyping; the list doubles as the source the ? key map renders from — help cannot drift from reality.",
  "dependencies": [],
  "files": [
    {
      "path": "hooks/useSurfaceKeyboard.ts",
      "type": "registry:hook",
      "content": "'use client'\n\n/**\n * useSurfaceKeyboard — one window keydown listener per surface, declared as\n * a binding map (spec §5).\n *\n * Bindings are ignored while the user is typing unless `whileTyping` is set,\n * so inputs always win. The binding list doubles as the source the `?` key\n * map renders from — the help screen can never drift from reality.\n *\n * Use when: a surface answers to keys (arm/Go/rename/strike, ⌘K, undo).\n * Don't use when: a focused widget should handle its own keys (InlineEdit,\n * Menu) — those swallow events before this hook sees them.\n */\n\nimport { useEffect, useLayoutEffect, useRef } from 'react'\n\nexport interface SurfaceKeyBinding {\n  /** 'mod+k', 'shift+enter', 'arrowdown', '?', 'r' — mod = ⌘ or Ctrl. */\n  combo: string\n  handler: (event: KeyboardEvent) => void\n  /** Printed in the surface's key map. */\n  description?: string\n  /** Fire even while an input/textarea has focus (search-field flows). */\n  whileTyping?: boolean\n  /** Skip preventDefault on match. */\n  allowDefault?: boolean\n  enabled?: boolean\n}\n\nfunction isTypingTarget(target: EventTarget | null): boolean {\n  const element = target as HTMLElement | null\n  if (!element) return false\n  return (\n    element.isContentEditable ||\n    element.tagName === 'INPUT' ||\n    element.tagName === 'TEXTAREA' ||\n    element.tagName === 'SELECT'\n  )\n}\n\nfunction matchesCombo(event: KeyboardEvent, combo: string): boolean {\n  const parts = combo.toLowerCase().split('+')\n  const key = parts[parts.length - 1]\n  const wantsMod = parts.includes('mod')\n  const wantsShift = parts.includes('shift')\n  const wantsAlt = parts.includes('alt')\n\n  const hasMod = event.metaKey || event.ctrlKey\n  if (wantsMod !== hasMod) return false\n  if (wantsAlt !== event.altKey) return false\n  if (wantsShift && !event.shiftKey) return false\n\n  const eventKey = event.key === ' ' ? 'space' : event.key.toLowerCase()\n  if (eventKey !== key) return false\n\n  // For plain letter keys an undeclared shift means a different intent\n  // (R vs shift+R). Symbols like '?' already encode shift in event.key.\n  if (!wantsShift && event.shiftKey && /^[a-z]$/.test(key)) return false\n\n  return true\n}\n\nexport function useSurfaceKeyboard(\n  bindings: SurfaceKeyBinding[],\n  options?: { enabled?: boolean }\n) {\n  const bindingsRef = useRef(bindings)\n  const enabled = options?.enabled ?? true\n\n  // Latest-ref mirror in a layout effect, not during render (discarded\n  // concurrent renders must never leak into the window listener).\n  useLayoutEffect(() => {\n    bindingsRef.current = bindings\n  })\n\n  useEffect(() => {\n    if (!enabled) return\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.defaultPrevented) return\n      const typing = isTypingTarget(event.target)\n      for (const binding of bindingsRef.current) {\n        if (binding.enabled === false) continue\n        if (typing && !binding.whileTyping) continue\n        if (!matchesCombo(event, binding.combo)) continue\n        if (!binding.allowDefault) event.preventDefault()\n        binding.handler(event)\n        return\n      }\n    }\n\n    // eslint-disable-next-line @candlelight-ds/no-bare-keydown -- this IS the surface-keyboard hook: the one sanctioned listener that owns the binding map and the generated help (spec §5)\n    window.addEventListener('keydown', onKeyDown)\n    return () => window.removeEventListener('keydown', onKeyDown)\n  }, [enabled])\n}\n",
      "target": "hooks/useSurfaceKeyboard.ts"
    }
  ],
  "meta": {
    "candlelight": {
      "category": "hook",
      "gRules": [
        "G2"
      ],
      "useWhen": "A surface answers to keys — arm/commit/rename/strike, ⌘K, undo, the number row.",
      "dontUseWhen": "A focused widget should handle its own keys (Ink, Menu, InlineEdit) — those swallow events before this hook sees them.",
      "states": [],
      "keyboard": [
        {
          "keys": "↑↓ ⏎ Esc Space R ⌘K ⌘Z ? 1–9",
          "does": "the grammar-reserved keys every surface declares here (§5)"
        }
      ],
      "traps": [
        "Bare window.addEventListener('keydown') outside this hook is lint-banned (@candlelight-ds/no-bare-keydown) — this file carries the one sanctioned disable."
      ],
      "acceptanceTests": [
        "Typing targets swallow surface keys unless the binding says whileTyping.",
        "Undeclared shift on a letter key is a different intent (R ≠ shift+R).",
        "The ? overlay renders from this binding list, never from hand-written help."
      ]
    }
  }
}