{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "poker-table",
  "title": "Poker table",
  "description": "The oval table from the Train tab: seats, portraits, hole cards, bets and the pot, two to nine players, drawn in the active table theme.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.pokerstudy.ai/r/table-themes.json",
    "https://www.pokerstudy.ai/r/chips.json",
    "https://www.pokerstudy.ai/r/playing-card.json",
    "https://www.pokerstudy.ai/r/poker-core.json"
  ],
  "files": [
    {
      "path": "src/components/trainer/table.tsx",
      "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\n\nimport { NativePokerTable } from \"@/components/native-trainer/poker-table\";\n\nimport { FeltWatermark } from \"./felt-watermark\";\nimport { useTrainerSkin } from \"./skin\";\nimport { TABLE_FRAME, TableOval, anchorAt, anchorsFor, feltToneFor, useOvalBox } from \"./table-felt\";\n\nexport { formatBb } from \"./bb-format\";\n\n/**\n * The shared table shell for the train table and the drills.\n *\n * A narrow table is a tall oval (phone). Once the table container is wide it\n * becomes the landscape ellipse, hero at the bottom. Green felt is the train\n * default; navy and zinc stay as themes. Seats, cards, and the pot pill are\n * drawn by `NativePokerTable`.\n */\n\nexport interface TableSeatView {\n  pos: string;\n  /** Chips still behind, in bb. */\n  remaining: number;\n  /** Chips in front of the seat this street, in bb. */\n  committed: number;\n  folded: boolean;\n  isHero: boolean;\n  isDealer: boolean;\n  /** Last action taken, if any (\"Fold\", \"Raise 60%\", …). */\n  action: string | null;\n  /** Face-up hole cards. Showdown reveals opponents; otherwise they stay as backs. */\n  hole?: readonly string[] | null;\n  /** Screen name. Falls back to `pos` on the nameplate. */\n  name?: string | null;\n  /** Clerk portrait for the hero. Bots use the animal set. */\n  avatarUrl?: string | null;\n  /** This seat is the one to act. Draws the timer ring and a cyan plate. */\n  acting?: boolean;\n  /** Seconds left on the action timer, drawn in the ring when `acting`. */\n  timerSec?: number | null;\n  /** Sitting out. Pink nameplate border and a coffee icon beside the name. */\n  sittingOut?: boolean;\n  /** Stack change this hand, in bb. Floats above the seat when set and non-zero. */\n  deltaBb?: number | null;\n  /**\n   * How hole cards render.\n   * `none` — no cards, so the avatar stays.\n   * `back` — blue patterned backs.\n   * `up` — face-up cards from `hole`, or the hero's cards.\n   * Omitted: a fold shows nothing; a seat with faces shows them; everyone else gets backs.\n   */\n  cardMode?: \"none\" | \"back\" | \"up\";\n  /** Hand-strength line under the cards, e.g. \"Two Pair\". */\n  handLabel?: string | null;\n  /** Orange EV Cashout badge under the nameplate. */\n  evCashout?: boolean;\n  /** Green glow on the revealed hand. The +BB line is `deltaBb`, not this flag. */\n  winner?: boolean;\n}\n\n/**\n * `seats` must already be ordered so index 0 is the hero and the rest run\n * clockwise — both callers rotate their own seat list before rendering.\n */\nexport function PokerTable({\n  seats,\n  pot,\n  heroCards,\n  center,\n  compact = false,\n  avatars = true,\n  handId,\n}: {\n  seats: TableSeatView[];\n  pot: number;\n  heroCards: string[] | null;\n  /** Extra content above the pot (the community board, postflop). */\n  center?: ReactNode;\n  /** Smaller hole cards, for the review preview. */\n  compact?: boolean;\n  /** Draw seat portraits. */\n  avatars?: boolean;\n  /** Changes once per hand; a new value deals the cards round the table. */\n  handId?: string | number;\n}) {\n  const skin = useTrainerSkin();\n  return (\n    <NativePokerTable\n      seats={seats}\n      pot={pot}\n      heroCards={heroCards}\n      center={center}\n      tone={feltToneFor(skin)}\n      compact={compact}\n      avatars={avatars}\n      handId={handId}\n    />\n  );\n}\n\n/** First-deal placeholder: the same oval with ghost seats, so nothing jumps. */\nexport function TableSkeleton({\n  label = \"Shuffling up…\",\n  seatCount = 6,\n}: {\n  label?: string;\n  seatCount?: number;\n}) {\n  const tone = feltToneFor(useTrainerSkin());\n  const [ovalRef, , aspect] = useOvalBox();\n  const anchors = anchorsFor(seatCount, aspect);\n  const ink =\n    tone === \"zinc\" ? \"text-muted-foreground\" : tone === \"matrix\" ? \"font-mono text-[#00ff41]/70\" : \"text-white/55\";\n  const ghost = tone === \"matrix\" ? \"border-[#00ff41]/25 bg-black/60\" : \"border-white/15 bg-black/25\";\n  return (\n    <div className={TABLE_FRAME}>\n      <TableOval tone={tone}>\n        <div ref={ovalRef} className=\"pointer-events-none absolute inset-0\" aria-hidden />\n        {anchors.map((anchor, i) => (\n          <div\n            key={i}\n            className={`absolute size-11 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 @[32rem]:size-14 ${ghost}`}\n            style={anchorAt(anchor.seat)}\n          />\n        ))}\n        <div className={`absolute inset-x-0 top-1/2 flex -translate-y-1/2 flex-col items-center gap-2 text-center text-sm ${ink}`}>\n          <FeltWatermark tone={tone} />\n          {label}\n        </div>\n      </TableOval>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/trainer/table.tsx"
    },
    {
      "path": "src/components/trainer/table-felt.tsx",
      "content": "import { useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\n\nimport { TALLEST_ASPECT, TALL_ASPECT, WIDE_ASPECT, seatRing, type SeatAnchor } from \"./seat-ring\";\n\nimport type { TrainerSkin } from \"./skin\";\n\nexport type FeltTone =\n  | \"green\"\n  | \"navy\"\n  | \"zinc\"\n  | \"matrix\"\n  | \"coinpoker\"\n  | \"ggpoker\"\n  | \"acr\"\n  | \"ignition\"\n  | \"halloween\"\n  | \"pokerdata\";\n\nexport function feltToneFor(skin: TrainerSkin): FeltTone {\n  if (skin === \"native\") return \"navy\";\n  if (skin === \"web\") return \"zinc\";\n  if (skin === \"felt\") return \"green\";\n  return skin;\n}\n\nconst PLAIN_RAIL_SHADOW = \"0 14px 36px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.2)\";\nconst PLAIN_FELT_SHADOW = \"inset 0 0 72px rgba(0,0,0,0.38), inset 0 0 0 1px rgba(255,255,255,0.08)\";\n\n/**\n * Rail, felt and their shadows per tone. The site tones follow the rooms'\n * own tables: CoinPoker's green cloth in a near-black rail, GGPoker's green\n * inside a brass rim, ACR's slate felt and dark rail, Ignition's charcoal\n * felt with an orange racetrack.\n */\nconst RAIL: Record<FeltTone, string> = {\n  green: \"linear-gradient(165deg, #5a4030 0%, #2c1c12 42%, #120c09 100%)\",\n  navy: \"linear-gradient(165deg, #1e3a52 0%, #102030 46%, #070e16 100%)\",\n  zinc: \"linear-gradient(165deg, #3f3f46 0%, #27272a 46%, #18181b 100%)\",\n  matrix: \"linear-gradient(165deg, #0c3a1a 0%, #06200e 46%, #020a04 100%)\",\n  coinpoker: \"linear-gradient(165deg, #2a2e33 0%, #15181c 46%, #0a0b0d 100%)\",\n  ggpoker: \"linear-gradient(165deg, #e2c27a 0%, #a8803a 38%, #6f4f1c 70%, #3d2a0e 100%)\",\n  acr: \"linear-gradient(165deg, #30353c 0%, #1a1e24 46%, #0b0d10 100%)\",\n  ignition: \"linear-gradient(165deg, #2c2d31 0%, #18191c 46%, #0a0a0b 100%)\",\n  halloween: \"linear-gradient(165deg, #3a1a0a 0%, #1c0c06 46%, #080303 100%)\",\n  pokerdata: \"linear-gradient(165deg, #2a2a2a 0%, #171717 46%, #0a0a0a 100%)\",\n};\n\nconst FELT: Record<FeltTone, string> = {\n  green:\n    \"radial-gradient(ellipse at 50% 40%, #4ec072 0%, #219447 44%, #0d6330 76%, #084422 100%)\",\n  navy:\n    \"radial-gradient(ellipse at 50% 42%, #1d4e72 0%, #0e2c48 52%, #071422 100%)\",\n  zinc:\n    \"radial-gradient(ellipse at 50% 42%, #52525b 0%, #3f3f46 50%, #27272a 100%)\",\n  matrix:\n    \"radial-gradient(ellipse at 50% 42%, #06301a 0%, #031b0d 48%, #010804 100%)\",\n  coinpoker:\n    \"radial-gradient(ellipse at 50% 42%, #2a8a5e 0%, #1a6a47 48%, #0e4a31 100%)\",\n  ggpoker:\n    \"radial-gradient(ellipse at 50% 42%, #2f9a5c 0%, #1e7a46 50%, #11522f 100%)\",\n  acr:\n    \"radial-gradient(ellipse at 50% 42%, #2b3340 0%, #1c222c 52%, #10141a 100%)\",\n  ignition:\n    \"radial-gradient(ellipse at 50% 42%, #44474d 0%, #303338 50%, #1e2024 100%)\",\n  halloween:\n    \"radial-gradient(ellipse at 50% 42%, #3d1a5c 0%, #27103f 48%, #120620 100%)\",\n  pokerdata:\n    \"radial-gradient(ellipse at 50% 42%, #2b2b2b 0%, #202020 50%, #141414 100%)\",\n};\n\nconst RAIL_SHADOW: Record<FeltTone, string> = {\n  green: PLAIN_RAIL_SHADOW,\n  navy: PLAIN_RAIL_SHADOW,\n  zinc: PLAIN_RAIL_SHADOW,\n  matrix:\n    \"0 0 0 1px rgba(0,255,65,0.45), 0 0 28px rgba(0,255,65,0.22), 0 0 90px rgba(0,255,65,0.1), 0 18px 48px rgba(0,0,0,0.8), inset 0 1px 0 rgba(120,255,160,0.25)\",\n  coinpoker: \"0 16px 40px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.12)\",\n  ggpoker:\n    \"0 16px 40px rgba(0,0,0,0.6), 0 0 0 1px rgba(60,40,10,0.8), inset 0 1px 0 rgba(255,240,200,0.55), inset 0 -2px 0 rgba(0,0,0,0.35)\",\n  acr: \"0 16px 40px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.1)\",\n  ignition: \"0 16px 40px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.1)\",\n  halloween:\n    \"0 0 0 1px rgba(255,122,24,0.4), 0 0 30px rgba(255,122,24,0.18), 0 18px 48px rgba(0,0,0,0.8), inset 0 1px 0 rgba(255,180,120,0.25)\",\n  pokerdata: \"0 0 0 1px rgba(255,255,255,0.1), 0 16px 40px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.08)\",\n};\n\nconst FELT_SHADOW: Record<FeltTone, string> = {\n  green: PLAIN_FELT_SHADOW,\n  navy: PLAIN_FELT_SHADOW,\n  zinc: PLAIN_FELT_SHADOW,\n  matrix:\n    \"inset 0 0 90px rgba(0,0,0,0.7), inset 0 0 0 1px rgba(0,255,65,0.35), inset 0 0 24px rgba(0,255,65,0.08)\",\n  coinpoker: \"inset 0 0 80px rgba(0,0,0,0.45), inset 0 0 0 1px rgba(255,255,255,0.06)\",\n  ggpoker: \"inset 0 0 72px rgba(0,0,0,0.4), inset 0 0 0 2px rgba(20,12,4,0.6)\",\n  acr: \"inset 0 0 80px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.05)\",\n  ignition: \"inset 0 0 80px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.05)\",\n  halloween: \"inset 0 0 90px rgba(0,0,0,0.6), inset 0 0 0 1px rgba(255,122,24,0.25)\",\n  pokerdata: \"inset 0 0 70px rgba(0,0,0,0.45), inset 0 0 0 1px rgba(255,255,255,0.08)\",\n};\n\n/**\n * Padding box around the oval. `cqh` caps the felt to the space left for it.\n * Seats sit on the rail, so about half of each one hangs over the edge; the\n * padding leaves room for that overhang on every side.\n */\nexport const TABLE_FRAME =\n  \"@container flex h-full max-h-full w-full items-center justify-center px-9 py-10 [container-type:size] @[32rem]:px-12 @[32rem]:py-11\";\n\n/**\n * Oval felt with a dark rail. Green is the train-table default; navy and zinc\n * stay available as table themes. Portrait until the table itself is wide.\n */\nexport function TableOval({\n  tone,\n  children,\n}: {\n  tone: FeltTone;\n  children: ReactNode;\n}) {\n  return (\n    <div\n      data-table-oval\n      className=\"relative aspect-[3/4] w-full max-w-[min(100%,calc(100cqh*0.75))] rounded-[999px] p-[9px] @[32rem]:aspect-[1.72] @[32rem]:max-w-[min(54rem,calc(100cqh*1.72))]\"\n      style={{ background: RAIL[tone], boxShadow: RAIL_SHADOW[tone] }}\n    >\n      <div\n        className=\"pointer-events-none absolute inset-[9px] overflow-hidden rounded-[999px]\"\n        style={{ background: FELT[tone], boxShadow: FELT_SHADOW[tone] }}\n      >\n        {tone === \"matrix\" ? <MatrixFelt /> : null}\n        {tone === \"ignition\" ? <Racetrack color=\"rgba(245,132,31,0.55)\" /> : null}\n        {tone === \"acr\" ? <Racetrack color=\"rgba(255,255,255,0.08)\" /> : null}\n        {tone === \"halloween\" ? <HalloweenFelt /> : null}\n      </div>\n      {children}\n    </div>\n  );\n}\n\n/**\n * Seat, bet and button anchors for `count` seats on an oval of this aspect.\n * The wide and phone ovals have fixed shapes. The matrix skin's phone oval\n * (`table-skins.css`) stretches to fill the space it is given, so any other\n * aspect gets a ring for its own shape, in steps of 0.05.\n */\nexport function anchorsFor(count: number, aspect: number): SeatAnchor[] {\n  const shape =\n    Math.abs(aspect - WIDE_ASPECT) < 0.03 || aspect > WIDE_ASPECT\n      ? WIDE_ASPECT\n      : Math.abs(aspect - TALL_ASPECT) < 0.03\n        ? TALL_ASPECT\n        : Math.max(TALLEST_ASPECT, Math.round(aspect * 20) / 20);\n  const key = `${count}:${shape}`;\n  let ring = RINGS.get(key);\n  if (!ring) {\n    ring = seatRing(count, shape);\n    RINGS.set(key, ring);\n  }\n  return ring;\n}\n\nconst RINGS = new Map<string, SeatAnchor[]>();\n\nexport function anchorAt([left, top]: readonly [number, number]) {\n  return { left: `${left}%`, top: `${top}%` };\n}\n\n/**\n * Measures the oval: its pixel size, and its width ÷ height so the seats can\n * follow the phone or desktop shape. Before the first measure the table is\n * taken to be the phone oval.\n */\nexport function useOvalBox() {\n  const ref = useRef<HTMLDivElement>(null);\n  const [size, setSize] = useState<{ w: number; h: number } | null>(null);\n  // Layout effect so the first hand is measured before it paints: the seats\n  // land on the right shape and the cards can fly in instead of flashing.\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const read = () => setSize({ w: el.clientWidth, h: el.clientHeight });\n    read();\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  const aspect = size && size.h > 0 ? size.w / size.h : TALL_ASPECT;\n  return [ref, size, aspect] as const;\n}\n\n/**\n * The matrix felt's surface: a faint phosphor grid that fades out toward\n * the rail and a thin scanline raster. Still by design, so the hand is the\n * only thing moving on the table.\n */\nfunction MatrixFelt() {\n  return (\n    <>\n      <div\n        aria-hidden\n        className=\"absolute inset-0\"\n        style={{\n          backgroundImage:\n            \"linear-gradient(rgba(0,255,65,0.09) 1px, transparent 1px), linear-gradient(90deg, rgba(0,255,65,0.09) 1px, transparent 1px)\",\n          backgroundSize: \"26px 26px\",\n          backgroundPosition: \"center\",\n          maskImage: \"radial-gradient(ellipse at 50% 50%, #000 30%, transparent 78%)\",\n          WebkitMaskImage: \"radial-gradient(ellipse at 50% 50%, #000 30%, transparent 78%)\",\n        }}\n      />\n      <div\n        aria-hidden\n        className=\"absolute inset-0 opacity-60\"\n        style={{\n          backgroundImage:\n            \"repeating-linear-gradient(0deg, rgba(0,0,0,0.22) 0 1px, transparent 1px 3px)\",\n        }}\n      />\n    </>\n  );\n}\n\n/** The inner betting line some rooms draw a little inside the rail. */\nfunction Racetrack({ color }: { color: string }) {\n  return (\n    <div\n      aria-hidden\n      className=\"absolute inset-[7%] rounded-[999px]\"\n      style={{ boxShadow: `0 0 0 1.5px ${color}, 0 0 10px ${color}` }}\n    />\n  );\n}\n\n/**\n * The Halloween felt: a low moon glow at the top of the cloth, a drift of\n * fog along the bottom, and a few bats cut from the dark. All still.\n */\nconst BATS =\n  \"url(\\\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 60'><path d='M10 30c6-8 12-8 16-2 2-6 8-6 10 0 2-6 8-6 10 2-4 2-7 6-8 12-3-4-7-6-10-4-3-2-7 0-10 4-1-6-4-10-8-12z' fill='%23000' opacity='.55'/><path d='M70 14c4-6 9-6 12-1 1-5 6-5 7 0 1-5 6-5 7 1-3 2-5 5-6 9-2-3-5-4-7-3-2-1-5 0-7 3-1-4-3-7-6-9z' fill='%23000' opacity='.45'/></svg>\\\")\";\n\nfunction HalloweenFelt() {\n  return (\n    <>\n      <div\n        aria-hidden\n        className=\"absolute inset-0\"\n        style={{\n          background:\n            \"radial-gradient(ellipse 60% 45% at 50% 0%, rgba(255,196,110,0.16) 0%, rgba(255,196,110,0) 70%)\",\n        }}\n      />\n      <div\n        aria-hidden\n        className=\"absolute inset-x-0 bottom-0 h-2/5\"\n        style={{\n          background:\n            \"linear-gradient(180deg, rgba(190,160,220,0) 0%, rgba(190,160,220,0.08) 60%, rgba(190,160,220,0.14) 100%)\",\n        }}\n      />\n      <div\n        aria-hidden\n        className=\"absolute inset-0 opacity-70\"\n        style={{\n          backgroundImage: BATS,\n          backgroundSize: \"220px 110px\",\n          backgroundPosition: \"18% 22%, 70% 60%\",\n          backgroundRepeat: \"no-repeat\",\n        }}\n      />\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/trainer/table-felt.tsx"
    },
    {
      "path": "src/components/trainer/seat-ring.ts",
      "content": "/**\n * Seat positions round the oval table, as percentages of the oval box.\n *\n * The oval is a stadium (a box with fully rounded ends): tall on a phone,\n * wide once the table has room. Seats sit on the rail at equal steps of the\n * rail's length, starting with the hero at the bottom and running clockwise,\n * so six seats land at the bottom, the two lower corners, the two upper\n * corners and the top, the way a live table is laid out. Each seat's bet and\n * dealer button sit on the same line towards the middle of the felt, so they\n * never cross another seat's.\n */\n\nexport type Pct = readonly [number, number];\n\nexport interface SeatAnchor {\n  /** Centre of the seat (avatar, cards and nameplate). */\n  seat: Pct;\n  /** Where the seat's bet rests on the felt. */\n  pill: Pct;\n  /** The dealer button, beside the bet. */\n  dealer: Pct;\n}\n\n/** Depths below the rail, as a fraction of the oval's short side. */\nconst SEAT_DEPTH = 0.035;\nconst PILL_DEPTH = 0.28;\nconst DEALER_DEPTH = 0.22;\n/** How far the dealer button sits to one side of the bet, same units. The top\n *  and bottom seats push it further out, past the hand and the +BB line. */\nconst DEALER_SIDE = 0.11;\nconst APEX_DEALER_DEPTH = 0.16;\nconst APEX_DEALER_SIDE = 0.24;\n\n/** Oval width ÷ height for the phone and desktop layouts (see `TableOval`). */\nexport const TALL_ASPECT = 3 / 4;\nexport const WIDE_ASPECT = 1.72;\n/** The narrowest the matrix skin's stretched phone oval gets. */\nexport const TALLEST_ASPECT = 0.55;\n\ntype Point = { x: number; y: number; nx: number; ny: number; tx: number; ty: number };\n\n/**\n * The point `s` along the rail of a `w × h` stadium, clockwise from the bottom\n * centre, with its outward normal and clockwise tangent. Units are arbitrary.\n */\nfunction railPoint(w: number, h: number, s: number): Point {\n  const r = Math.min(w, h) / 2;\n  const a = (w - 2 * r) / 2; // half the flat top and bottom (wide)\n  const b = (h - 2 * r) / 2; // half the flat sides (tall)\n  const cx = w / 2;\n  const cy = h / 2;\n  const quarter = (Math.PI * r) / 2;\n  const segments: { len: number; at: (u: number) => Point }[] = [\n    // bottom flat, centre to the left\n    { len: a, at: (u) => ({ x: cx - u, y: cy + b + r, nx: 0, ny: 1, tx: -1, ty: 0 }) },\n    arc(cx - a, cy + b, r, Math.PI / 2),\n    // left flat, upwards\n    { len: 2 * b, at: (u) => ({ x: cx - a - r, y: cy + b - u, nx: -1, ny: 0, tx: 0, ty: -1 }) },\n    arc(cx - a, cy - b, r, Math.PI),\n    // top flat, left to right\n    { len: 2 * a, at: (u) => ({ x: cx - a + u, y: cy - b - r, nx: 0, ny: -1, tx: 1, ty: 0 }) },\n    arc(cx + a, cy - b, r, (3 * Math.PI) / 2),\n    // right flat, downwards\n    { len: 2 * b, at: (u) => ({ x: cx + a + r, y: cy - b + u, nx: 1, ny: 0, tx: 0, ty: 1 }) },\n    arc(cx + a, cy + b, r, 0),\n    // bottom flat, right end back to the centre\n    { len: a, at: (u) => ({ x: cx + a - u, y: cy + b + r, nx: 0, ny: 1, tx: -1, ty: 0 }) },\n  ];\n  let left = s;\n  for (const seg of segments) {\n    if (left <= seg.len) return seg.at(left);\n    left -= seg.len;\n  }\n  return segments[0].at(0);\n\n  function arc(ox: number, oy: number, radius: number, from: number) {\n    return {\n      len: quarter,\n      at: (u: number): Point => {\n        const t = from + u / radius;\n        const nx = Math.cos(t);\n        const ny = Math.sin(t);\n        return { x: ox + radius * nx, y: oy + radius * ny, nx, ny, tx: -ny, ty: nx };\n      },\n    };\n  }\n}\n\nfunction railLength(w: number, h: number): number {\n  const r = Math.min(w, h) / 2;\n  return 2 * Math.PI * r + 2 * (w - 2 * r) + 2 * (h - 2 * r);\n}\n\nconst round = (v: number) => Math.round(v * 10) / 10;\n\n/**\n * Anchors for `count` seats on an oval of the given width ÷ height. Index 0\n * is the hero at the bottom; the rest run clockwise.\n */\nexport function seatRing(count: number, aspect: number): SeatAnchor[] {\n  const w = aspect;\n  const h = 1;\n  const short = Math.min(w, h);\n  const total = railLength(w, h);\n  const pct = (x: number, y: number): Pct => [round((x / w) * 100), round((y / h) * 100)];\n  return Array.from({ length: Math.max(1, count) }, (_, i) => {\n    const p = railPoint(w, h, (i / Math.max(1, count)) * total);\n    const inward = (d: number) => [p.x - p.nx * d * short, p.y - p.ny * d * short] as const;\n    const [sx, sy] = inward(SEAT_DEPTH);\n    const [px, py] = inward(PILL_DEPTH);\n    // The button goes to the side of the bet nearer the table's horizontal\n    // midline, so it sits on the felt rather than out towards a corner; the\n    // top and bottom seats put it on their right.\n    const apex = Math.abs(p.ny) > 0.95;\n    const [dx0, dy0] = inward(apex ? APEX_DEALER_DEPTH : DEALER_DEPTH);\n    const side = apex ? (p.tx > 0 ? 1 : -1) : Math.sign((h / 2 - dy0) * p.ty) || 1;\n    const along = (apex ? APEX_DEALER_SIDE : DEALER_SIDE) * short * side;\n    const dx = dx0 + p.tx * along;\n    const dy = dy0 + p.ty * along;\n    return { seat: pct(sx, sy), pill: pct(px, py), dealer: pct(dx, dy) };\n  });\n}\n",
      "type": "registry:lib",
      "target": "components/trainer/seat-ring.ts"
    },
    {
      "path": "src/components/trainer/felt-watermark.tsx",
      "content": "import type { FeltTone } from \"./table-felt\";\n\nconst INK: Record<FeltTone | \"red\", string> = {\n  navy: \"text-teal-100/35\",\n  red: \"text-rose-100/35\",\n  green: \"text-emerald-100/35\",\n  zinc: \"text-foreground/30\",\n  matrix: \"\",\n  coinpoker: \"text-white/25\",\n  ggpoker: \"text-amber-50/30\",\n  acr: \"text-white/20\",\n  ignition: \"text-white/18\",\n  halloween: \"text-orange-200/30\",\n  pokerdata: \"\",\n};\n\n/** Felt wordmark that sits in the table stack above the pot. */\nexport function FeltWatermark({\n  tone = \"zinc\",\n}: {\n  tone?: FeltTone | \"red\";\n}) {\n  // The pokerdata table carries no wordmark.\n  if (tone === \"pokerdata\") return null;\n  if (tone === \"matrix\") {\n    return (\n      <span\n        aria-hidden\n        className=\"select-none text-center font-mono text-[clamp(0.6rem,1.4cqw,0.8rem)] font-semibold uppercase tracking-[0.42em] text-[#00ff41]/40 [text-shadow:0_0_10px_rgba(0,255,65,0.45)]\"\n      >\n        Poker Study AI\n      </span>\n    );\n  }\n  return (\n    <span\n      aria-hidden\n      className={`select-none text-center text-[clamp(0.7rem,1.6cqw,0.95rem)] font-black uppercase tracking-[0.28em] ${INK[tone]}`}\n    >\n      Poker Study AI\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/trainer/felt-watermark.tsx"
    },
    {
      "path": "src/components/trainer/seat-display.tsx",
      "content": "\"use client\";\n\nimport { createContext, useContext, useState, type ReactNode } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { CARD_FACE_CLASS, CardIndices, cardFaceParts, cardFaceStyle } from \"@/components/pokerstudy/playing-card\";\nimport { sortCardsHighFirst } from \"@/lib/cards/parse\";\n\nimport type { TableSeatView } from \"./table\";\nimport { useTrainerSkin } from \"./skin\";\nimport { portraitFor } from \"./table-avatars\";\n\n/**\n * CoinPoker-style seat. The avatar sits on top, the hand fans over its lower\n * part so the portrait peeks out above the cards, and the nameplate (name\n * and BB stack) covers the bottom of the hand. Hole cards fan at every\n * width.\n */\n\nconst CompactContext = createContext(false);\nconst AvatarsContext = createContext(true);\n\nexport function SeatCompactProvider({\n  compact,\n  avatars = true,\n  children,\n}: {\n  compact: boolean;\n  /** Draw the portraits. Off, a seat is its cards and nameplate only. */\n  avatars?: boolean;\n  children: ReactNode;\n}) {\n  return (\n    <CompactContext.Provider value={compact}>\n      <AvatarsContext.Provider value={avatars}>{children}</AvatarsContext.Provider>\n    </CompactContext.Provider>\n  );\n}\n\nfunction useCompact() {\n  return useContext(CompactContext);\n}\n\nfunction useAvatars() {\n  return useContext(AvatarsContext);\n}\n\n/**\n * Where this seat sits relative to the middle of the felt, and how its hand\n * comes and goes. The table fills this in; a seat drawn on its own (the\n * preview's state grid) leaves it out and stays still.\n */\nexport interface SeatMotion {\n  /** Pixel offset from the seat to the middle of the felt. */\n  toCenter: { x: number; y: number };\n  /**\n   * Set while a new hand is being dealt. Card `i` lands at\n   * `firstDelayS + i * roundS`, one card per seat per trip round the table.\n   */\n  deal: { firstDelayS: number; roundS: number; cardS: number } | null;\n  /** Fold flight to the muck, in seconds. 0 drops the cards at once. */\n  foldS: number;\n  /** Action-timer sweep, in seconds. 0 draws a still ring. */\n  timerS: number;\n}\n\ntype DealIn = SeatMotion[\"deal\"] & { x: number; y: number };\n\n/** Inline vars for `.coin-deal` on card `i`. */\nfunction dealStyle(deal: DealIn | null | undefined, i: number) {\n  if (!deal) return undefined;\n  return {\n    [\"--deal-x\" as string]: `${deal.x}px`,\n    [\"--deal-y\" as string]: `${deal.y}px`,\n    [\"--deal-delay\" as string]: `${deal.firstDelayS + i * deal.roundS}s`,\n    [\"--deal-dur\" as string]: `${deal.cardS}s`,\n  };\n}\n\n/**\n * Rotation for card `i` of `n`. Every card in a hand turns about one pivot\n * well below the cards (see `.coin-hole-card` in globals.css), so the\n * rotation alone spreads them into a held fan: a few degrees apart, each\n * showing just its top-left index, the outer cards dipping a little.\n */\nfunction fanDeg(i: number, n: number): number {\n  if (n <= 1) return 0;\n  const step = n === 2 ? 12 : n === 3 ? 9.5 : n === 4 ? 8.5 : n === 5 ? 8 : 7;\n  return (i - (n - 1) / 2) * step;\n}\n\n/**\n * How far each card tucks under the next in the straight phone row, in em\n * of the card's type size (the card is 2.9em wide). The exposed strip must\n * still fit the top-left rank and suit.\n */\nfunction overlapEm(n: number): string {\n  if (n <= 2) return \"0.85em\";\n  if (n <= 4) return \"1.2em\";\n  return \"1.32em\";\n}\n\n// Opponent cards, the hero's larger hand, and the review preview's compact\n// set. The box is in em of the type size, so the overlap and the fan pivot\n// (also in em) stay proportional at every size. On a phone an opponent's\n// cards are small chips over the avatar, as on the reference tables, and\n// the hero's are a readable row; each steps up once the table container is\n// desktop-wide (32rem).\nconst FACE = \"relative h-[4.1em] w-[2.9em] shrink-0 text-[8px] @[32rem]:text-[14px]\";\nconst FACE_HERO = \"relative h-[4.1em] w-[2.9em] shrink-0 text-[14px] @[32rem]:text-[17px]\";\nconst FACE_COMPACT = \"relative h-[4.1em] w-[2.9em] shrink-0 text-[8px] @[32rem]:text-[11px]\";\n\nfunction faceClass(compact: boolean, hero: boolean): string {\n  return compact ? FACE_COMPACT : hero ? FACE_HERO : FACE;\n}\n\nexport function CompactHoleCards({\n  cards,\n  hero = false,\n  deal,\n}: {\n  cards: readonly string[];\n  /** The hero's own hand draws one size up. */\n  hero?: boolean;\n  /** Fly each card in from the middle of the felt. */\n  deal?: DealIn | null;\n}) {\n  const ordered = sortCardsHighFirst(cards);\n  const n = ordered.length;\n  return (\n    <div\n      className=\"flex items-end justify-center pt-1\"\n      aria-label={ordered\n        .map((c) => {\n          const { rank, sym } = cardFaceParts(c);\n          return `${rank}${sym}`;\n        })\n        .join(\" \")}\n    >\n      {ordered.map((card, i) => (\n        <CompactFace key={`${card}-${i}`} card={card} i={i} n={n} hero={hero} deal={deal} />\n      ))}\n    </div>\n  );\n}\n\nexport function CompactCardBacks({\n  count,\n  hero = false,\n  deal,\n}: {\n  count: number;\n  hero?: boolean;\n  deal?: DealIn | null;\n}) {\n  const n = Math.max(2, count);\n  return (\n    <div className=\"flex items-end justify-center pt-1\" aria-label={`${n} hole cards`}>\n      {Array.from({ length: n }, (_, i) => (\n        <CompactBack key={i} i={i} n={n} hero={hero} deal={deal} />\n      ))}\n    </div>\n  );\n}\n\nfunction CompactFace({\n  card,\n  i,\n  n,\n  hero,\n  deal,\n}: {\n  card: string;\n  i: number;\n  n: number;\n  hero: boolean;\n  deal?: DealIn | null;\n}) {\n  const { rank, sym, bg } = cardFaceParts(card);\n  const compact = useCompact();\n  return (\n    <div\n      aria-hidden\n      style={{\n        ...cardFaceStyle(bg),\n        zIndex: i,\n        // A straight overlapping row on a phone, a fan about one shared\n        // pivot on a desktop-wide table. See `.coin-hole-card` in globals.css.\n        [\"--row\" as string]: i === 0 ? \"0\" : `-${overlapEm(n)}`,\n        [\"--stack\" as string]: i === 0 ? \"0\" : \"-2.9em\",\n        [\"--fan\" as string]: `${fanDeg(i, n)}deg`,\n        ...dealStyle(deal, i),\n      }}\n      className={`${faceClass(compact, hero)} ${CARD_FACE_CLASS} coin-hole-card ${deal ? \"coin-deal\" : \"\"}`}\n    >\n      {/* Only the top card shows the big rank; under a fan the others would peek out. */}\n      <CardIndices rank={rank} sym={sym} corner={i === n - 1} />\n    </div>\n  );\n}\n\nfunction CompactBack({\n  i,\n  n,\n  hero,\n  deal,\n}: {\n  i: number;\n  n: number;\n  hero: boolean;\n  deal?: DealIn | null;\n}) {\n  const compact = useCompact();\n  return (\n    <div\n      aria-hidden\n      style={{\n        zIndex: i,\n        [\"--row\" as string]: i === 0 ? \"0\" : `-${overlapEm(n)}`,\n        [\"--stack\" as string]: i === 0 ? \"0\" : \"-2.9em\",\n        [\"--fan\" as string]: `${fanDeg(i, n)}deg`,\n        ...CARD_BACK_STYLE,\n        ...dealStyle(deal, i),\n      }}\n      className={`${faceClass(compact, hero)} coin-hole-card ${deal ? \"coin-deal\" : \"\"} relative overflow-hidden rounded-[7px] shadow-[0_2px_6px_rgba(0,0,0,0.42),0_0_0_1px_rgba(10,30,70,0.6)]`}\n    >\n      <span className=\"absolute inset-[12%] rounded-[3px] border border-[var(--card-back-inner,rgba(255,255,255,0.55))]\" />\n    </div>\n  );\n}\n\n/**\n * Blue patterned back: a fine diamond lattice over a royal-blue gradient, a\n * light frame, and a white inner border. Shared with the hero's face-down\n * preview and the opponents' hands.\n */\nconst CARD_BACK_IMAGE = [\n  \"linear-gradient(158deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0.02) 45%, rgba(0,0,0,0.18) 100%)\",\n  \"radial-gradient(circle at 50% 50%, rgba(255,255,255,0.28) 0 14%, rgba(255,255,255,0) 15%)\",\n  \"repeating-linear-gradient(45deg, rgba(255,255,255,0.14) 0 1px, transparent 1px 4px)\",\n  \"repeating-linear-gradient(-45deg, rgba(255,255,255,0.14) 0 1px, transparent 1px 4px)\",\n].join(\", \");\n\n// A table theme (the matrix skin) repaints the back through these variables.\nconst CARD_BACK_STYLE = {\n  backgroundColor: \"var(--card-back-bg, #1d55b8)\",\n  backgroundImage: `var(--card-back-image, ${CARD_BACK_IMAGE})`,\n  boxShadow:\n    \"inset 0 0 0 2px var(--card-back-frame, #dbe8fb), 0 2px 6px rgba(0,0,0,0.42), 0 0 0 1px var(--card-back-edge, rgba(10,30,70,0.6))\",\n} as const;\n\nfunction actionTag(action: string): { label: string; className: string; kind: string } {\n  const a = action.toLowerCase();\n  if (a.startsWith(\"fold\")) return { label: \"FOLD\", className: \"bg-[#7d9084] text-white\", kind: \"fold\" };\n  if (a.startsWith(\"muck\")) return { label: \"Muck\", className: \"bg-[#7d9084] text-white\", kind: \"fold\" };\n  if (a.startsWith(\"check\")) return { label: \"CHECK\", className: \"bg-[#3e7cb8] text-white\", kind: \"check\" };\n  if (a.startsWith(\"call\")) return { label: \"CALL\", className: \"bg-[#2f9e5a] text-white\", kind: \"call\" };\n  if (a.startsWith(\"bet\")) return { label: \"BET\", className: \"bg-[#e57a12] text-white\", kind: \"bet\" };\n  if (a.startsWith(\"all\")) return { label: \"ALL-IN\", className: \"bg-[#e57a12] text-white\", kind: \"bet\" };\n  if (a.startsWith(\"raise\")) return { label: \"RAISE\", className: \"bg-[#e57a12] text-white\", kind: \"bet\" };\n  const word = action.split(\" \")[0] ?? action;\n  return { label: word.toUpperCase(), className: \"bg-[#e57a12] text-white\", kind: \"bet\" };\n}\n\n/** Coffee cup beside a sitting-out name. CoinPoker draws this on the plate. */\nfunction CoffeeIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-3 shrink-0\" aria-hidden>\n      <path\n        fill=\"#f3e6d8\"\n        d=\"M2.2 5.2h8.2v5.1a2.3 2.3 0 0 1-2.3 2.3H4.5a2.3 2.3 0 0 1-2.3-2.3V5.2z\"\n      />\n      <path\n        fill=\"none\"\n        stroke=\"#f3e6d8\"\n        strokeWidth=\"1.3\"\n        strokeLinecap=\"round\"\n        d=\"M10.4 6.4h1.5a1.7 1.7 0 0 1 0 3.4h-1.5\"\n      />\n      <path\n        fill=\"none\"\n        stroke=\"#f3e6d8\"\n        strokeWidth=\"1\"\n        strokeLinecap=\"round\"\n        d=\"M4.6 3.1c.35.55.35 1.05 0 1.7M6.8 2.6c.35.55.35 1.05 0 1.7\"\n      />\n    </svg>\n  );\n}\n\nfunction formatStackBb(v: number): string {\n  const rounded = Math.round(v * 10) / 10;\n  return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);\n}\n\nfunction formatDeltaBb(v: number): string {\n  return `${v > 0 ? \"+\" : \"-\"}${formatStackBb(Math.abs(v))}BB`;\n}\n\nfunction SeatPortrait({\n  src,\n  alt,\n  onError,\n}: {\n  src: string;\n  alt: string;\n  onError?: () => void;\n}) {\n  return (\n    // The wrapper is the theme hook: a table skin sets the frame shape, ring\n    // and tint.\n    <span\n      data-portrait\n      className=\"block size-11 overflow-hidden rounded-[var(--seat-portrait-radius,9999px)] shadow-[0_2px_6px_rgba(0,0,0,0.45),0_0_0_var(--seat-portrait-ring-width,0px)_var(--seat-portrait-ring,transparent)] @[32rem]:size-14\"\n    >\n      {/* Clerk portraits are remote; the animal set is a local SVG. A plain img\n          keeps both off the image optimizer. */}\n      {/* eslint-disable-next-line @next/next/no-img-element */}\n      <img\n        src={src}\n        alt={alt}\n        onError={onError}\n        className=\"size-full object-cover [filter:var(--seat-portrait-filter,none)]\"\n        draggable={false}\n      />\n    </span>\n  );\n}\n\nexport type SeatSide = \"left\" | \"center\" | \"right\";\n\nexport function CoinSeat({\n  seat,\n  heroCards,\n  side = \"center\",\n  motion: seatMotion,\n  portrait,\n}: {\n  seat: TableSeatView;\n  heroCards: string[] | null;\n  /** Table-driven deal, fold, and timer motion. Omitted: the seat is still. */\n  motion?: SeatMotion;\n  /**\n   * Which rail the seat sits on. On a phone an opponent's small cards sit\n   * over the avatar's shoulder that faces the middle of the felt, so they\n   * never run off the edge of the oval.\n   */\n  side?: SeatSide;\n  /** This seat's portrait, picked by the table so no two seats share one. */\n  portrait?: string;\n}) {\n  const reduce = useReducedMotion();\n  const name = seat.name?.trim() || seat.pos;\n  const skin = useTrainerSkin();\n  // The table hands each seat a face no other seat has; a seat drawn on its\n  // own falls back to its theme's cast by stable hash.\n  const animal = portrait ?? portraitFor(skin, seat);\n  const [photoOk, setPhotoOk] = useState(true);\n  const photo = photoOk && seat.avatarUrl ? seat.avatarUrl : null;\n  const muck = !!seat.action?.toLowerCase().startsWith(\"muck\");\n  const acting = !!seat.acting && !seat.folded && !muck;\n  const muted = seat.folded || muck;\n  // Plate border by state. A table theme repaints these through the variables.\n  const border = seat.sittingOut\n    ? \"var(--seat-sitout, #ee5fa0)\"\n    : acting\n      ? \"var(--seat-acting, #2ec8d6)\"\n      : muted\n        ? \"var(--seat-muted, #7d9084)\"\n        : \"var(--seat-live, #3dce6e)\";\n  const dealt = heroCards && heroCards.length > 0 ? heroCards : null;\n  const faces =\n    seat.hole && seat.hole.length > 0 ? seat.hole : seat.isHero && dealt ? dealt : null;\n  const holeCount = faces?.length ?? dealt?.length ?? 2;\n  const compact = useCompact();\n  const showAvatar = useAvatars();\n  // Explicit cardMode wins. Otherwise a fold (or a muck) drops the cards and\n  // the avatar returns; face-up holes show; every other dealt seat gets backs.\n  const mode: \"none\" | \"back\" | \"up\" =\n    seat.cardMode ?? (seat.folded || muck ? \"none\" : faces ? \"up\" : \"back\");\n  const showFaces = mode === \"up\" && faces != null && faces.length > 0;\n  const showCards = mode !== \"none\";\n  const tag = seat.folded ? actionTag(\"fold\") : seat.action ? actionTag(seat.action) : null;\n  const delta = seat.deltaBb;\n  const stack = formatStackBb(seat.remaining);\n  const seconds = acting && seat.timerSec != null ? seat.timerSec : null;\n  const deal: DealIn | null =\n    seatMotion?.deal && !reduce\n      ? { ...seatMotion.deal, x: seatMotion.toCenter.x, y: seatMotion.toCenter.y }\n      : null;\n  const foldS = reduce ? 0 : (seatMotion?.foldS ?? 0);\n  const timerS = reduce ? 0 : (seatMotion?.timerS ?? 0);\n\n  return (\n    <div\n      className={`relative flex flex-col items-center ${\n        compact ? \"w-[4rem] @[32rem]:w-[5rem]\" : \"w-[4.7rem] @[32rem]:w-[6.5rem]\"\n      } ${seat.folded ? \"opacity-70\" : \"\"}`}\n      role=\"group\"\n      aria-label={`${name}, ${stack} BB${seat.sittingOut ? \", sitting out\" : \"\"}${\n        seat.action ? `, ${seat.action}` : \"\"\n      }${seat.handLabel ? `, ${seat.handLabel}` : \"\"}`}\n    >\n      {delta != null && Math.abs(delta) >= 0.05 ? (\n        <span\n          className={`absolute -top-4 left-1/2 z-30 -translate-x-1/2 whitespace-nowrap text-[11px] font-black @[32rem]:-top-5 @[32rem]:text-[13px] ${\n            delta > 0 ? \"text-[#3dce6e]\" : \"text-[#ef4444]\"\n          }`}\n          style={{ textShadow: \"0 1px 2px rgba(0,0,0,0.85)\" }}\n        >\n          {formatDeltaBb(delta)}\n        </span>\n      ) : null}\n\n      {/* The portrait always sits above the plate. When an opponent holds\n          cards they fan over its lower part, leaving the top of the avatar\n          peeking out behind the hand. The hero's larger hand takes the\n          avatar's place instead, as on the reference tables; the portrait\n          comes back once the hero has no cards. */}\n      {(seat.isHero && showCards) || !showAvatar ? null : (\n        <div className={showCards ? \"relative z-0\" : \"relative z-30 mb-1\"}>\n          {acting ? <TimerRing durationS={timerS} /> : null}\n          <SeatPortrait\n            src={photo ?? animal}\n            alt=\"\"\n            onError={photo ? () => setPhotoOk(false) : undefined}\n          />\n          {seconds != null && !showCards ? (\n            <span className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center text-[17px] font-black tabular-nums text-[var(--seat-timer-ink,#7ee7f2)] [text-shadow:0_1px_2px_#000,0_0_6px_#042026] @[32rem]:text-xl\">\n              {seconds}\n            </span>\n          ) : null}\n        </div>\n      )}\n\n      {/* A fold sends the hand to the muck in the middle of the felt;\n          popLayout lets the seat settle while the cards are still flying. */}\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {showCards ? (\n          // The hand overlaps the avatar above it and tucks its lower part\n          // behind the nameplate below, so the plate reads as the player\n          // holding the cards. On a phone an opponent's small cards sit over\n          // the avatar's inward shoulder instead.\n          <motion.div\n            key=\"hand\"\n            data-seat-hand={seat.isHero ? \"hero\" : side}\n            exit={\n              foldS > 0 && seatMotion\n                ? {\n                    x: seatMotion.toCenter.x,\n                    y: seatMotion.toCenter.y,\n                    scale: 0.45,\n                    opacity: 0,\n                    transition: { duration: foldS, ease: [0.4, 0, 0.6, 1] },\n                  }\n                : { opacity: 0, transition: { duration: 0 } }\n            }\n            className={`relative z-10 ${\n              seat.isHero || !showAvatar\n                ? \"-mb-4 @[32rem]:-mb-5\"\n                : side === \"right\"\n                  ? \"-mt-9 -translate-x-3 @[32rem]:translate-x-0 @[32rem]:-mb-5\"\n                  : \"-mt-9 translate-x-3 @[32rem]:translate-x-0 @[32rem]:-mb-5\"\n            } ${\n              seat.winner ? \"rounded-md shadow-[0_0_14px_3px_var(--seat-winner-glow,rgba(61,206,110,0.85))]\" : \"\"\n            }`}\n          >\n            {showFaces && faces ? (\n              <CompactHoleCards cards={faces} hero={seat.isHero} deal={deal} />\n            ) : (\n              <CompactCardBacks count={holeCount} hero={seat.isHero} deal={deal} />\n            )}\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n\n      <div\n        data-seat-plate\n        data-acting={acting || undefined}\n        className={`relative z-20 w-full rounded-xl border-2 px-1.5 py-1 text-center shadow-[0_4px_10px_rgba(0,0,0,0.4)] ${\n          !showAvatar && !showCards ? \"mt-2\" : \"\"\n        } ${\n          muted ? \"bg-[var(--seat-plate-muted,rgba(18,23,28,0.7))]\" : \"bg-[var(--seat-plate,rgba(18,23,28,0.95))]\"\n        }`}\n        style={{\n          borderColor: border,\n          boxShadow: acting\n            ? \"var(--seat-acting-glow, 0 0 12px rgba(46,200,214,0.45)), 0 4px 10px rgba(0,0,0,0.4)\"\n            : undefined,\n        }}\n      >\n        {acting && (showCards || !showAvatar) && seconds != null ? (\n          <span className=\"absolute -right-1.5 -top-2 z-30 flex size-5 items-center justify-center rounded-full bg-[var(--seat-timer-bg,#042026)] text-[11px] font-black tabular-nums text-[var(--seat-timer-ink,#7ee7f2)] ring-2 ring-[var(--seat-acting,#2ec8d6)]\">\n            {seconds}\n          </span>\n        ) : null}\n        {tag ? (\n          <span\n            key={tag.label}\n            data-action-tag={tag.kind}\n            className={`absolute left-1/2 top-0 z-10 -translate-x-1/2 -translate-y-1/2 whitespace-nowrap rounded-full px-1.5 py-px text-[8px] font-black tracking-wide shadow-sm @[32rem]:text-[9px] ${tag.className} ${\n              seatMotion ? \"coin-pop\" : \"\"\n            }`}\n          >\n            {tag.label}\n          </span>\n        ) : null}\n        <div\n          data-seat-name\n          className={`flex items-center justify-center gap-0.5 text-[10px] font-semibold leading-tight @[32rem]:text-xs ${\n            muted ? \"text-[var(--seat-name-muted,rgba(255,255,255,0.45))]\" : \"text-[var(--seat-name,#fff)]\"\n          }`}\n        >\n          {seat.sittingOut ? <CoffeeIcon /> : null}\n          <span className=\"min-w-0 truncate\">{name}</span>\n        </div>\n        <div\n          data-seat-stack\n          className={`mt-0.5 text-[13px] font-extrabold leading-none tabular-nums @[32rem]:text-base ${\n            muted ? \"text-[var(--seat-stack-muted,rgba(246,196,69,0.45))]\" : \"text-[var(--seat-stack,#f6c445)]\"\n          }`}\n        >\n          {stack}\n          <span className=\"ml-0.5 text-[0.68em] font-bold\">BB</span>\n        </div>\n      </div>\n\n      {seat.evCashout ? (\n        <span className=\"z-20 mt-1 inline-flex items-center rounded-md bg-[#e57a12] px-1.5 py-0.5 text-[8px] font-black tracking-wide text-white shadow-sm @[32rem]:text-[9px]\">\n          EV Cashout\n        </span>\n      ) : null}\n\n      {seat.handLabel ? (\n        <span className=\"relative z-10 mt-1 text-center text-[10px] font-bold leading-tight text-[var(--seat-stack,#f6c445)] [text-shadow:0_1px_2px_rgba(0,0,0,0.9)] @[32rem]:text-[11px]\">\n          {seat.handLabel}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n\n/**\n * Action timer on the avatar. With a duration the ring drains clockwise from\n * the top and warms from cyan to red (`.coin-timer`); it remounts, and so\n * restarts, each time the seat comes on turn. Without one it is a full ring.\n */\nfunction TimerRing({ durationS }: { durationS: number }) {\n  return (\n    <svg\n      viewBox=\"0 0 40 40\"\n      className={`pointer-events-none absolute -inset-[3px] z-20 h-[calc(100%+6px)] w-[calc(100%+6px)] ${\n        durationS > 0 ? \"coin-pop\" : \"\"\n      }`}\n      aria-hidden\n    >\n      <circle cx=\"20\" cy=\"20\" r=\"18\" fill=\"none\" stroke=\"var(--seat-acting-track, rgba(46,200,214,0.28))\" strokeWidth=\"2.2\" />\n      <circle\n        cx=\"20\"\n        cy=\"20\"\n        r=\"18\"\n        fill=\"none\"\n        stroke=\"var(--seat-acting, #2ec8d6)\"\n        strokeWidth=\"2.4\"\n        strokeLinecap=\"round\"\n        strokeDasharray=\"113.1\"\n        transform=\"rotate(-90 20 20)\"\n        className={durationS > 0 ? \"coin-timer\" : undefined}\n        style={durationS > 0 ? { [\"--timer-dur\" as string]: `${durationS}s` } : undefined}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/trainer/seat-display.tsx"
    },
    {
      "path": "src/components/trainer/table-avatars.ts",
      "content": "/** Original flat animal portraits for the table. Assigned by seat or bot name. */\nexport const TABLE_AVATARS = [\n  \"/avatars/table/fox.svg\",\n  \"/avatars/table/owl.svg\",\n  \"/avatars/table/panda.svg\",\n  \"/avatars/table/frog.svg\",\n  \"/avatars/table/cat.svg\",\n  \"/avatars/table/bear.svg\",\n  \"/avatars/table/rabbit.svg\",\n  \"/avatars/table/penguin.svg\",\n] as const;\n\n/**\n * Suited \"agent\" portraits for bot opponents, generated by\n * scripts/gen_agent_avatars.py (the mobile app shares the same roster). The\n * hero keeps the animal set.\n */\nexport const AGENT_AVATARS = Array.from(\n  { length: 50 },\n  (_, i) => `/avatars/agents/agent-${String(i + 1).padStart(2, \"0\")}.svg`,\n);\n\nfunction hashKey(key: string): number {\n  let h = 2166136261;\n  for (let i = 0; i < key.length; i++) {\n    h ^= key.charCodeAt(i);\n    h = Math.imul(h, 16777619);\n  }\n  return h >>> 0;\n}\n\n/** Same key always picks the same portrait. */\nexport function avatarSrcFor(key: string): string {\n  return TABLE_AVATARS[hashKey(key) % TABLE_AVATARS.length];\n}\n\n/** Same bot always gets the same agent portrait. */\nexport function agentAvatarSrcFor(key: string): string {\n  return AGENT_AVATARS[hashKey(key) % AGENT_AVATARS.length];\n}\n\n/**\n * Each table theme seats its own cast, every seat the hero included (unless\n * the hero has a profile photo). Halloween is hand-drawn; the rest come from\n * scripts/gen_theme_avatars.py. A theme missing here keeps the animal set for\n * the hero and the suited agents for the bots.\n */\nconst THEME_CASTS: Partial<Record<string, readonly string[]>> = {\n  matrix: [\"operator\", \"agent\", \"oracle\", \"sentinel\", \"hacker\", \"rabbit\", \"android\", \"glitch\", \"keymaker\", \"twin\", \"code\", \"drone\"],\n  coinpoker: [\"bull\", \"bear\", \"whale\", \"ape\", \"shiba\", \"astronaut\", \"rocket\", \"diamond\", \"bitcoin\", \"laser\", \"miner\", \"eth\"],\n  ggpoker: [\"dragon\", \"tiger\", \"panda\", \"samurai\", \"ninja\", \"oni\", \"kitsune\", \"cat\", \"monk\", \"koi\", \"crane\", \"phoenix\"],\n  acr: [\"cowboy\", \"sheriff\", \"outlaw\", \"gambler\", \"prospector\", \"horse\", \"bison\", \"eagle\", \"coyote\", \"longhorn\", \"saloon\", \"deputy\"],\n  ignition: [\"highroller\", \"dealer\", \"magician\", \"slots\", \"shark\", \"dice\", \"king\", \"queen\", \"joker\", \"boxer\", \"flame\", \"showgirl\"],\n  halloween: [\"pumpkin\", \"ghost\", \"skull\", \"witch\", \"vampire\", \"frankenstein\", \"mummy\", \"blackcat\", \"bat\", \"zombie\", \"reaper\", \"spider\"],\n};\n\nexport const THEME_AVATARS: Partial<Record<string, string[]>> = Object.fromEntries(\n  Object.entries(THEME_CASTS).map(([theme, cast]) => [theme, cast!.map((name) => `/avatars/${theme}/${name}.svg`)]),\n);\n\ntype PortraitSeat = { isHero?: boolean; name?: string | null; pos: string };\n\n/** The stable key a seat's portrait hashes from. */\nexport function portraitKey(seat: PortraitSeat): string {\n  const name = seat.name?.trim() || seat.pos;\n  return seat.isHero ? `hero:${name}` : name;\n}\n\n/** One seat's portrait on its own, for a seat drawn without a table. */\nexport function portraitFor(theme: string, seat: PortraitSeat): string {\n  const key = portraitKey(seat);\n  const cast = THEME_AVATARS[theme];\n  if (cast) return cast[hashKey(key) % cast.length];\n  return seat.isHero ? avatarSrcFor(key) : agentAvatarSrcFor(key);\n}\n\n/**\n * Portraits for a whole table, in seat order, with no face repeated. Each seat\n * starts from its stable hash and steps to the next free face on a clash.\n */\nexport function tablePortraits(theme: string, seats: readonly PortraitSeat[]): string[] {\n  const used = new Set<string>();\n  return seats.map((seat) => {\n    const key = portraitKey(seat);\n    const cast = THEME_AVATARS[theme] ?? (seat.isHero ? TABLE_AVATARS : AGENT_AVATARS);\n    let i = hashKey(key) % cast.length;\n    for (let tries = 0; tries < cast.length && used.has(cast[i]); tries++) i = (i + 1) % cast.length;\n    used.add(cast[i]);\n    return cast[i];\n  });\n}\n",
      "type": "registry:lib",
      "target": "components/trainer/table-avatars.ts"
    },
    {
      "path": "src/components/trainer/bb-format.ts",
      "content": "/** Big-blind amounts already converted from chips. One decimal under 100. */\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",
      "type": "registry:lib",
      "target": "components/trainer/bb-format.ts"
    },
    {
      "path": "src/components/native-trainer/poker-table.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\n\nimport { ChipStack, PotChips } from \"@/components/trainer/chips\";\nimport { FeltWatermark } from \"@/components/trainer/felt-watermark\";\nimport {\n  CoinSeat,\n  SeatCompactProvider,\n  type SeatMotion,\n} from \"@/components/trainer/seat-display\";\nimport {\n  TABLE_FRAME,\n  TableOval,\n  anchorAt,\n  anchorsFor,\n  useOvalBox,\n  type FeltTone,\n} from \"@/components/trainer/table-felt\";\nimport { formatBb } from \"@/components/trainer/bb-format\";\nimport { useTrainerSkin } from \"@/components/trainer/skin\";\nimport { tablePortraits } from \"@/components/trainer/table-avatars\";\nimport type { TableSeatView } from \"@/components/trainer/table\";\n\nimport { SOFT_SPRING, SPRING, TABLE_EASE, TABLE_MOTION, paced } from \"./motion\";\nimport { useMotionPace } from \"./motion-pace\";\n\n/**\n * Train-table felt. Green is the default; navy and zinc are the other themes.\n * Seats are round portraits over a bordered nameplate, with the board and a\n * pot pill in the middle. Portrait on a narrow table, wide oval once it has room.\n */\n\nexport function NativePokerTable({\n  seats,\n  pot,\n  heroCards,\n  center,\n  tone = \"green\",\n  compact = false,\n  avatars = true,\n  handId,\n}: {\n  seats: TableSeatView[];\n  pot: number;\n  heroCards: string[] | null;\n  center?: ReactNode;\n  tone?: FeltTone;\n  /** Draw seat portraits. Off, seats are cards and nameplates only. */\n  avatars?: boolean;\n  /** Smaller hole cards. The preview page toggles this. */\n  compact?: boolean;\n  /**\n   * Changes once per hand. A new value deals the hole cards round the table\n   * from the middle of the felt. Omitted: cards appear in place.\n   */\n  handId?: string | number;\n}) {\n  const pace = useMotionPace();\n  const reduce = useReducedMotion() || pace === \"off\";\n  const spring = pace === \"fast\" ? { duration: 0.12 } : SPRING;\n  const soft = pace === \"fast\" ? { duration: 0.12 } : SOFT_SPRING;\n  const [feltRef, felt, aspect] = useOvalBox();\n  const anchors = anchorsFor(seats.length, aspect);\n  const skin = useTrainerSkin();\n  const portraits = tablePortraits(skin, seats);\n  const n = seats.length;\n\n  // Deal order: one card per seat, starting left of the button and going\n  // clockwise, round the table once per hole card.\n  const dealerIdx = seats.findIndex((s) => s.isDealer);\n  const holeCount = Math.max(2, heroCards?.length ?? 0, ...seats.map((s) => s.hole?.length ?? 0));\n  const gapS = paced(TABLE_MOTION.dealGapS, pace);\n  const cardS = paced(TABLE_MOTION.dealCardS, pace);\n  const dealTotalS = holeCount * n * gapS + cardS;\n  const dealing = useDealWindow(handId, reduce ? 0 : dealTotalS);\n\n  const winners = seats\n    .map((seat, i) => ({ seat, i }))\n    .filter(({ seat }) => seat.deltaBb != null && seat.deltaBb > 0.05);\n  // Only a live hand pushes the pot; a still table (the preview) keeps its pill.\n  const pushing = winners.length > 0 && !reduce && handId != null;\n  const pushDelayS = paced(TABLE_MOTION.pushDelayS, pace);\n  const pushS = paced(TABLE_MOTION.pushS, pace);\n\n  const seatMotion = (i: number): SeatMotion | undefined => {\n    if (!felt) return undefined;\n    const [left, top] = anchors[i % anchors.length].seat;\n    const order = dealerIdx >= 0 ? (((i - dealerIdx - 1) % n) + n) % n : i;\n    return {\n      toCenter: { x: (0.5 - left / 100) * felt.w, y: (0.5 - top / 100) * felt.h },\n      deal: dealing ? { firstDelayS: order * gapS, roundS: n * gapS, cardS } : null,\n      foldS: reduce ? 0 : paced(TABLE_MOTION.foldS, pace),\n      timerS: reduce ? 0 : TABLE_MOTION.turnTimerS,\n    };\n  };\n\n  return (\n    <SeatCompactProvider compact={compact} avatars={avatars}>\n      <div className={TABLE_FRAME}>\n        <TableOval tone={tone}>\n          <div ref={feltRef} className=\"pointer-events-none absolute inset-0\" aria-hidden />\n          <div className=\"absolute inset-x-0 top-1/2 z-10 flex -translate-y-1/2 flex-col items-center gap-1.5 @[32rem]:gap-2\">\n            <FeltWatermark tone={tone} />\n            {center}\n            <motion.div\n              key={pot}\n              initial={reduce ? false : { scale: 0.92, y: 6, opacity: 0.5 }}\n              animate={{ scale: 1, y: 0, opacity: pushing ? 0 : 1 }}\n              transition={\n                pushing\n                  ? { ...soft, opacity: { delay: pushDelayS + pushS * 0.4, duration: pushS * 0.4 } }\n                  : soft\n              }\n              data-pot-pill\n              className=\"rounded-full bg-black/60 px-3 py-1 text-[12px] font-bold tracking-wide text-white shadow-[0_2px_8px_rgba(0,0,0,0.45)] @[32rem]:px-3.5 @[32rem]:text-sm\"\n            >\n              Pot {formatBb(pot)}\n            </motion.div>\n          </div>\n\n          {seats.map((seat, i) => {\n            const anchor = anchors[i % anchors.length];\n            return (\n              <div key={`seat-${seat.pos}-${i}`}>\n                <motion.div\n                  className=\"absolute z-20 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center\"\n                  style={anchorAt(anchor.seat)}\n                  animate={{\n                    opacity: seat.folded ? 0.72 : 1,\n                    scale: seat.folded ? 0.97 : 1,\n                  }}\n                  transition={spring}\n                >\n                  <CoinSeat\n                    key={handId ?? undefined}\n                    seat={seat}\n                    heroCards={heroCards}\n                    motion={seatMotion(i)}\n                    portrait={portraits[i]}\n                    side={\n                      anchor.seat[0] < 40\n                        ? \"left\"\n                        : anchor.seat[0] > 60\n                          ? \"right\"\n                          : \"center\"\n                    }\n                  />\n                </motion.div>\n                <BetChips\n                  seat={seat}\n                  from={anchor.seat}\n                  to={anchor.pill}\n                  reduce={reduce}\n                  slideS={paced(TABLE_MOTION.betSlideS, pace)}\n                  collectS={paced(TABLE_MOTION.collectS, pace)}\n                />\n                {seat.isDealer && (\n                  <motion.div\n                    className=\"absolute z-30 flex size-5 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-[var(--dealer-bg,#d3121c)] text-[10px] font-black text-[var(--dealer-ink,#fff)] shadow-[0_1px_3px_rgba(0,0,0,0.5)] ring-[1.5px] ring-[var(--dealer-ring,#fff)] @[32rem]:size-6 @[32rem]:text-[11px]\"\n                    style={anchorAt(anchor.dealer)}\n                    initial={reduce ? false : { scale: 0.5, opacity: 0 }}\n                    animate={{ scale: 1, opacity: 1 }}\n                    transition={spring}\n                    aria-label=\"Dealer button\"\n                  >\n                    D\n                  </motion.div>\n                )}\n              </div>\n            );\n          })}\n\n          {/* The pot slides from the middle to each winner once the last\n              bets have been swept in, then fades into their stack. */}\n          {pushing\n            ? winners.map(({ seat, i }) => {\n                const [left, top] = anchors[i % anchors.length].seat;\n                return (\n                  <motion.div\n                    key={`push-${handId ?? \"\"}-${seat.pos}`}\n                    className=\"pointer-events-none absolute z-40 -translate-x-1/2 -translate-y-1/2\"\n                    initial={{ left: \"50%\", top: \"50%\", opacity: 0, scale: 0.9 }}\n                    animate={{\n                      left: `${left}%`,\n                      top: `${top}%`,\n                      opacity: [0, 1, 1, 0],\n                      scale: [0.9, 1, 1, 0.8],\n                    }}\n                    transition={{\n                      delay: pushDelayS,\n                      duration: pushS,\n                      ease: TABLE_EASE,\n                      opacity: { delay: pushDelayS, duration: pushS * 1.3, times: [0, 0.1, 0.75, 1] },\n                      scale: { delay: pushDelayS, duration: pushS * 1.3, times: [0, 0.1, 0.75, 1] },\n                    }}\n                    aria-hidden\n                  >\n                    {/* The engine empties the pot once the hand settles, so size the\n                        chips off what the winner took. */}\n                    <PotChips amount={Math.max(pot, seat.deltaBb ?? 0)} size={18} />\n                  </motion.div>\n                );\n              })\n            : null}\n        </TableOval>\n      </div>\n    </SeatCompactProvider>\n  );\n}\n\n/**\n * A seat's bet on the felt. The first chips of the street slide out from the\n * seat; each raise or call on top sends another chip out to join them. When\n * the street ends (the amount drops to zero) the stack sweeps into the pot.\n */\nfunction BetChips({\n  seat,\n  from,\n  to,\n  reduce,\n  slideS,\n  collectS,\n}: {\n  seat: TableSeatView;\n  from: readonly [number, number];\n  to: readonly [number, number];\n  reduce: boolean;\n  slideS: number;\n  collectS: number;\n}) {\n  const amount = seat.committed;\n  const prev = useRef(amount);\n  const [topUp, setTopUp] = useState<number | null>(null);\n  useEffect(() => {\n    if (prev.current > 0 && amount > prev.current) setTopUp(amount);\n    prev.current = amount;\n  }, [amount]);\n\n  const seatAt = { left: `${from[0]}%`, top: `${from[1]}%` };\n  const spotAt = { left: `${to[0]}%`, top: `${to[1]}%` };\n  return (\n    <>\n      <AnimatePresence>\n        {amount > 0 ? (\n          <motion.div\n            key=\"bet\"\n            className=\"absolute z-20 -translate-x-1/2 -translate-y-1/2\"\n            initial={reduce ? false : { ...seatAt, opacity: 0, scale: 0.6 }}\n            animate={{ ...spotAt, opacity: 1, scale: 1 }}\n            exit={\n              reduce\n                ? { opacity: 0, transition: { duration: 0 } }\n                : {\n                    left: \"50%\",\n                    top: \"50%\",\n                    opacity: 0,\n                    scale: 0.7,\n                    transition: { duration: collectS, ease: [0.4, 0, 0.2, 1] },\n                  }\n            }\n            transition={{ duration: slideS, ease: TABLE_EASE }}\n            aria-label={`${seat.pos} ${formatBb(amount)}bb`}\n          >\n            <ChipStack amount={amount} label={formatBb(amount)} variant=\"table\" size={16} />\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n      {topUp != null && amount > 0 && !reduce ? (\n        <motion.div\n          key={`top-up-${topUp}`}\n          className=\"pointer-events-none absolute z-20 -translate-x-1/2 -translate-y-1/2\"\n          initial={{ ...seatAt, opacity: 0, scale: 0.6 }}\n          animate={{ ...spotAt, opacity: [0, 1, 1, 0], scale: 1 }}\n          transition={{\n            duration: slideS,\n            ease: TABLE_EASE,\n            opacity: { duration: slideS, times: [0, 0.15, 0.85, 1] },\n          }}\n          onAnimationComplete={() => setTopUp(null)}\n          aria-hidden\n        >\n          <ChipStack amount={amount} variant=\"table\" size={16} />\n        </motion.div>\n      ) : null}\n    </>\n  );\n}\n\n/**\n * True from the moment `handId` changes until the deal has had time to land,\n * so cards dealt in fly in while cards turned face up later (a showdown\n * reveal) appear in place.\n */\nfunction useDealWindow(handId: string | number | undefined, totalS: number): boolean {\n  const [settled, setSettled] = useState<string | number | undefined>(undefined);\n  useEffect(() => {\n    if (handId == null || totalS <= 0) return;\n    const t = setTimeout(() => setSettled(handId), totalS * 1000 + 80);\n    return () => clearTimeout(t);\n  }, [handId, totalS]);\n  return handId != null && totalS > 0 && handId !== settled;\n}\n",
      "type": "registry:component",
      "target": "components/native-trainer/poker-table.tsx"
    },
    {
      "path": "src/components/native-trainer/motion.ts",
      "content": "/** Moti-like spring — stiff enough to feel dealt, not floaty. */\nexport const SPRING = { type: \"spring\" as const, stiffness: 420, damping: 28, mass: 0.7 };\n\nexport const SOFT_SPRING = { type: \"spring\" as const, stiffness: 260, damping: 24, mass: 0.8 };\n\n/** Mobile `DEAL_STAGGER_MS` / `DEAL_DURATION_MS` from table-layout.ts. */\nexport const DEAL_STAGGER_S = 0.07;\nexport const DEAL_DURATION_S = 0.22;\n\n/**\n * Table action beats, in seconds at the normal pace. Fast divides by three;\n * Off and reduced motion skip them. Kept together so the whole table can be\n * retimed against a reference recording in one place.\n */\nexport const TABLE_MOTION = {\n  /** One hole card's flight from the middle of the felt to its seat. */\n  dealCardS: 0.26,\n  /** Gap between consecutive cards as the dealer goes round the table. */\n  dealGapS: 0.045,\n  /** A bet or call sliding from the seat out to its spot on the felt. */\n  betSlideS: 0.28,\n  /** The street's bets sweeping into the pot. */\n  collectS: 0.32,\n  /** A folded hand flying to the muck in the middle. */\n  foldS: 0.3,\n  /** The pot sliding to the winner, after the last bets are swept in. */\n  pushDelayS: 0.4,\n  pushS: 0.5,\n  /** Action-timer sweep shown while a seat is to act. */\n  turnTimerS: 20,\n} as const;\n\nexport const TABLE_EASE = [0.22, 1, 0.36, 1] as const;\n\n/** Scale a beat for the play pace. `off` returns 0 so motion snaps. */\nexport function paced(seconds: number, pace: \"normal\" | \"fast\" | \"off\"): number {\n  if (pace === \"off\") return 0;\n  return pace === \"fast\" ? seconds / 3 : seconds;\n}\n",
      "type": "registry:lib",
      "target": "components/native-trainer/motion.ts"
    },
    {
      "path": "src/components/native-trainer/motion-pace.tsx",
      "content": "\"use client\";\n\nimport { createContext, useContext, type ReactNode } from \"react\";\n\nimport type { PlayPace } from \"@/lib/play/pace\";\n\nconst MotionPaceContext = createContext<PlayPace>(\"normal\");\n\n/** Play-table only. Trainer surfaces leave this at the default pace. */\nexport function MotionPaceProvider({ pace, children }: { pace: PlayPace; children: ReactNode }) {\n  return <MotionPaceContext.Provider value={pace}>{children}</MotionPaceContext.Provider>;\n}\n\nexport function useMotionPace(): PlayPace {\n  return useContext(MotionPaceContext);\n}\n",
      "type": "registry:component",
      "target": "components/native-trainer/motion-pace.tsx"
    },
    {
      "path": "src/lib/play/pace.ts",
      "content": "/** How the play table deals cards and waits between actions. */\nexport type PlayPace = \"normal\" | \"fast\" | \"off\";\n\nconst PACES: readonly PlayPace[] = [\"normal\", \"fast\", \"off\"];\n\nexport function isPlayPace(value: unknown): value is PlayPace {\n  return typeof value === \"string\" && (PACES as readonly string[]).includes(value);\n}\n\n/** Pause after each agent action, in milliseconds. Off still yields a frame. */\nexport function agentGapMs(pace: PlayPace): number {\n  if (pace === \"off\") return 0;\n  if (pace === \"fast\") return 160;\n  return 550;\n}\n\n/** How long a finished hand stays up before the next one is dealt. */\nexport function nextHandPauseMs(pace: PlayPace, wonByFold: boolean): number {\n  if (pace === \"off\") return wonByFold ? 350 : 500;\n  if (pace === \"fast\") return wonByFold ? 600 : 900;\n  return wonByFold ? 1800 : 2600;\n}\n",
      "type": "registry:lib",
      "target": "lib/play/pace.ts"
    },
    {
      "path": "src/lib/cards/parse.ts",
      "content": "/**\n * Hole-card strings as they show up in the coach table and the dashboard:\n * compact (`AdJh7s6s4s3h`), spaced (`As Ks`), or Hydra order (`3h4s6s7sJhAd`).\n */\n\nconst RANK_HI = \"AKQJT98765432\";\nconst SUIT_ORDER = \"shdc\";\n\n/** Extract every rank-suit token, normalizing `10s` → `Ts`. */\nexport function splitCards(value: string): string[] {\n  return (value.match(/(?:10|[2-9TJQKA])[cdhs]/gi) ?? []).map((card) =>\n    card.toUpperCase().startsWith(\"10\")\n      ? `T${card.slice(2).toLowerCase()}`\n      : `${card[0].toUpperCase()}${card[1].toLowerCase()}`,\n  );\n}\n\n/**\n * If `value` is nothing but 2/4/5/6 hole cards, return them; otherwise null.\n * Three cards are skipped so a flop cell is not treated as a holding.\n */\nexport function parseHoleCards(value: string): string[] | null {\n  const cards = splitCards(value);\n  if (cards.length < 2 || cards.length > 6 || cards.length === 3) return null;\n  const compact = value.replace(/[\\s,]+/g, \"\").replace(/10/gi, \"T\");\n  if (compact.length !== cards.length * 2) return null;\n  if (compact.toUpperCase() !== cards.join(\"\").toUpperCase()) return null;\n  return cards;\n}\n\n/** High card first (A→2), then spades-hearts-diamonds-clubs within a rank. */\nexport function sortCardsHighFirst(cards: readonly string[]): string[] {\n  return [...cards].sort((a, b) => {\n    const rd = RANK_HI.indexOf(a[0]) - RANK_HI.indexOf(b[0]);\n    if (rd !== 0) return rd;\n    return SUIT_ORDER.indexOf(a[1]) - SUIT_ORDER.indexOf(b[1]);\n  });\n}\n",
      "type": "registry:lib",
      "target": "lib/cards/parse.ts"
    }
  ],
  "type": "registry:block"
}