{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "trainer-feedback",
  "title": "Trainer feedback and history",
  "description": "What the trainer shows after a decision: the grade overlay and toast, tier badges, the session strip, the end-of-hand summary, the action strip and the hand history panel.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://www.pokerstudy.ai/r/trainer-lib.json",
    "https://www.pokerstudy.ai/r/trainer-theme.json",
    "https://www.pokerstudy.ai/r/table-themes.json",
    "https://www.pokerstudy.ai/r/trainer-cards.json",
    "https://www.pokerstudy.ai/r/gto-strategy-bar.json",
    "https://www.pokerstudy.ai/r/side-panel.json",
    "https://www.pokerstudy.ai/r/poker-table.json"
  ],
  "files": [
    {
      "path": "src/components/trainer/feedback.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { actionEvBb, TIER_LABEL, type GradeResult } from \"@/lib/trainer/grade\";\nimport { EvFigure } from \"./history-panel\";\nimport type { HandDecision } from \"@/lib/trainer/postflop/state\";\nimport { computeAccuracy } from \"@/lib/trainer/state\";\nimport type { FeedbackTier } from \"@/lib/trainer/grade\";\nimport type { NodeAction } from \"@/lib/trainer/types\";\n\nimport { NativeFeedbackOverlay } from \"@/components/native-trainer/feedback\";\n\nimport { GtoStrategyBar } from \"./gto-strategy-bar\";\nimport { useTrainerSkin } from \"./skin\";\nimport { TIER_COLORS } from \"./theme\";\n\n/**\n * Preflop feedback: a blocking card with the grade, what GTO does here, and an\n * auto-advance timer. Preflop drills are one decision per hand, so pausing on\n * the answer is the point.\n */\nexport function FeedbackOverlay(props: {\n  grade: GradeResult;\n  actions: NodeAction[];\n  autoAdvanceMs?: number;\n  onAdvance: () => void;\n}) {\n  const skin = useTrainerSkin();\n  if (skin === \"native\") return <NativeFeedbackOverlay {...props} />;\n  return <WebFeedbackOverlay {...props} />;\n}\n\nfunction WebFeedbackOverlay({\n  grade,\n  actions,\n  autoAdvanceMs = 2200,\n  onAdvance,\n}: {\n  grade: GradeResult;\n  actions: NodeAction[];\n  autoAdvanceMs?: number;\n  onAdvance: () => void;\n}) {\n  useEffect(() => {\n    const t = setTimeout(onAdvance, autoAdvanceMs);\n    return () => clearTimeout(t);\n  }, [autoAdvanceMs, onAdvance]);\n\n  return (\n    <div\n      role=\"dialog\"\n      aria-label=\"Hand feedback\"\n      onClick={onAdvance}\n      className=\"fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-6\"\n    >\n      <div\n        onClick={(e) => e.stopPropagation()}\n        className=\"animate-in fade-in flex w-full max-w-md flex-col gap-4 rounded-xl border border-border bg-card p-6 shadow-2xl duration-150\"\n      >\n        <TierBadge tier={grade.tier} />\n        <p className=\"text-sm leading-6 text-foreground\">\n          You chose <span className=\"font-semibold\">{grade.chosen}</span> —{\" \"}\n          {grade.outOfRange\n            ? \"this hand is not in the actor's range\"\n            : `GTO plays it ${Math.round(grade.freq * 100)}% of the time`}\n          .{\" \"}\n          <EvFigure\n            evBb={actionEvBb(grade.chosenEv, \"sb\")}\n            unpriced={grade.basis === \"estimated\" && grade.chosen !== \"Fold\"}\n            uncertain={grade.evUncertain}\n          />\n        </p>\n        <GtoStrategyBar\n          strategy={grade.strategy}\n          actions={actions}\n          highlightAction={grade.chosen}\n        />\n        <p className=\"text-center text-xs text-muted-foreground\">Click to continue</p>\n      </div>\n    </div>\n  );\n}\n\n/**\n * Postflop feedback: a non-blocking pill that fades in over the table after each\n * decision while the hand plays on. The detailed breakdown waits for the\n * end-of-hand summary.\n */\nexport function GradeToast({\n  decision,\n  showKey,\n}: {\n  decision: HandDecision | null;\n  showKey: number;\n}) {\n  // Mounted fresh per decision by the caller's `key`, so \"show, then hide after\n  // 1.4s\" needs no state sync — the timer is the only thing that runs.\n  const [hidden, setHidden] = useState(false);\n\n  useEffect(() => {\n    const t = setTimeout(() => setHidden(true), 1400);\n    return () => clearTimeout(t);\n  }, []);\n\n  if (!decision || hidden || showKey <= 0) return null;\n\n  return (\n    <div\n      aria-live=\"polite\"\n      className=\"pointer-events-none absolute inset-x-0 top-8 z-30 flex justify-center\"\n    >\n      <span\n        key={showKey}\n        style={{ backgroundColor: TIER_COLORS[decision.tier] }}\n        className=\"animate-in fade-in slide-in-from-top-2 flex items-baseline gap-2 rounded-full px-4 py-1.5 text-white duration-150\"\n      >\n        <span className=\"text-sm font-bold uppercase tracking-wide\">\n          {TIER_LABEL[decision.tier]}\n        </span>\n        <span className=\"text-xs font-semibold opacity-90\">\n          GTO {Math.round(decision.freq * 100)}%\n        </span>\n        <span className=\"text-xs font-semibold opacity-90\">\n          <EvFigure\n            evBb={actionEvBb(decision.chosenEv, \"bb\")}\n            unpriced={\n              decision.basis === \"estimated\" &&\n              decision.label !== \"Fold\" &&\n              decision.label !== \"Check\"\n            }\n          />\n        </span>\n      </span>\n    </div>\n  );\n}\n\nexport function TierBadge({ tier }: { tier: FeedbackTier }) {\n  return (\n    <span\n      style={{ backgroundColor: TIER_COLORS[tier] }}\n      className=\"self-start rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide text-white\"\n    >\n      {TIER_LABEL[tier]}\n    </span>\n  );\n}\n\n/** Session counters above the table: hands drilled and the correct rate. */\nexport function StatsStrip({\n  tally,\n  evBb,\n}: {\n  tally: Record<FeedbackTier, number>;\n  evBb?: number | null;\n}) {\n  const { hands, correctPct } = computeAccuracy(tally);\n  return (\n    <div className=\"flex items-baseline gap-1.5 text-xs\">\n      <span className=\"font-mono font-semibold text-foreground\">{hands}</span>\n      <span className=\"text-muted-foreground\">hands</span>\n      <span className=\"text-muted-foreground/50\">·</span>\n      <span className=\"font-mono font-semibold text-foreground\">\n        {hands === 0 ? \"—\" : `${correctPct}%`}\n      </span>\n      <span className=\"text-muted-foreground\">correct</span>\n      {hands > 0 ? (\n        <>\n          <span className=\"text-muted-foreground/50\">·</span>\n          <span className=\"font-mono text-xs\">\n            <EvFigure evBb={evBb ?? null} />\n          </span>\n        </>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/trainer/feedback.tsx"
    },
    {
      "path": "src/components/native-trainer/feedback.tsx",
      "content": "\"use client\";\n\nimport { useEffect } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nimport { GtoStrategyBar } from \"@/components/trainer/gto-strategy-bar\";\nimport { TIER_COLORS } from \"@/components/trainer/theme\";\nimport { actionEvBb, TIER_LABEL, type GradeResult } from \"@/lib/trainer/grade\";\nimport { EvFigure } from \"@/components/trainer/history-panel\";\nimport type { NodeAction } from \"@/lib/trainer/types\";\n\nimport { SPRING } from \"./motion\";\n\n/**\n * Blocking grade card from the mobile Train tab: springs onto the felt,\n * holds longer on a miss, tap to continue.\n */\nexport function NativeFeedbackOverlay({\n  grade,\n  actions,\n  autoAdvanceMs = 2200,\n  onAdvance,\n}: {\n  grade: GradeResult;\n  actions: NodeAction[];\n  autoAdvanceMs?: number;\n  onAdvance: () => void;\n}) {\n  const reduce = useReducedMotion();\n\n  useEffect(() => {\n    const t = setTimeout(onAdvance, autoAdvanceMs);\n    return () => clearTimeout(t);\n  }, [autoAdvanceMs, onAdvance]);\n\n  return (\n    <div\n      role=\"dialog\"\n      aria-label=\"Hand feedback\"\n      onClick={onAdvance}\n      className=\"fixed inset-0 z-40 flex items-center justify-center bg-black/65 p-6 backdrop-blur-[2px]\"\n    >\n      <motion.div\n        onClick={(e) => e.stopPropagation()}\n        initial={reduce ? false : { scale: 0.82, y: 24, opacity: 0 }}\n        animate={{ scale: 1, y: 0, opacity: 1 }}\n        transition={SPRING}\n        className=\"flex w-full max-w-md flex-col gap-4 rounded-2xl border border-teal-400/20 bg-[#0b1524] p-6 shadow-[0_24px_60px_rgba(0,0,0,0.55)]\"\n      >\n        <span\n          style={{ backgroundColor: TIER_COLORS[grade.tier] }}\n          className=\"self-start rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide text-white\"\n        >\n          {TIER_LABEL[grade.tier]}\n        </span>\n        <p className=\"text-sm leading-6 text-white\">\n          You chose <span className=\"font-semibold\">{grade.chosen}</span> —{\" \"}\n          {grade.outOfRange\n            ? \"this hand is not in the actor's range\"\n            : `GTO plays it ${Math.round(grade.freq * 100)}% of the time`}\n          .{\" \"}\n          <EvFigure\n            evBb={actionEvBb(grade.chosenEv, \"sb\")}\n            unpriced={grade.basis === \"estimated\" && grade.chosen !== \"Fold\"}\n            uncertain={grade.evUncertain}\n          />\n        </p>\n        <GtoStrategyBar\n          strategy={grade.strategy}\n          actions={actions}\n          highlightAction={grade.chosen}\n        />\n        <p className=\"text-center text-xs text-teal-100/50\">Tap to continue</p>\n      </motion.div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/native-trainer/feedback.tsx"
    },
    {
      "path": "src/components/trainer/history-panel.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { ChevronDown, X } from \"lucide-react\";\n\nimport { TIER_LABEL, type FeedbackTier } from \"@/lib/trainer/grade\";\nimport { SidePanel } from \"@/components/ui/side-panel\";\n\nimport { PlayingCard } from \"./cards\";\nimport { TIER_COLORS } from \"./theme\";\n\n/**\n * One graded decision in a history row. Both drill loops reduce to this: the\n * preflop trainer has exactly one per hand, the postflop loop has one per\n * street (plus the preflop line in full-hand mode).\n */\nexport interface HistoryDecision {\n  /** \"Preflop\" / \"Flop\" / … — omitted when the hand has a single decision. */\n  phase?: string;\n  label: string;\n  tier: FeedbackTier;\n  /** The solver's frequency for the chosen action, 0–1. */\n  freq: number;\n  /** What GTO actually does here, biggest first. */\n  gtoMix?: { label: string; freq: number }[];\n  /** Chosen-action EV in bb. `null` when Hydra never priced this line. */\n  evBb?: number | null;\n  /** EV given up versus the solver's best line, in bb. 0 when the action is in the mix. */\n  evLossBb?: number;\n  /** Raise/call the solver never takes — pack has no EV row, but the line is worse than fold. */\n  unpriced?: boolean;\n  /** The EV is an estimate with too large a standard error to show or score by. */\n  evUncertain?: boolean;\n}\n\nexport interface HistoryItem {\n  id: string;\n  /** Hero's holding, as cards when we have them (\"As\",\"Kd\"). */\n  cards?: string[];\n  /** Fallback title when there are no cards — e.g. a PLO class string. */\n  title?: string;\n  /** The spot: a preflop line, or a board + how the hand ended. */\n  subtitle: string;\n  decisions: HistoryDecision[];\n}\n\n/**\n * Session hand history, newest first — ported from the mobile app's\n * TrainerHistory.\n *\n * Desktop docks this beside the table (open by default). Phones still get the\n * same content as a drawer — a persistent column would crush the felt.\n *\n * Session-scoped on purpose, matching mobile: it clears on reload, because the\n * durable record is the DB behind the Stats drawer. This is \"what did I just\n * do\", not an archive.\n */\nexport function HistoryPanel({\n  items,\n  onClose,\n  variant = \"sidebar\",\n}: {\n  items: HistoryItem[];\n  onClose: () => void;\n  variant?: \"sidebar\" | \"drawer\";\n}) {\n  const body =\n    items.length === 0 ? (\n      <p className=\"flex flex-1 items-center justify-center px-5 text-center text-sm text-muted-foreground\">\n        No hands yet this session. Every hand you play shows up here with its grade.\n      </p>\n    ) : (\n      <div className=\"flex flex-col gap-2\">\n        {items.map((item) => (\n          <HistoryRow key={item.id} item={item} />\n        ))}\n      </div>\n    );\n\n  if (variant === \"drawer\") {\n    return (\n      <SidePanel title=\"History\" onClose={onClose}>\n        {body}\n      </SidePanel>\n    );\n  }\n\n  return (\n    <aside\n      aria-label=\"History\"\n      className=\"flex h-full min-h-0 w-full flex-col border-l border-border bg-card\"\n    >\n      <header className=\"flex shrink-0 items-center justify-between border-b border-border px-5 py-4\">\n        <div className=\"flex min-w-0 items-baseline gap-2\">\n          <h2 className=\"text-base font-semibold text-foreground\">History</h2>\n          {items.length > 0 ? (\n            <SessionEv items={items} />\n          ) : null}\n        </div>\n        <button\n          type=\"button\"\n          onClick={onClose}\n          aria-label=\"Close history\"\n          className=\"rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n        >\n          <X className=\"size-4\" />\n        </button>\n      </header>\n      <div className=\"flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-5 py-5\">{body}</div>\n    </aside>\n  );\n}\n\nfunction SessionEv({ items }: { items: HistoryItem[] }) {\n  const loss = sessionEvLossBb(items);\n  return (\n    <span className=\"text-[11px] tabular-nums text-muted-foreground\">\n      lost{\" \"}\n      <span className={loss > 0 ? \"text-rose-400\" : undefined}>{loss.toFixed(2)} bb</span>\n    </span>\n  );\n}\n\nconst TIER_RANK: Record<FeedbackTier, number> = {\n  perfect: 0,\n  correct: 1,\n  inaccuracy: 2,\n  mistake: 3,\n  blunder: 4,\n};\n\n/** Worst grade in the hand. A single blunder is the grade the row shows. */\nexport function handGrade(decisions: readonly HistoryDecision[]): FeedbackTier | null {\n  if (decisions.length === 0) return null;\n  return decisions.reduce(\n    (worst, d) => (TIER_RANK[d.tier] > TIER_RANK[worst] ? d.tier : worst),\n    decisions[0].tier,\n  );\n}\n\n/** Measured EV given up across the hand, in bb. In-mix actions contribute 0. */\nexport function handEvLossBb(decisions: readonly HistoryDecision[]): number {\n  return decisions.reduce((sum, d) => sum + (d.evLossBb ?? 0), 0);\n}\n\nexport function sessionEvLossBb(items: readonly HistoryItem[]): number {\n  return items.reduce((sum, item) => sum + handEvLossBb(item.decisions), 0);\n}\n\nexport function streetLossLabel(d: HistoryDecision): string {\n  const loss = d.evLossBb ?? 0;\n  if (d.evUncertain && loss <= 0) return \"EV uncertain\";\n  if (loss <= 0 && (d.tier === \"mistake\" || d.tier === \"blunder\")) return \"unmeasured\";\n  return `${loss.toFixed(2)} bb`;\n}\n\n/** One hand in the history list. Exported for layouts that show the log outside the drawer. */\nexport function HistoryRow({ item }: { item: HistoryItem }) {\n  const [open, setOpen] = useState(false);\n  const loss = handEvLossBb(item.decisions);\n  const grade = handGrade(item.decisions);\n  return (\n    <div className=\"flex flex-col gap-2 rounded-xl border border-border bg-card/40 p-3\">\n      <button\n        type=\"button\"\n        aria-expanded={open}\n        aria-label={open ? \"Hide street detail\" : \"Show street detail\"}\n        onClick={() => setOpen((v) => !v)}\n        className=\"flex w-full items-center gap-2 text-left\"\n      >\n        {item.cards && item.cards.length > 0 ? (\n          <div className=\"flex shrink-0 items-center gap-1\">\n            {item.cards.map((c, i) => (\n              <PlayingCard key={`${item.id}-${i}`} card={c} size=\"xs\" />\n            ))}\n          </div>\n        ) : (\n          <span className=\"font-mono text-sm font-semibold text-foreground\">{item.title}</span>\n        )}\n        <span\n          className={`ml-auto shrink-0 text-[11px] tabular-nums ${loss > 0 ? \"text-rose-400\" : \"text-muted-foreground\"}`}\n        >\n          lost {loss.toFixed(2)} bb\n        </span>\n        {grade ? (\n          <span\n            style={{ backgroundColor: TIER_COLORS[grade] }}\n            className=\"min-w-[68px] shrink-0 rounded-full px-2 py-0.5 text-center text-[10px] font-bold uppercase tracking-wide text-white\"\n          >\n            {TIER_LABEL[grade]}\n          </span>\n        ) : null}\n        <ChevronDown\n          className={`size-4 shrink-0 text-muted-foreground transition-transform ${open ? \"rotate-180\" : \"\"}`}\n          aria-hidden\n        />\n      </button>\n\n      {open ? (\n        <div className=\"flex flex-col gap-1.5 border-t border-border pt-2\">\n          {item.subtitle ? (\n            <span className=\"text-[11px] leading-4 text-muted-foreground\">{item.subtitle}</span>\n          ) : null}\n          {item.decisions.map((d, i) => (\n            <div key={i} className=\"flex flex-col gap-0.5\">\n              <div className=\"flex items-center gap-2\">\n                {d.phase ? (\n                  <span className=\"w-12 shrink-0 text-[11px] text-muted-foreground\">{d.phase}</span>\n                ) : null}\n                <span className=\"flex-1 truncate text-xs font-semibold text-foreground\">\n                  {d.label}\n                </span>\n                <span\n                  className={`text-[11px] tabular-nums ${(d.evLossBb ?? 0) > 0 ? \"text-rose-400\" : \"text-muted-foreground\"}`}\n                >\n                  {streetLossLabel(d)}\n                </span>\n                <span className=\"text-[11px] tabular-nums text-muted-foreground\">\n                  GTO {Math.round(d.freq * 100)}%\n                </span>\n                <span\n                  style={{ backgroundColor: TIER_COLORS[d.tier] }}\n                  className=\"min-w-[68px] rounded-full px-2 py-0.5 text-center text-[10px] font-bold uppercase tracking-wide text-white\"\n                >\n                  {TIER_LABEL[d.tier]}\n                </span>\n              </div>\n              {d.gtoMix && d.gtoMix.length > 0 ? (\n                <span className={`truncate text-[11px] text-muted-foreground ${d.phase ? \"pl-14\" : \"\"}`}>\n                  {d.gtoMix.map((m) => `${m.label} ${Math.round(m.freq * 100)}%`).join(\" · \")}\n                </span>\n              ) : null}\n            </div>\n          ))}\n          {item.decisions.length === 0 ? (\n            <span className=\"text-[11px] text-muted-foreground\">No graded decisions.</span>\n          ) : null}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nexport function sessionEvBb(items: HistoryItem[]): number | null {\n  let sum = 0;\n  let measured = false;\n  for (const item of items) {\n    for (const d of item.decisions) {\n      if (d.evBb == null) continue;\n      sum += d.evBb;\n      measured = true;\n    }\n  }\n  return measured ? sum : null;\n}\n\nexport function sessionUnpriced(items: HistoryItem[]): number {\n  let n = 0;\n  for (const item of items) {\n    for (const d of item.decisions) if (d.unpriced) n += 1;\n  }\n  return n;\n}\n\n/** `+0.12` / `−0.40` / `0.00` / `EV < 0` when Hydra never stored the raise. */\nexport function EvFigure({\n  evBb,\n  unpriced,\n  uncertain,\n}: {\n  evBb?: number | null;\n  unpriced?: boolean;\n  /** The solver's EV here is a noisy estimate: say so instead of showing a number. */\n  uncertain?: boolean;\n}) {\n  if (uncertain) {\n    return (\n      <span\n        className=\"text-amber-400\"\n        title=\"The solver's EV for this line is an estimate with a large error, so it is not used to grade.\"\n      >\n        EV uncertain\n      </span>\n    );\n  }\n  if (unpriced) {\n    return <span className=\"text-rose-400\">EV &lt; 0</span>;\n  }\n  if (evBb == null) {\n    return <span className=\"text-muted-foreground\">EV —</span>;\n  }\n  const abs = Math.abs(evBb).toFixed(2);\n  const label = evBb > 0 ? `+${abs}` : evBb < 0 ? `−${abs}` : \"0.00\";\n  const color =\n    evBb > 0 ? \"text-emerald-400\" : evBb < 0 ? \"text-rose-400\" : \"text-muted-foreground\";\n  return <span className={color}>EV {label}</span>;\n}\n",
      "type": "registry:component",
      "target": "components/trainer/history-panel.tsx"
    },
    {
      "path": "src/components/trainer/hand-summary.tsx",
      "content": "\"use client\";\n\nimport { TIER_LABEL } from \"@/lib/trainer/grade\";\nimport type { HandDecision } from \"@/lib/trainer/postflop/state\";\nimport type { DrillTerminal } from \"@/lib/trainer/postflop/types\";\n\nimport { TIER_COLORS } from \"./theme\";\n\nconst PHASE_LABEL: Record<HandDecision[\"phase\"], string> = {\n  preflop: \"Preflop\",\n  flop: \"Flop\",\n  turn: \"Turn\",\n  river: \"River\",\n};\n\n/** Dataset action tokens → readable words (\"111%\" → \"Raise 111%\"). */\nexport function readable(label: string): string {\n  if (label.endsWith(\"%\") && !label.includes(\" \")) return `Raise ${label}`;\n  if (label === \"Min\") return \"Min-raise\";\n  if (label === \"AI\" || label === \"AllIn\") return \"All-in\";\n  const plo6 = label.match(/^Raise-([\\d.]+)x$/i);\n  if (plo6) return `Raise ${plo6[1]}bb`;\n  if (/^(Open|3B|4B|5B|6B|7B|8B)(-\\d+)?$/.test(label)) return \"Raise\";\n  return label;\n}\n\n/** \"Call 71% · Fold 25%\" — what GTO actually does at this decision. */\nfunction mixLine(mix: HandDecision[\"gtoMix\"]): string {\n  return mix.map((m) => `${readable(m.label)} ${Math.round(m.freq * 100)}%`).join(\" · \");\n}\n\n/**\n * End-of-hand summary: how the hand ended, every hero decision with its grade +\n * GTO frequency, and the EV cost of the mistakes. Exact EV deltas exist only\n * where the solver actually plays the chosen action, so zero-frequency picks are\n * counted separately — the number never pretends to more precision than the data\n * has.\n */\nexport function HandSummaryCard({\n  terminal,\n  decisions,\n  onNext,\n}: {\n  terminal: DrillTerminal;\n  decisions: HandDecision[];\n  onNext: () => void;\n}) {\n  const measuredLoss = decisions.reduce((s, d) => s + (d.basis === \"ev\" ? d.evLoss : 0), 0);\n  const unmeasured = decisions.filter(\n    (d) => d.basis === \"estimated\" && (d.tier === \"mistake\" || d.tier === \"blunder\"),\n  ).length;\n\n  return (\n    <div\n      role=\"dialog\"\n      aria-label=\"Hand summary\"\n      onClick={onNext}\n      className=\"absolute inset-0 z-40 flex items-center justify-center bg-black/60 p-6\"\n    >\n      <div\n        onClick={(e) => e.stopPropagation()}\n        className=\"animate-in fade-in flex w-full max-w-md flex-col gap-4 rounded-xl border border-border bg-card p-6 shadow-2xl duration-150\"\n      >\n        <h2 className=\"text-base font-semibold text-foreground\">{terminal.note}</h2>\n\n        <div className=\"flex flex-col gap-3\">\n          {decisions.map((d, i) => (\n            <div key={i} className=\"flex flex-col gap-0.5\">\n              <div className=\"flex items-center gap-2\">\n                <span className=\"w-14 shrink-0 text-xs text-muted-foreground\">\n                  {PHASE_LABEL[d.phase]}\n                </span>\n                <span className=\"flex-1 truncate text-sm font-semibold text-foreground\">\n                  {readable(d.label)}\n                </span>\n                <span\n                  style={{ backgroundColor: TIER_COLORS[d.tier] }}\n                  className=\"min-w-[76px] rounded-full px-2 py-0.5 text-center text-[11px] font-bold uppercase tracking-wide text-white\"\n                >\n                  {TIER_LABEL[d.tier]}\n                </span>\n              </div>\n              {d.gtoMix.length > 0 && (\n                <span className=\"truncate pl-16 text-xs text-muted-foreground\">\n                  GTO: {mixLine(d.gtoMix)}\n                </span>\n              )}\n            </div>\n          ))}\n          {decisions.length === 0 && (\n            <p className=\"text-xs text-muted-foreground\">No graded decisions this hand.</p>\n          )}\n        </div>\n\n        <div className=\"flex items-baseline gap-2 border-t border-border pt-3\">\n          <span className=\"text-sm font-semibold text-foreground\">\n            EV {measuredLoss === 0 ? \"0.00\" : `−${measuredLoss.toFixed(2)}`} bb\n          </span>\n          {unmeasured > 0 && (\n            <span className=\"text-xs text-muted-foreground\">\n              + {unmeasured} unmeasured {unmeasured === 1 ? \"error\" : \"errors\"}\n            </span>\n          )}\n        </div>\n\n        <button\n          type=\"button\"\n          onClick={onNext}\n          className=\"rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90\"\n        >\n          Next hand\n        </button>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/trainer/hand-summary.tsx"
    },
    {
      "path": "src/components/trainer/hand-strip.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nimport { MiniCard } from \"./cards\";\n\n/** One chip of the hand timeline: a position action, a dealt card, or the\n *  trailing hero prompt. */\nexport type StripStep =\n  | { kind: \"action\"; pos: string; label: string }\n  | { kind: \"card\"; card: string }\n  | { kind: \"take-action\"; pos: string };\n\n/**\n * The continuous hand timeline: preflop folds/raises, dealt cards, postflop\n * actions and the hero's \"Take action\" prompt flow through one horizontally\n * scrolling strip that follows the hand.\n */\nexport function HandActionStrip({ steps }: { steps: StripStep[] }) {\n  const ref = useRef<HTMLDivElement>(null);\n\n  // Follow the action: keep the newest chip in view as the hand plays on.\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const t = setTimeout(() => el.scrollTo({ left: el.scrollWidth, behavior: \"smooth\" }), 50);\n    return () => clearTimeout(t);\n  }, [steps.length]);\n\n  return (\n    <div ref={ref} className=\"flex items-center gap-2 overflow-x-auto px-1 py-1\">\n      {steps.map((step, i) => {\n        if (step.kind === \"card\") {\n          return <MiniCard key={i} card={step.card} className=\"h-8 w-6 text-xl\" />;\n        }\n        if (step.kind === \"take-action\") {\n          return (\n            <span\n              key={i}\n              data-strip-chip=\"take-action\"\n              className=\"flex shrink-0 items-center gap-2 rounded-full border border-emerald-500 py-1 pl-1 pr-3 text-xs font-semibold text-foreground\"\n            >\n              <PosChip pos={step.pos} />\n              Take action\n            </span>\n          );\n        }\n        const folded = step.label === \"Fold\";\n        return (\n          <span\n            key={i}\n            data-strip-chip={folded ? \"fold\" : \"action\"}\n            className={`flex shrink-0 items-center gap-2 rounded-full bg-secondary py-1 pl-1 pr-3 text-xs font-semibold ${\n              folded ? \"text-muted-foreground opacity-70\" : \"text-foreground\"\n            }`}\n          >\n            <PosChip pos={step.pos} muted={folded} />\n            {step.label}\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n\n/**\n * Preflop-only variant: the action so far for the current spot, one pill per\n * position that has acted (\"UTG Raise 100%\", \"HJ Fold\").\n */\nexport function SpotActionStrip({ history }: { history: string; stack?: number }) {\n  const tokens = history.split(\"_\");\n  const acted: { position: string; action: string }[] = [];\n  for (let i = 0; i + 1 < tokens.length; i += 2) {\n    acted.push({ position: tokens[i], action: tokens[i + 1] });\n  }\n  if (acted.length === 0) return null;\n\n  return (\n    <div className=\"flex items-center gap-2 overflow-x-auto px-1 py-1\">\n      {acted.map((seat) => {\n        const folded = seat.action === \"Fold\";\n        return (\n          <span\n            key={`${seat.position}-${seat.action}`}\n            className={`flex shrink-0 items-center gap-2 rounded-full bg-secondary py-1 pl-1 pr-3 text-xs font-semibold ${\n              folded ? \"text-muted-foreground opacity-70\" : \"text-foreground\"\n            }`}\n          >\n            <PosChip pos={seat.position} muted={folded} />\n            {actionLabel(seat.action)}\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n\n// PLO depth-raise tokens read nicer than \"Raise 3B\"; NL raises are \"<pct>%\".\nconst PLO_LABEL: Record<string, string> = {\n  Open: \"Raise\",\n  \"3B\": \"3-Bet\",\n  \"4B\": \"4-Bet\",\n  \"5B\": \"5-Bet\",\n  \"6B\": \"6-Bet\",\n};\n\nfunction actionLabel(action: string): string {\n  if (action === \"Fold\" || action === \"Call\" || action === \"All-in\" || action === \"AllIn\") {\n    return action === \"AllIn\" ? \"All-in\" : action;\n  }\n  const plo6 = action.match(/^Raise-([\\d.]+)x$/i);\n  if (plo6) return `Raise ${plo6[1]}bb`;\n  return PLO_LABEL[action] ?? `Raise ${action}`;\n}\n\nfunction PosChip({ pos, muted }: { pos: string; muted?: boolean }) {\n  return (\n    <span\n      data-pos-chip={muted ? \"muted\" : \"live\"}\n      className={`min-w-9 rounded-full px-2 py-0.5 text-center text-[11px] font-bold tracking-wide ${\n        muted ? \"bg-muted text-muted-foreground\" : \"bg-background text-foreground\"\n      }`}\n    >\n      {pos}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/trainer/hand-strip.tsx"
    }
  ],
  "type": "registry:component"
}