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