Files
cabrits-math/components/explorers/integer-order-compare-explorer.tsx
2026-07-05 11:12:45 -04:00

796 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { shuffle } from "@/lib/utils";
type Mode = "number-line" | "order" | "compare";
type Direction = "asc" | "desc";
type Tone = "start" | "highlight" | "muted";
const MIN_VAL = -20;
const MAX_VAL = 20;
const RANGE = MAX_VAL - MIN_VAL;
interface Marker {
value: number;
label?: string;
tone?: Tone;
}
interface OrderChips {
pool: number[];
ordered: number[];
total: number;
}
interface Step {
label: string;
math: string;
markers: Marker[];
order?: OrderChips;
}
// Real-world contexts for the "Number Line" mode
const CONTEXTS = {
number: {
emoji: "🔢",
label: "Number",
describe: (n: number) =>
n === 0
? "0 is neither positive nor negative — it separates the two sides."
: `${n} is ${Math.abs(n)} step${Math.abs(n) === 1 ? "" : "s"} to the ${n < 0 ? "LEFT" : "RIGHT"} of zero.`,
},
thermometer: {
emoji: "🌡️",
label: "Temperature",
describe: (n: number) =>
n === 0
? "0 °C is freezing point — the boundary between warm and cold."
: `${n} °C means ${Math.abs(n)} degree${Math.abs(n) === 1 ? "" : "s"} ${n < 0 ? "BELOW" : "ABOVE"} freezing.`,
},
money: {
emoji: "💰",
label: "Money",
describe: (n: number) =>
n === 0
? "$0 means you have nothing and owe nothing."
: n < 0
? `$${Math.abs(n)} means you OWE $${Math.abs(n)} (an overdraft).`
: `+$${n} means you HAVE $${n} in the bank.`,
},
sea: {
emoji: "🌊",
label: "Sea level",
describe: (n: number) =>
n === 0
? "0 m is exactly at sea level."
: `${n} m means ${Math.abs(n)} metre${Math.abs(n) === 1 ? "" : "s"} ${n < 0 ? "BELOW" : "ABOVE"} sea level.`,
},
} as const;
type ContextKey = keyof typeof CONTEXTS;
function buildNumberLineSteps(n: number, ctx: ContextKey): Step[] {
const context = CONTEXTS[ctx];
const typeWord = n === 0 ? "zero" : n < 0 ? "a NEGATIVE integer" : "a POSITIVE integer";
return [
{
label: "Zero sits in the middle. Negative numbers are to its LEFT, positive numbers to its RIGHT.",
math: `\\ldots -3,\\ -2,\\ -1,\\ 0,\\ 1,\\ 2,\\ 3 \\ldots`,
markers: [{ value: 0, label: "0", tone: "start" }],
},
{
label: `Find ${n} on the number line and mark it.`,
math: `\\text{Mark } ${n}`,
markers: [
{ value: 0, tone: "muted" },
{ value: n, label: `${n}`, tone: "highlight" },
],
},
{
label: `${context.emoji} ${context.describe(n)}`,
math: `${n}`,
markers: [{ value: n, label: `${n}`, tone: "start" }],
},
{
label: `So ${n} is ${typeWord}.`,
math: n === 0 ? `0` : `${n} \\text{ is } ${n < 0 ? "<" : ">"} 0`,
markers: [{ value: n, label: `${n}`, tone: "start" }],
},
];
}
function buildOrderSteps(values: number[], direction: Direction): Step[] {
const sorted = [...values].sort((a, b) => (direction === "asc" ? a - b : b - a));
const sizeWord = direction === "asc" ? "smallest" : "largest";
const posWord = direction === "asc" ? "furthest LEFT" : "furthest RIGHT";
const total = values.length;
const steps: Step[] = [
{
label: "Here are the numbers we need to put in order.",
math: `\\{\\, ${values.join(",\\ ")} \\,\\}`,
markers: values.map((v) => ({ value: v, tone: "muted" as Tone })),
order: { pool: [...values], ordered: [], total },
},
{
label: "Place every number on the number line. Smaller numbers sit further left.",
math: `\\text{left = smaller} \\qquad \\text{right = larger}`,
markers: values.map((v) => ({ value: v, tone: "highlight" as Tone })),
order: { pool: [...values], ordered: [], total },
},
];
const pool = [...values];
const ordered: number[] = [];
for (let i = 0; i < sorted.length; i++) {
const pick = sorted[i];
pool.splice(pool.indexOf(pick), 1);
ordered.push(pick);
steps.push({
label: `The ${sizeWord} number still to place is the one ${posWord}: ${pick}.`,
math: `${ordered.join(direction === "asc" ? " < " : " > ")}${pool.length ? "\\ \\ldots" : ""}`,
markers: [
...ordered.map((v) => ({ value: v, tone: "start" as Tone })),
...pool.map((v) => ({ value: v, tone: "muted" as Tone })),
],
order: { pool: [...pool], ordered: [...ordered], total },
});
}
steps.push({
label:
direction === "asc"
? "Done! In ascending order: least → greatest."
: "Done! In descending order: greatest → least.",
math: `${sorted.join(direction === "asc" ? " < " : " > ")}`,
markers: sorted.map((v) => ({ value: v, tone: "start" as Tone })),
order: { pool: [], ordered: [...sorted], total },
});
return steps;
}
function buildCompareSteps(a: number, b: number): Step[] {
const symbol = a < b ? "<" : a > b ? ">" : "=";
const steps: Step[] = [
{
label: `Put both numbers on the number line.`,
math: `${a} \\;\\square\\; ${b}`,
markers: [
{ value: a, label: `${a}`, tone: "highlight" },
{ value: b, label: `${b}`, tone: "highlight" },
],
},
];
if (symbol === "=") {
steps.push({
label: "Both numbers land on the same spot, so they are equal.",
math: `${a} = ${b}`,
markers: [{ value: a, label: `${a}`, tone: "start" }],
});
return steps;
}
const larger = a > b ? a : b;
const smaller = a > b ? b : a;
steps.push(
{
label: `The number further RIGHT is always larger. ${larger} is to the right of ${smaller}.`,
math: `${larger} \\text{ is larger}`,
markers: [
{ value: smaller, label: `${smaller}`, tone: "muted" },
{ value: larger, label: `${larger}`, tone: "start" },
],
},
{
label: `The open mouth of the sign always points to the LARGER number.`,
math: `${a} ${symbol} ${b}`,
markers: [
{ value: a, label: `${a}`, tone: a === larger ? "start" : "muted" },
{ value: b, label: `${b}`, tone: b === larger ? "start" : "muted" },
],
},
);
return steps;
}
function NumberLine({ markers }: { markers: Marker[] }) {
return (
<div className="mx-auto w-full max-w-2xl select-none">
<div className="relative h-28 px-4">
{/* Markers above the line */}
{markers.map((m, i) => {
const x = ((m.value - MIN_VAL) / RANGE) * 100;
const tone = m.tone ?? "highlight";
const bg =
tone === "start" ? "bg-unit-5" : tone === "muted" ? "bg-muted/70" : "bg-correct";
return (
<div
key={`${m.value}-${i}`}
className="absolute bottom-14 -translate-x-1/2 transition-all duration-500"
style={{ left: `calc(${x}% + 16px)` }}
>
<div className="flex flex-col items-center">
{m.label !== undefined && (
<span
className={`rounded-full ${bg} px-2 py-0.5 text-[10px] font-bold text-white shadow-md`}
>
{m.label}
</span>
)}
<div className={`h-3 w-0.5 ${bg}`} />
</div>
</div>
);
})}
{/* Number line */}
<div className="absolute bottom-8 left-4 right-4">
<div className="h-0.5 bg-foreground/40" />
{Array.from({ length: RANGE + 1 }, (_, i) => {
const val = MIN_VAL + i;
const x = (i / RANGE) * 100;
const isZero = val === 0;
const marked = markers.some((m) => m.value === val);
return (
<div key={val} className="absolute -translate-x-1/2" style={{ left: `${x}%` }}>
<div
className={`h-3 w-0.5 -translate-y-1.5 transition-colors duration-300 ${
marked
? "h-4 bg-unit-5"
: isZero
? "bg-foreground"
: "bg-foreground/30"
}`}
/>
{(val % 5 === 0 || marked) && (
<span
className={`mt-1 block text-center text-[10px] transition-all duration-300 ${
marked
? "font-extrabold text-unit-5"
: isZero
? "font-bold text-foreground"
: "text-muted/70"
}`}
>
{val}
</span>
)}
</div>
);
})}
</div>
{/* Edge labels */}
<div className="absolute bottom-0 left-2 right-2 flex justify-between text-xs text-muted/50">
<span> negative</span>
<span>positive </span>
</div>
</div>
</div>
);
}
function OrderChipsView({ order, direction }: { order: OrderChips; direction: Direction }) {
const emptySlots = order.total - order.ordered.length;
return (
<div className="w-full space-y-3">
<div>
<p className="mb-1.5 text-xs font-bold uppercase tracking-wider text-muted">Still to place</p>
<div className="flex min-h-9 flex-wrap gap-2">
{order.pool.length === 0 ? (
<span className="text-sm text-muted/50"> all placed </span>
) : (
order.pool.map((v, i) => (
<span
key={`${v}-${i}`}
className="rounded-lg border-2 border-border bg-surface px-3 py-1.5 text-sm font-bold text-muted"
>
{v}
</span>
))
)}
</div>
</div>
<div>
<p className="mb-1.5 text-xs font-bold uppercase tracking-wider text-unit-5">
Ordered ({direction === "asc" ? "small → large" : "large → small"})
</p>
<div className="flex flex-wrap items-center gap-2">
{order.ordered.map((v, i) => (
<span key={`${v}-${i}`} className="flex items-center gap-2">
{i > 0 && <span className="text-sm font-bold text-unit-5">{direction === "asc" ? "<" : ">"}</span>}
<span className="rounded-lg border-2 border-unit-5 bg-unit-5-light px-3 py-1.5 text-sm font-extrabold text-unit-5-dark">
{v}
</span>
</span>
))}
{Array.from({ length: emptySlots }, (_, i) => (
<span key={`empty-${i}`} className="flex items-center gap-2">
{(order.ordered.length > 0 || i > 0) && (
<span className="text-sm font-bold text-muted/40">{direction === "asc" ? "<" : ">"}</span>
)}
<span className="h-9 w-11 rounded-lg border-2 border-dashed border-border" />
</span>
))}
</div>
</div>
</div>
);
}
// ── Quick practice ───────────────────────────────────────────────────────────
const SEED_PAIRS: [number, number][] = [
[-5, 5],
[4, -16],
[-6, -11],
[0, -3],
[8, -1],
];
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function makePair(): [number, number] {
if (Math.random() < 0.4) return SEED_PAIRS[Math.floor(Math.random() * SEED_PAIRS.length)];
const a = randInt(-15, 15);
let b = randInt(-15, 15);
if (a === b) b = a + 1;
return [a, b];
}
function makeOrderValues(): number[] {
const set = new Set<number>();
while (set.size < 4) set.add(randInt(-12, 12));
return shuffle([...set]);
}
function ComparePractice({ onScore }: { onScore: (correct: boolean) => void }) {
const [pair, setPair] = useState<[number, number]>(() => makePair());
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [a, b] = pair;
const answer = a < b ? "<" : ">";
function pick(sign: "<" | ">") {
if (feedback === "correct") return;
const correct = sign === answer;
setFeedback(correct ? "correct" : "incorrect");
if (correct) onScore(true);
else onScore(false);
}
function next() {
setPair(makePair());
setFeedback(null);
}
return (
<div className="space-y-4 text-center">
<p className="text-sm text-muted">Which sign makes it true?</p>
<div className="flex items-center justify-center gap-4 text-3xl font-extrabold">
<span>{a}</span>
<span className="inline-flex h-11 w-11 items-center justify-center rounded-xl border-2 border-dashed border-unit-5/50 text-unit-5">
?
</span>
<span>{b}</span>
</div>
<div className="flex justify-center gap-3">
{(["<", ">"] as const).map((sign) => (
<button
key={sign}
onClick={() => pick(sign)}
disabled={feedback === "correct"}
className={`h-14 w-14 rounded-xl border-2 text-2xl font-extrabold transition-colors ${
feedback === "correct" && sign === answer
? "border-correct bg-correct-light text-correct"
: feedback === "incorrect" && sign !== answer
? "border-border text-muted"
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light disabled:opacity-50"
}`}
>
{sign}
</button>
))}
</div>
{feedback === "correct" && (
<div className="space-y-2">
<p className="text-sm font-bold text-correct">Correct! {a} {answer} {b}</p>
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
</div>
)}
{feedback === "incorrect" && (
<p className="text-sm font-bold text-incorrect">Not quite remember, further right means larger. Try again.</p>
)}
</div>
);
}
function OrderPractice({ onScore }: { onScore: (correct: boolean) => void }) {
const [values, setValues] = useState<number[]>(() => makeOrderValues());
const [placed, setPlaced] = useState<number[]>([]);
const [wrong, setWrong] = useState<number | null>(null);
const [mistakes, setMistakes] = useState(0);
const [done, setDone] = useState(false);
const sorted = [...values].sort((a, b) => a - b);
const nextExpected = sorted[placed.length];
function clickChip(v: number) {
if (done || placed.includes(v)) return;
if (v === nextExpected) {
const nextPlaced = [...placed, v];
setPlaced(nextPlaced);
setWrong(null);
if (nextPlaced.length === sorted.length) {
setDone(true);
onScore(mistakes === 0);
}
} else {
setWrong(v);
setMistakes((m) => m + 1);
setTimeout(() => setWrong((w) => (w === v ? null : w)), 500);
}
}
function next() {
setValues(makeOrderValues());
setPlaced([]);
setWrong(null);
setMistakes(0);
setDone(false);
}
return (
<div className="space-y-4 text-center">
<p className="text-sm text-muted">Click the numbers in ascending order (smallest first).</p>
<div className="flex flex-wrap justify-center gap-2">
{values.map((v) => {
const isPlaced = placed.includes(v);
const isWrong = wrong === v;
return (
<button
key={v}
onClick={() => clickChip(v)}
disabled={isPlaced || done}
className={`h-12 w-14 rounded-xl border-2 text-lg font-extrabold transition-all ${
isPlaced
? "border-correct bg-correct-light text-correct"
: isWrong
? "border-incorrect bg-incorrect-light text-incorrect"
: "border-unit-5/40 text-unit-5-dark hover:bg-unit-5-light"
}`}
>
{v}
</button>
);
})}
</div>
<div className="flex items-center justify-center gap-2 text-lg font-bold text-unit-5">
{placed.map((v, i) => (
<span key={v}>
{i > 0 && <span className="mr-2 text-muted">{"<"}</span>}
{v}
</span>
))}
{placed.length < sorted.length && <span className="text-muted/40"></span>}
</div>
{done && (
<div className="space-y-2">
<p className="text-sm font-bold text-correct">
{mistakes === 0 ? "Perfect ordering!" : `Ordered — with ${mistakes} slip${mistakes === 1 ? "" : "s"}.`}
</p>
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
</div>
)}
</div>
);
}
function QuickPractice() {
const [kind, setKind] = useState<"compare" | "order">("compare");
const [score, setScore] = useState({ correct: 0, total: 0 });
const record = useCallback((correct: boolean) => {
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}, []);
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex gap-1.5">
{(["compare", "order"] as const).map((k) => (
<button
key={k}
onClick={() => setKind(k)}
className={`rounded-full px-3 py-1 text-xs font-bold transition-colors ${
kind === k ? "bg-unit-5 text-white" : "text-unit-5 hover:bg-unit-5-light"
}`}
>
{k === "compare" ? "Compare" : "Order"}
</button>
))}
</div>
<span className="rounded-full bg-unit-5-light px-3 py-1 text-xs font-bold text-unit-5-dark">
{score.correct}/{score.total}
</span>
</div>
{kind === "compare" ? (
<ComparePractice key={`c-${score.total}`} onScore={record} />
) : (
<OrderPractice key={`o-${score.total}`} onScore={record} />
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function IntegerOrderCompareExplorer() {
const [mode, setMode] = useState<Mode>("number-line");
// number-line mode
const [nlInput, setNlInput] = useState("-7");
const [context, setContext] = useState<ContextKey>("thermometer");
// order mode
const [orderInput, setOrderInput] = useState("-8, -10, -4, -3, -6, 5");
const [direction, setDirection] = useState<Direction>("asc");
// compare mode
const [cmpA, setCmpA] = useState("-5");
const [cmpB, setCmpB] = useState("5");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function switchMode(m: Mode) {
setMode(m);
setError("");
reset();
}
function handleGo() {
setError("");
try {
if (mode === "number-line") {
const n = parseInt(nlInput, 10);
if (isNaN(n)) return setError("Enter a valid integer.");
if (n < MIN_VAL || n > MAX_VAL) return setError(`Keep the number between ${MIN_VAL} and ${MAX_VAL}.`);
setSteps(buildNumberLineSteps(n, context));
} else if (mode === "order") {
const parts = orderInput
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
const nums = parts.map((s) => parseInt(s, 10));
if (nums.some((n) => isNaN(n))) return setError("Use whole numbers separated by commas, e.g. -8, -10, 5.");
if (nums.length < 3 || nums.length > 6) return setError("Enter between 3 and 6 numbers.");
if (nums.some((n) => n < MIN_VAL || n > MAX_VAL)) return setError(`Keep every number between ${MIN_VAL} and ${MAX_VAL}.`);
setSteps(buildOrderSteps(nums, direction));
} else {
const a = parseInt(cmpA, 10);
const b = parseInt(cmpB, 10);
if (isNaN(a) || isNaN(b)) return setError("Enter two valid integers.");
if ([a, b].some((n) => n < MIN_VAL || n > MAX_VAL)) return setError(`Keep both numbers between ${MIN_VAL} and ${MAX_VAL}.`);
setSteps(buildCompareSteps(a, b));
}
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not build the steps. Check your input.");
}
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["number-line", "order", "compare"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => switchMode(m)}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === m
? "border-unit-5 bg-unit-5 text-white"
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light"
}`}
>
{m === "number-line" ? "Number Line" : m === "order" ? "Order" : "Compare"}
</button>
))}
</div>
{/* Input card */}
<Card>
{mode === "number-line" && (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Place an integer on the number line
</p>
<div className="mb-3 flex flex-wrap gap-2">
{(Object.keys(CONTEXTS) as ContextKey[]).map((key) => (
<button
key={key}
onClick={() => {
setContext(key);
reset();
}}
className={`rounded-full border-2 px-3 py-1.5 text-xs font-semibold transition-colors ${
context === key
? "border-unit-5-dark bg-unit-5-dark text-white"
: "border-unit-5/30 text-unit-5-dark hover:bg-unit-5-light"
}`}
>
{CONTEXTS[key].emoji} {CONTEXTS[key].label}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={nlInput}
onChange={(e) => setNlInput(e.target.value)}
className="w-24 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="Integer"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Go
</button>
</div>
</>
)}
{mode === "order" && (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Enter 36 integers to order
</p>
<div className="mb-3 flex flex-wrap gap-2">
{(["asc", "desc"] as Direction[]).map((d) => (
<button
key={d}
onClick={() => {
setDirection(d);
reset();
}}
className={`rounded-full border-2 px-4 py-1.5 text-xs font-semibold transition-colors ${
direction === d
? "border-unit-5-dark bg-unit-5-dark text-white"
: "border-unit-5/30 text-unit-5-dark hover:bg-unit-5-light"
}`}
>
{d === "asc" ? "Ascending (small → large)" : "Descending (large → small)"}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="text"
value={orderInput}
onChange={(e) => setOrderInput(e.target.value)}
className="min-w-[16rem] flex-1 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="Comma-separated integers"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Go
</button>
</div>
</>
)}
{mode === "compare" && (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Compare two integers with &lt; or &gt;
</p>
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={cmpA}
onChange={(e) => setCmpA(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="First integer"
/>
<span className="text-xl font-bold text-muted">?</span>
<input
type="number"
value={cmpB}
onChange={(e) => setCmpB(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="Second integer"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Go
</button>
</div>
</>
)}
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Set up the numbers above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<NumberLine markers={step.markers} />
{step.order && <OrderChipsView order={step.order} direction={direction} />}
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}