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