"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Gauge, Heart, Keyboard, Pause, Play, RotateCcw, Share2, Snowflake, Sparkles, Target, Trophy, Volume2, VolumeX, Zap } from "lucide-react";
import { cn } from "@/lib/utils";
import { DAY, DIFFICULTIES, DIFFICULTY_ORDER, NIGHT, POWER_INFO, TypeEngine, type Difficulty, type Stats, type Status, type TypePalette, type TypeSound } from "./engine";
import { keyBelongsTo, loadJSON, saveJSON, useAutoPause, useFitCanvas, useIsDark, useTones } from "./kit";
import { POWER_WORDS, WORDS } from "./words";
export type { Difficulty, TypePalette } from "./engine";
export interface TypeDefenderProps {
/** Level to start on (1–20). Higher levels fall faster and spawn more often. */
initialLevel?: number;
/** Starting difficulty; the player can change it on the start screen. */
difficulty?: Difficulty;
/** Seed for word order and bubble placement (tests, daily challenges). */
seed?: number;
/** Replace the built-in vocabulary (lowercase a–z words, 3–10 letters work best). */
words?: string[];
/** Called with the final score when the base runs out of hearts. */
onGameOver?: (score: number) => void;
/** Override canvas colours (hex) on top of the day / night palette. */
theme?: Partial<TypePalette>;
/** localStorage key for best scores and preferences. */
storageKey?: string;
title?: string;
className?: string;
}
interface Best {
score: number;
wpm: number;
}
interface Saved {
difficulty: Difficulty;
best: Record<Difficulty, Best>;
games: number;
}
interface Hud extends Stats {
status: Status;
}
const EMPTY_BEST: Record<Difficulty, Best> = { easy: { score: 0, wpm: 0 }, normal: { score: 0, wpm: 0 }, hard: { score: 0, wpm: 0 } };
const toHud = (e: TypeEngine): Hud => ({ status: e.status, ...e.stats() });
export function TypeDefender({ initialLevel = 1, difficulty: initialDifficulty = "normal", seed = 2026, words, onGameOver, theme, storageKey = "type-defender:v1", title = "Type Defender", className }: TypeDefenderProps) {
const reduce = useReducedMotion() ?? false;
const dark = useIsDark();
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const sizeRef = useFitCanvas(canvasRef);
const engineRef = React.useRef<TypeEngine | null>(null);
const runRef = React.useRef(0);
const [muted, setMuted] = React.useState(true);
const tone = useTones(muted);
const [difficulty, setDifficulty] = React.useState<Difficulty>(initialDifficulty);
const [saved, setSaved] = React.useState<Saved>({ difficulty: initialDifficulty, best: EMPTY_BEST, games: 0 });
const [newBest, setNewBest] = React.useState(false);
const [miss, setMiss] = React.useState(0);
const [shared, setShared] = React.useState<"idle" | "copied" | "manual">("idle");
const [hud, setHud] = React.useState<Hud>(() => ({ status: "ready", score: 0, level: initialLevel, lives: DIFFICULTIES[initialDifficulty].lives, maxLives: DIFFICULTIES[initialDifficulty].lives, streak: 0, bestStreak: 0, mult: 1, popped: 0, wpm: 0, accuracy: 100, freeze: 0, double: 0, typed: "", target: null }));
const vocab = React.useMemo(() => (words && words.length ? words.map((w) => w.toLowerCase()).filter((w) => /^[a-z]+$/.test(w)) : WORDS), [words]);
const palette = React.useMemo(() => ({ ...(dark ? NIGHT : DAY), ...theme }), [dark, theme]);
const paletteRef = React.useRef(palette);
const darkRef = React.useRef(dark);
React.useEffect(() => {
paletteRef.current = palette;
darkRef.current = dark;
}, [palette, dark]);
const cb = React.useRef({ onGameOver, tone, storageKey });
React.useEffect(() => {
cb.current = { onGameOver, tone, storageKey };
}, [onGameOver, tone, storageKey]);
React.useEffect(() => {
const s = loadJSON<Saved>(storageKey, { difficulty: initialDifficulty, best: EMPTY_BEST, games: 0 });
const best = { ...EMPTY_BEST, ...s.best };
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate persisted scores after mount
setSaved({ ...s, best });
if (DIFFICULTY_ORDER.includes(s.difficulty)) setDifficulty(s.difficulty);
}, [storageKey, initialDifficulty]);
const sound = React.useCallback((s: TypeSound) => {
const t = cb.current.tone;
if (s === "key") t(880, 0.03, "sine", 0.02, 120);
else if (s === "miss") t(150, 0.08, "square", 0.025, -40);
else if (s === "pop") t(620, 0.12, "triangle", 0.05, 520);
else if (s === "power") t(440, 0.35, "triangle", 0.05, 660);
else if (s === "hit") t(200, 0.3, "sawtooth", 0.04, -120);
else if (s === "level") t(523, 0.3, "sine", 0.05, 262);
else if (s === "over") t(300, 0.7, "triangle", 0.05, -180);
}, []);
const syncHud = React.useCallback(() => {
const e = engineRef.current;
if (e) setHud(toHud(e));
}, []);
const makeEngine = React.useCallback(
(diff: Difficulty) => {
runRef.current += 1;
const eng = new TypeEngine({
seed: seed + runRef.current * 7919,
difficulty: diff,
startLevel: initialLevel,
words: vocab,
reduced: reduce,
onSound: sound,
onEnd: (e) => {
const key = cb.current.storageKey;
const prev = loadJSON<Saved>(key, { difficulty: diff, best: EMPTY_BEST, games: 0 });
const best = { ...EMPTY_BEST, ...prev.best };
const old = best[diff];
const next: Saved = { ...prev, difficulty: diff, games: prev.games + 1, best: { ...best, [diff]: { score: Math.max(old.score, e.score), wpm: Math.max(old.wpm, e.wpm) } } };
saveJSON(key, next);
setSaved(next);
setNewBest(e.score > old.score && e.score > 0);
cb.current.onGameOver?.(e.score);
},
});
const { w, h } = sizeRef.current;
eng.setSize(w, h);
engineRef.current = eng;
return eng;
},
[initialLevel, reduce, seed, sizeRef, sound, vocab],
);
// main loop
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 lastWords = "";
const frame = (now: number) => {
const dt = now - last;
last = now;
const { w, h, dpr } = sizeRef.current;
const eng = engineRef.current ?? makeEngine(difficulty);
eng.setSize(w, h);
eng.update(dt);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
eng.render(ctx, paletteRef.current, darkRef.current);
const list = [...eng.bubbles].sort((a, b) => b.y - a.y).map((b) => b.word).join(",");
if (list !== lastWords) {
lastWords = list;
canvas.dataset.words = list;
}
const next = toHud(eng);
const key = JSON.stringify(next);
if (key !== lastHud) {
lastHud = key;
setHud(next);
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [difficulty, makeEngine, sizeRef]);
const focusInput = () => inputRef.current?.focus({ preventScroll: true });
const begin = React.useCallback(() => {
const e = engineRef.current;
if (!e) return;
if (e.status === "ready") e.start();
else if (e.status === "paused") e.togglePause();
else if (e.status === "over") {
makeEngine(e.difficulty).start();
setNewBest(false);
setShared("idle");
}
syncHud();
focusInput();
}, [makeEngine, syncHud]);
const restart = React.useCallback(() => {
const e = engineRef.current;
makeEngine(e?.difficulty ?? difficulty).start();
setNewBest(false);
setShared("idle");
syncHud();
focusInput();
}, [difficulty, makeEngine, syncHud]);
const toMenu = React.useCallback(() => {
makeEngine(difficulty);
setNewBest(false);
setShared("idle");
syncHud();
}, [difficulty, makeEngine, syncHud]);
const chooseDifficulty = React.useCallback(
(d: Difficulty) => {
setDifficulty(d);
setSaved((s) => {
const n = { ...s, difficulty: d };
saveJSON(cb.current.storageKey, n);
return n;
});
makeEngine(d);
syncHud();
},
[makeEngine, syncHud],
);
const togglePause = React.useCallback(() => {
const e = engineRef.current;
if (!e) return;
e.togglePause();
syncHud();
if (e.status === "playing") focusInput();
}, [syncHud]);
useAutoPause(
React.useCallback(() => {
const e = engineRef.current;
if (e?.status === "playing") {
e.togglePause();
syncHud();
}
}, [syncHud]),
);
const feed = React.useCallback(
(chars: string) => {
const e = engineRef.current;
if (!e || e.status !== "playing") return;
let bad = false;
for (const ch of chars) if (!e.type(ch)) bad = true;
if (bad) setMiss((m) => m + 1);
syncHud();
},
[syncHud],
);
const onInput = (ev: React.ChangeEvent<HTMLInputElement>) => {
const e = engineRef.current;
if (!e) return;
const prev = e.stats().typed;
const v = ev.target.value.toLowerCase().replace(/[^a-z]/g, "");
if (e.status !== "playing") {
syncHud();
return;
}
if (v.length < prev.length) {
e.release();
syncHud();
return;
}
if (v.startsWith(prev)) feed(v.slice(prev.length));
else {
e.release();
feed(v);
}
};
// typing anywhere in the game (e.g. after clicking a button) still reaches the input
React.useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
if (!keyBelongsTo(ev, rootRef.current)) return;
const e = engineRef.current;
if (!e) return;
if (ev.key === "Escape") {
ev.preventDefault();
togglePause();
} else if (/^[a-zA-Z]$/.test(ev.key) && e.status === "playing") {
ev.preventDefault();
focusInput();
feed(ev.key);
} else if (ev.key === "Enter" && !(ev.target instanceof HTMLButtonElement) && (e.status === "ready" || e.status === "paused")) {
ev.preventDefault();
begin();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [begin, feed, togglePause]);
const share = async () => {
const d = DIFFICULTIES[difficulty];
const text = `${title} — ${hud.score.toLocaleString()} points · ${hud.wpm} WPM · ${hud.accuracy}% accuracy · level ${hud.level} on ${d.label}. Best streak ${hud.bestStreak}.`;
try {
if (typeof navigator !== "undefined" && "share" in navigator && window.matchMedia("(pointer: coarse)").matches) {
await navigator.share({ title, text });
setShared("copied");
return;
}
await navigator.clipboard.writeText(text);
setShared("copied");
} catch {
setShared("manual");
}
};
const status = hud.status;
const playing = status === "playing";
const spec = DIFFICULTIES[difficulty];
const best = saved.best[difficulty] ?? EMPTY_BEST[difficulty];
const shareText = `${title} — ${hud.score.toLocaleString()} points · ${hud.wpm} WPM · ${hud.accuracy}% accuracy · level ${hud.level} on ${spec.label}.`;
return (
<div
ref={rootRef}
data-status={status}
data-score={hud.score}
data-lives={hud.lives}
data-level={hud.level}
data-streak={hud.streak}
data-popped={hud.popped}
aria-label={`${title} typing game`}
className={cn("relative flex h-[700px] w-full flex-col overflow-hidden bg-background text-foreground", className)}
>
<header className="relative z-10 flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b bg-background/85 px-3 py-2 backdrop-blur sm:h-14 sm:flex-nowrap sm:px-5 sm:py-0">
<div className="flex min-w-0 items-center gap-2.5">
<DefenderMark className="size-8 shrink-0" />
<div className="min-w-0 leading-none">
<p className="truncate text-sm font-black tracking-tight">{title}</p>
<p className="mt-1 truncate text-[11px] text-muted-foreground">
{spec.label} · best {best.score.toLocaleString()}
</p>
</div>
</div>
<div className="order-3 grid w-full grid-cols-4 gap-1.5 sm:order-none sm:ml-auto sm:flex sm:w-auto sm:items-center sm:gap-2">
<Chip icon={<Trophy className="size-3.5 text-amber-500" />} label="Score" value={hud.score.toLocaleString()} />
<Chip icon={<Zap className="size-3.5 text-violet-500" />} label="Streak" value={hud.mult > 1 ? `${hud.streak} ×${hud.mult}` : hud.streak} hot={hud.mult > 1} />
<Chip icon={<Gauge className="size-3.5 text-sky-500" />} label="Words per minute" value={`${hud.wpm}`} suffix="wpm" />
<Chip icon={<Target className="size-3.5 text-emerald-500" />} label="Accuracy" value={`${hud.accuracy}%`} />
</div>
<div className="ml-auto flex items-center gap-0.5 sm:ml-0">
<div className="mr-1 flex items-center gap-0.5" aria-label={`${hud.lives} of ${hud.maxLives} hearts`} role="img">
{Array.from({ length: hud.maxLives }, (_, i) => (
<motion.span key={i} animate={i < hud.lives ? { scale: 1, opacity: 1 } : { scale: 0.75, opacity: 0.3 }} transition={{ type: "spring", stiffness: 500, damping: 20 }}>
<Heart className={cn("size-4", i < hud.lives ? "fill-rose-500 text-rose-500" : "text-muted-foreground")} aria-hidden />
</motion.span>
))}
</div>
<IconButton label={playing ? "Pause (Esc)" : "Resume (Esc)"} onClick={playing ? togglePause : begin} disabled={status === "over" || status === "ready"}>
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
</IconButton>
<IconButton label={muted ? "Unmute" : "Mute"} onClick={() => setMuted((m) => !m)} pressed={!muted}>
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
</IconButton>
<IconButton label="Restart" onClick={restart}>
<RotateCcw className="size-4" />
</IconButton>
</div>
</header>
<div className="relative min-h-0 flex-1 select-none" onPointerDown={() => status === "playing" && setTimeout(focusInput, 0)}>
<canvas ref={canvasRef} className="absolute inset-0 block" role="img" aria-label={`Word bubbles falling toward your base. Level ${hud.level}, ${hud.lives} hearts left.`} />
<div className="pointer-events-none absolute top-3 left-3 flex flex-col items-start gap-1.5">
<span className="rounded-full bg-background/80 px-2.5 py-1 text-[11px] font-bold shadow-sm backdrop-blur">Level {hud.level}</span>
<AnimatePresence>
{hud.freeze > 0 && (
<motion.span key="f" initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0 }} className="inline-flex items-center gap-1 rounded-full bg-sky-500 px-2.5 py-1 text-[11px] font-bold text-white shadow">
<Snowflake className="size-3" aria-hidden /> Freeze {hud.freeze}s
</motion.span>
)}
{hud.double > 0 && (
<motion.span key="d" initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0 }} className="inline-flex items-center gap-1 rounded-full bg-amber-400 px-2.5 py-1 text-[11px] font-bold text-amber-950 shadow">
<Sparkles className="size-3" aria-hidden /> Double {hud.double}s
</motion.span>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{status !== "playing" && (
<Overlay key={status} reduce={reduce}>
{status === "ready" && (
<>
<DefenderMark 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">Friendly word bubbles are drifting down onto your little house. Type each word to pop it before it lands — golden bubbles hold power-ups.</p>
<div className="mx-auto mt-5 grid max-w-sm grid-cols-3 gap-2" role="radiogroup" aria-label="Difficulty">
{DIFFICULTY_ORDER.map((d) => {
const s = DIFFICULTIES[d];
const on = d === difficulty;
return (
<button key={d} type="button" role="radio" aria-checked={on} onClick={() => chooseDifficulty(d)} className={cn("rounded-xl border bg-card px-2 py-2.5 text-center transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", on ? "border-sky-500 ring-2 ring-sky-500/30" : "hover:border-foreground/20")}>
<span className="block text-sm font-bold">{s.label}</span>
<span className="block text-[10px] leading-tight text-muted-foreground">{s.blurb}</span>
<span className="mt-1 block text-[10px] font-semibold text-muted-foreground tabular-nums">best {(saved.best[d] ?? EMPTY_BEST[d]).score.toLocaleString()}</span>
</button>
);
})}
</div>
<PrimaryButton onClick={begin}>
<Keyboard className="size-4" /> Start typing
</PrimaryButton>
<ul className="mx-auto mt-5 grid max-w-sm grid-cols-2 gap-1.5 text-left text-[11px] text-muted-foreground">
{(Object.keys(POWER_INFO) as (keyof typeof POWER_INFO)[]).map((p) => (
<li key={p} className="flex items-center gap-1.5 rounded-lg border bg-card/70 px-2 py-1.5">
<span className="size-2.5 shrink-0 rounded-full" style={{ background: POWER_INFO[p].color }} />
<span className="min-w-0">
<b className="text-foreground">{POWER_INFO[p].name}</b> · {POWER_WORDS[p][0]}, {POWER_WORDS[p][1]}…
</span>
</li>
))}
</ul>
<p className="mt-3 hidden text-[11px] text-muted-foreground sm:block">Backspace lets go of a word · Esc pauses · every 5 clean words raises your multiplier</p>
</>
)}
{status === "paused" && (
<>
<p className="text-xs font-semibold tracking-[0.3em] text-muted-foreground uppercase">Bubbles on hold</p>
<h2 className="mt-2 text-4xl font-black">Paused</h2>
<p className="mt-2 text-sm text-muted-foreground tabular-nums">
{hud.score.toLocaleString()} points · {hud.wpm} wpm · {hud.accuracy}% accuracy
</p>
<div className="mt-6 flex justify-center gap-2">
<PrimaryButton onClick={begin} className="mt-0">
<Play className="size-4" /> Resume
</PrimaryButton>
<SecondaryButton onClick={restart}>
<RotateCcw className="size-4" /> Restart
</SecondaryButton>
</div>
</>
)}
{status === "over" && (
<>
<p className="text-xs font-bold tracking-[0.3em] text-sky-600 uppercase dark:text-sky-400">The bubbles landed</p>
<h2 className="mt-2 text-5xl font-black tabular-nums">{hud.score.toLocaleString()}</h2>
{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-sky-400 to-violet-500 px-3 py-1 text-xs font-bold text-white">
<Trophy className="size-3.5" /> New {spec.label} best!
</motion.p>
) : (
<p className="mt-3 text-xs text-muted-foreground">
{spec.label} best {best.score.toLocaleString()}
</p>
)}
<dl className="mx-auto mt-5 grid max-w-sm grid-cols-3 gap-2">
<MiniStat label="WPM" value={hud.wpm} />
<MiniStat label="Accuracy" value={`${hud.accuracy}%`} />
<MiniStat label="Level" value={hud.level} />
<MiniStat label="Words" value={hud.popped} />
<MiniStat label="Best streak" value={hud.bestStreak} />
<MiniStat label="Top WPM" value={best.wpm} />
</dl>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<PrimaryButton onClick={begin} className="mt-0">
<RotateCcw className="size-4" /> Play again
</PrimaryButton>
<SecondaryButton onClick={() => void share()}>
{shared === "copied" ? <Check className="size-4 text-emerald-500" /> : <Share2 className="size-4" />}
{shared === "copied" ? "Copied!" : "Share result"}
</SecondaryButton>
<SecondaryButton onClick={toMenu}>Difficulty</SecondaryButton>
</div>
{shared === "manual" && (
<label className="mx-auto mt-3 block max-w-sm text-left text-[11px] text-muted-foreground">
Copy your result
<input readOnly value={shareText} onFocus={(e) => e.currentTarget.select()} className="mt-1 h-9 w-full rounded-lg border bg-background px-2 text-xs text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" />
</label>
)}
</>
)}
</Overlay>
)}
</AnimatePresence>
</div>
{/* typing bar */}
<div className="relative z-10 flex shrink-0 items-center gap-2 border-t bg-muted/40 p-2 sm:gap-3 sm:px-5 sm:py-3">
<motion.div key={miss} className="relative min-w-0 flex-1" animate={miss && !reduce ? { x: [0, -7, 7, -4, 0] } : undefined} transition={{ duration: 0.28 }}>
<label htmlFor="type-defender-input" className="sr-only">
Type the falling words
</label>
<Keyboard className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<input
id="type-defender-input"
ref={inputRef}
value={hud.typed}
onChange={onInput}
onKeyDown={(ev) => {
if (ev.key === "Escape") {
ev.preventDefault();
togglePause();
} else if (ev.key === "Enter") {
ev.preventDefault();
if (status === "ready" || status === "paused" || status === "over") begin();
}
}}
disabled={status === "over"}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
enterKeyHint="go"
placeholder={playing ? "Type a word…" : status === "ready" ? "Press Enter or tap Start" : "Paused"}
className={cn("h-11 w-full rounded-xl border-2 bg-background pr-3 pl-9 font-mono text-base font-bold tracking-wide text-emerald-600 shadow-inner transition outline-none placeholder:font-sans placeholder:font-normal placeholder:tracking-normal placeholder:text-muted-foreground focus-visible:border-sky-500 focus-visible:ring-2 focus-visible:ring-sky-500/30 dark:text-emerald-400")}
/>
</motion.div>
<div className="hidden min-w-0 items-center gap-2 text-sm sm:flex sm:w-56" aria-live="off">
{hud.target ? (
<>
<span className="text-[10px] font-bold tracking-wider text-muted-foreground uppercase">Target</span>
<span className="truncate font-mono text-base font-black">
<span className="text-emerald-600 dark:text-emerald-400">{hud.typed}</span>
<span>{hud.target.slice(hud.typed.length)}</span>
</span>
</>
) : (
<span className="text-xs text-muted-foreground">{playing ? "Start any word — the lowest match locks on" : `${hud.popped} words popped`}</span>
)}
</div>
</div>
<p className="sr-only" aria-live="polite">
{status === "over" ? `Game over. ${hud.score} points, ${hud.wpm} words per minute, ${hud.accuracy} percent accuracy.` : status === "paused" ? "Paused" : ""}
</p>
</div>
);
}
/* ───────────────────────── bits ───────────────────────── */
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.22 }} className="absolute inset-0 z-20 grid place-items-center overflow-y-auto bg-background/70 p-5 backdrop-blur-[3px]">
<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-sky-500 to-violet-500 px-6 text-sm font-bold text-white shadow-lg shadow-sky-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 SecondaryButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<button type="button" onClick={onClick} 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">
{children}
</button>
);
}
function IconButton({ label, onClick, children, disabled, pressed }: { label: string; onClick: () => void; children: React.ReactNode; disabled?: boolean; pressed?: boolean }) {
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")}>
{children}
</button>
);
}
function Chip({ icon, label, value, suffix, hot }: { icon: React.ReactNode; label: string; value: React.ReactNode; suffix?: string; hot?: boolean }) {
return (
<div className={cn("flex min-w-0 items-center gap-1.5 rounded-lg border bg-card px-2 py-1 sm:px-2.5", hot && "border-violet-500/50 bg-violet-500/10")} title={label}>
{icon}
<span className="sr-only">{label}</span>
<span className="truncate text-sm font-bold tabular-nums">{value}</span>
{suffix && <span className="hidden text-[10px] font-semibold text-muted-foreground md:inline">{suffix}</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">
<dt className="text-[10px] tracking-wider text-muted-foreground uppercase">{label}</dt>
<dd className="mt-0.5 text-base font-bold tabular-nums">{value}</dd>
</div>
);
}
function DefenderMark({ className }: { className?: string }) {
const id = React.useId();
return (
<svg viewBox="0 0 40 40" className={className} aria-hidden>
<defs>
<linearGradient id={`${id}s`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#7dd3fc" />
<stop offset="1" stopColor="#c4b5fd" />
</linearGradient>
<radialGradient id={`${id}b`} cx="0.35" cy="0.3" r="0.7">
<stop offset="0" stopColor="#fff" />
<stop offset="1" stopColor="#f9a8d4" />
</radialGradient>
</defs>
<rect width="40" height="40" rx="11" fill={`url(#${id}s)`} />
<path d="M0 31 Q20 25 40 31 V40 H0Z" fill="#4ade80" />
<rect x="14" y="23" width="12" height="10" rx="2" fill="#fff7ed" />
<path d="M12.5 24 Q20 13 27.5 24Z" fill="#f97366" />
<rect x="18.3" y="28" width="3.4" height="5" rx="1.4" fill="#b45309" />
<circle cx="12" cy="11" r="6.5" fill={`url(#${id}b)`} stroke="#fff" strokeWidth="1" />
<circle cx="10.4" cy="11.8" r="0.8" fill="#1e293b" />
<circle cx="13.6" cy="11.8" r="0.8" fill="#1e293b" />
<circle cx="29" cy="8" r="3.5" fill="#fde68a" stroke="#fff" strokeWidth="0.8" />
</svg>
);
}