{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "range-drawer",
  "title": "Range drawer",
  "description": "pokerdata.io's Browse drawer: one pack's solution, slid in over the page. Preflop packs walk the tree in the Range Explorer (hold'em grid, PLO and PLO5 hand lists, ICM risk premium); postflop packs get the node walker and game tree. Point it at your feeds with setExplorerEndpoints.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.pokerstudy.ai/r/range-explorer.json",
    "https://www.pokerstudy.ai/r/hand-matrix.json",
    "https://www.pokerstudy.ai/r/poker-core.json",
    "https://www.pokerstudy.ai/r/payouts-dialog.json"
  ],
  "files": [
    {
      "path": "src/components/pokerdata/explorer/action-bar.tsx",
      "content": "\"use client\";\n\n// A GTO-Wizard–style aggregate action bar: one full-width stacked bar showing\n// each action's combo-weighted frequency across the whole range, plus a legend\n// row of cards (label · % · combos). Ported from pokerstudy.ai's Explorer.\n\nexport interface ActionItem {\n  label: string;\n  color: string;\n  /** Aggregate frequency across the range, in [0, 1]. */\n  freq: number;\n  /** Preformatted count subtitle (\"239.6 combos\" / \"3,261 hands\"). */\n  detail?: string;\n}\n\nexport function ActionBar({ items }: { items: ActionItem[] }) {\n  const total = items.reduce((s, i) => s + Math.max(0, i.freq), 0) || 1;\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div className=\"flex h-2.5 w-full overflow-hidden rounded-full bg-white/[0.06]\">\n        {items.map((it, i) =>\n          it.freq <= 0.0005 ? null : (\n            <div\n              key={i}\n              style={{ flexGrow: it.freq / total, backgroundColor: it.color }}\n              title={`${it.label} ${(it.freq * 100).toFixed(1)}%`}\n            />\n          ),\n        )}\n      </div>\n      {/* Label stacked over the numbers — the site's mono font is too wide for\n          the label and figures to share a row in a narrow column. */}\n      <div className=\"grid grid-cols-2 gap-2\">\n        {items.map((it, i) => (\n          <div\n            key={i}\n            className=\"flex flex-col gap-1 rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2\"\n          >\n            <div className=\"flex items-center gap-2\">\n              <span\n                className=\"size-3 shrink-0 rounded-[3px]\"\n                style={{ backgroundColor: it.color }}\n              />\n              <span className=\"truncate text-sm font-medium text-neutral-100\">\n                {it.label}\n              </span>\n            </div>\n            <span className=\"text-xs text-neutral-500 tabular-nums\">\n              <span className=\"font-semibold text-neutral-100\">\n                {(it.freq * 100).toFixed(1)}%\n              </span>\n              {it.detail ? ` · ${it.detail}` : null}\n            </span>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/explorer/action-bar.tsx"
    },
    {
      "path": "src/components/pokerdata/explorer/gate-prompt.tsx",
      "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport {\n  GATE_PLANS_HREF,\n  GATE_SIGN_IN_HREF,\n  gateReason,\n} from \"@/lib/pokerdata/explorer/gate-copy\";\n\n/**\n * What a data view shows in place of its error line. A plan-gate denial gets\n * a panel that says what unlocks the data and links there (sign in, see\n * plans); anything else stays the plain error it was.\n */\nexport function GatePrompt({ message }: { message: string }) {\n  const pathname = usePathname();\n  const reason = gateReason(message);\n  if (!reason) return <p className=\"text-xs text-red-400\">{message}</p>;\n\n  return (\n    <div className=\"flex flex-col gap-3 rounded-xl border border-neutral-800 bg-neutral-900/60 p-4 font-sans sm:flex-row sm:items-center sm:justify-between\">\n      <div className=\"flex flex-col gap-1\">\n        <p className=\"text-sm font-medium text-neutral-100\">{message}</p>\n        <p className=\"text-xs text-neutral-400\">\n          Browsing is free. Downloads and the API are paid.\n        </p>\n      </div>\n      <div className=\"flex shrink-0 gap-2\">\n        {reason === \"sign-in\" ? (\n          <Link\n            href={`${GATE_SIGN_IN_HREF}${encodeURIComponent(pathname)}`}\n            className={buttonVariants({ size: \"lg\" })}\n          >\n            Sign in\n          </Link>\n        ) : null}\n        <Link\n          href={GATE_PLANS_HREF}\n          className={buttonVariants({\n            size: \"lg\",\n            variant: reason === \"sign-in\" ? \"outline\" : \"default\",\n          })}\n        >\n          Create account\n        </Link>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/explorer/gate-prompt.tsx"
    },
    {
      "path": "src/components/pokerdata/explorer/icm-range-tabs.tsx",
      "content": "\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport { HandMatrix, type MatrixCell } from \"@/components/pokerstudy/hand-matrix\";\nimport type { ExplorerAction } from \"@/components/pokerstudy/range-explorer-panels\";\nimport { ALL_HANDS } from \"@/lib/pokerstudy/poker\";\nimport { parseHistory, type TableSpec, type WalkNode } from \"@/lib/pokerstudy/preflop-walk\";\nimport { icmPressure, icmStackSwings, type IcmStructure, type StackSwing } from \"@/lib/pokerdata/explorer/icm-pressure\";\nimport { dollars, payoutRows } from \"@/lib/pokerdata/payouts\";\n\n/**\n * The range panel for an ICM pack: the acting seat's 13×13 range, or its ICM\n * risk curve against one other seat — what winning or losing chips to that\n * seat does to its tournament $EV, and the gap between the two (the risk\n * premium). Stacks are the pack's starting stacks, the same ones the\n * details page's bubble factor grid uses. A third view lists the payouts\n * the solve used.\n */\n\ntype View = \"range\" | \"risk\" | \"payouts\";\n\nexport function IcmRangeTabs({\n  node,\n  history,\n  actions,\n  table,\n  structure,\n}: {\n  node: WalkNode;\n  history: string;\n  actions: ExplorerAction[];\n  table: TableSpec;\n  structure: IcmStructure;\n}) {\n  const [view, setView] = useState<View>(\"range\");\n  const [pinned, setPinned] = useState<string | null>(null);\n  const seats = table.order;\n  const stacks = useMemo(\n    () => seats.map((p) => table.seatStacks?.[p] ?? table.stack),\n    [seats, table.seatStacks, table.stack],\n  );\n\n  // Hero is the seat to act; villain defaults to the seat with the most in\n  // the pot (the raiser), or the big blind when nobody has raised.\n  const walk = useMemo(() => parseHistory(history, table), [history, table]);\n  const heroPos = node.actor ?? walk.actor ?? seats[0];\n  const hero = Math.max(0, seats.indexOf(heroPos));\n  const defaultVillain = useMemo(() => {\n    let best = -1;\n    let most = 1;\n    seats.forEach((p, i) => {\n      if (i !== hero && walk.seats[p].committed > most) {\n        most = walk.seats[p].committed;\n        best = i;\n      }\n    });\n    if (best >= 0) return best;\n    const bb = seats.indexOf(\"BB\");\n    return bb !== hero ? bb : seats.indexOf(\"SB\");\n  }, [seats, walk, hero]);\n  const [villainPick, setVillainPick] = useState<number | null>(null);\n  const villain = villainPick !== null && villainPick !== hero ? villainPick : defaultVillain;\n\n  const colors = actions.map((a) => a.color);\n  const cells = useMemo<(MatrixCell | null)[]>(\n    () =>\n      ALL_HANDS.map((h) => {\n        const freqs = actions.map((a) => a.action.weights[h] ?? 0);\n        return freqs.reduce((s, f) => s + f, 0) > 0.001 ? { label: h, freqs } : null;\n      }),\n    [actions],\n  );\n  const foldIndex = actions.findIndex((a) => a.action.kind === \"fold\");\n\n  return (\n    <div className=\"flex w-full flex-col gap-4\">\n      <div\n        role=\"tablist\"\n        aria-label=\"Range view\"\n        className=\"inline-flex w-fit rounded-full border border-neutral-800 bg-neutral-950 p-0.5 text-xs\"\n      >\n        {(\n          [\n            [\"range\", \"Range\"],\n            [\"risk\", \"Risk premium\"],\n            [\"payouts\", \"Payouts\"],\n          ] as const\n        ).map(([key, label]) => (\n          <button\n            key={key}\n            type=\"button\"\n            role=\"tab\"\n            aria-selected={view === key}\n            onClick={() => setView(key)}\n            className={`h-7 cursor-pointer rounded-full px-3 transition-colors duration-150 ${\n              view === key ? \"bg-neutral-800 text-neutral-100\" : \"text-neutral-500 hover:text-neutral-200\"\n            }`}\n          >\n            {label}\n          </button>\n        ))}\n      </div>\n      {view === \"range\" ? (\n        /* In the drawer the explorer sizes the range to the card's height\n           (`cqh`); the tab row above the matrix takes another 2.75rem. */\n        <div className=\"w-full lg:w-[min(100%,calc(100cqh_-_5.5rem))]\">\n        <HandMatrix\n          cells={cells}\n          colors={colors}\n          legend={actions.map((a) => ({\n            label: `${a.label} ${a.freq === undefined ? \"\" : `${(a.freq * 100).toFixed(0)}%`}`.trim(),\n            color: a.color,\n          }))}\n          selected={pinned}\n          onSelect={(h) => setPinned((cur) => (cur === h ? null : h))}\n          foldIndex={foldIndex < 0 ? undefined : foldIndex}\n          aggressiveFirst\n        />\n        </div>\n      ) : view === \"payouts\" ? (\n        <PayoutsPanel structure={structure} />\n      ) : (\n        <RiskPanel\n          seats={seats}\n          stacks={stacks}\n          structure={structure}\n          hero={hero}\n          villain={villain}\n          onVillain={setVillainPick}\n        />\n      )}\n    </div>\n  );\n}\n\nconst fmtBb = (bb: number) => `${Number.isInteger(bb) ? bb : bb.toFixed(1)}bb`;\nconst fmtUsd = (v: number) => `${v < 0 ? \"−\" : v > 0 ? \"+\" : \"\"}$${Math.abs(v).toFixed(0)}`;\n\nfunction RiskPanel({\n  seats,\n  stacks,\n  structure,\n  hero,\n  villain,\n  onVillain,\n}: {\n  seats: string[];\n  stacks: number[];\n  structure: IcmStructure;\n  hero: number;\n  villain: number;\n  onVillain: (i: number) => void;\n}) {\n  const swings = useMemo(\n    () => icmStackSwings(stacks, structure, hero, villain),\n    [stacks, structure, hero, villain],\n  );\n  const pressure = useMemo(() => icmPressure(stacks, structure), [stacks, structure]);\n  const bf = pressure.bubbleFactor[hero][villain] ?? 1;\n  const rp = pressure.riskPremium[hero][villain] ?? 0;\n  const last = swings[swings.length - 1];\n\n  return (\n    <div className=\"flex w-full max-w-[560px] flex-col gap-4\">\n      <div className=\"flex flex-col gap-2\">\n        <div className=\"flex flex-wrap gap-1.5\">\n          {seats.map((p, i) =>\n            i === hero ? null : (\n              <button\n                key={p}\n                type=\"button\"\n                onClick={() => onVillain(i)}\n                className={`h-7 cursor-pointer rounded-full px-3 text-xs transition-colors duration-150 ${\n                  i === villain\n                    ? \"bg-neutral-100 font-medium text-neutral-950\"\n                    : \"border border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:text-neutral-200\"\n                }`}\n              >\n                {p} {fmtBb(stacks[i])}\n              </button>\n            ),\n          )}\n        </div>\n      </div>\n      <SwingChart swings={swings} />\n      <dl className=\"grid grid-cols-2 gap-2 text-xs sm:grid-cols-4\">\n        <Fact label=\"Bubble factor\" value={bf.toFixed(2)} />\n        <Fact label=\"Risk premium\" value={`${rp >= 0 ? \"+\" : \"\"}${(rp * 100).toFixed(1)}%`} />\n        <Fact label={`Win ${fmtBb(last.bb)}`} value={fmtUsd(last.win)} />\n        <Fact label={`Lose ${fmtBb(last.bb)}`} value={fmtUsd(last.lose)} />\n      </dl>\n    </div>\n  );\n}\n\n/** The payouts the solve used: place, prize and share of what's left. */\nfunction PayoutsPanel({ structure }: { structure: IcmStructure }) {\n  const rows = payoutRows(structure.payouts);\n  return (\n    <div className=\"flex w-full max-w-[560px] flex-col gap-3\">\n      <p className=\"text-xs text-neutral-400\">\n        {structure.source.replace(/^./, (c) => c.toUpperCase())}. {structure.playersLeft} players left\n        {structure.playersLeft > 8\n          ? `, everyone off this table at ${fmtBb(structure.fieldAvgBb)}`\n          : \", all at this table\"}\n        .\n      </p>\n      <div className=\"grid grid-cols-[1fr_auto_auto] gap-x-6 text-xs text-neutral-500\">\n        <span className=\"pb-1.5\">Place</span>\n        <span className=\"pb-1.5 text-right\">Prize</span>\n        <span className=\"pb-1.5 text-right\">Share</span>\n      </div>\n      <dl className=\"-mt-3 divide-y divide-neutral-800\">\n        {rows.map((r) => (\n          <div key={r.places} className=\"grid grid-cols-[1fr_auto_auto] items-center gap-x-6 py-1.5 text-[13px]\">\n            <dt className=\"text-neutral-300\">{r.places}</dt>\n            <dd className=\"text-right tabular-nums text-neutral-100\">{dollars(r.prize)}</dd>\n            <dd className=\"w-12 text-right tabular-nums text-neutral-500\">{(r.share * 100).toFixed(1)}%</dd>\n          </div>\n        ))}\n      </dl>\n    </div>\n  );\n}\n\nfunction Fact({ label, value }: { label: string; value: string }) {\n  return (\n    <div className=\"flex flex-col gap-0.5 rounded-[var(--rb-r-md,8px)] border border-neutral-800 bg-neutral-900/60 px-3 py-2\">\n      <dt className=\"text-neutral-500\">{label}</dt>\n      <dd className=\"text-[13px] font-medium tabular-nums text-neutral-100\">{value}</dd>\n    </div>\n  );\n}\n\nconst WIN = \"#86b56f\";\nconst LOSE = \"#4f7fd1\";\nconst PREMIUM = \"#c9533e\";\n\n/** $EV change by chips won or lost: three filled areas from the zero line. */\nfunction SwingChart({ swings }: { swings: StackSwing[] }) {\n  const W = 560;\n  const H = 320;\n  const pad = { l: 52, r: 44, t: 12, b: 32 };\n  const maxBb = swings[swings.length - 1].bb || 1;\n  const top = Math.max(...swings.map((s) => s.win), 1);\n  const bottom = Math.min(...swings.map((s) => s.lose), -1);\n  const span = niceStep((top - bottom) / 5);\n  const yMax = Math.ceil(top / span) * span;\n  const yMin = Math.floor(bottom / span) * span;\n  const x = (bb: number) => pad.l + (bb / maxBb) * (W - pad.l - pad.r);\n  const y = (v: number) => pad.t + ((yMax - v) / (yMax - yMin)) * (H - pad.t - pad.b);\n  const area = (key: \"win\" | \"lose\" | \"premium\") =>\n    `M${x(0)},${y(0)} ` +\n    swings.map((s) => `L${x(s.bb).toFixed(1)},${y(s[key]).toFixed(1)}`).join(\" \") +\n    ` L${x(maxBb)},${y(0)} Z`;\n  const line = (key: \"win\" | \"lose\" | \"premium\") =>\n    swings.map((s, i) => `${i ? \"L\" : \"M\"}${x(s.bb).toFixed(1)},${y(s[key]).toFixed(1)}`).join(\" \");\n  const yTicks: number[] = [];\n  for (let v = yMin; v <= yMax + 1e-9; v += span) yTicks.push(v);\n  const xStep = niceStep(maxBb / 5);\n  const xTicks: number[] = [];\n  for (let v = xStep; v <= maxBb + 1e-9; v += xStep) xTicks.push(v);\n  const last = swings[swings.length - 1];\n\n  return (\n    <figure className=\"flex flex-col gap-2\">\n      <svg viewBox={`0 0 ${W} ${H}`} className=\"w-full\" role=\"img\" aria-label=\"Change to tournament $EV by chips won or lost\">\n        {yTicks.map((v) => (\n          <g key={`y${v}`}>\n            <line x1={pad.l} x2={W - pad.r} y1={y(v)} y2={y(v)} stroke=\"currentColor\" className=\"text-neutral-800\" />\n            <text x={pad.l - 8} y={y(v)} dy=\"0.32em\" textAnchor=\"end\" className=\"fill-neutral-500 text-[11px] tabular-nums\">\n              {fmtUsd(v)}\n            </text>\n          </g>\n        ))}\n        {xTicks.map((v) => (\n          <g key={`x${v}`}>\n            <line x1={x(v)} x2={x(v)} y1={pad.t} y2={H - pad.b} stroke=\"currentColor\" className=\"text-neutral-800/60\" />\n            <text x={x(v)} y={H - pad.b + 18} textAnchor=\"middle\" className=\"fill-neutral-500 text-[11px] tabular-nums\">\n              {fmtBb(v)}\n            </text>\n          </g>\n        ))}\n        <path d={area(\"win\")} fill={WIN} fillOpacity={0.35} />\n        <path d={area(\"lose\")} fill={LOSE} fillOpacity={0.35} />\n        <path d={area(\"premium\")} fill={PREMIUM} fillOpacity={0.45} />\n        <path d={line(\"win\")} fill=\"none\" stroke={WIN} strokeWidth={2} />\n        <path d={line(\"lose\")} fill=\"none\" stroke={LOSE} strokeWidth={2} />\n        <path d={line(\"premium\")} fill=\"none\" stroke={PREMIUM} strokeWidth={2} />\n        <line x1={pad.l} x2={W - pad.r} y1={y(0)} y2={y(0)} stroke=\"currentColor\" className=\"text-neutral-500\" />\n        {([\"win\", \"premium\", \"lose\"] as const).map((k) => (\n          <text key={k} x={x(maxBb) + 6} y={y(last[k])} dy=\"0.32em\" className=\"fill-neutral-200 text-[11px] tabular-nums\">\n            {fmtUsd(last[k])}\n          </text>\n        ))}\n      </svg>\n      <figcaption className=\"flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-400\">\n        <Key color={WIN} label=\"$EV change if we win\" />\n        <Key color={LOSE} label=\"$EV change if we lose\" />\n        <Key color={PREMIUM} label=\"Risk premium\" />\n        <span className=\"text-neutral-500\">x: chips won or lost</span>\n      </figcaption>\n    </figure>\n  );\n}\n\nfunction Key({ color, label }: { color: string; label: string }) {\n  return (\n    <span className=\"inline-flex items-center gap-1.5\">\n      <span aria-hidden className=\"size-2.5 rounded-sm\" style={{ background: color }} />\n      {label}\n    </span>\n  );\n}\n\n/** 1, 2, 2.5 or 5 times a power of ten, at least `raw`. */\nfunction niceStep(raw: number): number {\n  const p = 10 ** Math.floor(Math.log10(Math.max(raw, 1e-9)));\n  for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= raw) return m * p;\n  return 10 * p;\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/explorer/icm-range-tabs.tsx"
    },
    {
      "path": "src/components/pokerdata/explorer/pack-explorer.tsx",
      "content": "\"use client\";\n\n// One preflop pack in the pokerstudy.ai Range Explorer block: the block walks\n// the tree one seat at a time, asking `getNodeOrNull` for each decision. A 404\n// closes the line: the packs are preflop-only, so that is the end of the\n// preview. The pack's catalog facts (rake, an ICM stage's seat stacks) come in\n// as props, so any host can show any pack without the catalog itself.\n\nimport { type ReactNode, useCallback, useMemo } from \"react\";\nimport { GatePrompt } from \"@/components/pokerdata/explorer/gate-prompt\";\nimport { RangeExplorer } from \"@/components/pokerstudy/range-explorer\";\nimport type { ExplorerAction } from \"@/components/pokerstudy/range-explorer-panels\";\nimport { houseRaiseToBb } from \"@/lib/pokerdata/explorer/house\";\nimport type { TableSpec, WalkNode } from \"@/lib/pokerstudy/preflop-walk\";\nimport {\n  getNodeOrNull,\n  SEAT_ORDER,\n  type Game,\n  type NodeAction,\n  type Position,\n} from \"@/lib/pokerdata/explorer/preflop\";\nimport { icmStructureForScenario } from \"@/lib/pokerdata/explorer/icm-pressure\";\nimport { IcmRangeTabs } from \"@/components/pokerdata/explorer/icm-range-tabs\";\nimport { PloRangeList } from \"@/components/pokerdata/explorer/plo-range-list\";\nimport { Plo5RangePanel } from \"@/components/pokerdata/explorer/plo5-range-panel\";\n\n/** Dead big-blind ante per game — MTT packs play a 1bb BB-ante. */\nconst ANTE_BB: Record<Game, number> = {\n  nl: 0,\n  nl9: 0,\n  plo: 0,\n  plo9: 0,\n  plo5: 0,\n  mtt9: 1,\n  nlmtt8: 1,\n  \"nlh8-hydra\": 0,\n  \"nlh8-icm\": 0,\n  nlhu: 0,\n};\n\n/** Ante every seat posts — the classic-ante MTT set plays 0.1bb each, ICM 0.125bb. */\nconst ANTE_PER_SEAT_BB: Record<Game, number> = {\n  nl: 0,\n  nl9: 0,\n  plo: 0,\n  plo9: 0,\n  plo5: 0,\n  mtt9: 0,\n  nlmtt8: 0,\n  \"nlh8-hydra\": 0.1,\n  \"nlh8-icm\": 0.125,\n  nlhu: 0.125,\n};\n\n/** Dead ante no seat's stack pays — the PLO5 tree's 1.2bb. */\nconst DEAD_ANTE_BB: Partial<Record<Game, number>> = { plo5: 1.2 };\n\n/** Games rendered on the 13×13 hold'em grid (vs the PLO hand-class list). */\nexport function usesGrid(game: Game): boolean {\n  return (\n    game === \"nl\" ||\n    game === \"nl9\" ||\n    game === \"mtt9\" ||\n    game === \"nlmtt8\" ||\n    game === \"nlh8-hydra\" ||\n    game === \"nlh8-icm\" ||\n    game === \"nlhu\"\n  );\n}\n\n/** The game and seat count, for the explorer's stat strip. */\nconst GAME_FACTS: Record<Game, { game: string; players: string }> = {\n  nl: { game: \"NLHE\", players: \"6-max\" },\n  nl9: { game: \"NLHE\", players: \"9-max\" },\n  nlmtt8: { game: \"NLHE\", players: \"8-max\" },\n  \"nlh8-hydra\": { game: \"NLHE\", players: \"8-max\" },\n  \"nlh8-icm\": { game: \"NLHE\", players: \"8-max\" },\n  nlhu: { game: \"NLHE\", players: \"Heads-up\" },\n  mtt9: { game: \"NLHE\", players: \"9-max\" },\n  plo: { game: \"PLO\", players: \"6-max\" },\n  plo9: { game: \"PLO\", players: \"9-max\" },\n  plo5: { game: \"PLO5\", players: \"6-max\" },\n};\n\n/** The ante structure the pack was solved with. */\nfunction anteFact(game: Game): string {\n  // The tables hold 0 for \"no ante of this kind\", so fall through on 0, not just undefined.\n  const perSeat = ANTE_PER_SEAT_BB[game];\n  if (perSeat) return `${perSeat}bb per seat`;\n  const ante = ANTE_BB[game] || DEAD_ANTE_BB[game];\n  return ante ? `${ante}bb` : \"None\";\n}\n\n/** The catalog's rake line, short: \"5%, 0.5bb cap\", or none for a tournament. */\nfunction rakeFact(rake: string | undefined): string {\n  const m = /^(\\d+(?:\\.\\d+)?%),\\s*([\\d.]+bb cap)/.exec(rake ?? \"\");\n  if (m) return `${m[1]}, ${m[2]}`;\n  if (/^none/i.test(rake ?? \"\")) return \"None\";\n  return rake || \"—\";\n}\n\nexport interface PackExplorerProps {\n  game: Game;\n  stack: number;\n  /** ICM scenario id (`35left-avg40-even`) for the nlh8-icm game. */\n  scenario?: string;\n  /** The catalog's rake line (\"5%, 2bb cap\", \"None — tournament…\"). */\n  rake?: string;\n  /** An ICM stage's stack per seat, in seat order; omit when every seat has `stack`. */\n  seatStacks?: number[];\n  /**\n   * Drawer mode: the explorer fills its parent and scrolls its own panels,\n   * with `header` across the top (Back, Reset, and the drawer's close).\n   */\n  embedded?: boolean;\n  header?: { title?: ReactNode; trailing?: ReactNode };\n  /** Below the explorer, for the current node (the full page's hand summary). */\n  footer?: (props: { node: WalkNode | null }) => ReactNode;\n}\n\nexport function PackExplorer({\n  game,\n  stack,\n  scenario,\n  rake,\n  seatStacks,\n  embedded = false,\n  header,\n  footer,\n}: PackExplorerProps) {\n  // The table the pack was solved on: its seats, antes and (for an ICM\n  // stage) each seat's own stack. House raise codes size the retired MTT set.\n  const table = useMemo<TableSpec>(() => {\n    const order = SEAT_ORDER[game];\n    return {\n      order,\n      stack,\n      seatStacks: seatStacks\n        ? (Object.fromEntries(order.map((p, i) => [p, seatStacks[i] ?? stack])) as Partial<\n            Record<Position, number>\n          >)\n        : undefined,\n      bbAnte: ANTE_BB[game],\n      antePerSeat: ANTE_PER_SEAT_BB[game],\n      deadAnte: DEAD_ANTE_BB[game] ?? 0,\n      sizeOf: houseRaiseToBb,\n    };\n  }, [game, stack, seatStacks]);\n\n  // A 404 (null) means the betting round closed on this line; a gate answer\n  // throws, and the block shows it through `GatePrompt`.\n  const loadNode = useCallback(\n    (history: string): Promise<WalkNode | null> =>\n      getNodeOrNull(game, stack, history, game === \"nlh8-icm\" ? scenario : undefined),\n    [game, stack, scenario],\n  );\n\n  const grid = usesGrid(game);\n  const stats = useMemo(() => {\n    const f = GAME_FACTS[game];\n    return [\n      { label: \"Game\", value: f.game },\n      { label: \"Stack\", value: `${stack}bb` },\n      { label: \"Players\", value: f.players },\n      { label: \"Rake\", value: rakeFact(rake) },\n      { label: \"Ante\", value: anteFact(game) },\n    ];\n  }, [game, stack, rake]);\n\n  // PLO nodes carry no 13×13 weights: each action's share is its pool % (PLO5)\n  // or its hand count, and the range is a hand-class list.\n  const actionsFor = useMemo(\n    () =>\n      grid\n        ? undefined\n        : (node: WalkNode, colored: ExplorerAction[]): ExplorerAction[] => {\n            const pool = colored.reduce((s, { action }) => s + ((action as NodeAction).poolPct ?? 0), 0);\n            return colored.map((c) => {\n              const a = c.action as NodeAction;\n              return {\n                ...c,\n                freq: pool > 0 ? (a.poolPct ?? 0) / pool : undefined,\n                detail: a.hands != null ? `${a.hands.toLocaleString(\"en-US\")} hands` : undefined,\n              };\n            });\n          },\n    [grid],\n  );\n  // ICM packs tab between the range and the acting seat's risk premium.\n  const icm = useMemo(\n    () => (game === \"nlh8-icm\" && scenario ? icmStructureForScenario(scenario) : null),\n    [game, scenario],\n  );\n  const renderRange = useMemo(\n    () =>\n      icm\n        ? ({ node, history, actions }: { node: WalkNode; history: string; actions: ExplorerAction[] }) => (\n            <IcmRangeTabs node={node} history={history} actions={actions} table={table} structure={icm} />\n          )\n        : grid\n        ? undefined\n        : ({ node, history }: { node: WalkNode; history: string }) =>\n            game === \"plo5\" ? (\n              <Plo5RangePanel actions={node.actions as NodeAction[]} stack={stack} history={history} />\n            ) : (\n              <PloRangeList actions={node.actions as NodeAction[]} stack={stack} />\n            ),\n    [icm, table, grid, game, stack],\n  );\n\n  return (\n    // Keyed so a pack change restarts the walk at the root.\n    <RangeExplorer\n      key={`${game}-${stack}-${scenario ?? \"\"}`}\n      table={table}\n      loadNode={loadNode}\n      actionsFor={actionsFor}\n      renderRange={renderRange}\n      renderError={(message) => <GatePrompt message={message} />}\n      header={embedded ? header : undefined}\n      fill={embedded}\n      stats={stats}\n      theme=\"pokerdata\"\n      contentClassName=\"mx-auto w-full max-w-[1760px] p-3 sm:p-4\"\n      footer={footer}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/explorer/pack-explorer.tsx"
    },
    {
      "path": "src/components/pokerdata/explorer/plo-range-list.tsx",
      "content": "\"use client\";\n\n// PLO hand-class viewer for the range explorer — GTO-Wizard-style layout: one\n// section per action (Fold / Call / Raise pot …), each listing every hand that\n// takes the action, sorted by EV. PLO has no 13×13 grid (16,432\n// suit-isomorphism classes), so hands render as four-color card tiles. The\n// filter chips (ported from the mobile app's PloRangeList) apply to every\n// section at once.\n\nimport { useMemo, useState } from \"react\";\nimport {\n  labelForAction,\n  sortActionsForDisplay,\n  type NodeAction,\n} from \"@/lib/pokerdata/explorer/preflop\";\nimport {\n  ploStringToCards,\n  SUIT_COLOR_4,\n  suitGlyph,\n  type Suit,\n} from \"@/lib/pokerdata/explorer/plo-hand\";\nimport {\n  FILTER_GROUPS,\n  handProps,\n  matchesFilters,\n  type FilterId,\n  type HandProps,\n} from \"@/lib/pokerdata/explorer/plo-filter\";\nimport { colorActions } from \"./preflop-strategy-grid\";\n\ninterface Row {\n  hand: string;\n  /** Frequency of taking THIS action, in [0, 1]. */\n  freq: number;\n  /** EV (bb) of this action, when the data carries EV. */\n  evBb: number | null;\n  /** EV edge (bb) over the hand's best alternative action; null if unknown. */\n  evDiffBb: number | null;\n  props: HandProps;\n}\n\n/** Rows initially rendered per section — expand on demand so the 13k-hand\n *  fold list can't lock the tab. */\nconst SECTION_PREVIEW = 150;\n\nexport function PloRangeList({\n  actions,\n  stack,\n}: {\n  actions: NodeAction[];\n  stack: number;\n}) {\n  const [active, setActive] = useState<Set<FilterId>>(() => new Set());\n  const [expanded, setExpanded] = useState<Set<string>>(() => new Set());\n\n  const colored = useMemo(\n    () => colorActions(sortActionsForDisplay(actions)),\n    [actions],\n  );\n\n  // hand → filterable props, computed once across all actions' key sets.\n  const propsByHand = useMemo(() => {\n    const map = new Map<string, HandProps>();\n    for (const { action } of colored)\n      for (const h of Object.keys(action.weights))\n        if (!map.has(h)) map.set(h, handProps(h));\n    return map;\n  }, [colored]);\n\n  // One section per action: every hand taking it, sorted by EV desc.\n  const sections = useMemo(() => {\n    return colored.map(({ action, color }, idx) => {\n      const rows: Row[] = [];\n      for (const [hand, freq] of Object.entries(action.weights)) {\n        if (freq <= 0.0005) continue;\n        const ev = action.evs?.[hand];\n        // Edge over the best alternative action offering an EV for this hand.\n        let bestOther: number | null = null;\n        colored.forEach(({ action: other }, j) => {\n          if (j === idx) return;\n          const e = other.evs?.[hand];\n          if (e !== undefined && (bestOther === null || e > bestOther))\n            bestOther = e;\n        });\n        rows.push({\n          hand,\n          freq,\n          evBb: ev === undefined ? null : ev / 2,\n          evDiffBb:\n            ev === undefined || bestOther === null\n              ? null\n              : (ev - bestOther) / 2,\n          props: propsByHand.get(hand)!,\n        });\n      }\n      rows.sort(\n        (a, b) => (b.evBb ?? -Infinity) - (a.evBb ?? -Infinity) || b.freq - a.freq,\n      );\n      const mass = rows.reduce((s, r) => s + r.freq, 0);\n      return { action, color, label: labelForAction(action, stack), rows, mass };\n    });\n  }, [colored, propsByHand, stack]);\n\n  const totalMass =\n    sections.reduce((s, sec) => s + sec.mass, 0) || 1;\n\n  const toggle = (id: FilterId) =>\n    setActive((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n\n  const filtered = sections.map((sec) => ({\n    ...sec,\n    rows:\n      active.size === 0\n        ? sec.rows\n        : sec.rows.filter((r) => matchesFilters(r.props, active)),\n  }));\n\n  const matching = useMemo(() => {\n    if (active.size === 0) return propsByHand.size;\n    let n = 0;\n    for (const p of propsByHand.values()) if (matchesFilters(p, active)) n++;\n    return n;\n  }, [propsByHand, active]);\n\n  const hasEv = sections.some((s) => s.rows.some((r) => r.evBb !== null));\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      {/* Filter chips: OR within a group, AND across groups; they slice every\n          action section below at once. */}\n      <div className=\"flex items-center justify-between gap-3\">\n        <span className=\"text-xs text-neutral-500 tabular-nums\">\n          {matching.toLocaleString(\"en-US\")} of{\" \"}\n          {propsByHand.size.toLocaleString(\"en-US\")} hands\n        </span>\n        {active.size > 0 ? (\n          <button\n            type=\"button\"\n            onClick={() => setActive(new Set())}\n            className=\"text-xs text-neutral-400 underline underline-offset-2 hover:text-neutral-200\"\n          >\n            Clear filters\n          </button>\n        ) : null}\n      </div>\n      <div className=\"flex flex-wrap items-center gap-1.5\">\n        {FILTER_GROUPS.map((group, gi) => (\n          <div key={group.key} className=\"flex items-center gap-1.5\">\n            {gi > 0 ? <span className=\"h-4 w-px bg-neutral-800\" /> : null}\n            {group.chips.map((chip) => {\n              const on = active.has(chip.id);\n              return (\n                <button\n                  key={chip.id}\n                  type=\"button\"\n                  onClick={() => toggle(chip.id)}\n                  className={\n                    on\n                      ? \"rounded-full bg-neutral-100 px-3 py-1 text-xs font-medium text-neutral-950\"\n                      : \"rounded-full border border-neutral-700 px-3 py-1 text-xs text-neutral-400 hover:border-neutral-500 hover:text-neutral-200\"\n                  }\n                >\n                  {chip.label}\n                </button>\n              );\n            })}\n          </div>\n        ))}\n      </div>\n\n      {/* One section per action, side by side on wide screens. */}\n      <div\n        className=\"grid gap-3\"\n        style={{\n          gridTemplateColumns: `repeat(auto-fit, minmax(280px, 1fr))`,\n        }}\n      >\n        {filtered.map((sec) => {\n          const isOpen = expanded.has(sec.action.action);\n          const shown = isOpen ? sec.rows : sec.rows.slice(0, SECTION_PREVIEW);\n          const pct = (sec.mass / totalMass) * 100;\n          return (\n            <section\n              key={sec.action.action}\n              className=\"flex flex-col overflow-hidden rounded-xl border border-neutral-800 bg-neutral-950\"\n            >\n              {/* Tinted header, GTO-Wizard style. */}\n              <div\n                className=\"flex flex-col gap-1.5 px-3 py-2.5\"\n                style={{ backgroundColor: `${sec.color}26` }}\n              >\n                <div className=\"flex items-baseline justify-between gap-2\">\n                  <span className=\"text-sm font-medium text-neutral-100\">\n                    {sec.label}\n                  </span>\n                  <span className=\"text-[11px] text-neutral-400 tabular-nums\">\n                    {sec.rows.length.toLocaleString(\"en-US\")}{\" \"}\n                    {sec.rows.length === 1 ? \"hand\" : \"hands\"}\n                  </span>\n                </div>\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"text-base font-semibold text-neutral-50 tabular-nums\">\n                    {pct.toFixed(1)}%\n                  </span>\n                  <div className=\"flex h-1.5 flex-1 overflow-hidden rounded-full bg-white/[0.08]\">\n                    <div\n                      style={{\n                        width: `${pct}%`,\n                        backgroundColor: sec.color,\n                      }}\n                    />\n                  </div>\n                </div>\n              </div>\n\n              <div className=\"flex max-h-[560px] flex-col overflow-y-auto px-2 pb-2\">\n                <div\n                  className={`sticky top-0 z-10 grid items-center gap-2 bg-neutral-950 px-1 py-1.5 text-[10px] uppercase tracking-wide text-neutral-600 ${\n                    hasEv\n                      ? \"grid-cols-[minmax(0,1fr)_44px_52px_52px]\"\n                      : \"grid-cols-[minmax(0,1fr)_44px]\"\n                  }`}\n                >\n                  <span>Hand</span>\n                  <span className=\"text-right\">Freq %</span>\n                  {hasEv ? (\n                    <>\n                      <span className=\"text-right\">EV bb</span>\n                      <span className=\"text-right\">Diff bb</span>\n                    </>\n                  ) : null}\n                </div>\n                {shown.length === 0 ? (\n                  <p className=\"py-6 text-center text-xs text-neutral-600\">\n                    No hands match these filters.\n                  </p>\n                ) : (\n                  shown.map((r) => (\n                    <div\n                      key={r.hand}\n                      className={`grid items-center gap-2 rounded px-1 py-[3px] hover:bg-neutral-900/60 ${\n                        hasEv\n                          ? \"grid-cols-[minmax(0,1fr)_44px_52px_52px]\"\n                          : \"grid-cols-[minmax(0,1fr)_44px]\"\n                      }`}\n                      style={{\n                        contentVisibility: \"auto\",\n                        containIntrinsicSize: \"auto 30px\",\n                      }}\n                    >\n                      <HandCards hand={r.hand} />\n                      <span className=\"text-right text-[11px] text-neutral-300 tabular-nums\">\n                        {Math.round(r.freq * 100)}\n                      </span>\n                      {hasEv ? (\n                        <>\n                          <span className=\"text-right text-[11px] text-neutral-300 tabular-nums\">\n                            {r.evBb === null ? \"—\" : r.evBb.toFixed(2)}\n                          </span>\n                          <span\n                            className={`text-right text-[11px] tabular-nums ${\n                              r.evDiffBb === null\n                                ? \"text-neutral-600\"\n                                : r.evDiffBb >= 0\n                                  ? \"text-emerald-400\"\n                                  : \"text-rose-400\"\n                            }`}\n                          >\n                            {r.evDiffBb === null\n                              ? \"—\"\n                              : `${r.evDiffBb >= 0 ? \"+\" : \"\"}${r.evDiffBb.toFixed(2)}`}\n                          </span>\n                        </>\n                      ) : null}\n                    </div>\n                  ))\n                )}\n                {!isOpen && sec.rows.length > SECTION_PREVIEW ? (\n                  <button\n                    type=\"button\"\n                    onClick={() =>\n                      setExpanded((prev) => new Set(prev).add(sec.action.action))\n                    }\n                    className=\"mx-1 my-1 self-start rounded-full border border-neutral-800 px-3 py-1 text-[11px] text-neutral-500 hover:border-neutral-600 hover:text-neutral-300\"\n                  >\n                    Show all {sec.rows.length.toLocaleString(\"en-US\")} ↓\n                  </button>\n                ) : null}\n              </div>\n            </section>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\n// Four-color deck so each suit reads at a glance: the canonical hand string is\n// expanded to 4 concrete cards (parenthesised cards share a suit), then each is\n// drawn as a small suit-tinted tile.\nfunction HandCards({ hand }: { hand: string }) {\n  const cards = ploStringToCards(hand);\n  return (\n    <div className=\"flex items-center gap-[3px]\">\n      {cards.map((card, i) => {\n        const suit = card[1] as Suit;\n        return (\n          <span\n            key={`${hand}-${i}`}\n            className=\"flex h-[26px] w-5 flex-col items-center justify-center rounded text-white\"\n            style={{ backgroundColor: SUIT_COLOR_4[suit] }}\n          >\n            <span className=\"text-[11px] font-bold leading-[12px]\">\n              {card[0] === \"T\" ? \"10\" : card[0]}\n            </span>\n            <span className=\"text-[8px] leading-[9px]\">{suitGlyph(suit)}</span>\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/explorer/plo-range-list.tsx"
    },
    {
      "path": "src/components/pokerdata/explorer/plo5-range-panel.tsx",
      "content": "\"use client\";\n\n// PLO5 range viewer for the explorer, ported from pokerstudy.ai's Explorer\n// (web/src/components/explorer/plo5-range-panel.tsx). A PLO5 node has 204k\n// hand classes, so the node carries only each action's pool %; the hand list\n// is each action's top hands by EV, fetched a page at a time, with a rank\n// filter (`AK222`) and a one-combo lookup (`AsAhKdKcQs`).\n\nimport { useEffect, useState } from \"react\";\nimport {\n  getPlo5HandStrategy,\n  getPlo5NodeList,\n  labelForAction,\n  plo5RaiseToBb,\n  formatBb,\n  type NodeAction,\n  type Plo5ActionList,\n  type Plo5HandStrategy,\n  type Plo5ListedHand,\n} from \"@/lib/pokerdata/explorer/preflop\";\nimport { SUIT_COLOR_4, suitGlyph, type Suit } from \"@/lib/pokerdata/explorer/plo-hand\";\nimport { colorActions } from \"./preflop-strategy-grid\";\n\nconst LIST_PAGE = 200;\nconst LIST_MAX = 2000;\n\n/** A rank filter as the API reads it (`AK222`), or \"\" when `q` isn't one. */\nfunction rankFilter(q: string): string {\n  const compact = q.replace(/[\\s,]+/g, \"\").toUpperCase();\n  return /^[AKQJT2-9]{1,5}$/.test(compact) ? compact : \"\";\n}\n\n/** Five distinct cards typed in any spacing or case. */\nfunction isCombo(q: string): boolean {\n  const compact = q.replace(/[\\s,]+/g, \"\");\n  if (!/^([AKQJT2-9][shdc]){5}$/i.test(compact)) return false;\n  const cards = compact.toLowerCase().match(/../g)!;\n  return new Set(cards).size === 5;\n}\n\nfunction actionName(action: string): string {\n  const bb = plo5RaiseToBb(action);\n  if (bb !== null) return `Raise ${formatBb(bb)}`;\n  return action === \"AllIn\" ? \"All-in\" : action;\n}\n\nexport function Plo5RangePanel({\n  actions,\n  stack,\n  history,\n}: {\n  actions: NodeAction[];\n  stack: number;\n  history: string;\n}) {\n  const colored = colorActions(actions);\n  const denom = colored.reduce((s, { action }) => s + (action.poolPct ?? 0), 0) || 1;\n  const [query, setQuery] = useState(\"\");\n  const [lookup, setLookup] = useState<Plo5HandStrategy | null>(null);\n  const [busy, setBusy] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [listed, setListed] = useState<Plo5ActionList[] | null>(null);\n  const [listLimit, setListLimit] = useState(LIST_PAGE);\n  const [listBusy, setListBusy] = useState(false);\n  const [listError, setListError] = useState<string | null>(null);\n\n  const ranks = rankFilter(query);\n  const [debouncedRanks, setDebouncedRanks] = useState(\"\");\n  useEffect(() => {\n    const t = window.setTimeout(() => setDebouncedRanks(ranks), 300);\n    return () => window.clearTimeout(t);\n  }, [ranks]);\n\n  useEffect(() => {\n    let cancelled = false;\n    setListed(null);\n    setListLimit(LIST_PAGE);\n    setListError(null);\n    setListBusy(true);\n    getPlo5NodeList(stack, history, LIST_PAGE, debouncedRanks || undefined)\n      .then((rows) => {\n        if (!cancelled) setListed(rows);\n      })\n      .catch((err) => {\n        if (!cancelled) setListError(err instanceof Error ? err.message : \"Could not load hands.\");\n      })\n      .finally(() => {\n        if (!cancelled) setListBusy(false);\n      });\n    return () => {\n      cancelled = true;\n    };\n  }, [stack, history, debouncedRanks]);\n\n  // A new spot drops the last spot's lookup.\n  useEffect(() => {\n    setLookup(null);\n    setError(null);\n  }, [history]);\n\n  const loadMore = async () => {\n    const next = Math.min(listLimit + LIST_PAGE, LIST_MAX);\n    setListBusy(true);\n    setListError(null);\n    try {\n      setListed(await getPlo5NodeList(stack, history, next, debouncedRanks || undefined));\n      setListLimit(next);\n    } catch (err) {\n      setListError(err instanceof Error ? err.message : \"Could not load more hands.\");\n    } finally {\n      setListBusy(false);\n    }\n  };\n\n  const runLookup = async () => {\n    const hand = query.trim();\n    if (!hand || ranks) return;\n    if (!isCombo(hand)) {\n      setLookup(null);\n      setError(\"Use ranks (AK222) or five cards with suits (AsAhKdKcQs).\");\n      return;\n    }\n    setBusy(true);\n    setError(null);\n    try {\n      setLookup(await getPlo5HandStrategy(stack, history, hand));\n    } catch (err) {\n      setLookup(null);\n      setError(err instanceof Error ? err.message : \"Lookup failed.\");\n    } finally {\n      setBusy(false);\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <p className=\"text-xs text-neutral-500\">\n        Filter by ranks (<span className=\"font-mono text-neutral-300\">AK222</span>) or look up\n        a hand (<span className=\"font-mono text-neutral-300\">AsAhKdKcQs</span>). Five ranks\n        match exactly; fewer mean &ldquo;at least these.&rdquo;\n      </p>\n      <form\n        className=\"flex flex-wrap items-center gap-2\"\n        onSubmit={(e) => {\n          e.preventDefault();\n          void runLookup();\n        }}\n      >\n        <input\n          value={query}\n          onChange={(e) => setQuery(e.target.value)}\n          placeholder=\"AK222 or AsAhKdKcQs\"\n          spellCheck={false}\n          aria-label=\"Filter by ranks or look up a hand\"\n          className=\"h-8 min-w-[180px] flex-1 rounded-md border border-neutral-800 bg-neutral-950 px-2.5 font-mono text-xs text-neutral-100 placeholder:text-neutral-600\"\n        />\n        <button\n          type=\"submit\"\n          disabled={busy || !query.trim() || !!ranks}\n          className=\"inline-flex h-8 items-center rounded-md border border-neutral-700 px-3 text-xs font-medium text-neutral-200 hover:border-neutral-500 disabled:opacity-50\"\n        >\n          {busy ? \"Looking up…\" : \"Look up\"}\n        </button>\n      </form>\n      {error ? <p className=\"text-xs text-rose-400\">{error}</p> : null}\n\n      {lookup ? (\n        <div className=\"flex flex-col gap-2 rounded-xl border border-neutral-800 bg-neutral-950 px-3 py-2.5\">\n          <div className=\"flex flex-wrap items-center gap-2\">\n            <HandCards hand={lookup.hdvHand} />\n            {lookup.isoMapped ? (\n              <span className=\"text-[11px] text-neutral-500\">same as {lookup.hand}</span>\n            ) : null}\n          </div>\n          <div className=\"flex flex-col\">\n            {lookup.actions.map((a) => (\n              <div key={a.action} className=\"flex items-center justify-between gap-3 py-0.5 text-xs\">\n                <span className=\"text-neutral-200\">{actionName(a.action)}</span>\n                <span className=\"tabular-nums text-neutral-400\">\n                  {(a.freq * 100).toFixed(1)}%\n                  {a.ev_sb != null ? ` · ${(a.ev_sb / 2).toFixed(2)} bb` : \"\"}\n                </span>\n              </div>\n            ))}\n          </div>\n        </div>\n      ) : null}\n\n      {debouncedRanks ? (\n        <p className=\"text-xs text-neutral-500\">\n          Hands with <span className=\"font-mono text-neutral-300\">{debouncedRanks}</span>\n        </p>\n      ) : null}\n      {listError ? <p className=\"text-xs text-rose-400\">{listError}</p> : null}\n\n      <div className=\"grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(280px,1fr))]\">\n        {colored.map(({ action, color }) => {\n          const block = listed?.find((a) => a.action === action.action);\n          return (\n            <ActionSection\n              key={action.action}\n              label={labelForAction(action, stack)}\n              color={color}\n              pct={((action.poolPct ?? 0) / denom) * 100}\n              rows={block?.listed ?? []}\n              total={block?.listedTotal ?? action.hands ?? 0}\n              busy={listBusy}\n              onMore={listLimit < LIST_MAX ? () => void loadMore() : undefined}\n            />\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\n/** Whole percents, but a sliver of a mix (0.3%) isn't shown as 0. */\nfunction formatFreq(freq: number): string {\n  const pct = freq * 100;\n  return pct > 0 && pct < 0.95 ? pct.toFixed(1) : String(Math.round(pct));\n}\n\nfunction ActionSection({\n  label,\n  color,\n  pct,\n  rows,\n  total,\n  busy,\n  onMore,\n}: {\n  label: string;\n  color: string;\n  /** Pool share of this action, 0–100. */\n  pct: number;\n  rows: Plo5ListedHand[];\n  total: number;\n  busy: boolean;\n  onMore?: () => void;\n}) {\n  const hasEv = rows.some((r) => r.ev != null);\n  const cols = hasEv ? \"grid-cols-[minmax(0,1fr)_44px_52px]\" : \"grid-cols-[minmax(0,1fr)_44px]\";\n  return (\n    <section className=\"flex flex-col overflow-hidden rounded-xl border border-neutral-800 bg-neutral-950\">\n      <div className=\"flex flex-col gap-1.5 px-3 py-2.5\" style={{ backgroundColor: `${color}26` }}>\n        <div className=\"flex items-baseline justify-between gap-2\">\n          <span className=\"text-sm font-medium text-neutral-100\">{label}</span>\n          <span className=\"text-[11px] tabular-nums text-neutral-400\">\n            {total.toLocaleString(\"en-US\")} {total === 1 ? \"hand\" : \"hands\"}\n          </span>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-base font-semibold tabular-nums text-neutral-50\">\n            {pct.toFixed(1)}%\n          </span>\n          <div className=\"flex h-1.5 flex-1 overflow-hidden rounded-full bg-white/[0.08]\">\n            <div style={{ width: `${pct}%`, backgroundColor: color }} />\n          </div>\n        </div>\n      </div>\n      <div className=\"flex max-h-[560px] flex-col overflow-y-auto px-2 pb-2\">\n        <div\n          className={`sticky top-0 z-10 grid items-center gap-2 bg-neutral-950 px-1 py-1.5 text-[10px] uppercase tracking-wide text-neutral-600 ${cols}`}\n        >\n          <span>Hand</span>\n          <span className=\"text-right\">Freq %</span>\n          {hasEv ? <span className=\"text-right\">EV bb</span> : null}\n        </div>\n        {busy && rows.length === 0 ? (\n          <p className=\"px-1 py-6 text-xs text-neutral-500\">Loading hands…</p>\n        ) : rows.length === 0 ? (\n          <p className=\"py-6 text-center text-xs text-neutral-600\">No hands listed for this action.</p>\n        ) : (\n          rows.map((r) => (\n            <div\n              key={r.hand}\n              className={`grid items-center gap-2 rounded px-1 py-[3px] hover:bg-neutral-900/60 ${cols}`}\n              style={{ contentVisibility: \"auto\", containIntrinsicSize: \"auto 30px\" }}\n            >\n              <HandCards hand={r.hand} />\n              <span className=\"text-right text-[11px] tabular-nums text-neutral-300\">\n                {formatFreq(r.freq)}\n              </span>\n              {hasEv ? (\n                <span className=\"text-right text-[11px] tabular-nums text-neutral-300\">\n                  {r.ev == null ? \"—\" : (r.ev / 2).toFixed(2)}\n                </span>\n              ) : null}\n            </div>\n          ))\n        )}\n        {onMore && rows.length > 0 && rows.length < total ? (\n          <button\n            type=\"button\"\n            onClick={onMore}\n            disabled={busy}\n            className=\"mx-1 my-1 self-start rounded-full border border-neutral-800 px-3 py-1 text-[11px] text-neutral-500 hover:border-neutral-600 hover:text-neutral-300 disabled:opacity-50\"\n          >\n            {busy\n              ? \"Loading…\"\n              : `Show more (${rows.length.toLocaleString(\"en-US\")} of ${total.toLocaleString(\"en-US\")})`}\n          </button>\n        ) : null}\n      </div>\n    </section>\n  );\n}\n\nconst RANK_ORDER = \"23456789TJQKA\";\nconst SUIT_ORDER = \"shdc\";\n\n/** `2s2h2d2c3s` → cards, high rank first. */\nexport function plo5Cards(hand: string): string[] {\n  const compact = hand.replace(/[\\s,]+/g, \"\");\n  const out: string[] = [];\n  for (let i = 0; i + 1 < compact.length; i += 2) {\n    out.push(compact[i].toUpperCase() + compact[i + 1].toLowerCase());\n  }\n  return out.sort(\n    (a, b) =>\n      RANK_ORDER.indexOf(b[0]) - RANK_ORDER.indexOf(a[0]) ||\n      SUIT_ORDER.indexOf(a[1]) - SUIT_ORDER.indexOf(b[1]),\n  );\n}\n\n// Same four-color tiles as the PLO hand-class list.\nfunction HandCards({ hand }: { hand: string }) {\n  return (\n    <div className=\"flex items-center gap-[3px]\">\n      {plo5Cards(hand).map((card, i) => {\n        const suit = card[1] as Suit;\n        return (\n          <span\n            key={`${hand}-${i}`}\n            className=\"flex h-[26px] w-5 flex-col items-center justify-center rounded text-white\"\n            style={{ backgroundColor: SUIT_COLOR_4[suit] }}\n          >\n            <span className=\"text-[11px] font-bold leading-[12px]\">\n              {card[0] === \"T\" ? \"10\" : card[0]}\n            </span>\n            <span className=\"text-[8px] leading-[9px]\">{suitGlyph(suit)}</span>\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/explorer/plo5-range-panel.tsx"
    },
    {
      "path": "src/components/pokerdata/explorer/preflop-strategy-grid.tsx",
      "content": "\"use client\";\n\n// The 13×13 preflop strategy grid. The ranges API hands us each action's\n// per-class frequency directly (`NodeAction.weights`), so a cell is just the\n// stacked action split for that hand class. Ported from pokerstudy.ai's\n// Explorer (web/src/components/explorer/preflop-strategy-grid.tsx).\n\nimport { useMemo } from \"react\";\nimport { ALL_HANDS, comboCountOf } from \"@/lib/pokerdata/explorer/range-model\";\nimport {\n  labelForAction,\n  sortActionsForDisplay,\n  type NodeAction,\n} from \"@/lib/pokerdata/explorer/preflop\";\nimport { actionPalette } from \"@/lib/pokerstudy/poker\";\nimport type { ActionItem } from \"./action-bar\";\n\n// pokerstudy.ai's default range palette (Mint): blue fold, call (and the\n// BB's check) green, raises a red that darkens by size so multiple sizings\n// stay distinguishable when stacked in one cell.\nconst PALETTE = actionPalette();\n\n/** Stable per-action colors for a node, ordered fold → call → raises → all-in. */\nexport function colorActions(\n  actions: NodeAction[],\n): { action: NodeAction; color: string }[] {\n  const sorted = sortActionsForDisplay(actions);\n  let raiseIdx = 0;\n  return sorted.map((action) => {\n    let color: string;\n    if (action.kind === \"fold\") color = PALETTE.Fold;\n    else if (action.kind === \"call\") color = PALETTE.Call;\n    else if (action.kind === \"allin\") color = PALETTE.AllIn;\n    else color = PALETTE.raises[Math.min(raiseIdx++, PALETTE.raises.length - 1)];\n    return { action, color };\n  });\n}\n\n/** Combo-weighted aggregate action frequencies for the ActionBar. */\nexport function preflopActionItems(\n  actions: NodeAction[],\n  stack: number,\n): ActionItem[] {\n  const colored = colorActions(actions);\n  // Total combos in the range: weight every in-range class by its combo count.\n  let rangeCombos = 0;\n  for (const hand of ALL_HANDS) {\n    const any = colored.some(({ action }) => (action.weights[hand] ?? 0) > 0);\n    if (any) rangeCombos += comboCountOf(hand);\n  }\n  const denom = rangeCombos || 1;\n  return colored.map(({ action, color }) => {\n    let combos = 0;\n    for (const hand of ALL_HANDS) {\n      const w = action.weights[hand] ?? 0;\n      if (w > 0) combos += w * comboCountOf(hand);\n    }\n    return {\n      label: labelForAction(action, stack),\n      color,\n      freq: combos / denom,\n      detail: `${combos.toFixed(1)} combos`,\n    };\n  });\n}\n\ninterface Cell {\n  label: string;\n  /** Per-action frequency for this class, parallel to `colored`. */\n  freqs: number[];\n}\n\nexport function PreflopStrategyGrid({\n  actions,\n  selected,\n  onHover,\n  onSelect,\n}: {\n  actions: NodeAction[];\n  /** The hand outlined in the grid (hovered or pinned). */\n  selected?: string | null;\n  /** Hovering a cell (null on leaving the grid). */\n  onHover?: (hand: string | null) => void;\n  /** Clicking a cell. */\n  onSelect?: (hand: string) => void;\n}) {\n  const colored = useMemo(() => colorActions(actions), [actions]);\n\n  const cells = useMemo<Cell[]>(() => {\n    return ALL_HANDS.map((hand) => ({\n      label: hand,\n      freqs: colored.map(({ action }) => action.weights[hand] ?? 0),\n    }));\n  }, [colored]);\n  const foldIndex = colored.findIndex(({ action }) => action.kind === \"fold\");\n\n  return (\n    <div className=\"flex w-full max-w-[680px] flex-col gap-4\">\n      <div\n        onMouseLeave={onHover ? () => onHover(null) : undefined}\n        className=\"grid aspect-square w-full grid-cols-[repeat(13,minmax(0,1fr))] grid-rows-[repeat(13,minmax(0,1fr))] gap-px overflow-hidden rounded-md bg-[#0b0b0c]\">\n        {cells.map((cell, i) => {\n          const sum = cell.freqs.reduce((s, f) => s + f, 0);\n          // Out of range: the label goes muted. Always folding: a softer\n          // white that still reads on the blue fold.\n          const inRange = sum > 0.001;\n          const folds = inRange && foldIndex >= 0 && cell.freqs[foldIndex] >= sum - 0.001;\n          const isSelected = selected === cell.label;\n          return (\n            <div\n              key={i}\n              onMouseEnter={onHover ? () => onHover(cell.label) : undefined}\n              onClick={onSelect ? () => onSelect(cell.label) : undefined}\n              className={`relative overflow-hidden rounded-[1px] bg-[#111113] ${\n                onSelect ? \"cursor-pointer\" : \"\"\n              }`}\n            >\n              {inRange ? (\n                // Raises on the left, fold on the right.\n                <div className=\"absolute inset-0 flex flex-row-reverse\">\n                  {cell.freqs.map((f, a) =>\n                    f <= 0.001 ? null : (\n                      <div\n                        key={a}\n                        style={{ flexGrow: f, backgroundColor: colored[a].color }}\n                      />\n                    ),\n                  )}\n                </div>\n              ) : null}\n              <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n                <span\n                  className={`text-[9px] font-bold leading-[11px] ${\n                    !inRange\n                      ? \"text-white/30\"\n                      : folds\n                        ? \"text-white/70\"\n                        : \"text-white [text-shadow:0_0_2px_rgba(0,0,0,0.85)]\"\n                  }`}\n                >\n                  {cell.label}\n                </span>\n              </div>\n              {isSelected ? (\n                <div className=\"pointer-events-none absolute inset-0 rounded-[1px] ring-2 ring-inset ring-white\" />\n              ) : null}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/explorer/preflop-strategy-grid.tsx"
    },
    {
      "path": "src/components/pokerdata/explorer/range-drawer.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { PackExplorer } from \"@/components/pokerdata/explorer/pack-explorer\";\nimport { GameTree } from \"@/components/pokerdata/postflop/game-tree\";\nimport { PostflopBrowser } from \"@/components/pokerdata/postflop/postflop-browser\";\nimport type { Game } from \"@/lib/pokerdata/explorer/preflop\";\nimport { explorerEndpoints } from \"@/lib/pokerdata/explorer/endpoints\";\n\n/**\n * Slide-in solution viewer, in the shell's drawer idiom (same easing and\n * surfaces as pd-shell's mobile nav): the catalog's Browse buttons open it\n * with the row's pack preselected.\n *\n * Preflop packs get the embedded range browser full-page (the 13×13 grid\n * and the PLO action columns need the width) — seat walk, range, action\n * legend; no picker, no hand summary. Postflop packs get the node walker with\n * a board picker across the pack's solved boards.\n */\n\nconst EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];\nconst EASE_DRAWER: [number, number, number, number] = [0.32, 0.72, 0, 1];\n\nexport type RangeDrawerTarget =\n  | {\n      kind: \"preflop\";\n      game: Game;\n      stack: number;\n      title: string;\n      /** ICM scenario id (`35left-avg40-even`) when the pack is an ICM stage. */\n      scenario?: string;\n      /** The catalog's rake line, for the explorer's stat strip. */\n      rake?: string;\n      /** An ICM stage's stack per seat, in seat order. */\n      seatStacks?: number[];\n    }\n  | {\n      kind: \"postflop\";\n      pack: string;\n      /** Solved boards, in the API's spaced form (\"3h 3d 2s\"). */\n      boards: string[];\n      title: string;\n    };\n\nexport function RangeDrawer({\n  target,\n  onClose,\n}: {\n  target: RangeDrawerTarget | null;\n  onClose: () => void;\n}) {\n  const reduce = useReducedMotion();\n  const panelRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (!target) return;\n    const doc = panelRef.current?.ownerDocument ?? document;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") onClose();\n    };\n    doc.addEventListener(\"keydown\", onKeyDown);\n    return () => doc.removeEventListener(\"keydown\", onKeyDown);\n  }, [target, onClose]);\n\n  return (\n    <AnimatePresence>\n      {target && (\n        <>\n          <motion.div\n            aria-hidden=\"true\"\n            onClick={onClose}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.16, ease: EASE_OUT } }}\n            transition={{ duration: 0.24, ease: EASE_OUT }}\n            className=\"fixed inset-0 z-40 bg-neutral-950/60 backdrop-blur-[2px]\"\n          />\n          <motion.aside\n            ref={panelRef}\n            role=\"dialog\"\n            aria-modal=\"true\"\n            aria-label={`${target.title} solution`}\n            initial={reduce ? { opacity: 0 } : { x: \"100%\" }}\n            animate={reduce ? { opacity: 1 } : { x: 0 }}\n            exit={\n              reduce\n                ? { opacity: 0, transition: { duration: 0.16 } }\n                : { x: \"100%\", transition: { duration: 0.22, ease: EASE_DRAWER } }\n            }\n            transition={{ duration: 0.32, ease: EASE_DRAWER }}\n            className={`fixed inset-y-0 right-0 z-50 flex w-full flex-col bg-neutral-950 ${\n              target.kind === \"preflop\" ? \"\" : \"max-w-4xl border-l border-neutral-800\"\n            }`}\n          >\n            {target.kind === \"preflop\" ? null : (\n              <div className=\"flex h-14 shrink-0 items-center justify-between border-b border-neutral-800 px-4 sm:px-6\">\n              <span className=\"truncate text-[14px] font-medium text-neutral-100\">\n                {target.title}\n              </span>\n              <button\n                type=\"button\"\n                aria-label=\"Close solution viewer\"\n                onClick={onClose}\n                className=\"inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-[var(--rb-r-md,8px)] text-neutral-400 transition-colors duration-150 hover:bg-neutral-800 hover:text-neutral-100\"\n              >\n                <X aria-hidden=\"true\" className=\"h-4 w-4\" />\n              </button>\n              </div>\n            )}\n            {/* The preflop explorer fills the drawer and scrolls its own panels\n                (so the walk fits the screen); the postflop body scrolls here. */}\n            <div\n              className={`min-h-0 flex-1 ${target.kind === \"preflop\" ? \"flex flex-col\" : \"overflow-y-auto p-4 sm:p-6\"}`}\n            >\n              {target.kind === \"preflop\" ? (\n                /* Keyed so switching packs resets the walk instead of\n                   carrying the previous pack's line into the new one. The\n                   explorer's own header carries the title and the close. */\n                <PackExplorer\n                  key={`${target.game}-${target.stack}-${target.scenario ?? \"\"}`}\n                  game={target.game}\n                  stack={target.stack}\n                  scenario={target.scenario}\n                  rake={target.rake}\n                  seatStacks={target.seatStacks}\n                  embedded\n                  header={{\n                    trailing: (\n                      <button\n                        type=\"button\"\n                        aria-label=\"Close solution viewer\"\n                        onClick={onClose}\n                        className=\"inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-[var(--rb-r-sm,6px)] text-neutral-400 transition-colors duration-150 hover:bg-neutral-800 hover:text-neutral-100\"\n                      >\n                        <X aria-hidden=\"true\" className=\"size-4\" />\n                      </button>\n                    ),\n                  }}\n                />\n              ) : (\n                <PostflopDrawerBody key={target.pack} target={target} />\n              )}\n            </div>\n          </motion.aside>\n        </>\n      )}\n    </AnimatePresence>\n  );\n}\n\nfunction PostflopDrawerBody({\n  target,\n}: {\n  target: Extract<RangeDrawerTarget, { kind: \"postflop\" }>;\n}) {\n  const [boardId, setBoardId] = useState(target.boards[0] ?? \"\");\n  const [path, setPath] = useState<string[]>([]);\n  const [showTree, setShowTree] = useState(false);\n  const [generatedAt, setGeneratedAt] = useState<string | undefined>();\n\n  useEffect(() => {\n    let alive = true;\n    void fetch(`${explorerEndpoints().postflop}/catalog`)\n      .then((r) => (r.ok ? r.json() : null))\n      .then((body: { packs?: { id: string; generatedAt?: string }[] } | null) => {\n        if (!alive || !body?.packs) return;\n        const pack = body.packs.find((p) => p.id === target.pack);\n        if (pack?.generatedAt) setGeneratedAt(pack.generatedAt);\n      })\n      .catch(() => undefined);\n    return () => {\n      alive = false;\n    };\n  }, [target.pack]);\n\n  if (!boardId) {\n    return <p className=\"text-sm text-neutral-500\">No solved boards in this pack.</p>;\n  }\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <div className=\"flex flex-wrap items-center gap-2\">\n        <span className=\"text-[11px] font-medium uppercase tracking-wider text-neutral-500\">\n          Board\n        </span>\n        <select\n          value={boardId}\n          onChange={(e) => {\n            setBoardId(e.target.value);\n            setPath([]);\n          }}\n          className=\"rounded-[var(--rb-r-md,8px)] border border-neutral-800 bg-neutral-900 px-2 py-1.5 text-sm text-neutral-200\"\n        >\n          {target.boards.map((b) => (\n            <option key={b} value={b}>\n              {b}\n            </option>\n          ))}\n        </select>\n        <button\n          type=\"button\"\n          onClick={() => setShowTree((open) => !open)}\n          className={`rounded-[var(--rb-r-md,8px)] border px-2.5 py-1.5 text-xs font-medium transition-colors ${\n            showTree\n              ? \"border-neutral-500 bg-neutral-800 text-neutral-100\"\n              : \"border-neutral-800 bg-neutral-900 text-neutral-300 hover:border-neutral-600\"\n          }`}\n        >\n          Game tree\n        </button>\n      </div>\n      {showTree ? (\n        <GameTree\n          pack={target.pack}\n          board={boardId}\n          generatedAt={generatedAt}\n          selectedPath={path}\n          onSelect={setPath}\n        />\n      ) : null}\n      {/* Keyed so a board change restarts the walk at the root. */}\n      <PostflopBrowser\n        key={boardId}\n        pack={target.pack}\n        board={boardId}\n        path={path}\n        onPathChange={setPath}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/explorer/range-drawer.tsx"
    },
    {
      "path": "src/components/pokerdata/postflop/game-tree.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useState } from \"react\";\nimport { GitBranch } from \"lucide-react\";\nimport { explorerEndpoints } from \"@/lib/pokerdata/explorer/endpoints\";\nimport { GatePrompt } from \"@/components/pokerdata/explorer/gate-prompt\";\nimport { GATE_UPGRADE } from \"@/lib/pokerdata/explorer/gate-copy\";\n\n/**\n * Flop-first game tree for a published pack/board. Every row is one\n * `/api/postflop/node` response — same payload as the walker — so legal\n * actions, sizes, pot, actor, and aggregate frequencies come from the API.\n * Turn/river runouts stay collapsed until clicked (49 deals per street).\n */\n\ntype NodeWire = {\n  node: {\n    player: 0 | 1;\n    pot: number;\n    actions: { label: string; add: number }[];\n  } | null;\n  childSegments: string[];\n  actionFreqs: number[];\n};\n\nexport type TreeRow = {\n  path: string[];\n  actor: string;\n  pot: number | null;\n  actions: { label: string; freq: number }[];\n  deals: number;\n  children: string[];\n};\n\nfunction actionLabel(a: { label: string; add: number }): string {\n  return a.add > 0 ? `${a.label} ${Math.round(a.add * 10) / 10}` : a.label;\n}\n\nfunction compactBoard(board: string): string {\n  return board.replace(/\\s+/g, \"\");\n}\n\nasync function fetchNode(\n  pack: string,\n  board: string,\n  path: string[],\n): Promise<NodeWire | null> {\n  const q = new URLSearchParams({ pack, board, path: JSON.stringify(path) });\n  const res = await fetch(`${explorerEndpoints().postflop}/node?${q}`);\n  if (res.status === 401 || res.status === 403 || res.status === 429) {\n    const body = (await res.json().catch(() => null)) as { error?: string } | null;\n    throw new Error(body?.error ?? GATE_UPGRADE);\n  }\n  if (!res.ok) return null;\n  return (await res.json()) as NodeWire;\n}\n\nfunction toRow(path: string[], view: NodeWire): TreeRow {\n  const deals = view.childSegments.filter((s) => s.startsWith(\"deal:\")).length;\n  const children = view.childSegments.filter((s) => !s.startsWith(\"deal:\"));\n  if (!view.node) {\n    return {\n      path,\n      actor: \"Deal\",\n      pot: null,\n      actions: [],\n      deals,\n      children,\n    };\n  }\n  return {\n    path,\n    actor: view.node.player === 0 ? \"OOP\" : \"IP\",\n    pot: view.node.pot,\n    actions: view.node.actions.map((a, i) => ({\n      label: actionLabel(a),\n      freq: view.actionFreqs[i] ?? 0,\n    })),\n    deals,\n    children,\n  };\n}\n\nexport function GameTree({\n  pack,\n  board,\n  generatedAt,\n  selectedPath,\n  onSelect,\n}: {\n  pack: string;\n  board: string;\n  generatedAt?: string;\n  selectedPath: string[];\n  onSelect: (path: string[]) => void;\n}) {\n  const [rows, setRows] = useState<TreeRow[]>([]);\n  const [error, setError] = useState<string | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [expanding, setExpanding] = useState<string | null>(null);\n\n  const loadTree = useCallback(async () => {\n    setLoading(true);\n    setError(null);\n    try {\n      const root = await fetchNode(pack, board, []);\n      if (!root) throw new Error(\"No root node for this board.\");\n      const out: TreeRow[] = [toRow([], root)];\n      const queue = root.childSegments\n        .filter((s) => !s.startsWith(\"deal:\"))\n        .map((s) => [s]);\n      // Flop action tree only — enough to see every size/raise without\n      // pulling 49 turn cards per closed street.\n      while (queue.length > 0 && out.length < 48) {\n        const path = queue.shift()!;\n        const view = await fetchNode(pack, board, path);\n        if (!view) continue;\n        const row = toRow(path, view);\n        out.push(row);\n        for (const child of row.children) queue.push([...path, child]);\n      }\n      setRows(out);\n    } catch (err) {\n      setError(err instanceof Error ? err.message : \"failed to load tree\");\n      setRows([]);\n    } finally {\n      setLoading(false);\n    }\n  }, [pack, board]);\n\n  useEffect(() => {\n    void loadTree();\n  }, [loadTree]);\n\n  const expandDeals = async (path: string[]) => {\n    const key = path.join(\" / \");\n    setExpanding(key);\n    try {\n      const view = await fetchNode(pack, board, path);\n      if (!view) return;\n      const deals = view.childSegments.filter((s) => s.startsWith(\"deal:\"));\n      const extra: TreeRow[] = [];\n      for (const deal of deals.slice(0, 12)) {\n        const childPath = [...path, deal];\n        const child = await fetchNode(pack, board, childPath);\n        if (child) extra.push(toRow(childPath, child));\n      }\n      setRows((current) => {\n        const seen = new Set(current.map((r) => r.path.join(\"\\0\")));\n        const next = [...current];\n        for (const row of extra) {\n          const id = row.path.join(\"\\0\");\n          if (!seen.has(id)) {\n            seen.add(id);\n            next.push(row);\n          }\n        }\n        return next;\n      });\n    } catch (err) {\n      setError(err instanceof Error ? err.message : \"failed to expand\");\n    } finally {\n      setExpanding(null);\n    }\n  };\n\n  const selected = selectedPath.join(\"\\0\");\n  const { postflop, postflopPage } = explorerEndpoints();\n  const pageHref = postflopPage ? `${postflopPage}/${pack}/${compactBoard(board)}` : null;\n  const apiHref = `${postflop}/node?pack=${encodeURIComponent(pack)}&board=${encodeURIComponent(board)}&path=${encodeURIComponent(JSON.stringify(selectedPath))}`;\n\n  return (\n    <div className=\"flex flex-col gap-3 rounded-xl border border-neutral-800 bg-neutral-950 p-3\">\n      <div className=\"flex flex-wrap items-start justify-between gap-2\">\n        <div>\n          <p className=\"flex items-center gap-1.5 text-xs font-semibold text-neutral-100\">\n            <GitBranch className=\"size-3.5 text-neutral-400\" />\n            Game tree\n          </p>\n          <p className=\"mt-0.5 text-[11px] text-neutral-500\">\n            {pack}\n            {generatedAt ? ` · ${generatedAt}` : \"\"} · flop actions from{\" \"}\n            <code className=\"text-neutral-400\">{postflop}/node</code>\n          </p>\n        </div>\n        <div className=\"flex flex-wrap gap-2 text-[11px]\">\n          {pageHref ? (\n            <a\n              href={pageHref}\n              className=\"text-neutral-400 underline underline-offset-2 hover:text-neutral-200\"\n            >\n              Full page\n            </a>\n          ) : null}\n          <a\n            href={apiHref}\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            className=\"text-neutral-400 underline underline-offset-2 hover:text-neutral-200\"\n          >\n            This node JSON\n          </a>\n        </div>\n      </div>\n\n      {error ? <GatePrompt message={error} /> : null}\n      {loading ? (\n        <p className=\"text-xs text-neutral-500\">Loading nodes…</p>\n      ) : (\n        <ul className=\"flex max-h-[280px] flex-col gap-1 overflow-y-auto\">\n          {rows.map((row) => {\n            const id = row.path.join(\"\\0\");\n            const depth = row.path.length;\n            const active = id === selected;\n            return (\n              <li key={id || \"root\"}>\n                <button\n                  type=\"button\"\n                  onClick={() => onSelect(row.path)}\n                  className={`flex w-full flex-col gap-0.5 rounded-md px-2 py-1.5 text-left text-xs transition-colors ${\n                    active\n                      ? \"bg-neutral-800 text-neutral-100\"\n                      : \"text-neutral-300 hover:bg-neutral-900\"\n                  }`}\n                  style={{ paddingLeft: 8 + depth * 12 }}\n                >\n                  <span className=\"flex flex-wrap items-center gap-x-2 gap-y-0.5\">\n                    <span className=\"font-semibold\">{row.actor}</span>\n                    {row.pot != null ? (\n                      <span className=\"tabular-nums text-neutral-500\">\n                        pot {row.pot}bb\n                      </span>\n                    ) : null}\n                    <span className=\"truncate text-neutral-500\">\n                      {row.path.length === 0\n                        ? \"flop root\"\n                        : row.path\n                            .map((s) => (s.startsWith(\"deal:\") ? s.slice(5) : s))\n                            .join(\" › \")}\n                    </span>\n                  </span>\n                  {row.actions.length > 0 ? (\n                    <span className=\"flex flex-wrap gap-1.5 text-neutral-400\">\n                      {row.actions.map((a) => (\n                        <span key={a.label} className=\"tabular-nums\">\n                          {a.label} {(a.freq * 100).toFixed(0)}%\n                        </span>\n                      ))}\n                    </span>\n                  ) : null}\n                </button>\n                {row.deals > 0 ? (\n                  <button\n                    type=\"button\"\n                    onClick={() => void expandDeals(row.path)}\n                    className=\"ml-4 text-[11px] text-neutral-500 underline underline-offset-2 hover:text-neutral-300\"\n                    style={{ marginLeft: 8 + depth * 12 }}\n                  >\n                    {expanding === row.path.join(\" / \")\n                      ? \"Loading runouts…\"\n                      : `${row.deals} runouts — expand sample`}\n                  </button>\n                ) : null}\n              </li>\n            );\n          })}\n        </ul>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/postflop/game-tree.tsx"
    },
    {
      "path": "src/components/pokerdata/postflop/postflop-browser.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { StrategyGrid, ACTION_COLORS } from \"./strategy-grid\";\nimport { ActionBar } from \"@/components/pokerdata/explorer/action-bar\";\nimport type { SolverNode } from \"@/lib/pokerdata/postflop/types\";\nimport { explorerEndpoints } from \"@/lib/pokerdata/explorer/endpoints\";\nimport { GatePrompt } from \"@/components/pokerdata/explorer/gate-prompt\";\nimport { GATE_UPGRADE } from \"@/lib/pokerdata/explorer/gate-copy\";\n\n/**\n * The postflop walker in the preflop drawer's idiom: a horizontal strip of\n * step cards (each taken action is a card you can click to rewind; the acting\n * player's card lists its options as rows), then the strategy grid on the\n * left with the aggregate action legend on the right — the same navigation\n * and range-view layout as `PackExplorer`.\n *\n * The fetch contract is the /postflop viewer's: one `/api/postflop/node` call\n * per step, boards in the API's spaced form.\n */\n\ninterface NodeViewWire {\n  node: SolverNode | null;\n  childSegments: string[];\n  board: string[];\n  reach: [string, number][];\n  actionFreqs: number[];\n}\n\nconst SUIT_GLYPH: Record<string, string> = { s: \"♠\", h: \"♥\", d: \"♦\", c: \"♣\" };\nconst SUIT_CLASS: Record<string, string> = {\n  s: \"text-neutral-200\",\n  h: \"text-rose-400\",\n  d: \"text-sky-400\",\n  c: \"text-emerald-400\",\n};\n\nfunction BoardCard({ card }: { card: string }) {\n  const suit = card[1]?.toLowerCase() ?? \"\";\n  return (\n    <span\n      className={`inline-flex items-center gap-0.5 rounded bg-neutral-800 px-1.5 py-0.5 text-xs font-semibold ${SUIT_CLASS[suit] ?? \"\"}`}\n    >\n      {card[0]?.toUpperCase()}\n      {SUIT_GLYPH[suit] ?? suit}\n    </span>\n  );\n}\n\n/** One taken step, remembered so past cards can label themselves without\n *  refetching the nodes they came from. */\ntype Step = {\n  seg: string;\n  /** Who acted: \"OOP\", \"IP\", or \"Deal\" for a runout card. */\n  actor: string;\n};\n\nconst stripCard = (active: boolean, clickable: boolean) =>\n  `flex min-h-[96px] w-[132px] shrink-0 flex-col gap-1 rounded-xl border p-2 text-left transition-colors ${\n    active ? \"border-neutral-400 bg-neutral-900\" : \"border-neutral-800 bg-neutral-950\"\n  } ${clickable ? \"cursor-pointer hover:border-neutral-500\" : \"\"}`;\n\nexport function PostflopBrowser({\n  pack,\n  board,\n  path,\n  onPathChange,\n}: {\n  pack: string;\n  board: string;\n  /** When set, the walker stays on this API path (game-tree clicks). */\n  path?: string[];\n  onPathChange?: (path: string[]) => void;\n}) {\n  const [steps, setSteps] = useState<Step[]>([]);\n  const [view, setView] = useState<NodeViewWire | null>(null);\n  const [error, setError] = useState<string | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  const load = useCallback(\n    async (nextSteps: Step[]) => {\n      setLoading(true);\n      setError(null);\n      try {\n        const q = new URLSearchParams({\n          pack,\n          board,\n          path: JSON.stringify(nextSteps.map((s) => s.seg)),\n        });\n        const res = await fetch(`${explorerEndpoints().postflop}/node?${q}`);\n        if (!res.ok)\n          throw new Error(\n            res.status === 404 ? \"No solved node here.\"\n            : res.status === 401 || res.status === 403 || res.status === 429\n              ? ((await res.json().catch(() => null)) as { error?: string } | null)?.error ?? GATE_UPGRADE\n              : `HTTP ${res.status}`,\n          );\n        setView((await res.json()) as NodeViewWire);\n        setSteps(nextSteps);\n        onPathChange?.(nextSteps.map((s) => s.seg));\n      } catch (err) {\n        setError(err instanceof Error ? err.message : \"failed to load\");\n      } finally {\n        setLoading(false);\n      }\n    },\n    [pack, board, onPathChange],\n  );\n\n  useEffect(() => {\n    void load([]);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [pack, board]);\n\n  const external = (path ?? []).join(\"\\0\");\n  const internal = steps.map((s) => s.seg).join(\"\\0\");\n  useEffect(() => {\n    if (path === undefined || external === internal) return;\n    void load(\n      path.map((seg) => ({\n        seg,\n        actor: seg.startsWith(\"deal:\") ? \"Deal\" : \"…\",\n      })),\n    );\n    // Only react to parent-driven jumps (game tree), not our own onPathChange.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [external]);\n\n  const reachMap = useMemo(() => new Map(view?.reach ?? []), [view]);\n\n  const actionSegs = useMemo(() => {\n    if (!view?.node) return [];\n    return view.node.actions.map((a) =>\n      a.add > 0 ? `${a.label} ${Math.round(a.add * 10) / 10}` : a.label,\n    );\n  }, [view]);\n\n  const deals = useMemo(\n    () => (view?.childSegments ?? []).filter((s) => s.startsWith(\"deal:\")),\n    [view],\n  );\n\n  const actor = view?.node ? (view.node.player === 0 ? \"OOP\" : \"IP\") : view ? \"Deal\" : \"\";\n\n  const legendItems = useMemo(() => {\n    if (!view?.node) return [];\n    return view.node.actions.map((a, i) => ({\n      label: actionSegs[i],\n      color: ACTION_COLORS[a.label],\n      freq: view.actionFreqs[i] ?? 0,\n    }));\n  }, [view, actionSegs]);\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      {/* Step strip — the postflop counterpart of the preflop seat strip:\n          board first, one card per taken action (click to rewind), then the\n          acting player's card listing its options. */}\n      <div className=\"flex gap-2 overflow-x-auto pb-1\">\n        <div className={stripCard(false, false)}>\n          <span className=\"text-sm font-semibold text-neutral-100\">Board</span>\n          <span className=\"flex flex-wrap gap-1 py-1\">\n            {(view?.board ?? []).map((c) => (\n              <BoardCard key={c} card={c} />\n            ))}\n          </span>\n          {view?.node && (\n            <span className=\"text-[11px] font-medium tabular-nums text-neutral-500\">\n              pot {view.node.pot}bb\n            </span>\n          )}\n        </div>\n\n        {steps.map((s, i) => (\n          <button\n            key={`${s.seg}-${i}`}\n            type=\"button\"\n            onClick={() => void load(steps.slice(0, i))}\n            className={stripCard(false, true)}\n          >\n            <span className=\"flex items-center justify-between\">\n              <span className=\"text-sm font-semibold text-neutral-100\">\n                {s.actor}\n              </span>\n            </span>\n            <span className=\"flex flex-1 items-center py-1\">\n              <span className=\"inline-block max-w-full truncate rounded bg-neutral-800 px-2 py-0.5 text-xs font-semibold text-neutral-100\">\n                {s.seg.startsWith(\"deal:\") ? s.seg.slice(5) : s.seg}\n              </span>\n            </span>\n          </button>\n        ))}\n\n        <div className={stripCard(true, false)}>\n          <span className=\"flex items-center justify-between\">\n            <span className=\"text-sm font-semibold text-neutral-100\">\n              {actor || \"…\"}\n            </span>\n            <span className=\"text-[11px] font-medium text-neutral-500\">to act</span>\n          </span>\n          <span className=\"flex flex-col\">\n            {!view ? (\n              <span className=\"py-1 text-xs text-neutral-500\">\n                {loading ? \"Loading…\" : \"—\"}\n              </span>\n            ) : (\n              (view.node?.actions ?? []).map((a, i) => (\n                <button\n                  key={actionSegs[i]}\n                  type=\"button\"\n                  onClick={() =>\n                    void load([...steps, { seg: actionSegs[i], actor }])\n                  }\n                  className=\"flex items-center justify-between gap-1 rounded px-1 py-1 text-left text-xs text-neutral-100 transition-colors hover:bg-neutral-800\"\n                >\n                  <span className=\"flex min-w-0 items-center gap-1.5\">\n                    <span\n                      className=\"size-2 shrink-0 rounded-sm\"\n                      style={{ backgroundColor: ACTION_COLORS[a.label] }}\n                    />\n                    <span className=\"truncate\">{actionSegs[i]}</span>\n                  </span>\n                  <span className=\"shrink-0 tabular-nums text-neutral-500\">\n                    {((view.actionFreqs[i] ?? 0) * 100).toFixed(0)}%\n                  </span>\n                </button>\n              ))\n            )}\n            {deals.length > 0 && (\n              <select\n                className=\"mt-1 rounded border border-neutral-800 bg-neutral-900 px-1 py-1 text-xs text-neutral-300\"\n                value=\"\"\n                onChange={(e) =>\n                  e.target.value &&\n                  void load([...steps, { seg: e.target.value, actor: \"Deal\" }])\n                }\n              >\n                <option value=\"\">Deal runout…</option>\n                {deals.map((d) => (\n                  <option key={d} value={d}>\n                    {d.slice(5)}\n                  </option>\n                ))}\n              </select>\n            )}\n          </span>\n        </div>\n      </div>\n\n      {error ? <GatePrompt message={error} /> : null}\n\n      {view?.node ? (\n        <div\n          className={`grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,320px)] ${\n            loading ? \"opacity-60\" : \"\"\n          }`}\n        >\n          <div className=\"flex flex-col gap-3\">\n            <StrategyGrid\n              actions={view.node.actions}\n              hands={view.node.hands}\n              reach={reachMap}\n            />\n          </div>\n          <div className=\"flex flex-col gap-4\">\n            <ActionBar items={legendItems} />\n          </div>\n        </div>\n      ) : view && !view.node ? (\n        <p className=\"text-xs text-neutral-500\">Street closed — pick a runout to continue.</p>\n      ) : loading ? (\n        // A board's first open reads its bundle from Blob storage, which can\n        // take several seconds: say so, or the drawer reads as broken.\n        <div\n          role=\"status\"\n          className=\"flex min-h-[200px] flex-col items-center justify-center gap-3 text-center\"\n        >\n          <div className=\"size-4 animate-spin rounded-full border-2 border-neutral-700 border-t-neutral-300\" />\n          <p className=\"text-xs text-neutral-400\">\n            Loading the {board} solve\n            <span className=\"block text-neutral-500\">\n              First open of a board can take a few seconds.\n            </span>\n          </p>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/postflop/postflop-browser.tsx"
    },
    {
      "path": "src/components/pokerdata/postflop/strategy-grid.tsx",
      "content": "\"use client\";\n\n// The postflop 13×13 strategy grid (a hand list for Omaha) — ported from pokerstudy.ai's\n// web/src/components/solver/strategy-grid.tsx. Per-combo strategies aggregate\n// into hand classes weighted by how often the acting player actually HOLDS\n// each combo at this node; without reach weighting a deep node renders a wall\n// of action for hands the line already removed.\n\nimport { useMemo } from \"react\";\nimport type { SolverActionInfo, SolverHandStrategy } from \"@/lib/pokerdata/postflop/types\";\nimport { RANGE_COLORS } from \"@/lib/pokerstudy/poker\";\n\nconst RANKS = [\"A\", \"K\", \"Q\", \"J\", \"T\", \"9\", \"8\", \"7\", \"6\", \"5\", \"4\", \"3\", \"2\"];\n\n// pokerstudy.ai's default range palette (Mint: blue fold, check and call\n// green, bets red), so an action reads the same in the postflop and preflop\n// explorers.\nexport const ACTION_COLORS: Record<SolverActionInfo[\"label\"], string> = RANGE_COLORS;\n\ninterface MatrixCell {\n  label: string;\n  freqs: number[];\n}\n\nfunction classOf(combo: string): { row: number; col: number; label: string } | null {\n  if (combo.length !== 4) return null;\n  const r1 = RANKS.indexOf(combo[0].toUpperCase());\n  const r2 = RANKS.indexOf(combo[2].toUpperCase());\n  if (r1 < 0 || r2 < 0) return null;\n  const suited = combo[1].toLowerCase() === combo[3].toLowerCase();\n  if (r1 === r2) return { row: r1, col: r1, label: `${RANKS[r1]}${RANKS[r1]}` };\n  const hi = Math.min(r1, r2);\n  const lo = Math.max(r1, r2);\n  const label = `${RANKS[hi]}${RANKS[lo]}${suited ? \"s\" : \"o\"}`;\n  return suited ? { row: hi, col: lo, label } : { row: lo, col: hi, label };\n}\n\nconst REACH_EPS = 1e-9;\n\nfunction buildGrid(\n  actions: SolverActionInfo[],\n  hands: SolverHandStrategy[],\n  reach: ReadonlyMap<string, number>,\n): (MatrixCell | null)[] {\n  const n = actions.length;\n  const sums = RANKS.map(() =>\n    RANKS.map(() => ({ freqs: new Array<number>(n).fill(0), weight: 0, label: \"\" })),\n  );\n  for (const hand of hands) {\n    const cls = classOf(hand.combo);\n    if (!cls) continue;\n    const w = reach.get(hand.combo) ?? 0;\n    if (w <= REACH_EPS) continue;\n    const slot = sums[cls.row][cls.col];\n    slot.label = cls.label;\n    slot.weight += w;\n    for (let a = 0; a < n; a++) slot.freqs[a] += w * (hand.freqs[a] ?? 0);\n  }\n  return sums\n    .map((rowArr) =>\n      rowArr.map((slot) =>\n        slot.weight <= REACH_EPS\n          ? null\n          : { label: slot.label, freqs: slot.freqs.map((f) => f / slot.weight) },\n      ),\n    )\n    .flat();\n}\n\n/** Omaha combos are 8 or 10 characters and have no 13×13 class. */\nfunction isOmaha(hands: SolverHandStrategy[]): boolean {\n  return hands.some((h) => h.combo.length > 4);\n}\n\nexport function StrategyGrid({\n  actions,\n  hands,\n  reach,\n}: {\n  actions: SolverActionInfo[];\n  hands: SolverHandStrategy[];\n  reach: ReadonlyMap<string, number>;\n}) {\n  const omaha = useMemo(() => isOmaha(hands), [hands]);\n  const cells = useMemo(\n    () => (omaha ? [] : buildGrid(actions, hands, reach)),\n    [omaha, actions, hands, reach],\n  );\n  const colors = useMemo(() => actions.map((a) => ACTION_COLORS[a.label]), [actions]);\n  const legend = useMemo(\n    () =>\n      actions.map((a) => ({\n        label: `${a.label}${a.add > 0 ? ` ${Math.round(a.add * 100) / 100}` : \"\"}`,\n        color: ACTION_COLORS[a.label],\n      })),\n    [actions],\n  );\n\n  if (omaha) {\n    return <HandList hands={hands} reach={reach} colors={colors} legend={legend} />;\n  }\n\n  return (\n    <div className=\"flex w-full max-w-[560px] flex-col gap-4\">\n      <div className=\"grid aspect-square w-full grid-cols-[repeat(13,minmax(0,1fr))] grid-rows-[repeat(13,minmax(0,1fr))] gap-px overflow-hidden rounded-md bg-[#0b0b0c]\">\n        {cells.map((cell, i) => (\n          <div key={i} className=\"relative bg-[#111113]\">\n            {cell && (\n              <>\n                <div className=\"absolute inset-0 flex flex-row-reverse\">\n                  {cell.freqs.map((f, a) =>\n                    f > 0 ? (\n                      <div\n                        key={a}\n                        style={{ width: `${f * 100}%`, backgroundColor: colors[a], opacity: 0.85 }}\n                      />\n                    ) : null,\n                  )}\n                </div>\n                <span className=\"absolute left-0.5 top-0 text-[9px] font-medium leading-tight text-white/90 sm:text-[10px]\">\n                  {cell.label}\n                </span>\n              </>\n            )}\n          </div>\n        ))}\n      </div>\n      <div className=\"flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-400\">\n        {legend.map((l) => (\n          <span key={l.label} className=\"inline-flex items-center gap-1.5\">\n            <span className=\"h-2.5 w-2.5 rounded-sm\" style={{ backgroundColor: l.color }} />\n            {l.label}\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nconst SUIT_CLASS: Record<string, string> = {\n  s: \"text-neutral-200\",\n  h: \"text-rose-400\",\n  d: \"text-sky-400\",\n  c: \"text-emerald-400\",\n};\n\n/**\n * Omaha hands one per row: the acting player's sampled hands still in reach,\n * strongest EV first, each with its action split. A grid of ~1,000 five-card\n * hands has no 13×13 shape to fold into.\n */\nfunction HandList({\n  hands,\n  reach,\n  colors,\n  legend,\n}: {\n  hands: SolverHandStrategy[];\n  reach: ReadonlyMap<string, number>;\n  colors: string[];\n  legend: { label: string; color: string }[];\n}) {\n  const rows = useMemo(\n    () =>\n      hands\n        .filter((h) => (reach.get(h.combo) ?? 0) > REACH_EPS)\n        .sort((a, b) => (b.ev ?? 0) - (a.ev ?? 0)),\n    [hands, reach],\n  );\n\n  return (\n    <div className=\"flex w-full max-w-[560px] flex-col gap-4\">\n      <p className=\"text-xs text-neutral-500\">\n        {rows.length} hands in range, highest EV first.\n      </p>\n      <ul className=\"flex max-h-[560px] flex-col gap-px overflow-y-auto rounded-md\">\n        {rows.map((h) => (\n          <li\n            key={h.combo}\n            className=\"grid grid-cols-[minmax(0,9rem)_minmax(0,1fr)_3.5rem] items-center gap-3 bg-neutral-900 px-2 py-1 text-xs\"\n          >\n            <span className=\"font-mono tabular-nums\">\n              {h.combo.match(/../g)?.map((c, i) => (\n                <span key={i} className={SUIT_CLASS[c[1]] ?? \"\"}>\n                  {c}\n                </span>\n              ))}\n            </span>\n            <span className=\"flex h-2.5 overflow-hidden rounded-sm bg-neutral-800\">\n              {h.freqs.map((f, a) =>\n                f > 0 ? (\n                  <span\n                    key={a}\n                    style={{ width: `${f * 100}%`, backgroundColor: colors[a], opacity: 0.85 }}\n                  />\n                ) : null,\n              )}\n            </span>\n            <span className=\"text-right tabular-nums text-neutral-400\">\n              {typeof h.ev === \"number\" ? `${h.ev.toFixed(2)}bb` : \"—\"}\n            </span>\n          </li>\n        ))}\n      </ul>\n      <div className=\"flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-400\">\n        {legend.map((l) => (\n          <span key={l.label} className=\"inline-flex items-center gap-1.5\">\n            <span className=\"h-2.5 w-2.5 rounded-sm\" style={{ backgroundColor: l.color }} />\n            {l.label}\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/postflop/strategy-grid.tsx"
    },
    {
      "path": "src/lib/pokerdata/explorer/endpoints.ts",
      "content": "/**\n * Where the explorers read their data. The defaults are pokerdata.io's own\n * same-origin routes; another host (pokerstudy.ai's /downloads) points them at\n * its own feeds once, before the drawer opens, with `setExplorerEndpoints`.\n *\n * Every feed keeps the same contract wherever it is served: a 401/403 answers\n * with the gate copy, a 429 with the Browse rate limit's own words, and a 404\n * on a node means the betting round closed there.\n */\nexport interface ExplorerEndpoints {\n  /** Ranges feed, the `/api/ranges/*` layout (`/nl/v2/node?…`, `/plo5/node?…`). */\n  ranges: string;\n  /** PLO 9-max per-node feed: `/manifest.json`, `/<stack>/<history>.json`. */\n  plo9: string;\n  /** Postflop feed: `/node?pack=…&board=…&path=…` and `/catalog`. */\n  postflop: string;\n  /** Bundled preview assets (the retired MTT 9-max set). */\n  previews: string;\n  /** The full-page postflop viewer (`/<pack>/<board>`), or null for none. */\n  postflopPage: string | null;\n}\n\nlet current: ExplorerEndpoints = {\n  ranges: \"/api/explorer/ranges\",\n  plo9: \"/api/explorer/plo9\",\n  postflop: \"/api/postflop\",\n  previews: \"/previews\",\n  postflopPage: \"/postflop\",\n};\n\nexport function explorerEndpoints(): ExplorerEndpoints {\n  return current;\n}\n\nexport function setExplorerEndpoints(next: Partial<ExplorerEndpoints>): void {\n  current = { ...current, ...next };\n}\n",
      "type": "registry:lib",
      "target": "lib/pokerdata/explorer/endpoints.ts"
    },
    {
      "path": "src/lib/pokerdata/explorer/gate-copy.ts",
      "content": "/**\n * The words the data gate answers with. Shared by the server routes that deny\n * (lib/access) and the client views that show the denial, so the views can\n * tell a plan gate apart from a real error and offer a way in instead.\n */\n\nexport const GATE_SIGN_IN = \"Sign in to browse this data.\";\nexport const GATE_UPGRADE = \"This data is not available to browse yet.\";\n\n/** Where a gated visitor goes next. */\nexport const GATE_SIGN_IN_HREF = \"/sign-in?redirect_url=\";\nexport const GATE_PLANS_HREF = \"/sign-up\";\n\nexport type GateReason = \"sign-in\" | \"upgrade\";\n\n/** The gate reason a message carries, or null for any other error. */\nexport function gateReason(message: string | null | undefined): GateReason | null {\n  if (message === GATE_SIGN_IN) return \"sign-in\";\n  if (message === GATE_UPGRADE) return \"upgrade\";\n  return null;\n}\n",
      "type": "registry:lib",
      "target": "lib/pokerdata/explorer/gate-copy.ts"
    },
    {
      "path": "src/lib/pokerdata/explorer/house.ts",
      "content": "// House-solver bundle adapter — the range explorer's \"mtt9\" game mode.\n//\n// The tournament packs aren't MonkerSolver exports served by pokerstudy.ai's\n// ranges API; they're gzipped JSON strategy bundles from the in-house GPU\n// solver (repo issue #296). The bundles are small enough to ship decompressed\n// as public preview assets, so this adapter fetches\n// `/previews/NLH-MTT-9max-{stack}bb.json` once and answers the explorer's\n// manifest/node queries from it — shaped exactly like the NL ranges API's\n// responses so the 13×13 grid and tree walk render identically.\n\nimport { explorerEndpoints } from \"./endpoints\";\nimport type {\n  ActionKind,\n  Manifest,\n  NodeAction,\n  NodeResponse,\n  Position,\n} from \"./preflop\";\n\n// ─── Bundle format (pokerstudy-house-preflop-v0) ───────────────────────────\n\n/** One decision node in a house bundle. */\nexport interface HouseNode {\n  id: number;\n  /** Seat index into HouseBundle.positions. */\n  actor: number;\n  /** \"/\"-joined action labels from the root (\"\" = root, e.g. \"F/R220/AI\"). */\n  path: string;\n  /** Action labels offered at this node (\"F\", \"C\", \"X\", \"R220\", \"AI\"). */\n  labels: string[];\n  reach_ssum: number;\n  /** [169 hand classes] × [labels.length] action frequencies. */\n  strategy: number[][];\n}\n\nexport interface HouseBundle {\n  format: string;\n  game: string;\n  stack_bb: number;\n  /** Seat names in act order, indexed by HouseNode.actor. */\n  positions: string[];\n  nodes: HouseNode[];\n}\n\n// ─── 169-class indexing ────────────────────────────────────────────────────\n// Bundle strategy rows are indexed by 169-class id: pairs hi·13+hi, suited\n// hi·13+lo, offsuit lo·13+hi, with rank 2 = 0 … A = 12. Precompute id → the\n// explorer's hand key (\"AA\", \"AKs\", \"AKo\" — higher rank written first, the\n// same keys range-model.ts's ALL_HANDS uses), so the grid renders these\n// weights exactly like the NL ranges API's.\n\nconst RANKS_ASC = \"23456789TJQKA\";\n\nconst HAND_BY_CLASS: string[] = (() => {\n  const hands = new Array<string>(169);\n  for (let hi = 0; hi < 13; hi++) {\n    for (let lo = 0; lo <= hi; lo++) {\n      const h = RANKS_ASC[hi];\n      const l = RANKS_ASC[lo];\n      if (hi === lo) hands[hi * 13 + hi] = `${h}${h}`;\n      else {\n        hands[hi * 13 + lo] = `${h}${l}s`;\n        hands[lo * 13 + hi] = `${h}${l}o`;\n      }\n    }\n  }\n  return hands;\n})();\n\n// ─── Action labels ─────────────────────────────────────────────────────────\n\n/** House raise token (\"R220\" = raise to 2.20bb) → bb amount, or null. */\nexport function houseRaiseToBb(token: string): number | null {\n  const m = token.match(/^[RB](\\d+)$/);\n  return m ? Number(m[1]) / 100 : null;\n}\n\nfunction kindOf(label: string): ActionKind {\n  if (label === \"F\") return \"fold\";\n  if (label === \"C\" || label === \"X\") return \"call\";\n  if (label === \"AI\") return \"allin\";\n  if (houseRaiseToBb(label) !== null) return \"raise\";\n  return \"other\";\n}\n\n// ─── Manifest ──────────────────────────────────────────────────────────────\n\n/** Stacks with a published preview asset under public/previews/. */\nexport const HOUSE_PREVIEW_STACKS = [10, 15, 20, 30, 40, 50, 75, 100];\n\nexport const HOUSE_POSITIONS: Position[] = [\n  \"UTG\",\n  \"UTG1\",\n  \"UTG2\",\n  \"LJ\",\n  \"HJ\",\n  \"CO\",\n  \"BTN\",\n  \"SB\",\n  \"BB\",\n];\n\n/** Static manifest — the preview assets ship with the app, no fetch needed. */\nexport function houseManifest(): Manifest {\n  return {\n    game: \"mtt9\",\n    generatedAt: \"\",\n    stacks: HOUSE_PREVIEW_STACKS,\n    positions: HOUSE_POSITIONS,\n  };\n}\n\n// ─── Node lookup ───────────────────────────────────────────────────────────\n\n/** Explorer history (\"UTG_F_UTG1_AI_BB\") → bundle path (\"F/AI\"). */\nexport function historyToPath(history: string): string {\n  const tokens = history.split(\"_\");\n  const labels: string[] = [];\n  for (let i = 1; i < tokens.length; i += 2) labels.push(tokens[i]);\n  return labels.join(\"/\");\n}\n\nconst nodeIndexes = new WeakMap<HouseBundle, Map<string, HouseNode>>();\n\nfunction nodeIndex(bundle: HouseBundle): Map<string, HouseNode> {\n  let index = nodeIndexes.get(bundle);\n  if (!index) {\n    index = new Map(bundle.nodes.map((n) => [n.path, n]));\n    nodeIndexes.set(bundle, index);\n  }\n  return index;\n}\n\n/**\n * The bundle's decision node at `history` as a ranges-API-shaped NodeResponse,\n * or null when the bundle has no node there — i.e. the betting round closed\n * on this line (the adapter's equivalent of the ranges API's 404).\n */\nexport function houseNodeFromBundle(\n  bundle: HouseBundle,\n  stack: number,\n  history: string,\n): NodeResponse | null {\n  const node = nodeIndex(bundle).get(historyToPath(history));\n  if (!node) return null;\n\n  const actions: NodeAction[] = node.labels.map((label, a) => {\n    const weights: Record<string, number> = {};\n    for (let c = 0; c < node.strategy.length; c++) {\n      const w = node.strategy[c][a];\n      if (w > 0) weights[HAND_BY_CLASS[c]] = w;\n    }\n    const action: NodeAction = { action: label, kind: kindOf(label), weights };\n    // Multiple raise sizes at one node sort ascending in the display order.\n    const bb = houseRaiseToBb(label);\n    if (bb !== null) action.sizeCode = Math.round(bb * 100);\n    return action;\n  });\n\n  return {\n    game: \"mtt9\",\n    stack,\n    history,\n    actor: bundle.positions[node.actor] as Position,\n    actions,\n  };\n}\n\n// ─── Preview-asset client ──────────────────────────────────────────────────\n\nconst bundleCache = new Map<number, Promise<HouseBundle>>();\n\nexport function loadHouseBundle(stack: number): Promise<HouseBundle> {\n  const hit = bundleCache.get(stack);\n  if (hit) return hit;\n  const promise = (async () => {\n    const res = await fetch(`${explorerEndpoints().previews}/NLH-MTT-9max-${stack}bb.json`, {\n      headers: { Accept: \"application/json\" },\n    });\n    if (!res.ok) {\n      throw new Error(`GET house preview ${stack}bb -> ${res.status}`);\n    }\n    return (await res.json()) as HouseBundle;\n  })();\n  bundleCache.set(stack, promise);\n  promise.catch(() => bundleCache.delete(stack));\n  return promise;\n}\n\nexport async function getHouseNodeOrNull(\n  stack: number,\n  history: string,\n): Promise<NodeResponse | null> {\n  return houseNodeFromBundle(await loadHouseBundle(stack), stack, history);\n}\n",
      "type": "registry:lib",
      "target": "lib/pokerdata/explorer/house.ts"
    },
    {
      "path": "src/lib/pokerdata/explorer/icm-pressure.ts",
      "content": "/**\n * Bubble factor and risk premium for the 8-max ICM packs, from each spot's\n * stacks and payouts — the same ICM model the solves used, so these numbers\n * agree with the ranges.\n *\n * The model is solver-engine's `IcmModel` (src/icm.rs): exact\n * Malmuth-Harville over the eight table seats plus the off-table players,\n * who all sit at the field average. A player who busts takes the bottom\n * place among the players left. Payouts and field come from the solve\n * configs (solver-engine bench/icm/prod-gtow for the 33-left tables, the\n * HydraSolver 35-left benchmark for the older pack).\n *\n * Bubble factor (hero vs villain): hero and villain get all-in for the\n * smaller of their two starting stacks, everyone else unchanged. It is the\n * prize equity hero gives up by losing, divided by what hero gains by\n * winning. Risk premium is what that costs in a stack-for-stack flip with no\n * dead money: a call needs BF / (1 + BF) equity instead of 50%, and the risk\n * premium is the difference.\n */\n\nconst EPS = 1e-9;\n\nexport type IcmStructure = {\n  /** Prize per remaining place, 1st first, in dollars. */\n  payouts: number[];\n  playersLeft: number;\n  /** Stack of every off-table player, in bb. */\n  fieldAvgBb: number;\n  /** Where the numbers come from, for the page's footnote. */\n  source: string;\n};\n\n/** GTO Wizard's MTT 200-player payout table, 33 left, 30 paid: $40,000 still to pay. */\nconst GTOW_33_LEFT = [\n  7656, 5668, 4196, 3204, 2456, 1924, 1484, 1140, 888, 748, 748, 676, 676, 596, 596, 596, 596, 512,\n  512, 512, 512, 512, 512, 440, 440, 440, 440, 440, 440, 440,\n];\n\n/** The HydraSolver 35-left benchmark: 200 players, 30 paid, $50,000 still to pay. */\nconst HYDRA_35_LEFT = [\n  8839, 5148, 3752, 2998, 2519, 2185, 1937, 1746, 1592, 1156, 1156, 1156, 1156, 1156, 1156, 1156,\n  1156, 1156, 740, 740, 740, 740, 740, 740, 740, 740, 740, 740, 740, 740,\n];\n\n/**\n * The payout table the ICM packs on sale were solved with, for the game\n * page's \"View payouts\" dialog: the near-the-bubble tables pay all 30\n * places, the final table the first 8 of the same list.\n */\nexport const ICM_PAYOUT_TABLE = {\n  payouts: GTOW_33_LEFT,\n  fieldSize: 200,\n  paid: 30,\n  source: \"GTO Wizard's 200-player MTT payout table\",\n} as const;\n\n/**\n * The payouts and field an ICM scenario id (`33left-avg30-short-btn`) was\n * solved with, or null for a stage we have no solve config for.\n */\nexport function icmStructureForScenario(id: string): IcmStructure | null {\n  const m = /^(\\d+)left-avg(\\d+)-/.exec(id);\n  if (!m) return null;\n  const playersLeft = Number(m[1]);\n  const fieldAvgBb = Number(m[2]);\n  if (playersLeft === 33) {\n    return {\n      payouts: GTOW_33_LEFT,\n      playersLeft,\n      fieldAvgBb,\n      source: \"GTO Wizard's 200-player payout table, 30 paid, $40,000 left in the pool\",\n    };\n  }\n  if (playersLeft === 8) {\n    return {\n      payouts: GTOW_33_LEFT.slice(0, 8),\n      playersLeft,\n      fieldAvgBb,\n      source: \"GTO Wizard's 200-player payout table, final 8 places, $27,728 left in the pool\",\n    };\n  }\n  if (playersLeft === 35) {\n    return {\n      payouts: HYDRA_35_LEFT,\n      playersLeft,\n      fieldAvgBb,\n      source: \"a 200-player field, 30 paid, $50,000 left in the pool\",\n    };\n  }\n  return null;\n}\n\n/**\n * Each table seat's prize equity, in the payouts' unit. `stacks` are the\n * table's stacks after the hand, `start` before it (only used to order\n * players who bust in the same hand). The `fieldPlayers` off-table players\n * all have `fieldStack`. Port of solver-engine's `IcmModel::equities_now`\n * for one field group.\n */\nexport function icmEquities(\n  stacks: number[],\n  start: number[],\n  payouts: number[],\n  fieldPlayers: number,\n  fieldStack: number,\n): number[] {\n  const n = stacks.length;\n  const m = fieldStack > 0 ? fieldPlayers : 0;\n  const prize = (k: number) => payouts[k] ?? 0;\n  const eq = new Array<number>(n).fill(0);\n\n  const live = stacks.flatMap((s, i) => (s > EPS ? [i] : []));\n  const busted = stacks.flatMap((s, i) => (s > EPS ? [] : [i]));\n  const livePlaces = n + m - busted.length;\n  const total = live.reduce((t, i) => t + stacks[i], 0) + m * fieldStack;\n\n  // prob[mask * (m + 1) + j]: the live table seats in `mask` and j field\n  // players have taken the first places, in some order.\n  const ln = live.length;\n  const radix = m + 1;\n  const prob = new Float64Array((1 << ln) * radix);\n  prob[0] = 1;\n  const maskSum = new Float64Array(1 << ln);\n  const popcount = new Uint8Array(1 << ln);\n  for (let mask = 1; mask < 1 << ln; mask++) {\n    const low = 31 - Math.clz32(mask & -mask);\n    maskSum[mask] = maskSum[mask & (mask - 1)] + stacks[live[low]];\n    popcount[mask] = popcount[mask & (mask - 1)] + 1;\n  }\n  const byCount: number[][] = Array.from({ length: ln + 1 }, () => []);\n  for (let mask = 0; mask < 1 << ln; mask++) byCount[popcount[mask]].push(mask);\n\n  const paidLive = Math.min(livePlaces, payouts.length);\n  for (let placed = 0; placed < paidLive; placed++) {\n    const pay = prize(placed);\n    for (let k = Math.max(0, placed - m); k <= Math.min(placed, ln); k++) {\n      const j = placed - k;\n      for (const mask of byCount[k]) {\n        const p = prob[mask * radix + j];\n        if (p === 0) continue;\n        const rest = total - maskSum[mask] - j * fieldStack;\n        if (rest <= EPS) continue;\n        for (let bit = 0; bit < ln; bit++) {\n          if (mask & (1 << bit)) continue;\n          const seat = live[bit];\n          const q = (p * stacks[seat]) / rest;\n          eq[seat] += q * pay;\n          prob[(mask | (1 << bit)) * radix + j] += q;\n        }\n        if (j < m) prob[mask * radix + j + 1] += (p * (m - j) * fieldStack) / rest;\n      }\n    }\n  }\n\n  // Busted players take the places from livePlaces down, bigger starting\n  // stack first; equal starting stacks split their places.\n  const order = [...busted].sort((x, y) => start[y] - start[x]);\n  let place = livePlaces;\n  for (let g = 0; g < order.length; ) {\n    let h = g + 1;\n    while (h < order.length && Math.abs(start[order[h]] - start[order[g]]) <= EPS) h++;\n    let share = 0;\n    for (let k = place; k < place + (h - g); k++) share += prize(k);\n    share /= h - g;\n    for (const seat of order.slice(g, h)) eq[seat] = share;\n    place += h - g;\n    g = h;\n  }\n  return eq;\n}\n\nexport type IcmPressure = {\n  /** bubbleFactor[hero][villain]; null on the diagonal. */\n  bubbleFactor: (number | null)[][];\n  /** Extra equity over 50% a stack-for-stack call needs, as a fraction; null on the diagonal. */\n  riskPremium: (number | null)[][];\n};\n\n/** Bubble factor and risk premium for every hero, villain pair at a table. */\nexport function icmPressure(stacks: number[], s: IcmStructure): IcmPressure {\n  const fieldPlayers = s.playersLeft - stacks.length;\n  const equity = (after: number[]) =>\n    icmEquities(after, stacks, s.payouts, fieldPlayers, s.fieldAvgBb);\n  const now = equity(stacks);\n  const bubbleFactor = stacks.map((_, hero) =>\n    stacks.map((_, villain) => {\n      if (hero === villain) return null;\n      const x = Math.min(stacks[hero], stacks[villain]);\n      const swing = (sign: number) => {\n        const after = [...stacks];\n        after[hero] += sign * x;\n        after[villain] -= sign * x;\n        return equity(after)[hero];\n      };\n      const gain = swing(1) - now[hero];\n      const loss = now[hero] - swing(-1);\n      return loss / gain;\n    }),\n  );\n  const riskPremium = bubbleFactor.map((row) =>\n    row.map((bf) => (bf === null ? null : bf / (1 + bf) - 0.5)),\n  );\n  return { bubbleFactor, riskPremium };\n}\n\nexport type StackSwing = {\n  /** Chips that change hands, in bb. */\n  bb: number;\n  /** Hero's prize equity change, in dollars, after winning `bb` from villain. */\n  win: number;\n  /** The same after losing `bb` to villain (negative). */\n  lose: number;\n  /** `win + lose`: what an even-money flip for `bb` costs hero (negative under ICM). */\n  premium: number;\n};\n\n/**\n * Hero's $EV change as chips move between hero and villain, from 0 up to the\n * smaller of their two starting stacks, everyone else unchanged — the\n * curve behind the bubble factor (its last point is the all-in one).\n */\nexport function icmStackSwings(\n  stacks: number[],\n  s: IcmStructure,\n  hero: number,\n  villain: number,\n  steps = 40,\n): StackSwing[] {\n  const fieldPlayers = s.playersLeft - stacks.length;\n  const equity = (after: number[]) =>\n    icmEquities(after, stacks, s.payouts, fieldPlayers, s.fieldAvgBb)[hero];\n  const now = equity(stacks);\n  const cover = Math.min(stacks[hero], stacks[villain]);\n  return Array.from({ length: steps + 1 }, (_, i) => {\n    const bb = (cover * i) / steps;\n    const moved = (sign: number) => {\n      const after = [...stacks];\n      after[hero] += sign * bb;\n      after[villain] -= sign * bb;\n      return equity(after) - now;\n    };\n    const win = moved(1);\n    const lose = moved(-1);\n    return { bb, win, lose, premium: win + lose };\n  });\n}\n",
      "type": "registry:lib",
      "target": "lib/pokerdata/explorer/icm-pressure.ts"
    },
    {
      "path": "src/lib/pokerdata/explorer/plo-filter.ts",
      "content": "/**\n * PLO hand-property filtering for the range explorer — ported verbatim from\n * mobile/src/lib/trainer/plo-filter.ts.\n *\n * Filter the 16,432 starting hands by suitedness, connectedness (rundown /\n * gaps), pairing, and card height. Properties are derived from a canonical\n * MonkerSolver string (see plo-hand.ts) so they line up exactly with the hands\n * in a node's weights.\n */\n\nimport { parseHandString, type Rank, rankIndex } from \"./plo-hand\";\n\nexport type Suitedness = \"Double Suited\" | \"Single Suited\" | \"Rainbow\";\nexport type Pairing = \"Quads\" | \"Trips\" | \"Two Pair\" | \"One Pair\" | \"Unpaired\";\nexport type Connect = \"Rundown\" | \"One Gap\" | \"Two Gap\" | null;\nexport type Height = \"High\" | \"Low\" | \"High-Low\";\n\nexport interface HandProps {\n  pairing: Pairing;\n  suitedness: Suitedness;\n  /** True when the hand holds a pair of aces (AAxx). */\n  hasAces: boolean;\n  /** Connectedness, only defined for four distinct ranks (else null). */\n  connect: Connect;\n  height: Height;\n}\n\n// Higher rank → higher value. A=12 … 2=0. (RANKS is 'AKQJT98765432'.)\nfunction rankValue(r: Rank): number {\n  return 12 - rankIndex(r);\n}\n\n/** Sum of interior gaps spanned by a set of distinct rank values. */\nfunction spanGaps(values: number[]): number {\n  const s = [...values].sort((a, b) => a - b);\n  return s[s.length - 1] - s[0] - (s.length - 1);\n}\n\nfunction connectedness(ranks: Rank[]): Connect {\n  const distinct = [...new Set(ranks)];\n  if (distinct.length !== 4) return null;\n  const base = distinct.map(rankValue);\n  let gaps = spanGaps(base);\n  // An ace also plays low for wheel-ish hands (A-2-3-4); take the tighter span.\n  if (distinct.includes(\"A\")) {\n    gaps = Math.min(gaps, spanGaps(base.map((v) => (v === 12 ? -1 : v))));\n  }\n  if (gaps === 0) return \"Rundown\";\n  if (gaps === 1) return \"One Gap\";\n  if (gaps === 2) return \"Two Gap\";\n  return null;\n}\n\nfunction heightOf(ranks: Rank[]): Height {\n  // High cards are T,J,Q,K,A (rankIndex 0–4); everything else is low.\n  let high = 0;\n  let low = 0;\n  for (const r of ranks) {\n    if (rankIndex(r) <= 4) high++;\n    else low++;\n  }\n  if (low === 0) return \"High\";\n  if (high === 0) return \"Low\";\n  return \"High-Low\";\n}\n\n/** Derive filterable properties from a canonical PLO hand string. */\nexport function handProps(hand: string): HandProps {\n  const groups = parseHandString(hand);\n  const ranks = groups.flat() as Rank[];\n\n  const counts = new Map<Rank, number>();\n  for (const r of ranks) counts.set(r, (counts.get(r) ?? 0) + 1);\n  const vals = [...counts.values()];\n  const maxOfRank = Math.max(...vals);\n  const pairs = vals.filter((v) => v >= 2).length;\n\n  let pairing: Pairing;\n  if (maxOfRank >= 4) pairing = \"Quads\";\n  else if (maxOfRank === 3) pairing = \"Trips\";\n  else if (pairs >= 2) pairing = \"Two Pair\";\n  else if (pairs === 1) pairing = \"One Pair\";\n  else pairing = \"Unpaired\";\n\n  const suited = groups.filter((g) => g.length >= 2).length;\n  const suitedness: Suitedness =\n    suited >= 2 ? \"Double Suited\" : suited === 1 ? \"Single Suited\" : \"Rainbow\";\n\n  return {\n    pairing,\n    suitedness,\n    hasAces: (counts.get(\"A\") ?? 0) >= 2,\n    connect: connectedness(ranks),\n    height: heightOf(ranks),\n  };\n}\n\nexport type FilterId =\n  | \"ds\"\n  | \"ss\"\n  | \"rainbow\"\n  | \"rundown\"\n  | \"gap1\"\n  | \"gap2\"\n  | \"aces\"\n  | \"twopair\"\n  | \"trips\"\n  | \"unpaired\"\n  | \"high\"\n  | \"low\"\n  | \"highlow\";\n\ninterface ChipDef {\n  id: FilterId;\n  label: string;\n}\n\n/**\n * Chips grouped by dimension. Selecting multiple chips in one group is an OR;\n * selecting across groups is an AND (e.g. Double Suited AND Rundown).\n */\nexport const FILTER_GROUPS: { key: string; chips: ChipDef[] }[] = [\n  {\n    key: \"Suits\",\n    chips: [\n      { id: \"ds\", label: \"Double Suited\" },\n      { id: \"ss\", label: \"Single Suited\" },\n      { id: \"rainbow\", label: \"Rainbow\" },\n    ],\n  },\n  {\n    key: \"Shape\",\n    chips: [\n      { id: \"rundown\", label: \"Rundown\" },\n      { id: \"gap1\", label: \"1-Gap\" },\n      { id: \"gap2\", label: \"2-Gap\" },\n    ],\n  },\n  {\n    key: \"Pairs\",\n    chips: [\n      { id: \"aces\", label: \"Aces\" },\n      { id: \"twopair\", label: \"Two Pair\" },\n      { id: \"trips\", label: \"Trips+\" },\n      { id: \"unpaired\", label: \"Unpaired\" },\n    ],\n  },\n  {\n    key: \"Height\",\n    chips: [\n      { id: \"high\", label: \"High\" },\n      { id: \"low\", label: \"Low\" },\n      { id: \"highlow\", label: \"High-Low\" },\n    ],\n  },\n];\n\nconst PREDICATE: Record<FilterId, (p: HandProps) => boolean> = {\n  ds: (p) => p.suitedness === \"Double Suited\",\n  ss: (p) => p.suitedness === \"Single Suited\",\n  rainbow: (p) => p.suitedness === \"Rainbow\",\n  rundown: (p) => p.connect === \"Rundown\",\n  gap1: (p) => p.connect === \"One Gap\",\n  gap2: (p) => p.connect === \"Two Gap\",\n  aces: (p) => p.hasAces,\n  twopair: (p) => p.pairing === \"Two Pair\",\n  trips: (p) => p.pairing === \"Trips\" || p.pairing === \"Quads\",\n  unpaired: (p) => p.pairing === \"Unpaired\",\n  high: (p) => p.height === \"High\",\n  low: (p) => p.height === \"Low\",\n  highlow: (p) => p.height === \"High-Low\",\n};\n\n/** A hand passes when, for every constrained dimension, it matches ≥1 chip. */\nexport function matchesFilters(p: HandProps, active: Set<FilterId>): boolean {\n  if (active.size === 0) return true;\n  for (const group of FILTER_GROUPS) {\n    const selected = group.chips.filter((c) => active.has(c.id));\n    if (selected.length === 0) continue;\n    if (!selected.some((c) => PREDICATE[c.id](p))) return false;\n  }\n  return true;\n}\n",
      "type": "registry:lib",
      "target": "lib/pokerdata/explorer/plo-filter.ts"
    },
    {
      "path": "src/lib/pokerdata/explorer/plo-hand.ts",
      "content": "/**\n * PLO (4-card) hand-class engine for the range explorer — ported from the\n * mobile app (mobile/src/lib/trainer/plo-hand.ts + hand.ts), viewing subset.\n *\n * MonkerSolver names each starting hand with suit-grouping notation over the\n * 16,432 suit-isomorphism classes — parentheses wrap cards sharing a suit, e.g.\n *\n *     AAAA       four aces (each a different suit)\n *     (2A)AA     A-A-A-2 with the 2 suited to one ace\n *     (KA)(KA)   AAKK double-suited\n *     AKQJ       four broadways, all different suits (rainbow)\n */\n\nexport const RANKS = \"AKQJT98765432\";\nexport const SUITS = [\"c\", \"d\", \"h\", \"s\"] as const;\n\nexport type Suit = (typeof SUITS)[number];\nexport type Rank = (typeof RANKS)[number];\nexport type Card = `${Rank}${Suit}`;\n\nexport function rankIndex(r: Rank): number {\n  return RANKS.indexOf(r);\n}\n\n/**\n * Four-color deck: a distinct, vivid color per suit so suits read at a glance\n * (used as a solid card-tile background with white text). Spades use a slate\n * rather than near-black so the tile stays visible on the dark background.\n */\nexport const SUIT_COLOR_4: Record<Suit, string> = {\n  s: \"#39414f\", // spades — slate\n  h: \"#FF3366\", // hearts — red\n  d: \"#20A4F3\", // diamonds — blue\n  c: \"#1f9d57\", // clubs — green\n};\n\nexport function suitGlyph(suit: Suit): string {\n  switch (suit) {\n    case \"s\":\n      return \"♠\";\n    case \"h\":\n      return \"♥\";\n    case \"d\":\n      return \"♦\";\n    case \"c\":\n      return \"♣\";\n  }\n}\n\nexport type HandCategory =\n  | \"Unpaired Double Suited\"\n  | \"Unpaired Single Suited\"\n  | \"Unpaired Rainbow\"\n  | \"One Pair Double Suited\"\n  | \"One Pair Single Suited\"\n  | \"One Pair Rainbow\"\n  | \"Two Pair Double Suited\"\n  | \"Two Pair Single Suited\"\n  | \"Two Pair Rainbow\"\n  | \"Trips Single Suited\"\n  | \"Trips Rainbow\"\n  | \"Quads Rainbow\";\n\n/** A group of ranks sharing one suit, sorted ascending by rank. */\nexport type Group = Rank[];\n\n/** Parse a canonical string into suit groups (parens = same-suit group). */\nexport function parseHandString(s: string): Group[] {\n  const groups: Group[] = [];\n  let i = 0;\n  while (i < s.length) {\n    if (s[i] === \"(\") {\n      const j = s.indexOf(\")\", i);\n      const ranks = s.slice(i + 1, j).split(\"\") as Rank[];\n      groups.push(sortRanks(ranks));\n      i = j + 1;\n    } else {\n      groups.push([s[i] as Rank]);\n      i += 1;\n    }\n  }\n  return groups;\n}\n\nfunction sortRanks(ranks: Rank[]): Rank[] {\n  return [...ranks].sort((a, b) => rankIndex(a) - rankIndex(b));\n}\n\n/** Classify a parsed hand into the viewer's category buckets. */\nexport function categorizeGroups(groups: Group[]): HandCategory {\n  const counts = new Map<Rank, number>();\n  for (const g of groups) for (const r of g) counts.set(r, (counts.get(r) ?? 0) + 1);\n  const values = [...counts.values()];\n  const pairs = values.filter((v) => v >= 2).length;\n  const maxOfRank = Math.max(...values);\n\n  let pairing: string;\n  if (maxOfRank >= 4) pairing = \"Quads\";\n  else if (maxOfRank === 3) pairing = \"Trips\";\n  else if (pairs >= 2) pairing = \"Two Pair\";\n  else if (pairs === 1) pairing = \"One Pair\";\n  else pairing = \"Unpaired\";\n\n  const suited = groups.filter((g) => g.length >= 2).length;\n  const suitedness =\n    suited >= 2 ? \"Double Suited\" : suited === 1 ? \"Single Suited\" : \"Rainbow\";\n\n  return `${pairing} ${suitedness}` as HandCategory;\n}\n\n/** Classify a canonical hand string. */\nexport function categorizeHand(hand: string): HandCategory {\n  return categorizeGroups(parseHandString(hand));\n}\n\n/**\n * Expand a canonical string into 4 concrete display cards, assigning a distinct\n * suit per group so the parenthesised cards visibly share a suit. Deterministic\n * (for stable rendering); suits are cosmetic since ranges are suit-isomorphic.\n */\nexport function ploStringToCards(hand: string): Card[] {\n  const groups = parseHandString(hand);\n  const cards: Card[] = [];\n  groups.forEach((g, gi) => {\n    const suit = SUITS[gi % SUITS.length];\n    for (const r of g) cards.push(`${r}${suit}` as Card);\n  });\n  return cards;\n}\n",
      "type": "registry:lib",
      "target": "lib/pokerdata/explorer/plo-hand.ts"
    },
    {
      "path": "src/lib/pokerdata/explorer/preflop.ts",
      "content": "// Preflop-tree walk + ranges-API client for the range explorer.\n//\n// Ported from pokerstudy.ai's Explorer (web/src/lib/solver/preflop.ts), minus\n// the postflop-solve plumbing — this app only previews ranges. Data comes\n// straight from pokerstudy.ai's public NL ranges API (CORS-open, CDN-cached),\n// which serves the same MonkerSolver solves the NLHE packs are exported from —\n// so a buyer previews exactly what's in the download, this app serves no range\n// bytes itself, and the paid `/api/v1/ranges` proxy stays the only ranges API\n// on this origin. The house-solved tournament packs (\"mtt9\") are the\n// exception: their JSON bundles ship as public preview assets and house.ts\n// adapts them to the same manifest/node contract.\n\nimport { GATE_SIGN_IN, GATE_UPGRADE } from \"@/lib/pokerdata/explorer/gate-copy\";\nimport { explorerEndpoints } from \"@/lib/pokerdata/explorer/endpoints\";\nimport {\n  getHouseNodeOrNull,\n  houseManifest,\n  houseRaiseToBb,\n  HOUSE_POSITIONS,\n} from \"./house\";\n\nexport type Game =\n  | \"nl\"\n  | \"nl9\"\n  | \"plo\"\n  | \"plo9\"\n  | \"plo5\"\n  | \"mtt9\"\n  | \"nlmtt8\"\n  | \"nlh8-hydra\"\n  | \"nlh8-icm\"\n  | \"nlhu\";\nexport type Position =\n  | \"UTG\"\n  | \"UTG1\"\n  | \"UTG2\"\n  | \"LJ\"\n  | \"MP\"\n  | \"HJ\"\n  | \"CO\"\n  | \"BTN\"\n  | \"SB\"\n  | \"BB\";\nexport type ActionKind = \"fold\" | \"call\" | \"raise\" | \"allin\" | \"other\";\n\nexport interface NodeAction {\n  action: string;\n  kind: ActionKind;\n  /** hand → frequency in [0, 1] for this action. */\n  weights: Record<string, number>;\n  /**\n   * hand → EV for this action (when the bundle carries EV): small blinds,\n   * except tournament dollars for the ICM game (`evUnit(game)`).\n   */\n  evs?: Record<string, number>;\n  /** NL: raise size as % of pot. */\n  sizePct?: number;\n  /** NL: weighted concrete-combo count. */\n  combos?: number;\n  /** PLO: betting level of the raise (1 = open, 2 = 3-bet, …). */\n  raiseDepth?: number;\n  /** PLO: opaque MonkerSolver size id when a node offers >1 raise size. */\n  sizeCode?: number;\n  /** PLO: count of hand classes taking this action. */\n  hands?: number;\n  /**\n   * PLO5: reach-weighted share of the range taking this action, 0–100. Its\n   * nodes carry no per-hand `weights` (204k hand classes); the hand list comes\n   * from `getPlo5NodeList` instead.\n   */\n  poolPct?: number;\n  /**\n   * Raise-to in bb, as the feed decodes it from the solver's token (NL, MTT).\n   * All-ins carry the solved stack depth. Absent for PLO, where the walk\n   * derives the pot-limit size itself.\n   */\n  sizeBb?: number | null;\n  /** NL: the raise as a percent of the pot after calling, when it is one. */\n  sizePctPot?: number | null;\n}\n\nexport interface NodeResponse {\n  game: Game;\n  stack: number;\n  history: string;\n  actor: Position;\n  actions: NodeAction[];\n  continuingCombos?: number;\n  continuingHands?: number;\n  /** Unit of `evs` as the feed states it (\"sb\", or \"usd\" for ICM). */\n  evUnit?: string;\n}\n\nexport interface Manifest {\n  game: Game;\n  generatedAt: string;\n  stacks: number[];\n  positions: Position[];\n}\n\nexport interface SeatState {\n  position: Position;\n  action: string | null;\n  committed: number;\n  remaining: number;\n  folded: boolean;\n  isHero: boolean;\n  isDealer: boolean;\n  /** Blind posted before any action (0.5 SB, 1 BB, else 0). */\n  blind: number;\n  /** Ante this seat paid from its stack (BB ante, or every seat's ante). */\n  ante: number;\n  /** Raise-to of this seat's last raise, when it raised. */\n  raiseTo?: number;\n}\n\nexport interface ParsedTable {\n  seats: Record<Position, SeatState>;\n  pot: number;\n  /**\n   * The seat to act, or null when the history ends on an action — i.e. the\n   * line is closed (everyone folded to one player, or the round is complete).\n   */\n  hero: Position | null;\n  /** The bet to match right now (the largest commitment). */\n  toCall: number;\n  /** Antes in the pot, from seats' stacks and dead (PLO5). */\n  antes: number;\n}\n\n// Preflop play order (CW from UTG) per game. MP isn't present in the 6-max\n// datasets; the 9-max orders match the plo9 dataset's / house bundles' seat\n// naming.\nexport const SEAT_ORDER: Record<Game, Position[]> = {\n  nl: [\"UTG\", \"HJ\", \"CO\", \"BTN\", \"SB\", \"BB\"],\n  nl9: [\"UTG\", \"UTG1\", \"UTG2\", \"LJ\", \"HJ\", \"CO\", \"BTN\", \"SB\", \"BB\"],\n  plo: [\"UTG\", \"HJ\", \"CO\", \"BTN\", \"SB\", \"BB\"],\n  plo9: [\"UTG\", \"UTG1\", \"UTG2\", \"LJ\", \"HJ\", \"CO\", \"BTN\", \"SB\", \"BB\"],\n  // HydraSolver PLO5: the first seat is LJ (a HUD's UTG).\n  plo5: [\"LJ\", \"HJ\", \"CO\", \"BTN\", \"SB\", \"BB\"],\n  mtt9: HOUSE_POSITIONS,\n  nlmtt8: [\"UTG\", \"UTG1\", \"LJ\", \"HJ\", \"CO\", \"BTN\", \"SB\", \"BB\"],\n  \"nlh8-hydra\": [\"UTG\", \"UTG1\", \"LJ\", \"HJ\", \"CO\", \"BTN\", \"SB\", \"BB\"],\n  \"nlh8-icm\": [\"UTG\", \"UTG1\", \"LJ\", \"HJ\", \"CO\", \"BTN\", \"SB\", \"BB\"],\n  // Heads-up: the SB is the button and acts first preflop.\n  nlhu: [\"SB\", \"BB\"],\n};\n\n/**\n * Unit of a node's `evs`: small blinds, or for the ICM scenarios tournament\n * dollars (the change in prize equity, each hand's fold at 0).\n */\nexport function evUnit(game: Game): \"sb\" | \"usd\" {\n  return game === \"nlh8-icm\" ? \"usd\" : \"sb\";\n}\n\nexport const ROOT_HISTORY = \"UTG\";\n\n/** The first seat to act, which is where a walk starts: UTG, or LJ for PLO5. */\nexport function rootHistory(game: Game): string {\n  return SEAT_ORDER[game][0];\n}\n\n// ─── Ranges data client ────────────────────────────────────────────────────\n// NL and PLO 6-max read our same-origin explorer feed (/api/explorer/ranges),\n// which asserts the visitor's plan to pokerstudy.ai's plan-gated ranges API;\n// a 401/403 means sign in / upgrade. PLO 9-max is different: its position bundles are too large for\n// that API's whole-bundle reader (issues #35/#36), so its nodes are published\n// as one small JSON blob per decision node, relayed one at a time by\n// /api/explorer/plo9 so they count against the same Browse rate limit.\n// Same contract either way: 404 at a history ⇒ the betting round closed there.\n\n// Feed origins come from `explorerEndpoints()`: same-origin on pokerdata.io,\n// the host's own feeds elsewhere. The PLO 9-max layout is the per-node blob\n// one seeded by web/scripts/upload-plo9-nodes.ts.\n\n// mtt9 never reaches these URL builders — getManifest/getNodeOrNull answer it\n// from the bundled preview asset (house.ts) before any URL is built.\nconst API_BASE: Record<\n  \"nl\" | \"nl9\" | \"plo\" | \"plo5\" | \"nlmtt8\" | \"nlh8-hydra\" | \"nlh8-icm\" | \"nlhu\",\n  string\n> = {\n  nl: \"/nl/v2\",\n  nl9: \"/nl9\",\n  plo: \"/plo\",\n  plo5: \"/plo5\",\n  nlmtt8: \"/nlmtt8\",\n  \"nlh8-hydra\": \"/nlh8-hydra\",\n  \"nlh8-icm\": \"/nlh8-icm\",\n  nlhu: \"/nlhu\",\n};\n\nfunction manifestUrl(game: Exclude<Game, \"mtt9\">): string {\n  const { plo9, ranges } = explorerEndpoints();\n  if (game === \"plo9\") return `${plo9}/manifest.json`;\n  return `${ranges}${API_BASE[game]}`;\n}\n\nfunction nodeUrl(\n  game: Exclude<Game, \"mtt9\">,\n  stack: number,\n  history: string,\n  scenario?: string,\n): string {\n  const { plo9, ranges } = explorerEndpoints();\n  if (game === \"plo9\") return `${plo9}/${stack}/${history}.json`;\n  // An ICM depth can hold several stages, so name the scenario when we have it.\n  const params = new URLSearchParams(\n    scenario ? { scenario, history } : { stack: String(stack), history },\n  );\n  return `${ranges}${API_BASE[game]}/node?${params.toString()}`;\n}\n\nconst cache = new Map<string, Promise<unknown>>();\n\n/** Readable message for the plan gate's answers; null for anything else. */\nfunction gateMessage(status: number): string | null {\n  if (status === 401) return GATE_SIGN_IN;\n  if (status === 403) return GATE_UPGRADE;\n  return null;\n}\n\n/** The Browse rate limit's own words (it says when to come back). */\nasync function rateLimitText(res: Response): Promise<string> {\n  const body = (await res.json().catch(() => null)) as { error?: string } | null;\n  return body?.error ?? \"You're browsing faster than we allow. Try again in a minute.\";\n}\n\nasync function getJson<T>(path: string): Promise<T> {\n  const hit = cache.get(path) as Promise<T> | undefined;\n  if (hit) return hit;\n  const promise = (async () => {\n    const res = await fetch(path, { headers: { Accept: \"application/json\" } });\n    const gated = gateMessage(res.status);\n    if (gated) throw new Error(gated);\n    if (res.status === 429) throw new Error(await rateLimitText(res));\n    if (!res.ok) {\n      const body = await res.text().catch(() => \"\");\n      throw new Error(`GET ${path} -> ${res.status}: ${body.slice(0, 200)}`);\n    }\n    return (await res.json()) as T;\n  })();\n  cache.set(path, promise);\n  promise.catch(() => cache.delete(path));\n  return promise;\n}\n\nexport function getManifest(game: Game): Promise<Manifest> {\n  if (game === \"mtt9\") return Promise.resolve(houseManifest());\n  return getJson<Manifest>(manifestUrl(game));\n}\n\n/**\n * The decision node at `history`, or null when the dataset has no decision\n * there (HTTP 404) — i.e. the preflop betting round has closed on this line.\n * The browser uses this to walk the full multiway tree and detect the end of\n * preflop, rather than assuming where the line stops. Throws on real failures.\n */\nexport async function getNodeOrNull(\n  game: Game,\n  stack: number,\n  history: string,\n  /** ICM scenario id (`35left-avg40-even`); only the nlh8-icm game reads it. */\n  scenario?: string,\n): Promise<NodeResponse | null> {\n  if (game === \"mtt9\") return getHouseNodeOrNull(stack, history);\n  const res = await fetch(nodeUrl(game, stack, history, scenario), {\n    headers: { Accept: \"application/json\" },\n  });\n  if (res.status === 404) return null;\n  const gated = gateMessage(res.status);\n  if (gated) throw new Error(gated);\n  if (res.status === 429) throw new Error(await rateLimitText(res));\n  if (!res.ok) {\n    const body = await res.text().catch(() => \"\");\n    throw new Error(`GET node -> ${res.status}: ${body.slice(0, 200)}`);\n  }\n  const node = (await res.json()) as NodeResponse;\n  // PLO5 nodes send pool % instead of per-hand weights.\n  return {\n    ...node,\n    actions: node.actions.map((a) => ({ ...a, weights: a.weights ?? {} })),\n  };\n}\n\n// ─── PLO5 hand lists ───────────────────────────────────────────────────────\n// A PLO5 node has 204k hand classes, so the node itself carries only each\n// action's pool %. `list=1` asks for each action's top hands by EV, and\n// `hand=` looks up one five-card combo. Same feed, same Browse rate limit.\n\nexport interface Plo5ListedHand {\n  /** Ten characters, ranks then suits: `2s2h2d2c3s`. */\n  hand: string;\n  /** Frequency of this action, 0–1. */\n  freq: number;\n  /** EV of this action in small blinds. */\n  ev: number | null;\n}\n\nexport interface Plo5ActionList {\n  action: string;\n  listed?: Plo5ListedHand[];\n  /** Hands that take the action at all (`listed` is the top slice). */\n  listedTotal?: number;\n}\n\nexport interface Plo5HandStrategy {\n  hand: string;\n  /** The stored spelling the lookup landed on (a suit relabeling of `hand`). */\n  hdvHand: string;\n  isoMapped: boolean;\n  actions: { action: string; kind: ActionKind; freq: number; ev_sb: number | null }[];\n}\n\nasync function getPlo5(params: URLSearchParams): Promise<unknown> {\n  const res = await fetch(`${explorerEndpoints().ranges}/plo5/node?${params.toString()}`, {\n    headers: { Accept: \"application/json\" },\n  });\n  const gated = gateMessage(res.status);\n  if (gated) throw new Error(gated);\n  if (res.status === 429) throw new Error(await rateLimitText(res));\n  const body = (await res.json().catch(() => null)) as { error?: string } | null;\n  if (!res.ok) throw new Error(body?.error ?? `GET plo5 node -> ${res.status}`);\n  return body;\n}\n\n/** Each action's top `limit` hands by EV; `ranks` (`AK222`) filters them. */\nexport async function getPlo5NodeList(\n  stack: number,\n  history: string,\n  limit: number,\n  ranks?: string,\n): Promise<Plo5ActionList[]> {\n  const params = new URLSearchParams({\n    stack: String(stack),\n    history,\n    list: \"1\",\n    limit: String(limit),\n  });\n  if (ranks) params.set(\"ranks\", ranks);\n  const body = (await getPlo5(params)) as { actions?: Plo5ActionList[] } | null;\n  return body?.actions ?? [];\n}\n\n/** One five-card combo's strategy at a node. */\nexport async function getPlo5HandStrategy(\n  stack: number,\n  history: string,\n  hand: string,\n): Promise<Plo5HandStrategy> {\n  const params = new URLSearchParams({ stack: String(stack), history, hand });\n  return (await getPlo5(params)) as Plo5HandStrategy;\n}\n\n// ─── History parsing (mirror of web/src/lib/solver/preflop.ts) ─────────────\n//\n// NL action tokens: Fold, Call, AI, \"<pct>%\" (raise to toCall + pct·pot).\n// PLO action tokens (MonkerSolver depth tags): Fold, Call, Open, 3B…8B, each\n// optionally size-disambiguated as \"Open-<code>\". Every PLO raise in these\n// packs is pot-sized (capped by stack): raise-to = 2·toCall + pot − committed.\n// House (mtt9) tokens are the bundle's own labels: F, C, X, AI and\n// R<bb·100> (\"R220\" = raise to 2.2bb).\n\nconst PLO_RAISE_RE = /^(Open|3B|4B|5B|6B|7B|8B)(?:-(\\d+))?$/;\n\n/** HydraSolver PLO5 raise: `Raise-4.7x` is a raise to 4.7bb. */\nconst PLO5_RAISE_RE = /^Raise-(\\d+(?:\\.\\d+)?)x$/;\n\n/** Raise-to in bb of a PLO5 raise token, or null. */\nexport function plo5RaiseToBb(token: string): number | null {\n  const m = PLO5_RAISE_RE.exec(token);\n  return m ? parseFloat(m[1]) : null;\n}\n\n/** PLO depth-tag → human label (\"Open\" → \"Pot\", \"3B\" → \"3-Bet\", …). */\nconst PLO_RAISE_LABEL: Record<string, string> = {\n  Open: \"Pot\",\n  \"3B\": \"3-Bet\",\n  \"4B\": \"4-Bet\",\n  \"5B\": \"5-Bet\",\n  \"6B\": \"6-Bet\",\n  \"7B\": \"7-Bet\",\n  \"8B\": \"8-Bet\",\n};\n\nfunction displayAction(token: string): string {\n  if (token === \"Fold\" || token === \"F\") return \"Fold\";\n  if (token === \"Call\" || token === \"C\") return \"Call\";\n  if (token === \"Limp\") return \"Limp\";\n  if (token === \"X\") return \"Check\";\n  if (token === \"AI\" || token === \"AllIn\") return \"All-in\";\n  const plo5 = plo5RaiseToBb(token);\n  if (plo5 !== null) return `Raise ${formatBb(plo5)}`;\n  const plo = token.match(PLO_RAISE_RE);\n  if (plo) return PLO_RAISE_LABEL[plo[1]] ?? token;\n  const houseBb = houseRaiseToBb(token);\n  if (houseBb !== null) return `Raise ${formatBb(houseBb)}`;\n  return token;\n}\n\nfunction sumCommitted(\n  seats: Record<Position, SeatState>,\n  order: Position[],\n): number {\n  let s = 0;\n  for (const p of order) s += seats[p].committed;\n  return s;\n}\n\nexport function parseHistory(\n  history: string,\n  stackBb: number,\n  order: Position[] = SEAT_ORDER.nl,\n  /** Dead big-blind ante (mtt9): posted by the BB, counted in the pot. */\n  anteBb = 0,\n  /** Ante every seat posts (nlh8-hydra, nlh8-icm, nlhu), counted in the pot. */\n  antePerSeatBb = 0,\n  /** Dead ante nobody's stack pays (PLO5's 1.2bb), counted in the pot. */\n  deadAnteBb = 0,\n  /**\n   * Raise-to in bb per line (`\"<history>_<token>\"` → bb), from the feed's\n   * `sizeBb`. Wins over decoding the token, which can't size NL MTT tokens\n   * like `Open`/`3B` or cash's `Min`.\n   */\n  sizes: Record<string, number> = {},\n  /** Per-seat starting stacks when they differ (ICM stages); else every seat has `stackBb`. */\n  seatStacks?: Partial<Record<Position, number>>,\n): ParsedTable {\n  const stackOf = (p: Position) => seatStacks?.[p] ?? stackBb;\n  const seats: Record<Position, SeatState> = Object.create(null);\n  for (const p of order) {\n    seats[p] = {\n      position: p,\n      action: null,\n      committed: 0,\n      remaining: stackOf(p),\n      folded: false,\n      isHero: false,\n      isDealer: p === \"BTN\",\n      blind: p === \"SB\" ? 0.5 : p === \"BB\" ? 1 : 0,\n      ante: (p === \"BB\" ? anteBb : 0) + antePerSeatBb,\n    };\n  }\n  seats.SB.committed = 0.5;\n  seats.BB.committed = 1;\n\n  const tokens = history.split(\"_\");\n  let prevIdx = -1;\n  let toCall = 1;\n\n  for (let i = 0; i + 1 < tokens.length; i += 2) {\n    const pos = tokens[i] as Position;\n    const action = tokens[i + 1];\n    const posIdx = order.indexOf(pos);\n    if (posIdx === -1) continue;\n\n    if (prevIdx >= 0) {\n      let cur = (prevIdx + 1) % order.length;\n      while (cur !== posIdx) {\n        const s = seats[order[cur]];\n        if (!s.folded && s.action === null) {\n          s.folded = true;\n          s.action = \"Fold\";\n        }\n        cur = (cur + 1) % order.length;\n      }\n    }\n\n    const line = tokens.slice(0, i + 2).join(\"_\");\n    const sized = sizes[line];\n    const houseRaise = houseRaiseToBb(action) ?? plo5RaiseToBb(action);\n    seats[pos].action = displayAction(action);\n    if (action === \"Fold\" || action === \"F\") {\n      seats[pos].folded = true;\n    } else if (\n      sized !== undefined &&\n      action !== \"Call\" &&\n      action !== \"C\" &&\n      action !== \"X\" &&\n      action !== \"Limp\"\n    ) {\n      const raiseTo = Math.min(stackOf(pos), sized);\n      seats[pos].committed = raiseTo;\n      seats[pos].raiseTo = raiseTo;\n      if (!(action === \"AI\" || action === \"AllIn\")) {\n        seats[pos].action = `Raise ${formatBbExact(raiseTo)}`;\n      }\n      toCall = Math.max(toCall, raiseTo);\n    } else if (\n      action === \"Call\" ||\n      action === \"C\" ||\n      action === \"X\" ||\n      action === \"Limp\"\n    ) {\n      seats[pos].committed = toCall;\n    } else if (action === \"AI\" || action === \"AllIn\") {\n      const allIn = stackOf(pos) - seats[pos].ante;\n      seats[pos].committed = allIn;\n      seats[pos].raiseTo = allIn;\n      toCall = Math.max(toCall, allIn);\n    } else if (houseRaise !== null) {\n      const raiseTo = Math.min(stackOf(pos), houseRaise);\n      seats[pos].committed = raiseTo;\n      seats[pos].raiseTo = raiseTo;\n      toCall = raiseTo;\n    } else if (action.endsWith(\"%\")) {\n      const potBefore = sumCommitted(seats, order);\n      const raiseTo = toCall + (parseFloat(action) / 100) * potBefore;\n      seats[pos].committed = raiseTo;\n      seats[pos].raiseTo = raiseTo;\n      toCall = raiseTo;\n    } else if (PLO_RAISE_RE.test(action)) {\n      // Pot-limit raise: call, then raise by the pot after the call — i.e.\n      // raise-to = 2·toCall + pot − alreadyCommitted, capped by the stack.\n      const potBefore = sumCommitted(seats, order);\n      const raiseTo = Math.min(\n        stackOf(pos),\n        2 * toCall + potBefore - seats[pos].committed,\n      );\n      seats[pos].committed = raiseTo;\n      seats[pos].raiseTo = raiseTo;\n      toCall = raiseTo;\n    }\n    prevIdx = posIdx;\n  }\n\n  // An odd token count ends on a seat (its decision); an even one ends on an\n  // action, which closed the line.\n  const closed = tokens.length % 2 === 0;\n  const hero = closed ? null : ((tokens[tokens.length - 1] || order[0]) as Position);\n  const heroIdx = hero ? order.indexOf(hero) : -1;\n  if (hero && seats[hero]) seats[hero].isHero = true;\n\n  if (prevIdx >= 0 && heroIdx !== -1) {\n    let cur = (prevIdx + 1) % order.length;\n    while (cur !== heroIdx) {\n      const s = seats[order[cur]];\n      if (!s.folded && s.action === null) {\n        s.folded = true;\n        s.action = \"Fold\";\n      }\n      cur = (cur + 1) % order.length;\n    }\n  }\n\n  for (const p of order) {\n    seats[p].remaining = Math.max(0, stackOf(p) - seats[p].committed - seats[p].ante);\n  }\n\n  const antes = anteBb + antePerSeatBb * order.length + deadAnteBb;\n  return {\n    seats,\n    pot: sumCommitted(seats, order) + antes,\n    hero,\n    toCall: Math.max(...order.map((p) => seats[p].committed)),\n    antes,\n  };\n}\n\n/** The seat that acts after `action` is taken; null if the line terminates. */\nexport function nextActorAfter(\n  history: string,\n  action: string,\n  stackBb: number,\n  order: Position[] = SEAT_ORDER.nl,\n): Position | null {\n  const { seats } = parseHistory(history, stackBb, order);\n  const tokens = history.split(\"_\");\n  const actor = tokens[tokens.length - 1] as Position;\n  const actorIdx = order.indexOf(actor);\n  if (actorIdx === -1) return null;\n\n  const liveAfter = new Set<Position>();\n  for (const p of order) if (!seats[p].folded) liveAfter.add(p);\n  if (action === \"Fold\" || action === \"F\") liveAfter.delete(actor);\n\n  for (let i = 1; i <= order.length; i++) {\n    const cand = order[(actorIdx + i) % order.length];\n    if (liveAfter.has(cand)) return cand;\n  }\n  return null;\n}\n\n// ─── Display helpers ───────────────────────────────────────────────────────\n\nconst KIND_ORDER: Record<string, number> = {\n  fold: 0,\n  call: 1,\n  raise: 2,\n  allin: 3,\n  other: 4,\n};\n\nexport function sortActionsForDisplay(actions: NodeAction[]): NodeAction[] {\n  return [...actions].sort((a, b) => {\n    const ka = KIND_ORDER[a.kind] ?? 99;\n    const kb = KIND_ORDER[b.kind] ?? 99;\n    if (ka !== kb) return ka - kb;\n    // NL raises sort by pot %, PLO5 by raise-to, PLO by depth then size code.\n    return (\n      (a.sizePct ?? 0) - (b.sizePct ?? 0) ||\n      (plo5RaiseToBb(a.action) ?? 0) - (plo5RaiseToBb(b.action) ?? 0) ||\n      (a.raiseDepth ?? 0) - (b.raiseDepth ?? 0) ||\n      (a.sizeCode ?? 0) - (b.sizeCode ?? 0)\n    );\n  });\n}\n\nexport function labelForAction(action: NodeAction, stack: number): string {\n  if (action.kind === \"fold\") return \"Fold\";\n  if (action.kind === \"call\") {\n    if (action.action === \"X\") return \"Check\";\n    if (action.action === \"Limp\") return \"Limp\";\n    return \"Call\";\n  }\n  if (action.kind === \"allin\" || action.action === \"AI\" || action.action === \"AllIn\") return `All-in ${formatBbExact(action.sizeBb ?? stack)}`;\n  // The feed's decoded raise-to beats any token spelling (\"Open\", \"3B\", \"Min\").\n  if (action.sizeBb != null) return `Raise ${formatBbExact(action.sizeBb)}`;\n  if (action.kind === \"raise\") {\n    if (action.action === \"Open\") return \"Open\";\n    const houseBb = houseRaiseToBb(action.action) ?? plo5RaiseToBb(action.action);\n    if (houseBb !== null) return `Raise ${formatBb(houseBb)}`;\n    const plo = action.action.match(PLO_RAISE_RE);\n    if (plo) {\n      const label =\n        (action.raiseDepth ?? 1) === 1\n          ? \"Raise pot\"\n          : `${PLO_RAISE_LABEL[plo[1]] ?? plo[1]} pot`;\n      return plo[2] ? `${label} (${plo[2]})` : label;\n    }\n    return `Raise ${action.action}`;\n  }\n  return action.action;\n}\n\nexport function formatBb(v: number): string {\n  if (v < 100) return v.toFixed(v % 1 === 0 ? 0 : 1);\n  return Math.round(v).toString();\n}\n\n/**\n * Bb cut (never rounded up) to two decimals, trailing zeros dropped: 2.19,\n * 39.875 → 39.87, 40. The epsilon keeps float noise (2.19 → 2.1899…) from\n * cutting a whole cent.\n */\nexport function formatBbExact(v: number): string {\n  return String(Math.trunc(v * 100 + (v < 0 ? -1e-6 : 1e-6)) / 100);\n}\n",
      "type": "registry:lib",
      "target": "lib/pokerdata/explorer/preflop.ts"
    },
    {
      "path": "src/lib/pokerdata/explorer/range-model.ts",
      "content": "// 169-class hand-grid model for the range explorer. A range is a map from\n// hand-class (\"AA\", \"AKs\", \"AKo\", …) to a weight in [0, 1] — the same shape the\n// ranges API returns (NodeAction.weights). Trimmed port of pokerstudy.ai's\n// web/src/lib/solver/range-model.ts (the solve-time serialization is dropped;\n// this app only views ranges).\n\nexport type RangeWeights = Record<string, number>;\n\nconst RANKS = \"AKQJT98765432\";\n\n/**\n * Every 169 hand-class in grid order: suited upper-right, offsuit lower-left,\n * pairs on the diagonal (\"AA\", \"AKs\", …, \"32o\"). Higher rank is written first.\n */\nexport const ALL_HANDS: string[] = (() => {\n  const hands: string[] = [];\n  for (let r = 0; r < 13; r++) {\n    for (let c = 0; c < 13; c++) {\n      const hi = RANKS[Math.min(r, c)];\n      const lo = RANKS[Math.max(r, c)];\n      if (r === c) hands.push(`${RANKS[r]}${RANKS[r]}`);\n      else if (r < c) hands.push(`${hi}${lo}s`);\n      else hands.push(`${hi}${lo}o`);\n    }\n  }\n  return hands;\n})();\n\n/** Concrete combos a class represents, ignoring card removal (pair 6, suited 4, offsuit 12). */\nexport function comboCountOf(hand: string): number {\n  if (hand.length === 2) {\n    return hand[0] === hand[1] && RANKS.includes(hand[0]) ? 6 : 0;\n  }\n  if (hand.length === 3 && RANKS.includes(hand[0]) && RANKS.includes(hand[1])) {\n    if (hand[2] === \"s\") return 4;\n    if (hand[2] === \"o\") return 12;\n  }\n  return 0;\n}\n",
      "type": "registry:lib",
      "target": "lib/pokerdata/explorer/range-model.ts"
    },
    {
      "path": "src/lib/pokerdata/postflop/types.ts",
      "content": "// Wire types for postflop solver nodes — mirrors pokerstudy.ai's\n// web/src/lib/solver/types.ts (the dump_nodes shape). Keep in sync.\n\nexport type SolverActionLabel = \"Fold\" | \"Check\" | \"Call\" | \"Bet\" | \"Raise\" | \"AllIn\";\n\nexport interface SolverActionInfo {\n  label: SolverActionLabel;\n  /** Chips added by taking this action (chips = bb in these solves). */\n  add: number;\n}\n\nexport interface SolverHandStrategy {\n  /** Combo string, 4 chars for NLHE (\"AsKh\"). */\n  combo: string;\n  /** Frequency per action, parallel to the node's `actions`. */\n  freqs: number[];\n  /** Per-combo equilibrium EV in bb (node-EV solves; absent on older data). */\n  ev?: number;\n  /** Absolute per-action EV in bb, reconstructed by PokerData when available. */\n  action_ev?: number[];\n  /** Per-action EV given up vs the best action (action-EV solves). */\n  ev_loss?: number[];\n  /** Distinguishes derived action values from fields published by the solver. */\n  ev_source?:\n    | \"reconstructed_complete_turn_tree\"\n    | \"reconstructed_action_loss_anchor\";\n}\n\nexport interface SolverNode {\n  path: string[];\n  player: 0 | 1;\n  pot: number;\n  actions: SolverActionInfo[];\n  hands: SolverHandStrategy[];\n}\n\n/** Per-combo reach weights (\"AsKh\" → weight). */\nexport type ComboWeights = Map<string, number>;\n",
      "type": "registry:lib",
      "target": "lib/pokerdata/postflop/types.ts"
    }
  ],
  "type": "registry:block"
}