"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowRight, Flag, ListOrdered, Pause, Play, RotateCcw, Trophy, Volume2, VolumeX, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { DAY, GolfEngine, MAX_STROKES, NIGHT, type GolfPalette, type GolfSound, type HoleResult, type Phase } from "./engine";
import { HOLES } from "./holes";
import { keyBelongsTo, loadJSON, saveJSON, useAutoPause, useFitCanvas, useIsDark, useTones } from "./kit";
export type { GolfPalette } from "./engine";
export interface MiniGolfPhysicsProps {
/** Hole to tee off on (1–9). The round still visits all nine. */
initialLevel?: number;
/** Seed for cosmetic randomness (sand speckles, lip-outs). */
seed?: number;
/** Called with the round's total strokes after the 9th hole. */
onGameOver?: (score: number) => void;
/** Override canvas colours (hex) on top of the day / night palette. */
theme?: Partial<GolfPalette>;
/** localStorage key for best round and per-hole bests. */
storageKey?: string;
title?: string;
className?: string;
}
interface Saved {
bestRound: number | null;
bestHoles: (number | null)[];
rounds: number;
}
interface Hud {
phase: Phase;
paused: boolean;
index: number;
strokes: number;
card: (number | null)[];
total: number;
parSoFar: number;
}
const EMPTY_SAVE: Saved = { bestRound: null, bestHoles: HOLES.map(() => null), rounds: 0 };
const TOTAL_PAR = HOLES.reduce((s, h) => s + h.par, 0);
function toPar(n: number) {
return n === 0 ? "E" : n > 0 ? `+${n}` : `${n}`;
}
export function MiniGolfPhysics({ initialLevel = 1, seed = 11, onGameOver, theme, storageKey = "mini-golf-physics:v1", title = "Tiny Fairways", className }: MiniGolfPhysicsProps) {
const reduce = useReducedMotion() ?? false;
const dark = useIsDark();
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const sizeRef = useFitCanvas(canvasRef);
const engineRef = React.useRef<GolfEngine | null>(null);
const aimRef = React.useRef({ angle: -Math.PI / 2, power: 0.45, visible: false, dragging: false, pointer: -1 });
const [muted, setMuted] = React.useState(true);
const tone = useTones(muted);
const [saved, setSaved] = React.useState<Saved>(EMPTY_SAVE);
const [result, setResult] = React.useState<HoleResult | null>(null);
const [cardOpen, setCardOpen] = React.useState(false);
const [newBest, setNewBest] = React.useState(false);
const [hud, setHud] = React.useState<Hud>({ phase: "ready", paused: false, index: 0, strokes: 0, card: HOLES.map(() => null), total: 0, parSoFar: 0 });
const palette = React.useMemo(() => ({ ...(dark ? NIGHT : DAY), ...theme }), [dark, theme]);
const paletteRef = React.useRef(palette);
React.useEffect(() => {
paletteRef.current = palette;
}, [palette]);
const cb = React.useRef({ onGameOver, tone, storageKey });
React.useEffect(() => {
cb.current = { onGameOver, tone, storageKey };
}, [onGameOver, tone, storageKey]);
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate persisted scores after mount
setSaved(loadJSON<Saved>(storageKey, EMPTY_SAVE));
}, [storageKey]);
const sound = React.useCallback((s: GolfSound, k: number) => {
const t = cb.current.tone;
if (s === "putt") t(180 + k * 120, 0.08, "triangle", 0.06, -60);
else if (s === "wall") t(260, 0.05, "square", 0.02 * k + 0.01, -80);
else if (s === "bump") t(520, 0.14, "sine", 0.05, 380);
else if (s === "cup") {
t(660, 0.12, "sine", 0.05, 0);
setTimeout(() => t(880, 0.2, "sine", 0.05, 0), 110);
} else if (s === "splash") t(140, 0.35, "sawtooth", 0.03, -80);
else if (s === "lip") t(420, 0.1, "square", 0.03, -200);
}, []);
const aimAtCup = React.useCallback(() => {
const e = engineRef.current;
if (!e) return;
const [cx, cy] = e.hole.cup;
aimRef.current.angle = Math.atan2(cy - e.ball.y, cx - e.ball.x);
}, []);
const makeEngine = React.useCallback(() => {
const eng = new GolfEngine({
seed,
startHole: initialLevel,
reduced: reduce,
onSound: sound,
onHoleDone: (index, r) => {
setResult(r);
const key = cb.current.storageKey;
const prev = loadJSON<Saved>(key, EMPTY_SAVE);
const bestHoles = HOLES.map((_, i) => (i === index ? (prev.bestHoles[i] === null || prev.bestHoles[i] === undefined ? r.strokes : Math.min(prev.bestHoles[i]!, r.strokes)) : (prev.bestHoles[i] ?? null)));
const next = { ...prev, bestHoles };
saveJSON(key, next);
setSaved(next);
},
onRoundDone: (total) => {
const key = cb.current.storageKey;
const prev = loadJSON<Saved>(key, EMPTY_SAVE);
const better = prev.bestRound === null || total < prev.bestRound;
const next = { ...prev, rounds: prev.rounds + 1, bestRound: better ? total : prev.bestRound };
saveJSON(key, next);
setSaved(next);
setNewBest(better);
cb.current.onGameOver?.(total);
},
});
engineRef.current = eng;
return eng;
}, [initialLevel, reduce, seed, sound]);
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
let raf = 0;
let last = performance.now();
let lastHud = "";
let lastPhase: Phase = "ready";
const frame = (now: number) => {
const dt = now - last;
last = now;
const eng = engineRef.current ?? makeEngine();
eng.update(dt);
if (eng.phase === "aim" && lastPhase !== "aim" && !aimRef.current.dragging) {
aimAtCup();
}
lastPhase = eng.phase;
const { w, h, dpr } = sizeRef.current;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
eng.render(ctx, w, h, paletteRef.current, aimRef.current);
const v = eng.view(w, h);
canvas.dataset.s = v.s.toFixed(4);
canvas.dataset.ox = v.ox.toFixed(2);
canvas.dataset.oy = v.oy.toFixed(2);
canvas.dataset.ball = `${eng.ball.x.toFixed(1)},${eng.ball.y.toFixed(1)}`;
const next: Hud = { phase: eng.phase, paused: eng.paused, index: eng.index, strokes: eng.strokes, card: [...eng.card], total: eng.total, parSoFar: eng.parSoFar };
const key = JSON.stringify(next);
if (key !== lastHud) {
lastHud = key;
setHud(next);
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [aimAtCup, makeEngine, sizeRef]);
const focusRoot = () => rootRef.current?.focus({ preventScroll: true });
const begin = React.useCallback(() => {
const e = engineRef.current;
if (!e) return;
if (e.phase === "ready") e.start();
e.paused = false;
focusRoot();
}, []);
const restart = React.useCallback(() => {
engineRef.current?.restartRound();
setResult(null);
setNewBest(false);
aimRef.current.visible = false;
focusRoot();
}, []);
const next = React.useCallback(() => {
const e = engineRef.current;
if (!e || e.phase !== "holed") return;
e.nextHole();
setResult(null);
aimRef.current.visible = false;
focusRoot();
}, []);
const togglePause = React.useCallback(() => {
const e = engineRef.current;
if (!e || e.phase === "ready" || e.phase === "complete") return;
e.paused = !e.paused;
}, []);
const shoot = React.useCallback(() => {
const e = engineRef.current;
const a = aimRef.current;
if (e?.shoot(a.angle, a.power)) a.visible = false;
}, []);
useAutoPause(
React.useCallback(() => {
const e = engineRef.current;
if (e && e.phase !== "ready" && e.phase !== "complete") e.paused = true;
}, []),
);
React.useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
if (!keyBelongsTo(ev, rootRef.current)) return;
const e = engineRef.current;
if (!e) return;
const onButton = ev.target instanceof HTMLButtonElement;
const a = aimRef.current;
const k = ev.key;
if (k === "ArrowLeft" || k === "ArrowRight" || k === "ArrowUp" || k === "ArrowDown") {
if (onButton && (k === "ArrowUp" || k === "ArrowDown")) return;
ev.preventDefault();
if (e.phase === "ready") e.start();
if (e.phase !== "aim" || e.paused) return;
a.visible = true;
const fine = ev.shiftKey ? 0.4 : 1;
if (k === "ArrowLeft") a.angle -= 0.05 * fine;
if (k === "ArrowRight") a.angle += 0.05 * fine;
if (k === "ArrowUp") a.power = Math.min(1, a.power + 0.04 * fine);
if (k === "ArrowDown") a.power = Math.max(0.05, a.power - 0.04 * fine);
return;
}
if ((k === " " || k === "Enter") && !onButton) {
ev.preventDefault();
if (e.phase === "ready") begin();
else if (e.paused) begin();
else if (e.phase === "holed") next();
else if (e.phase === "complete") restart();
else if (e.phase === "aim") {
if (!a.visible) a.visible = true;
else shoot();
}
} else if (k === "p" || k === "P") togglePause();
else if (k === "Escape") {
if (cardOpen) setCardOpen(false);
else if (a.visible) a.visible = false;
else togglePause();
} else if (k === "m" || k === "M") setMuted((m) => !m);
else if (k === "r" || k === "R") restart();
else if (k === "c" || k === "C") setCardOpen((o) => !o);
else if (k === "n" || k === "N") next();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [begin, cardOpen, next, restart, shoot, togglePause]);
const worldPoint = (ev: React.PointerEvent) => {
const e = engineRef.current;
const rect = canvasRef.current?.getBoundingClientRect();
if (!e || !rect) return null;
return e.toWorld(ev.clientX - rect.left, ev.clientY - rect.top, rect.width, rect.height);
};
const updateDrag = (ev: React.PointerEvent) => {
const e = engineRef.current;
const p = worldPoint(ev);
if (!e || !p) return;
const dx = e.ball.x - p[0];
const dy = e.ball.y - p[1];
const len = Math.hypot(dx, dy);
const a = aimRef.current;
a.visible = len > 10;
if (len > 10) {
a.angle = Math.atan2(dy, dx);
a.power = Math.min(1, (len - 10) / 150);
}
};
const phase = hud.phase;
const hole = HOLES[hud.index];
const overPar = hud.total - hud.parSoFar;
const paused = hud.paused;
const inPlay = phase !== "ready" && phase !== "complete";
return (
<div
ref={rootRef}
tabIndex={0}
data-phase={phase}
data-paused={paused}
data-hole={hud.index + 1}
data-strokes={hud.strokes}
data-total={hud.total}
aria-label={`${title} game`}
className={cn("relative flex h-[700px] w-full flex-col overflow-hidden bg-background text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-inset", className)}
>
<header className="relative z-10 flex h-14 shrink-0 items-center gap-3 border-b bg-background/85 px-3 backdrop-blur sm:px-5">
<div className="flex min-w-0 items-center gap-2.5">
<GolfMark className="size-8 shrink-0" />
<div className="min-w-0 leading-none">
<p className="hidden truncate text-sm font-black tracking-tight sm:block">{title}</p>
<p className="text-sm font-black sm:hidden">Hole {hud.index + 1}</p>
<p className="mt-1 truncate text-[11px] text-muted-foreground">
<span className="hidden sm:inline">Hole {hud.index + 1} · </span>
{hole.name}
</p>
</div>
</div>
<div className="ml-auto flex items-center gap-3 sm:gap-5">
<Stat label="Par" value={hole.par} />
<Stat label="Strokes" value={hud.strokes} highlight={hud.strokes > hole.par} />
<Stat label="Total" value={toPar(overPar)} className="hidden sm:flex" />
<div className="flex items-center gap-0.5">
<IconButton label={paused || !inPlay ? "Resume (P)" : "Pause (P)"} onClick={paused || phase === "ready" ? begin : togglePause} disabled={phase === "complete"}>
{paused || phase === "ready" ? <Play className="size-4" /> : <Pause className="size-4" />}
</IconButton>
<IconButton label={muted ? "Unmute (M)" : "Mute (M)"} onClick={() => setMuted((m) => !m)} pressed={!muted}>
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
</IconButton>
<IconButton label="Scorecard (C)" onClick={() => setCardOpen((o) => !o)} pressed={cardOpen} className="lg:hidden">
<ListOrdered className="size-4" />
</IconButton>
<IconButton label="Restart round (R)" onClick={restart}>
<RotateCcw className="size-4" />
</IconButton>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1">
<div
className="relative min-h-0 min-w-0 flex-1 touch-none select-none"
onPointerDown={(ev) => {
const e = engineRef.current;
if (!e || e.paused) return;
if (e.phase === "ready") e.start();
if (e.phase !== "aim") return;
aimRef.current.dragging = true;
aimRef.current.pointer = ev.pointerId;
(ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
updateDrag(ev);
}}
onPointerMove={(ev) => {
if (aimRef.current.dragging && aimRef.current.pointer === ev.pointerId) updateDrag(ev);
}}
onPointerUp={(ev) => {
const a = aimRef.current;
if (!a.dragging || a.pointer !== ev.pointerId) return;
a.dragging = false;
updateDrag(ev);
if (a.visible && a.power > 0.04) shoot();
a.visible = false;
}}
onPointerCancel={() => {
aimRef.current.dragging = false;
aimRef.current.visible = false;
}}
>
<canvas ref={canvasRef} className="absolute inset-0 block" role="img" aria-label={`Hole ${hud.index + 1}, ${hole.name}. Par ${hole.par}. ${hud.strokes} strokes so far.`} />
{phase === "aim" && !paused && hud.strokes === 0 && (
<p className="pointer-events-none absolute inset-x-3 top-3 mx-auto w-fit max-w-[92%] rounded-full bg-background/85 px-3 py-1.5 text-center text-xs text-muted-foreground shadow-sm backdrop-blur">
<span className="font-semibold text-foreground">{hole.tip}</span>
<span className="hidden sm:inline"> · drag back or ←→ aim, ↑↓ power, Space putt</span>
</p>
)}
{hud.strokes >= MAX_STROKES - 2 && phase === "aim" && <p className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-amber-500/90 px-3 py-1 text-xs font-bold text-amber-950">{MAX_STROKES - hud.strokes} {MAX_STROKES - hud.strokes === 1 ? "stroke" : "strokes"} before pickup</p>}
<AnimatePresence>
{result && phase === "holed" && (
<motion.div
key={`r${hud.index}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 30, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10 }}
transition={{ type: "spring", stiffness: 280, damping: 24 }}
className="absolute inset-x-3 bottom-4 z-10 mx-auto max-w-sm rounded-2xl border bg-card/95 p-4 text-center shadow-2xl backdrop-blur"
onPointerDown={(e) => e.stopPropagation()}
role="status"
>
<p className="text-xs font-semibold tracking-[0.2em] text-muted-foreground uppercase">Hole {hud.index + 1} · par {result.par}</p>
<p className={cn("mt-1 text-3xl font-black", result.strokes < result.par ? "text-emerald-500" : result.strokes === result.par ? "text-sky-500" : "text-amber-500")}>{result.label}</p>
<p className="mt-1 text-sm text-muted-foreground">
{result.strokes} {result.strokes === 1 ? "stroke" : "strokes"} · round {toPar(overPar)}
</p>
<button type="button" onClick={next} className="mt-3 inline-flex h-10 items-center gap-2 rounded-full bg-emerald-600 px-5 text-sm font-bold text-white shadow-lg shadow-emerald-600/30 transition hover:bg-emerald-500 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none active:scale-95">
{hud.card.filter((c) => c === null).length === 0 ? "See scorecard" : "Next hole"} <ArrowRight className="size-4" />
</button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{(phase === "ready" || paused || phase === "complete") && (
<Overlay key={phase === "complete" ? "c" : paused ? "p" : "r"} reduce={reduce}>
{phase === "ready" && (
<>
<GolfMark className="mx-auto mb-4 size-16" />
<h2 className="text-3xl font-black tracking-tight sm:text-5xl">{title}</h2>
<p className="mx-auto mt-3 max-w-sm text-sm text-muted-foreground">Nine hand-built holes of bumpers, slopes, sliding gates and sneaky water. Real rolling friction, real bounces — and a very judgemental flag.</p>
<PrimaryButton onClick={begin}>
<Flag className="size-4" /> Tee off
</PrimaryButton>
<p className="mt-4 text-xs text-muted-foreground">
Par {TOTAL_PAR} · best round {saved.bestRound ?? "—"}
</p>
</>
)}
{paused && phase !== "ready" && phase !== "complete" && (
<>
<p className="text-xs font-semibold tracking-[0.3em] text-muted-foreground uppercase">Caddie break</p>
<h2 className="mt-2 text-4xl font-black">Paused</h2>
<div className="mt-6 flex justify-center gap-2">
<PrimaryButton onClick={begin} className="mt-0">
<Play className="size-4" /> Resume
</PrimaryButton>
<button type="button" onClick={restart} className="inline-flex h-11 items-center gap-2 rounded-full border bg-background px-5 text-sm font-semibold transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none">
<RotateCcw className="size-4" /> New round
</button>
</div>
</>
)}
{phase === "complete" && (
<>
<p className="text-xs font-bold tracking-[0.3em] text-emerald-500 uppercase">Round complete</p>
<h2 className="mt-2 text-5xl font-black tabular-nums">{hud.total}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{toPar(hud.total - TOTAL_PAR)} against par {TOTAL_PAR}
</p>
{newBest ? (
<motion.p initial={reduce ? false : { scale: 0.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="mx-auto mt-3 inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-emerald-500 to-lime-400 px-3 py-1 text-xs font-bold text-emerald-950">
<Trophy className="size-3.5" /> New best round!
</motion.p>
) : (
<p className="mt-3 text-xs text-muted-foreground">Best round {saved.bestRound ?? "—"}</p>
)}
<div className="mt-5 text-left">
<Scorecard card={hud.card} best={saved.bestHoles} current={-1} compact />
</div>
<PrimaryButton onClick={restart}>
<RotateCcw className="size-4" /> Play again
</PrimaryButton>
</>
)}
</Overlay>
)}
</AnimatePresence>
<AnimatePresence>
{cardOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-30 grid place-items-center bg-background/70 p-4 backdrop-blur-sm lg:hidden"
onPointerDown={(e) => e.stopPropagation()}
role="dialog"
aria-label="Scorecard"
>
<div className="w-full max-w-sm rounded-2xl border bg-card p-4 shadow-2xl">
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-bold">Scorecard</p>
<button type="button" aria-label="Close scorecard" onClick={() => setCardOpen(false)} className="grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none">
<X className="size-4" />
</button>
</div>
<Scorecard card={hud.card} best={saved.bestHoles} current={hud.index} compact />
<p className="mt-3 text-center text-xs text-muted-foreground">
Round {toPar(overPar)} · best round {saved.bestRound ?? "—"}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<aside className="hidden w-72 shrink-0 flex-col gap-5 overflow-y-auto border-l bg-muted/30 p-5 lg:flex">
<section>
<h3 className="mb-2 text-[11px] font-bold tracking-[0.2em] text-muted-foreground uppercase">Now playing</h3>
<div className="rounded-xl border bg-card p-3">
<p className="text-xs text-muted-foreground">Hole {hud.index + 1} · Par {hole.par}</p>
<p className="text-lg font-black">{hole.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{hole.tip}</p>
<div className="mt-3 flex gap-1" aria-label={`${hud.strokes} strokes`}>
{Array.from({ length: MAX_STROKES }, (_, i) => (
<span key={i} className={cn("h-1.5 flex-1 rounded-full", i < hud.strokes ? (i < hole.par ? "bg-emerald-500" : "bg-amber-500") : "bg-muted")} />
))}
</div>
</div>
</section>
<section>
<h3 className="mb-2 text-[11px] font-bold tracking-[0.2em] text-muted-foreground uppercase">Scorecard</h3>
<Scorecard card={hud.card} best={saved.bestHoles} current={hud.index} />
<div className="mt-3 grid grid-cols-3 gap-2">
<MiniStat label="Round" value={toPar(overPar)} />
<MiniStat label="Strokes" value={hud.total} />
<MiniStat label="Best" value={saved.bestRound ?? "—"} />
</div>
</section>
<section className="mt-auto">
<h3 className="mb-2 text-[11px] font-bold tracking-[0.2em] text-muted-foreground uppercase">Controls</h3>
<ul className="space-y-1.5 text-xs text-muted-foreground">
<li className="flex justify-between"><span>Aim + power</span><span>drag back from ball</span></li>
<li className="flex justify-between"><span>Keyboard aim</span><span><Kbd>←→</Kbd> <Kbd>↑↓</Kbd> <Kbd>Shift</Kbd> fine</span></li>
<li className="flex justify-between"><span>Putt</span><Kbd>Space</Kbd></li>
<li className="flex justify-between"><span>Pause · restart</span><span><Kbd>P</Kbd> <Kbd>R</Kbd></span></li>
</ul>
</section>
</aside>
</div>
<p className="sr-only" aria-live="polite">
{phase === "holed" && result ? `${result.label} ${result.strokes} strokes on hole ${hud.index + 1}.` : phase === "complete" ? `Round complete with ${hud.total} strokes.` : paused ? "Paused" : ""}
</p>
</div>
);
}
function Scorecard({ card, best, current, compact }: { card: (number | null)[]; best: (number | null)[]; current: number; compact?: boolean }) {
return (
<div className="overflow-hidden rounded-xl border bg-card">
<table className="w-full table-fixed text-center text-xs tabular-nums">
<thead>
<tr className="bg-muted/60 text-[10px] text-muted-foreground">
<th scope="col" className="w-12 py-1.5 text-left pl-2 font-semibold">Hole</th>
{HOLES.map((_, i) => (
<th key={i} scope="col" className={cn("py-1.5 font-semibold", i === current && "text-foreground")}>
{i + 1}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="border-t">
<th scope="row" className="pl-2 text-left text-[10px] font-semibold text-muted-foreground">Par</th>
{HOLES.map((h, i) => (
<td key={i} className="py-1.5 text-muted-foreground">
{h.par}
</td>
))}
</tr>
<tr className="border-t">
<th scope="row" className="pl-2 text-left text-[10px] font-semibold text-muted-foreground">You</th>
{card.map((v, i) => {
const d = v === null ? 0 : v - HOLES[i].par;
return (
<td key={i} className={cn("py-1.5 font-bold", i === current && v === null && "bg-primary/10")}>
{v === null ? <span className="text-muted-foreground/50">·</span> : <span className={cn("inline-grid size-5 place-items-center", d < 0 && "rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", d > 0 && "rounded-sm bg-amber-500/15 text-amber-700 dark:text-amber-400")}>{v}</span>}
</td>
);
})}
</tr>
{!compact || best.some((b) => b !== null) ? (
<tr className="border-t">
<th scope="row" className="pl-2 text-left text-[10px] font-semibold text-muted-foreground">Best</th>
{best.map((v, i) => (
<td key={i} className="py-1.5 text-muted-foreground">
{v ?? "–"}
</td>
))}
</tr>
) : null}
</tbody>
</table>
</div>
);
}
function Overlay({ children, reduce }: { children: React.ReactNode; reduce: boolean }) {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: reduce ? 0 : 0.2 }} className="absolute inset-0 z-20 grid place-items-center overflow-y-auto bg-background/65 p-5 backdrop-blur-[3px]" onPointerDown={(e) => e.stopPropagation()}>
<motion.div initial={reduce ? false : { y: 18, scale: 0.97 }} animate={{ y: 0, scale: 1 }} transition={{ type: "spring", stiffness: 300, damping: 26 }} className="w-full max-w-md text-center">
{children}
</motion.div>
</motion.div>
);
}
function PrimaryButton({ children, onClick, className }: { children: React.ReactNode; onClick: () => void; className?: string }) {
return (
<button type="button" onClick={onClick} className={cn("mt-6 inline-flex h-11 items-center gap-2 rounded-full bg-gradient-to-r from-emerald-500 to-lime-500 px-6 text-sm font-bold text-emerald-950 shadow-lg shadow-emerald-500/30 transition hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none active:scale-95", className)}>
{children}
</button>
);
}
function IconButton({ label, onClick, children, disabled, pressed, className }: { label: string; onClick: () => void; children: React.ReactNode; disabled?: boolean; pressed?: boolean; className?: string }) {
return (
<button
type="button"
aria-label={label}
title={label}
aria-pressed={pressed}
disabled={disabled}
onClick={onClick}
className={cn("grid size-9 place-items-center rounded-lg text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:opacity-40", pressed && "bg-accent text-foreground", className)}
>
{children}
</button>
);
}
function Stat({ label, value, highlight, className }: { label: string; value: React.ReactNode; highlight?: boolean; className?: string }) {
return (
<div className={cn("flex flex-col items-end leading-none", className)}>
<span className="text-[9px] font-semibold tracking-[0.18em] text-muted-foreground uppercase">{label}</span>
<span className={cn("mt-1 text-lg font-black tabular-nums", highlight && "text-amber-500")}>{value}</span>
</div>
);
}
function MiniStat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="rounded-lg border bg-card px-2 py-2 text-center">
<p className="text-[9px] tracking-wider text-muted-foreground uppercase">{label}</p>
<p className="mt-0.5 text-base font-bold tabular-nums">{value}</p>
</div>
);
}
function Kbd({ children }: { children: React.ReactNode }) {
return <kbd className="rounded border bg-card px-1.5 py-0.5 font-mono text-[10px] text-foreground">{children}</kbd>;
}
function GolfMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 40 40" className={className} aria-hidden>
<rect width="40" height="40" rx="11" fill="#16a34a" />
<path d="M0 26 Q20 18 40 26 V40 H0Z" fill="#15803d" />
<ellipse cx="25" cy="27" rx="5" ry="2.2" fill="#052e16" />
<path d="M25 27 V9" stroke="#f8fafc" strokeWidth="1.8" strokeLinecap="round" />
<path d="M25.6 9 L34 12.5 L25.6 16Z" fill="#ef4444" />
<circle cx="12" cy="29" r="3.6" fill="#fff" />
<circle cx="11" cy="28" r="1" fill="#e5e7eb" />
</svg>
);
}