form 1 topics added

This commit is contained in:
2026-07-05 11:12:45 -04:00
parent a614532810
commit a23fa53772
58 changed files with 7089 additions and 475 deletions

View File

@@ -0,0 +1,795 @@
"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>
);
}