{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "trainer-action-bars",
  "title": "Trainer action bars",
  "description": "The drill trainer's decision buttons, preflop and postflop, in the solver's action colors, with F, C and R hotkeys and raise sizes in bb or % pot.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.pokerstudy.ai/r/trainer-lib.json",
    "https://www.pokerstudy.ai/r/trainer-theme.json",
    "https://www.pokerstudy.ai/r/table-themes.json"
  ],
  "files": [
    {
      "path": "src/components/trainer/action-bar.tsx",
      "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { motion } from \"motion/react\";\n\nimport { sortActionsForDisplay } from \"@/lib/trainer/grade\";\nimport { decodePloRaiseTo, parseSpotHistory, type RaiseCtx } from \"@/lib/trainer/parse-history\";\nimport { useSizeDisplay, type SizeDisplay } from \"@/lib/trainer/size-display\";\nimport type { NodeAction } from \"@/lib/trainer/types\";\n\nimport { useTrainerSkin } from \"./skin\";\nimport { actionColor } from \"./theme\";\nimport { useFoldRaiseHotkeys } from \"./use-action-hotkeys\";\n\ninterface Props {\n  actions: NodeAction[];\n  disabled: boolean;\n  onSelect: (action: string) => void;\n  stackBb?: number;\n  /** Current spot history — enables bb raise-to conversion; without it, bb mode\n   *  falls back to percentages. */\n  history?: string;\n}\n\n/** The preflop decision buttons, colored by the solver palette. */\nexport function ActionBar({ actions, disabled, onSelect, stackBb, history }: Props) {\n  const skin = useTrainerSkin();\n  const sorted = sortActionsForDisplay(actions);\n  const sizeDisplay = useSizeDisplay();\n\n  // Raise-to math needs the table state at this decision.\n  const ctx = useMemo<RaiseCtx | null>(() => {\n    if (!history || !stackBb) return null;\n    const parsed = parseSpotHistory(history, stackBb);\n    return {\n      toCall: parsed.toCall,\n      potBefore: parsed.pot,\n      heroIn: parsed.seats[parsed.hero].committed,\n      stackBb,\n      lastRaiseSize: parsed.lastRaiseSize,\n    };\n  }, [history, stackBb]);\n\n  const fold = sorted.find((a) => a.kind === \"fold\");\n  const call = sorted.find((a) => a.kind === \"call\");\n  const raise = sorted.find((a) => a.kind === \"raise\" || a.action === \"Min\");\n  useFoldRaiseHotkeys({\n    disabled,\n    onFold: fold ? () => onSelect(fold.action) : undefined,\n    onCall: call ? () => onSelect(call.action) : undefined,\n    onRaise: raise ? () => onSelect(raise.action) : undefined,\n  });\n\n  return (\n    <div className=\"flex w-full items-stretch gap-2\">\n      {sorted.map((action) => {\n        const label = labelForAction(action, sizeDisplay, ctx, stackBb);\n        const hotkey =\n          action.kind === \"fold\" ? \"F\" : action === call ? \"C\" : action === raise ? \"R\" : undefined;\n        const className =\n          \"flex min-h-12 flex-1 items-center justify-center rounded-lg px-2 text-xs font-bold uppercase tracking-wide text-white transition-opacity hover:opacity-90 disabled:opacity-40 sm:min-h-14 sm:px-3 sm:text-sm\";\n        const style = { backgroundColor: actionColor(action.kind, label) };\n        if (skin === \"native\") {\n          return (\n            <motion.button\n              key={action.action}\n              type=\"button\"\n              disabled={disabled}\n              onClick={() => onSelect(action.action)}\n              style={style}\n              className={`${className} rounded-2xl`}\n              aria-keyshortcuts={hotkey?.toLowerCase()}\n              title={hotkey ? `${label} (${hotkey})` : label}\n              whileTap={disabled ? undefined : { scale: 0.94 }}\n              whileHover={disabled ? undefined : { scale: 1.02 }}\n            >\n              {label}\n              {hotkey ? <HotkeyHint keyLabel={hotkey} /> : null}\n            </motion.button>\n          );\n        }\n        return (\n          <button\n            key={action.action}\n            type=\"button\"\n            disabled={disabled}\n            onClick={() => onSelect(action.action)}\n            style={style}\n            className={className}\n            aria-keyshortcuts={hotkey?.toLowerCase()}\n            title={hotkey ? `${label} (${hotkey})` : label}\n          >\n            {label}\n            {hotkey ? <HotkeyHint keyLabel={hotkey} /> : null}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nfunction HotkeyHint({ keyLabel }: { keyLabel: string }) {\n  return (\n    <span aria-hidden className=\"ml-2 text-[10px] font-semibold tracking-normal text-white/55\">\n      {keyLabel}\n    </span>\n  );\n}\n\n/** PLO size codes → pot percentage (see decodePloRaiseTo's table). */\nfunction ploPercent(sizeCode: number | undefined): string | null {\n  if (sizeCode === undefined) return \"100%\";\n  if (sizeCode > 40000) return `${sizeCode - 40000}%`;\n  switch (sizeCode) {\n    case 8:\n      return \"200%\";\n    case 9:\n      return \"75%\";\n    case 4:\n      return \"50%\";\n    case 7:\n      return \"25%\";\n    case 5:\n      return \"MIN\";\n    default:\n      return null;\n  }\n}\n\nfunction fmtBb(x: number): string {\n  const r = Math.round(x * 10) / 10;\n  return Number.isInteger(r) ? String(r) : r.toFixed(1);\n}\n\n/**\n * Raise labels render identically for Hold'em and PLO: the solver's\n * pot-percentage in `percent` mode, or a raise-to amount in big blinds in `bb`\n * mode (Settings toggle). bb needs the decision context; without it we fall back\n * to percentages.\n */\nfunction labelForAction(\n  action: NodeAction,\n  sizeDisplay: SizeDisplay,\n  ctx: RaiseCtx | null,\n  stackBb?: number,\n): string {\n  if (action.kind === \"fold\") return \"FOLD\";\n  if (action.kind === \"call\") return \"CALL\";\n  if (action.kind === \"allin\") return stackBb ? `ALL-IN ${stackBb}` : \"ALL-IN\";\n  if (action.kind === \"raise\") {\n    const plo6x = action.action.match(/^Raise-([\\d.]+)x$/i);\n    if (plo6x) {\n      const x = Number(plo6x[1]);\n      return sizeDisplay === \"bb\" ? `BET ${fmtBb(x)}` : `RAISE ${fmtBb(x)}BB`;\n    }\n    const isPlo = action.raiseDepth !== undefined;\n    if (sizeDisplay === \"bb\" && ctx) {\n      const raiseTo = isPlo\n        ? Math.min(ctx.stackBb, decodePloRaiseTo(action.sizeCode, ctx))\n        : // NL \"%\" tokens raise BY that fraction of the current pot on top of\n          // the standing bet — same decode parseHistory uses for the pills.\n          ctx.toCall + (parseFloat(action.action) / 100) * ctx.potBefore;\n      if (Number.isFinite(raiseTo)) return `BET ${fmtBb(raiseTo)}`;\n    }\n    if (isPlo) {\n      const pct = ploPercent(action.sizeCode);\n      if (pct) return pct === \"MIN\" ? \"BET MIN\" : `BET ${pct}`;\n      if (ctx) {\n        const to = Math.min(ctx.stackBb, decodePloRaiseTo(action.sizeCode, ctx));\n        return `BET ${fmtBb(to)}`;\n      }\n      return \"RAISE\";\n    }\n    return `BET ${action.action}`;\n  }\n  // Dataset min-raise opens are classified `other`, not `raise`.\n  if (action.action === \"Min\") {\n    if (sizeDisplay === \"bb\" && ctx) return `BET ${fmtBb(ctx.toCall + ctx.lastRaiseSize)}`;\n    return \"BET MIN\";\n  }\n  return action.action;\n}\n",
      "type": "registry:component",
      "target": "components/trainer/action-bar.tsx"
    },
    {
      "path": "src/components/trainer/postflop-action-bar.tsx",
      "content": "\"use client\";\n\nimport { motion } from \"motion/react\";\n\nimport type { DrillActionOption } from \"@/lib/trainer/postflop/types\";\n\nimport { useTrainerSkin } from \"./skin\";\nimport { actionColor } from \"./theme\";\nimport { useFoldRaiseHotkeys } from \"./use-action-hotkeys\";\n\nconst KIND_ORDER: Record<DrillActionOption[\"kind\"], number> = {\n  fold: 0,\n  check: 1,\n  call: 2,\n  raise: 3,\n  allin: 4,\n};\n\n/** The postflop decision buttons. Sizes on the labels are already in bb. */\nexport function PostflopActionBar({\n  actions,\n  disabled,\n  onSelect,\n}: {\n  actions: DrillActionOption[];\n  disabled: boolean;\n  onSelect: (label: string) => void;\n}) {\n  const skin = useTrainerSkin();\n  const sorted = [...actions].sort(\n    (a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.add - b.add,\n  );\n  const fold = sorted.find((a) => a.kind === \"fold\");\n  const call = sorted.find((a) => a.kind === \"call\" || a.kind === \"check\");\n  const raise = sorted.find((a) => a.kind === \"raise\");\n  useFoldRaiseHotkeys({\n    disabled,\n    onFold: fold ? () => onSelect(fold.label) : undefined,\n    onCall: call ? () => onSelect(call.label) : undefined,\n    onRaise: raise ? () => onSelect(raise.label) : undefined,\n  });\n\n  return (\n    <div className=\"flex w-full items-stretch gap-2\">\n      {sorted.map((action) => {\n        const label = displayLabel(action);\n        const hotkey =\n          action.kind === \"fold\" ? \"F\" : action === call ? \"C\" : action === raise ? \"R\" : undefined;\n        const className =\n          \"flex min-h-12 flex-1 items-center justify-center rounded-lg px-1.5 text-xs font-bold uppercase tracking-wide text-white transition-opacity hover:opacity-90 disabled:opacity-40 sm:min-h-14 sm:px-2 sm:text-sm\";\n        const style = { backgroundColor: actionColor(action.kind, label) };\n        if (skin === \"native\") {\n          return (\n            <motion.button\n              key={action.label}\n              type=\"button\"\n              disabled={disabled}\n              onClick={() => onSelect(action.label)}\n              style={style}\n              className={`${className} rounded-2xl`}\n              aria-keyshortcuts={hotkey?.toLowerCase()}\n              title={hotkey ? `${label} (${hotkey})` : label}\n              whileTap={disabled ? undefined : { scale: 0.94 }}\n              whileHover={disabled ? undefined : { scale: 1.02 }}\n            >\n              {label}\n              {hotkey ? (\n                <span aria-hidden className=\"ml-2 text-[10px] font-semibold tracking-normal text-white/55\">\n                  {hotkey}\n                </span>\n              ) : null}\n            </motion.button>\n          );\n        }\n        return (\n          <button\n            key={action.label}\n            type=\"button\"\n            disabled={disabled}\n            onClick={() => onSelect(action.label)}\n            style={style}\n            className={className}\n            aria-keyshortcuts={hotkey?.toLowerCase()}\n            title={hotkey ? `${label} (${hotkey})` : label}\n          >\n            {label}\n            {hotkey ? (\n              <span aria-hidden className=\"ml-2 text-[10px] font-semibold tracking-normal text-white/55\">\n                {hotkey}\n              </span>\n            ) : null}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\n/** \"Bet 5.8\" → \"BET 5.8\", \"AllIn 46.5\" → \"ALL-IN 46.5\". */\nfunction displayLabel(a: DrillActionOption): string {\n  const [head, size] = a.label.split(\" \");\n  const word = head === \"AllIn\" ? \"ALL-IN\" : head.toUpperCase();\n  return size ? `${word} ${size}` : word;\n}\n",
      "type": "registry:component",
      "target": "components/trainer/postflop-action-bar.tsx"
    },
    {
      "path": "src/components/trainer/use-action-hotkeys.ts",
      "content": "import { useHotkeys, type Hotkey } from \"@/lib/hotkeys\";\n\n/**\n * F folds, C checks or calls, R takes the raise/bet. `sizes` maps extra keys\n * (the Play bar's 2, 3, 4… size keys) to an immediate bet. Ignored while a field\n * is focused or the bar is disabled, so typing in Settings / chat doesn't\n * fire a decision; a dialog over the table mutes them too.\n */\nexport function useFoldRaiseHotkeys({\n  disabled,\n  onFold,\n  onCall,\n  onRaise,\n  sizes,\n}: {\n  disabled: boolean;\n  onFold?: () => void;\n  onCall?: () => void;\n  onRaise?: () => void;\n  sizes?: Readonly<Record<string, () => void>>;\n}) {\n  const group = \"Actions\";\n  const bindings: Hotkey[] = [];\n  if (onFold) bindings.push({ key: \"f\", label: \"Fold\", group, run: onFold });\n  if (onCall) bindings.push({ key: \"c\", label: \"Check / call\", group, run: onCall });\n  if (onRaise) bindings.push({ key: \"r\", label: \"Bet / raise the picked size\", group, run: onRaise });\n  for (const [key, run] of Object.entries(sizes ?? {})) {\n    bindings.push({ key, label: `Bet size ${key}`, group, run });\n  }\n  useHotkeys(bindings, !disabled);\n}\n",
      "type": "registry:hook",
      "target": "components/trainer/use-action-hotkeys.ts"
    },
    {
      "path": "src/lib/hotkeys.ts",
      "content": "\"use client\";\n\n// One keyboard layer for the whole app. Every shortcut registers here with a\n// label, so the `?` sheet can list what the current screen answers to, and a\n// dialog (a `HotkeyLayer`) mutes the page underneath it: only bindings on the\n// topmost open layer fire. Nothing fires while the user is typing.\n\nimport {\n  createContext,\n  createElement,\n  useContext,\n  useEffect,\n  useRef,\n  useSyncExternalStore,\n  type ReactNode,\n} from \"react\";\n\nexport type Hotkey = {\n  /** `KeyboardEvent.key`; letters are matched case-insensitively (\"f\", \"ArrowLeft\", \" \", \"?\"). */\n  key: string;\n  /** What it does, for the `?` sheet. */\n  label: string;\n  /** Heading it sits under in the `?` sheet. */\n  group?: string;\n  run: () => void;\n  /** Skip it this render (the button it mirrors is disabled). */\n  disabled?: boolean;\n  /** Fire again while the key is held. Arrows want this; actions don't. */\n  repeat?: boolean;\n  /**\n   * What a focused element blocks it on. \"typing\" (default): inputs and\n   * editors. \"controls\": also buttons and links, for keys like Space that a\n   * focused button already answers.\n   */\n  blockedOn?: \"typing\" | \"controls\";\n  /** Fires on every layer (the `?` sheet itself). */\n  global?: boolean;\n};\n\nexport type HotkeyEntry = Omit<Hotkey, \"run\" | \"disabled\"> & {\n  id: number;\n  layer: number;\n  run: () => void;\n};\n\nlet nextId = 1;\nlet entries: HotkeyEntry[] = [];\nconst layerCounts = new Map<number, number>();\nconst listeners = new Set<() => void>();\nlet attached = false;\n\nfunction emit() {\n  for (const l of listeners) l();\n}\n\n/** Letters match regardless of Shift or Caps Lock; named keys stay as they are. */\nexport function normalizeKey(key: string): string {\n  return key.length === 1 ? key.toLowerCase() : key;\n}\n\nexport function topLayer(): number {\n  let top = 0;\n  for (const [layer, count] of layerCounts) if (count > 0 && layer > top) top = layer;\n  return top;\n}\n\n/** The bindings that would fire right now, one per key, in registration order. */\nexport function activeHotkeys(): HotkeyEntry[] {\n  const top = topLayer();\n  const seen = new Set<string>();\n  const out: HotkeyEntry[] = [];\n  for (let i = entries.length - 1; i >= 0; i--) {\n    const e = entries[i];\n    if (!e.global && e.layer !== top) continue;\n    if (seen.has(e.key)) continue;\n    seen.add(e.key);\n    out.push(e);\n  }\n  return out.reverse();\n}\n\nexport function isTyping(target: EventTarget | null): boolean {\n  if (typeof HTMLElement === \"undefined\" || !(target instanceof HTMLElement)) return false;\n  // A focused bet slider is not typing: after a drag, R / 3 / 7 should still act.\n  if (target instanceof HTMLInputElement && target.type === \"range\") return false;\n  return Boolean(target.closest(\"input, textarea, select, [contenteditable=true], [contenteditable='']\"));\n}\n\nfunction onControl(target: EventTarget | null): boolean {\n  if (typeof HTMLElement === \"undefined\" || !(target instanceof HTMLElement)) return false;\n  return Boolean(target.closest(\"button, a, [role=button], [role=tab]\"));\n}\n\nexport function handleKeyDown(e: KeyboardEvent) {\n  if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;\n  const key = normalizeKey(e.key);\n  const top = topLayer();\n  for (let i = entries.length - 1; i >= 0; i--) {\n    const b = entries[i];\n    if (b.key !== key || (!b.global && b.layer !== top)) continue;\n    if (isTyping(e.target)) return;\n    if (b.blockedOn === \"controls\" && onControl(e.target)) return;\n    if (e.repeat && !b.repeat) {\n      e.preventDefault();\n      return;\n    }\n    e.preventDefault();\n    b.run();\n    return;\n  }\n}\n\nfunction attach() {\n  if (attached || typeof window === \"undefined\") return;\n  window.addEventListener(\"keydown\", handleKeyDown);\n  attached = true;\n}\n\nexport function registerHotkey(entry: Omit<HotkeyEntry, \"id\">): () => void {\n  attach();\n  const id = nextId++;\n  entries = [...entries, { ...entry, key: normalizeKey(entry.key), id }];\n  emit();\n  return () => {\n    entries = entries.filter((e) => e.id !== id);\n    emit();\n  };\n}\n\nexport function pushLayer(layer: number): () => void {\n  layerCounts.set(layer, (layerCounts.get(layer) ?? 0) + 1);\n  emit();\n  return () => {\n    layerCounts.set(layer, (layerCounts.get(layer) ?? 1) - 1);\n    emit();\n  };\n}\n\nfunction subscribe(listener: () => void) {\n  listeners.add(listener);\n  return () => listeners.delete(listener);\n}\n\nlet snapshot: HotkeyEntry[] = [];\nlet snapshotOf: { entries: HotkeyEntry[]; top: number } | null = null;\nfunction getSnapshot(): HotkeyEntry[] {\n  const top = topLayer();\n  if (!snapshotOf || snapshotOf.entries !== entries || snapshotOf.top !== top) {\n    snapshot = activeHotkeys();\n    snapshotOf = { entries, top };\n  }\n  return snapshot;\n}\nconst EMPTY: HotkeyEntry[] = [];\n\n/** Live list of what the current screen answers to (the `?` sheet). */\nexport function useActiveHotkeys(): HotkeyEntry[] {\n  return useSyncExternalStore(subscribe, getSnapshot, () => EMPTY);\n}\n\nconst LayerContext = createContext(0);\n\n/**\n * Wrap a dialog's contents: while it is mounted, hotkeys registered outside\n * it stop firing, and the ones inside it take over.\n */\nexport function HotkeyLayer({ children }: { children: ReactNode }) {\n  const parent = useContext(LayerContext);\n  const layer = parent + 1;\n  useEffect(() => pushLayer(layer), [layer]);\n  return createElement(LayerContext.Provider, { value: layer }, children);\n}\n\n/**\n * Bind keys for as long as the component is mounted. Handlers can change\n * every render; a binding re-registers only when its key, label or\n * disabled state changes.\n */\nexport function useHotkeys(bindings: readonly Hotkey[], enabled = true) {\n  const layer = useContext(LayerContext);\n  const latest = useRef(bindings);\n  useEffect(() => {\n    latest.current = bindings;\n  });\n\n  const live = enabled ? bindings.filter((b) => !b.disabled) : [];\n  const signature = live\n    .map((b) => [b.key, b.label, b.group ?? \"\", b.repeat ? 1 : 0, b.blockedOn ?? \"\", b.global ? 1 : 0].join(\"\\u0001\"))\n    .join(\"\\u0002\");\n\n  useEffect(() => {\n    if (!signature) return;\n    const offs = signature.split(\"\\u0002\").map((sig) => {\n      const [key, label, group, repeat, blockedOn, global] = sig.split(\"\\u0001\");\n      return registerHotkey({\n        key,\n        label,\n        group: group || undefined,\n        repeat: repeat === \"1\",\n        blockedOn: (blockedOn || undefined) as Hotkey[\"blockedOn\"],\n        global: global === \"1\",\n        layer,\n        run: () => latest.current.find((b) => normalizeKey(b.key) === normalizeKey(key) && !b.disabled)?.run(),\n      });\n    });\n    return () => offs.forEach((off) => off());\n  }, [signature, layer]);\n}\n\n/** How a key reads on a keycap. */\nexport function keyCap(key: string): string {\n  switch (key) {\n    case \"ArrowLeft\":\n      return \"←\";\n    case \"ArrowRight\":\n      return \"→\";\n    case \"ArrowUp\":\n      return \"↑\";\n    case \"ArrowDown\":\n      return \"↓\";\n    case \" \":\n      return \"Space\";\n    case \"Escape\":\n      return \"Esc\";\n    default:\n      return key.length === 1 ? key.toUpperCase() : key;\n  }\n}\n\n/** Test hook: drop every binding and layer. */\nexport function resetHotkeysForTest() {\n  entries = [];\n  layerCounts.clear();\n  emit();\n}\n",
      "type": "registry:lib",
      "target": "lib/hotkeys.ts"
    }
  ],
  "type": "registry:component"
}