Interactive maths practice for secondary school students
diff --git a/app/practice/page.tsx b/app/practice/page.tsx
index 3faa066..ad78b88 100644
--- a/app/practice/page.tsx
+++ b/app/practice/page.tsx
@@ -1,167 +1,338 @@
"use client";
import { useState } from "react";
-import { Card } from "@/components/ui/card";
+import Link from "next/link";
import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
import { Header } from "@/components/layout/header";
import { Footer } from "@/components/layout/footer";
import { PracticeSection } from "@/components/practice/practice-section";
+import { useCollapsibleSidebar, SidebarBackdrop, SidebarToggle, sidebarAsideClass, sidebarNavClass } from "@/components/layout/collapsible-sidebar";
+import { curriculum, type Unit, type Topic } from "@/lib/curriculum";
+import { cn } from "@/lib/utils";
import type { Difficulty, MathProblem } from "@/lib/problems/types";
-import { generateFractionAddSubtract, generateFractionMultiply, generateFractionDivide, generateFractionOfQuantity, generateWholeFromFraction } from "@/lib/problems/generators/fraction-problems";
+import { generateFractionAddSubtract, generateFractionMultiply, generateFractionDivide, generateFractionOfQuantity, generateWholeFromFraction, generateFractionBodmas, generateSignedFraction } from "@/lib/problems/generators/fraction-problems";
import { generateDecimalCompareOrder, generateDecimalRounding, generateStandardForm, generateDecimalAddSubtract, generateDecimalMultiplyDivide, generateDecimalConversion } from "@/lib/problems/generators/decimal-problems";
-import { generateSimplifyRatio, generateDivideInRatio, generateRatioWordProblem } from "@/lib/problems/generators/ratio-problems";
+import { generateSimplifyRatio, generateDivideInRatio, generateRatioWordProblem, generateDefineRatio, generateFractionsAndRatios } from "@/lib/problems/generators/ratio-problems";
+import { generateIntegerAddSubtractMixed, generateIntegerMultiplyDivide, generateIntegerOrderCompare } from "@/lib/problems/generators/integer-problems";
+import { generateDigitValue, generateBinaryToDenary, generateQuaternaryToDenary } from "@/lib/problems/generators/number-system-problems";
+import { generateIdentityInverse, generateExponentEvaluate, generateDistributive } from "@/lib/problems/generators/number-properties-problems";
-interface TopicGenerator {
- unitSlug: string;
- topicSlug: string;
- label: string;
- generator: (difficulty: Difficulty) => MathProblem;
- unitColor: "unit-1" | "unit-2" | "unit-3" | "unit-4";
-}
+type UnitColor = Unit["color"];
+type PracticeUnitColor = Exclude;
+type Generator = (difficulty: Difficulty) => MathProblem;
-type UnitColor = TopicGenerator["unitColor"];
-
-const TOPIC_GENERATORS: TopicGenerator[] = [
- { unitSlug: "unit-1-fractions", topicSlug: "add-subtract", label: "Add & Subtract Fractions", generator: generateFractionAddSubtract, unitColor: "unit-1" },
- { unitSlug: "unit-1-fractions", topicSlug: "multiply", label: "Multiply Fractions", generator: generateFractionMultiply, unitColor: "unit-1" },
- { unitSlug: "unit-1-fractions", topicSlug: "divide", label: "Divide Fractions", generator: generateFractionDivide, unitColor: "unit-1" },
- { unitSlug: "unit-1-fractions", topicSlug: "fraction-of-quantity", label: "Fraction of a Quantity", generator: generateFractionOfQuantity, unitColor: "unit-1" },
- { unitSlug: "unit-1-fractions", topicSlug: "whole-from-fractions", label: "Find the Whole", generator: generateWholeFromFraction, unitColor: "unit-1" },
- { unitSlug: "unit-2-decimals", topicSlug: "compare-order", label: "Compare & Order Decimals", generator: generateDecimalCompareOrder, unitColor: "unit-2" },
- { unitSlug: "unit-2-decimals", topicSlug: "approximate", label: "Approximate Decimals", generator: generateDecimalRounding, unitColor: "unit-2" },
- { unitSlug: "unit-2-decimals", topicSlug: "standard-form", label: "Standard Form", generator: generateStandardForm, unitColor: "unit-2" },
- { unitSlug: "unit-3-decimal-operations", topicSlug: "convert", label: "Convert Decimals & Fractions", generator: generateDecimalConversion, unitColor: "unit-3" },
- { unitSlug: "unit-3-decimal-operations", topicSlug: "add-subtract", label: "Add & Subtract Decimals", generator: generateDecimalAddSubtract, unitColor: "unit-3" },
- { unitSlug: "unit-3-decimal-operations", topicSlug: "multiply-divide", label: "Multiply & Divide Decimals", generator: generateDecimalMultiplyDivide, unitColor: "unit-3" },
- { unitSlug: "unit-4-ratio-proportion", topicSlug: "simplify-ratios", label: "Simplify Ratios", generator: generateSimplifyRatio, unitColor: "unit-4" },
- { unitSlug: "unit-4-ratio-proportion", topicSlug: "divide-in-ratio", label: "Divide in a Ratio", generator: generateDivideInRatio, unitColor: "unit-4" },
- { unitSlug: "unit-4-ratio-proportion", topicSlug: "word-problems", label: "Ratio Word Problems", generator: generateRatioWordProblem, unitColor: "unit-4" },
-];
-
-const unitColors: Record = {
- "unit-1": {
- activeFilter: "border-unit-1 bg-unit-1 text-white shadow-[var(--shadow-sm)]",
- inactiveFilter: "border-unit-1/40 text-unit-1-dark hover:bg-unit-1-light",
- label: "Fractions",
- },
- "unit-2": {
- activeFilter: "border-unit-2 bg-unit-2 text-white shadow-[var(--shadow-sm)]",
- inactiveFilter: "border-unit-2/40 text-unit-2-dark hover:bg-unit-2-light",
- label: "Decimals",
- },
- "unit-3": {
- activeFilter: "border-unit-3 bg-unit-3 text-white shadow-[var(--shadow-sm)]",
- inactiveFilter: "border-unit-3/40 text-unit-3-dark hover:bg-unit-3-light",
- label: "Decimal Operations",
- },
- "unit-4": {
- activeFilter: "border-unit-4 bg-unit-4 text-white shadow-[var(--shadow-sm)]",
- inactiveFilter: "border-unit-4/40 text-unit-4-dark hover:bg-unit-4-light",
- label: "Ratio & Proportion",
- },
+/**
+ * Practice for each curriculum topic. "lesson-only" topics (the coordinate grid)
+ * need the interactive visual, so they link to their lesson instead of a
+ * fill-in-the-answer generator. Keyed by `${unitSlug}/${topicSlug}`.
+ */
+const PRACTICE_BY_TOPIC: Record = {
+ "unit-1-fractions/add-subtract": generateFractionAddSubtract,
+ "unit-1-fractions/multiply": generateFractionMultiply,
+ "unit-1-fractions/divide": generateFractionDivide,
+ "unit-1-fractions/mixed-operations": generateFractionBodmas,
+ "unit-1-fractions/fraction-of-quantity": generateFractionOfQuantity,
+ "unit-1-fractions/whole-from-fractions": generateWholeFromFraction,
+ "unit-2-decimals/compare-order": generateDecimalCompareOrder,
+ "unit-2-decimals/approximate": generateDecimalRounding,
+ "unit-2-decimals/standard-form": generateStandardForm,
+ "unit-3-decimal-operations/convert": generateDecimalConversion,
+ "unit-3-decimal-operations/add-subtract": generateDecimalAddSubtract,
+ "unit-3-decimal-operations/multiply-divide": generateDecimalMultiplyDivide,
+ "unit-4-ratio-proportion/define-ratio": generateDefineRatio,
+ "unit-4-ratio-proportion/fractions-and-ratios": generateFractionsAndRatios,
+ "unit-4-ratio-proportion/simplify-ratios": generateSimplifyRatio,
+ "unit-4-ratio-proportion/divide-in-ratio": generateDivideInRatio,
+ "unit-4-ratio-proportion/word-problems": generateRatioWordProblem,
+ "unit-5-integers/order-compare": generateIntegerOrderCompare,
+ "unit-5-integers/add-subtract": generateIntegerAddSubtractMixed,
+ "unit-5-integers/multiply-divide": generateIntegerMultiplyDivide,
+ "unit-5-integers/negative-fractions": generateSignedFraction,
+ "unit-6-number-system/place-value": generateDigitValue,
+ "unit-6-number-system/binary": generateBinaryToDenary,
+ "unit-6-number-system/quaternary": generateQuaternaryToDenary,
+ "unit-7-coordinates/plot-points": "lesson-only",
+ "unit-7-coordinates/read-coordinates": "lesson-only",
+ "unit-8-number-properties/identity-inverse": generateIdentityInverse,
+ "unit-8-number-properties/number-laws": generateDistributive,
+ "unit-8-number-properties/exponents": generateExponentEvaluate,
};
-export default function PracticePage() {
- const [selectedTopic, setSelectedTopic] = useState(null);
- const [filterUnit, setFilterUnit] = useState(null);
+function practiceFor(unit: Unit, topic: Topic): Generator | "lesson-only" {
+ return PRACTICE_BY_TOPIC[`${unit.slug}/${topic.slug}`] ?? "lesson-only";
+}
- const filtered = filterUnit
- ? TOPIC_GENERATORS.filter((t) => t.unitColor === filterUnit)
- : TOPIC_GENERATORS;
+interface UnitStyle {
+ heading: string;
+ dot: string;
+ accent: string;
+ iconBg: string;
+ hover: string;
+ activeItem: string;
+ headingHover: string;
+}
+
+const UNIT_STYLE: Record = {
+ "unit-1": { heading: "text-unit-1-dark", dot: "bg-unit-1", accent: "border-l-unit-1", iconBg: "bg-unit-1-light text-unit-1-dark", hover: "hover:border-unit-1/40", activeItem: "bg-unit-1-light text-unit-1-dark border-unit-1/20", headingHover: "hover:bg-unit-1-light/50" },
+ "unit-2": { heading: "text-unit-2-dark", dot: "bg-unit-2", accent: "border-l-unit-2", iconBg: "bg-unit-2-light text-unit-2-dark", hover: "hover:border-unit-2/40", activeItem: "bg-unit-2-light text-unit-2-dark border-unit-2/20", headingHover: "hover:bg-unit-2-light/50" },
+ "unit-3": { heading: "text-unit-3-dark", dot: "bg-unit-3", accent: "border-l-unit-3", iconBg: "bg-unit-3-light text-unit-3-dark", hover: "hover:border-unit-3/40", activeItem: "bg-unit-3-light text-unit-3-dark border-unit-3/20", headingHover: "hover:bg-unit-3-light/50" },
+ "unit-4": { heading: "text-unit-4-dark", dot: "bg-unit-4", accent: "border-l-unit-4", iconBg: "bg-unit-4-light text-unit-4-dark", hover: "hover:border-unit-4/40", activeItem: "bg-unit-4-light text-unit-4-dark border-unit-4/20", headingHover: "hover:bg-unit-4-light/50" },
+ "unit-5": { heading: "text-unit-5-dark", dot: "bg-unit-5", accent: "border-l-unit-5", iconBg: "bg-unit-5-light text-unit-5-dark", hover: "hover:border-unit-5/40", activeItem: "bg-unit-5-light text-unit-5-dark border-unit-5/20", headingHover: "hover:bg-unit-5-light/50" },
+ "unit-6": { heading: "text-unit-6-dark", dot: "bg-unit-6", accent: "border-l-unit-6", iconBg: "bg-unit-6-light text-unit-6-dark", hover: "hover:border-unit-6/40", activeItem: "bg-unit-6-light text-unit-6-dark border-unit-6/20", headingHover: "hover:bg-unit-6-light/50" },
+ "unit-7": { heading: "text-unit-7-dark", dot: "bg-unit-7", accent: "border-l-unit-7", iconBg: "bg-unit-7-light text-unit-7-dark", hover: "hover:border-unit-7/40", activeItem: "bg-unit-7-light text-unit-7-dark border-unit-7/20", headingHover: "hover:bg-unit-7-light/50" },
+ "unit-8": { heading: "text-unit-8-dark", dot: "bg-unit-8", accent: "border-l-unit-8", iconBg: "bg-unit-8-light text-unit-8-dark", hover: "hover:border-unit-8/40", activeItem: "bg-unit-8-light text-unit-8-dark border-unit-8/20", headingHover: "hover:bg-unit-8-light/50" },
+};
+
+interface Selection {
+ unit: Unit;
+ topic: Topic;
+}
+
+function PlayIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+// ── Sidebar (mirrors the lessons sidebar) ────────────────────────────────────
+
+function PracticeSidebar({
+ selected,
+ onSelect,
+ onAllTopics,
+ mobileOpen,
+ desktopCollapsed,
+}: {
+ selected: Selection | null;
+ onSelect: (s: Selection) => void;
+ onAllTopics: () => void;
+ mobileOpen: boolean;
+ desktopCollapsed: boolean;
+}) {
+ const [open, setOpen] = useState>(() => new Set([curriculum[0].number]));
+
+ function toggle(num: number) {
+ setOpen((prev) => {
+ const next = new Set(prev);
+ if (next.has(num)) next.delete(num);
+ else next.add(num);
+ return next;
+ });
+ }
+
+ return (
+
+ );
+}
+
+// ── Topic browser (main area, all topics) ────────────────────────────────────
+
+function TopicBrowser({ onSelect }: { onSelect: (s: Selection) => void }) {
+ return (
+
+ {curriculum.map((unit) => {
+ const style = UNIT_STYLE[unit.color];
+ return (
+
+
+
+
+ Unit {unit.number}: {unit.title}
+
+
+ {unit.topics.length}
+
+
+
+
+ {unit.topics.map((topic) => (
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
+
+// ── Panel for topics that are practised in the interactive lesson ────────────
+
+function LessonOnlyPanel({ unit, topic }: { unit: Unit; topic: Topic }) {
+ return (
+
+
+
{topic.title}
+
+ This topic uses the interactive coordinate grid, so it's best practised right in the lesson.
+
+
+
+
+
+ );
+}
+
+// ── Page ─────────────────────────────────────────────────────────────────────
+
+export default function PracticePage() {
+ const [selected, setSelected] = useState(null);
+ const { mobileOpen, desktopCollapsed, toggle, closeMobile } = useCollapsibleSidebar();
+
+ function select(s: Selection) {
+ setSelected(s);
+ closeMobile();
+ }
+
+ const generator = selected ? practiceFor(selected.unit, selected.topic) : null;
return (
-
-
-
Practice
-
- Pick a topic and build your confidence with fresh questions each round.
-
+
+
- {/* Unit filters */}
-
-
- {(["unit-1", "unit-2", "unit-3", "unit-4"] as const).map((uc) => (
-
- ))}
+
{
+ setSelected(null);
+ closeMobile();
+ }}
+ mobileOpen={mobileOpen}
+ desktopCollapsed={desktopCollapsed}
+ />
+
+
+
+
+
+ {!selected ? (
+ <>
+
Practice
+
+ Pick a topic and build your confidence with fresh questions each round.
+
+
+ >
+ ) : (
+ <>
+
+
+ {generator === "lesson-only" || generator === null ? (
+
+ ) : (
+
+ )}
+ >
+ )}
-
- {/* Topic grid */}
- {!selectedTopic && (
-
- {filtered.map((topic) => (
-
setSelectedTopic(topic)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- setSelectedTopic(topic);
- }
- }}
- >
-
- {topic.label}
-
- {unitColors[topic.unitColor].label}
-
-
-
- ))}
-
- )}
-
- {/* Practice area */}
- {selectedTopic && (
-
-
-
-
-
- )}
-
-
+
+
);
diff --git a/components/explorers/bodmas-explorer.tsx b/components/explorers/bodmas-explorer.tsx
index 4fe9340..374fc6c 100644
--- a/components/explorers/bodmas-explorer.tsx
+++ b/components/explorers/bodmas-explorer.tsx
@@ -4,7 +4,7 @@ import { useState } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
-import { toKatex, simplify, add, subtract, multiply, divide } from "@/lib/math/fractions";
+import { toKatex, Fraction } from "@/lib/math/fractions";
interface FractionVal {
num: number;
@@ -30,24 +30,26 @@ const OP_PRIORITY: Record = {
};
function performOp(a: FractionVal, b: FractionVal, op: string): FractionVal {
- let result: [number, number];
+ const fa = new Fraction(a.num, a.den);
+ const fb = new Fraction(b.num, b.den);
+ let result: Fraction;
switch (op) {
case "+":
- result = add(a.num, a.den, b.num, b.den);
+ result = fa.plus(fb);
break;
case "−":
- result = subtract(a.num, a.den, b.num, b.den);
+ result = fa.minus(fb);
break;
case "×":
- result = multiply(a.num, a.den, b.num, b.den);
+ result = fa.times(fb);
break;
case "÷":
- result = divide(a.num, a.den, b.num, b.den);
+ result = fa.dividedBy(fb);
break;
default:
- result = [0, 1];
+ result = new Fraction(0, 1);
}
- return { num: result[0], den: result[1] };
+ return { num: result.n, den: result.d };
}
function expressionToKatex(fracs: FractionVal[], ops: string[], highlightIdx: number | null): string {
@@ -97,7 +99,7 @@ function buildBodmasSteps(expr: Expression): Step[] {
// Perform the operation
const result = performOp(fracs[opIdx], fracs[opIdx + 1], ops[opIdx]);
- const [sn, sd] = simplify(result.num, result.den);
+ const { n: sn, d: sd } = new Fraction(result.num, result.den).simplified();
const newFracs = [...fracs];
newFracs.splice(opIdx, 2, { num: sn, den: sd });
diff --git a/components/explorers/cartesian-grid.tsx b/components/explorers/cartesian-grid.tsx
new file mode 100644
index 0000000..7bb50ba
--- /dev/null
+++ b/components/explorers/cartesian-grid.tsx
@@ -0,0 +1,181 @@
+"use client";
+
+import { useId } from "react";
+
+export interface GridPoint {
+ x: number;
+ y: number;
+ label?: string;
+ tone?: "solid" | "target" | "wrong" | "muted";
+}
+
+export interface GridSegment {
+ from: { x: number; y: number };
+ to: { x: number; y: number };
+}
+
+export interface GridArrow {
+ from: { x: number; y: number };
+ to: { x: number; y: number };
+ dashed?: boolean;
+}
+
+interface CartesianGridProps {
+ points?: GridPoint[];
+ segments?: GridSegment[];
+ arrows?: GridArrow[];
+ /** Draws dashed guide lines from this point to the axes. `axis` picks which (default both). */
+ highlight?: { x: number; y: number; axis?: "x" | "y" | "both" };
+ onGridClick?: (x: number, y: number) => void;
+}
+
+const MIN = -10;
+const MAX = 10;
+const UNIT = 20;
+const MARGIN = 20;
+const SIZE = MARGIN * 2 + (MAX - MIN) * UNIT; // 440
+
+const sx = (x: number) => MARGIN + (x - MIN) * UNIT;
+const sy = (y: number) => MARGIN + (MAX - y) * UNIT;
+
+const toneColor: Record, string> = {
+ solid: "var(--unit-7)",
+ target: "var(--correct)",
+ wrong: "var(--incorrect)",
+ muted: "var(--muted)",
+};
+
+export function CartesianGrid({
+ points = [],
+ segments = [],
+ arrows = [],
+ highlight,
+ onGridClick,
+}: CartesianGridProps) {
+ const arrowHeadId = useId();
+ const axisArrowId = useId();
+
+ const ticks: number[] = [];
+ for (let v = MIN; v <= MAX; v++) ticks.push(v);
+ const labelTicks = ticks.filter((v) => v !== 0 && v % 2 === 0);
+
+ function handleClick(e: React.MouseEvent) {
+ if (!onGridClick) return;
+ const rect = e.currentTarget.getBoundingClientRect();
+ const px = ((e.clientX - rect.left) / rect.width) * SIZE;
+ const py = ((e.clientY - rect.top) / rect.height) * SIZE;
+ const x = Math.round((px - MARGIN) / UNIT + MIN);
+ const y = Math.round(MAX - (py - MARGIN) / UNIT);
+ if (x < MIN || x > MAX || y < MIN || y > MAX) return;
+ onGridClick(x, y);
+ }
+
+ return (
+
+ );
+}
diff --git a/components/explorers/conversion-explorer.tsx b/components/explorers/conversion-explorer.tsx
index 62bd957..03a9dc5 100644
--- a/components/explorers/conversion-explorer.tsx
+++ b/components/explorers/conversion-explorer.tsx
@@ -4,7 +4,7 @@ import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
-import { simplify, toKatex, gcd } from "@/lib/math/fractions";
+import { toKatex, gcd, Fraction } from "@/lib/math/fractions";
import { parseStrictDecimal, parseStrictInt } from "@/lib/math/validation";
type ConvertMode = "decToFrac" | "fracToDec";
@@ -42,7 +42,7 @@ function buildDecToFracSteps(decimal: string): Step[] {
});
// Simplify
- const [sn, sd] = simplify(numerator, denominator);
+ const { n: sn, d: sd } = new Fraction(numerator, denominator).simplified();
if (sn !== numerator || sd !== denominator) {
const g = gcd(numerator, denominator);
steps.push({
@@ -89,7 +89,7 @@ function buildFracToDecSteps(num: number, den: number): Step[] {
// Check for terminating vs repeating
let tempDen = den;
- const [, simpDen] = simplify(num, den);
+ const { d: simpDen } = new Fraction(num, den).simplified();
tempDen = simpDen;
// Remove factors of 2 and 5
while (tempDen % 2 === 0) tempDen /= 2;
diff --git a/components/explorers/exponents-explorer.tsx b/components/explorers/exponents-explorer.tsx
new file mode 100644
index 0000000..86477c3
--- /dev/null
+++ b/components/explorers/exponents-explorer.tsx
@@ -0,0 +1,372 @@
+"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";
+
+type Mode = "expand" | "index";
+type IndexOp = "multiply" | "divide";
+
+interface Step {
+ label: string;
+ math: string;
+}
+
+function repeat(base: number, count: number): string {
+ return Array.from({ length: count }, () => `${base}`).join(" \\times ");
+}
+
+function buildExpandSteps(base: number, exp: number): Step[] {
+ if (exp === 0) {
+ return [
+ { label: "The power (index) is 0.", math: `${base}^{0}` },
+ { label: "Any number raised to the power 0 is equal to 1.", math: `${base}^{0} = 1` },
+ ];
+ }
+ if (exp === 1) {
+ return [
+ { label: "The power (index) is 1.", math: `${base}^{1}` },
+ { label: "Any number raised to the power 1 is itself.", math: `${base}^{1} = ${base}` },
+ ];
+ }
+
+ const steps: Step[] = [
+ {
+ label: `The base is ${base} and the power is ${exp}: multiply the base by itself ${exp} times.`,
+ math: `${base}^{${exp}}`,
+ },
+ ];
+ let running = base;
+ for (let k = 2; k <= exp; k++) {
+ running *= base;
+ steps.push({
+ label: `Multiply on another ${base}.`,
+ math: `${repeat(base, k)} = ${running}`,
+ });
+ }
+ steps.push({ label: "So the power is worked out.", math: `${base}^{${exp}} = ${running}` });
+ return steps;
+}
+
+function buildIndexSteps(base: number, m: number, n: number, op: IndexOp): Step[] {
+ if (op === "multiply") {
+ const total = m + n;
+ const value = Math.pow(base, total);
+ const steps: Step[] = [
+ { label: "Multiplying powers of the SAME base.", math: `${base}^{${m}} \\times ${base}^{${n}}` },
+ { label: "Write each power out in full.", math: `(${repeat(base, m)}) \\times (${repeat(base, n)})` },
+ { label: `Count the factors: ${m} + ${n} = ${total}. When multiplying, ADD the powers.`, math: `${base}^{${m}} \\times ${base}^{${n}} = ${base}^{${m}+${n}} = ${base}^{${total}}` },
+ ];
+ if (value <= 1e7) steps.push({ label: "Work out the value.", math: `${base}^{${total}} = ${value}` });
+ return steps;
+ }
+ // divide, m >= n
+ const diff = m - n;
+ const value = Math.pow(base, diff);
+ const steps: Step[] = [
+ { label: "Dividing powers of the SAME base.", math: `\\frac{${base}^{${m}}}{${base}^{${n}}}` },
+ { label: "Write each power out in full.", math: `\\frac{${repeat(base, m)}}{${repeat(base, n)}}` },
+ { label: `Cancel the ${n} matching factor${n === 1 ? "" : "s"} from top and bottom.`, math: `${base}^{${m}} \\div ${base}^{${n}} = ${base}^{${m}-${n}} = ${base}^{${diff}}` },
+ ];
+ if (value <= 1e7) steps.push({ label: "Work out the value.", math: `${base}^{${diff}} = ${value}` });
+ return steps;
+}
+
+// ── Quick practice ───────────────────────────────────────────────────────────
+
+interface PracticeQ {
+ prompt: string;
+ answer: number;
+}
+
+function randInt(min: number, max: number) {
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+}
+
+const SUPERSCRIPT: Record = {
+ "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
+ "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹",
+};
+function sup(n: number): string {
+ return String(n).split("").map((c) => SUPERSCRIPT[c] ?? c).join("");
+}
+
+function makeQuestion(): PracticeQ {
+ const roll = Math.floor(Math.random() * 5);
+ if (roll === 0) {
+ const base = randInt(2, 7);
+ const exp = randInt(2, 3);
+ return { prompt: `What is ${base}${sup(exp)}?`, answer: Math.pow(base, exp) };
+ }
+ if (roll === 1) {
+ const base = randInt(2, 9);
+ return { prompt: `What is ${base}${sup(0)}?`, answer: 1 };
+ }
+ if (roll === 2) {
+ const base = randInt(2, 4);
+ const a = randInt(2, 3);
+ const b = 1;
+ return { prompt: `What is ${base}${sup(a)} − ${base}${sup(b)}?`, answer: Math.pow(base, a) - Math.pow(base, b) };
+ }
+ if (roll === 3) {
+ const base = randInt(2, 5);
+ const m = randInt(3, 6);
+ const n = randInt(1, m - 1);
+ return { prompt: `Simplify ${base}${sup(m)} ÷ ${base}${sup(n)}. The answer is ${base} to what power?`, answer: m - n };
+ }
+ const base = randInt(2, 6);
+ const m = randInt(1, 4);
+ const n = randInt(1, 4);
+ return { prompt: `Simplify ${base}${sup(m)} × ${base}${sup(n)}. The answer is ${base} to what power?`, answer: m + n };
+}
+
+function QuickPractice() {
+ const [q, setQ] = useState(() => makeQuestion());
+ const [val, setVal] = useState("");
+ const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
+ const [score, setScore] = useState({ correct: 0, total: 0 });
+
+ function check() {
+ const parsed = parseInt(val, 10);
+ if (isNaN(parsed)) return;
+ const correct = parsed === q.answer;
+ setFeedback(correct ? "correct" : "incorrect");
+ setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
+ }
+
+ function next() {
+ setQ(makeQuestion());
+ setVal("");
+ setFeedback(null);
+ }
+
+ return (
+
+
+
Quick Practice
+
+ {score.correct}/{score.total}
+
+
+ {q.prompt}
+
+ {
+ setVal(e.target.value);
+ setFeedback(null);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ if (feedback !== null) next();
+ else check();
+ }
+ }}
+ className={`w-24 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
+ feedback === "correct"
+ ? "border-correct bg-correct-light"
+ : feedback === "incorrect"
+ ? "border-incorrect bg-incorrect-light"
+ : "border-border focus:border-unit-8"
+ }`}
+ placeholder="?"
+ aria-label="Your answer"
+ />
+ {feedback === null ? (
+
+ ) : (
+
+ )}
+
+ {feedback === "correct" && Correct!
}
+ {feedback === "incorrect" && (
+ Not quite — the answer is {q.answer}.
+ )}
+
+ );
+}
+
+// ── Main explorer ────────────────────────────────────────────────────────────
+
+export function ExponentsExplorer() {
+ const [mode, setMode] = useState("expand");
+ const [indexOp, setIndexOp] = useState("multiply");
+ const [base, setBase] = useState("2");
+ const [power, setPower] = useState("4");
+ const [m, setM] = useState("4");
+ const [n, setN] = useState("3");
+ const [error, setError] = useState("");
+
+ const [steps, setSteps] = useState(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(md: Mode) {
+ setMode(md);
+ setError("");
+ reset();
+ }
+
+ function handleGo() {
+ setError("");
+ const b = parseInt(base, 10);
+ if (isNaN(b) || b < 2 || b > 12) return setError("Use a base between 2 and 12.");
+
+ if (mode === "expand") {
+ const e = parseInt(power, 10);
+ if (isNaN(e) || e < 0 || e > 6) return setError("Use a power between 0 and 6.");
+ if (Math.pow(b, e) > 1_000_000) return setError("That gets too big. Try a smaller base or power.");
+ setSteps(buildExpandSteps(b, e));
+ } else {
+ const mm = parseInt(m, 10);
+ const nn = parseInt(n, 10);
+ if (isNaN(mm) || isNaN(nn) || mm < 1 || nn < 1 || mm > 8 || nn > 8) return setError("Use powers between 1 and 8.");
+ if (indexOp === "divide" && mm < nn) return setError("For division, the top power must be at least the bottom power.");
+ setSteps(buildIndexSteps(b, mm, nn, indexOp));
+ }
+ setCurrentStep(0);
+ setIsPlaying(false);
+ }
+
+ 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 (
+
+ {/* Mode toggle */}
+
+ {(["expand", "index"] as Mode[]).map((md) => (
+
+ ))}
+
+
+ {/* Index op tabs */}
+ {mode === "index" && (
+
+ {(["multiply", "divide"] as IndexOp[]).map((o) => (
+
+ ))}
+
+ )}
+
+ {/* Input card */}
+
+ {mode === "expand" ? (
+ <>
+ Enter a base and a power
+
+ setBase(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-8" aria-label="Base" />
+ to the power
+ setPower(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-8" aria-label="Power" />
+
+
+ >
+ ) : (
+ <>
+
+ Same base, two powers {indexOp === "multiply" ? "to multiply" : "to divide"}
+
+
+ base
+ setBase(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-8" aria-label="Base" />
+ powers
+ setM(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-8" aria-label="First power" />
+ {indexOp === "multiply" ? "×" : "÷"}
+ setN(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-8" aria-label="Second power" />
+
+
+ >
+ )}
+ {error && {error}
}
+
+
+ {/* Display card */}
+
+ {!step ? (
+
+ Enter values above and click Go
+
+ ) : (
+ <>
+ {step.label}
+
+ >
+ )}
+
+
+ {/* Controls */}
+
+ 0}
+ />
+
+
+ {/* Quick practice */}
+
}>
+
+
+
+ );
+}
diff --git a/components/explorers/fraction-operation-explorer.tsx b/components/explorers/fraction-operation-explorer.tsx
index 5217ca1..07386fc 100644
--- a/components/explorers/fraction-operation-explorer.tsx
+++ b/components/explorers/fraction-operation-explorer.tsx
@@ -3,7 +3,7 @@
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
-import { gcd, lcm, simplify, toKatex } from "@/lib/math/fractions";
+import { gcd, lcm, toKatex, Fraction } from "@/lib/math/fractions";
import { MathDisplay } from "@/components/math/math-display";
type Operation = "add" | "subtract" | "multiply" | "divide";
@@ -68,7 +68,7 @@ function buildSteps(
});
// Step 4: Simplify if needed
- const [sn, sd] = simplify(resultNum, lcd);
+ const { n: sn, d: sd } = new Fraction(resultNum, lcd).simplified();
if (Math.abs(sn) !== Math.abs(resultNum) || sd !== lcd) {
const g = gcd(Math.abs(resultNum), lcd);
steps.push({
@@ -92,7 +92,7 @@ function buildSteps(
});
// Step 2: Simplify
- const [sn, sd] = simplify(rn, rd);
+ const { n: sn, d: sd } = new Fraction(rn, rd).simplified();
if (sn !== rn || sd !== rd) {
const g = gcd(Math.abs(rn), rd);
steps.push({
@@ -122,7 +122,7 @@ function buildSteps(
barResult: { filled: rn, total: rd },
});
- const [sn, sd] = simplify(rn, rd);
+ const { n: sn, d: sd } = new Fraction(rn, rd).simplified();
if (sn !== rn || sd !== rd) {
const g = gcd(Math.abs(rn), rd);
steps.push({
diff --git a/components/explorers/identity-inverse-explorer.tsx b/components/explorers/identity-inverse-explorer.tsx
new file mode 100644
index 0000000..ad8ef5d
--- /dev/null
+++ b/components/explorers/identity-inverse-explorer.tsx
@@ -0,0 +1,331 @@
+"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";
+
+type Mode = "identity" | "inverse" | "zero";
+type Op = "add" | "multiply";
+
+interface Step {
+ label: string;
+ math: string;
+ danger?: boolean;
+}
+
+function buildIdentitySteps(n: number, op: Op): Step[] {
+ if (op === "add") {
+ return [
+ { label: "The identity for addition is 0 — it leaves a number unchanged.", math: `${n} + 0` },
+ { label: "Adding 0 changes nothing.", math: `${n} + 0 = ${n}` },
+ { label: "So 0 is the identity element for addition.", math: `n + 0 = n` },
+ ];
+ }
+ return [
+ { label: "The identity for multiplication is 1 — it leaves a number unchanged.", math: `${n} \\times 1` },
+ { label: "Multiplying by 1 changes nothing.", math: `${n} \\times 1 = ${n}` },
+ { label: "So 1 is the identity element for multiplication.", math: `n \\times 1 = n` },
+ ];
+}
+
+function buildInverseSteps(n: number, op: Op): Step[] {
+ if (op === "add") {
+ const inv = -n;
+ const invText = inv < 0 ? `(${inv})` : `${inv}`;
+ return [
+ { label: "The additive inverse is the number that brings you back to the identity, 0.", math: `${n} + \\square = 0` },
+ { label: `Flip the sign of ${n} to get its additive inverse.`, math: `\\text{inverse of } ${n} = ${inv}` },
+ { label: "Check: adding a number and its inverse gives 0.", math: `${n} + ${invText} = 0` },
+ ];
+ }
+ // multiplicative inverse (reciprocal)
+ const absN = Math.abs(n);
+ const recip = n < 0 ? `-\\frac{1}{${absN}}` : `\\frac{1}{${absN}}`;
+ const recipParen = n < 0 ? `\\left(-\\frac{1}{${absN}}\\right)` : `\\frac{1}{${absN}}`;
+ return [
+ { label: "The multiplicative inverse (reciprocal) brings you back to the identity, 1.", math: `${n} \\times \\square = 1` },
+ { label: `Write ${n} as a fraction and flip it. The sign stays in front.`, math: `\\text{inverse of } ${n} = ${recip}` },
+ { label: "Check: a number times its reciprocal gives 1.", math: `${n} \\times ${recipParen} = 1` },
+ ];
+}
+
+function buildZeroSteps(n: number): Step[] {
+ return [
+ { label: "Multiplying any number by zero always gives zero.", math: `${n} \\times 0 = 0` },
+ { label: "Zero shared into any number of groups is still zero.", math: `0 \\div ${n === 0 ? 5 : n} = 0` },
+ {
+ label: "But you can never divide BY zero — there is no answer. It is undefined.",
+ math: `${n} \\div 0 = \\text{undefined}`,
+ danger: true,
+ },
+ ];
+}
+
+// ── Quick practice ───────────────────────────────────────────────────────────
+
+interface PracticeQ {
+ prompt: string;
+ answer: number;
+}
+
+function randInt(min: number, max: number) {
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+}
+
+function makeQuestion(): PracticeQ {
+ const roll = Math.floor(Math.random() * 5);
+ if (roll === 0) {
+ const n = randInt(-12, 12) || 3;
+ return { prompt: `What is the additive inverse of ${n}?`, answer: -n };
+ }
+ if (roll === 1) {
+ const n = randInt(1, 20);
+ return { prompt: `What is ${n} × 0?`, answer: 0 };
+ }
+ if (roll === 2) {
+ return { prompt: `What number is the identity for addition?`, answer: 0 };
+ }
+ if (roll === 3) {
+ return { prompt: `What number is the identity for multiplication?`, answer: 1 };
+ }
+ const n = randInt(2, 12);
+ return { prompt: `The multiplicative inverse of ${n} is 1 over what number?`, answer: n };
+}
+
+function QuickPractice() {
+ const [q, setQ] = useState(() => makeQuestion());
+ const [val, setVal] = useState("");
+ const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
+ const [score, setScore] = useState({ correct: 0, total: 0 });
+
+ function check() {
+ const parsed = parseInt(val, 10);
+ if (isNaN(parsed)) return;
+ const correct = parsed === q.answer;
+ setFeedback(correct ? "correct" : "incorrect");
+ setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
+ }
+
+ function next() {
+ setQ(makeQuestion());
+ setVal("");
+ setFeedback(null);
+ }
+
+ return (
+
+
+
Quick Practice
+
+ {score.correct}/{score.total}
+
+
+ {q.prompt}
+
+ {
+ setVal(e.target.value);
+ setFeedback(null);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ if (feedback !== null) next();
+ else check();
+ }
+ }}
+ className={`w-24 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
+ feedback === "correct"
+ ? "border-correct bg-correct-light"
+ : feedback === "incorrect"
+ ? "border-incorrect bg-incorrect-light"
+ : "border-border focus:border-unit-8"
+ }`}
+ placeholder="?"
+ aria-label="Your answer"
+ />
+ {feedback === null ? (
+
+ ) : (
+
+ )}
+
+ {feedback === "correct" && Correct!
}
+ {feedback === "incorrect" && (
+ Not quite — the answer is {q.answer}.
+ )}
+
+ );
+}
+
+// ── Main explorer ────────────────────────────────────────────────────────────
+
+export function IdentityInverseExplorer() {
+ const [mode, setMode] = useState("identity");
+ const [op, setOp] = useState("add");
+ const [input, setInput] = useState("3");
+ const [error, setError] = useState("");
+
+ const [steps, setSteps] = useState(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("");
+ const n = parseInt(input, 10);
+ if (isNaN(n)) return setError("Enter a valid whole number.");
+ if (Math.abs(n) > 100) return setError("Keep the number between -100 and 100.");
+ if (mode === "inverse" && op === "multiply" && n === 0) {
+ return setError("Zero has no multiplicative inverse — try another number.");
+ }
+ if (mode === "identity") setSteps(buildIdentitySteps(n, op));
+ else if (mode === "inverse") setSteps(buildInverseSteps(n, op));
+ else setSteps(buildZeroSteps(n));
+ setCurrentStep(0);
+ setIsPlaying(false);
+ }
+
+ 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;
+ const showOp = mode !== "zero";
+
+ return (
+
+ {/* Mode toggle */}
+
+ {(["identity", "inverse", "zero"] as Mode[]).map((m) => (
+
+ ))}
+
+
+ {/* Operation tabs */}
+ {showOp && (
+
+ {(["add", "multiply"] as Op[]).map((o) => (
+
+ ))}
+
+ )}
+
+ {/* Input card */}
+
+
+ {mode === "identity"
+ ? "See how the identity leaves a number unchanged"
+ : mode === "inverse"
+ ? "Find the inverse of a number"
+ : "Explore what happens with zero"}
+
+
+ setInput(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-8"
+ aria-label="Number"
+ />
+
+
+ {error && {error}
}
+
+
+ {/* Display card */}
+
+ {!step ? (
+
+ Enter a number above and click Go
+
+ ) : (
+ <>
+ {step.label}
+
+ >
+ )}
+
+
+ {/* Controls */}
+
+ 0}
+ />
+
+
+ {/* Quick practice */}
+
}>
+
+
+
+ );
+}
diff --git a/components/explorers/integer-add-subtract-explorer.tsx b/components/explorers/integer-add-subtract-explorer.tsx
index 06ccb2e..4a15a01 100644
--- a/components/explorers/integer-add-subtract-explorer.tsx
+++ b/components/explorers/integer-add-subtract-explorer.tsx
@@ -2,6 +2,7 @@
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";
@@ -282,21 +283,21 @@ function SignRuleCard({ rule, active }: { rule: string; active: boolean }) {
);
}
+function generateProblem() {
+ const ops = ["+", "-"] as const;
+ const op = ops[Math.floor(Math.random() * ops.length)];
+ const a = Math.floor(Math.random() * 21) - 10;
+ const b = Math.floor(Math.random() * 21) - 10;
+ const answer = op === "+" ? a + b : a - b;
+ return { a, b, op, answer };
+}
+
function QuickPractice() {
const [problem, setProblem] = useState(() => generateProblem());
const [userAnswer, setUserAnswer] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
- function generateProblem() {
- const ops = ["+", "-"] as const;
- const op = ops[Math.floor(Math.random() * ops.length)];
- const a = Math.floor(Math.random() * 21) - 10;
- const b = Math.floor(Math.random() * 21) - 10;
- const answer = op === "+" ? a + b : a - b;
- return { a, b, op, answer };
- }
-
function checkAnswer() {
const parsed = parseInt(userAnswer);
if (isNaN(parsed)) return;
@@ -587,7 +588,9 @@ export function IntegerAddSubtractExplorer() {
{/* Quick Practice */}
-
+ }>
+
+
);
}
diff --git a/components/explorers/integer-multiply-divide-explorer.tsx b/components/explorers/integer-multiply-divide-explorer.tsx
index 01e28fa..ffb8fb5 100644
--- a/components/explorers/integer-multiply-divide-explorer.tsx
+++ b/components/explorers/integer-multiply-divide-explorer.tsx
@@ -2,6 +2,7 @@
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";
@@ -191,8 +192,6 @@ function Thermometer({ data }: { data?: Step["thermometer"] }) {
const startTemp = data?.startTemp ?? 0;
const endTemp = data?.endTemp ?? 0;
- const startY = ((startTemp - minTemp) / range) * 100;
- const endY = ((endTemp - minTemp) / range) * 100;
const fillY = ((endTemp - minTemp) / range) * 100;
const ticks = [];
@@ -372,30 +371,30 @@ function ThermometerPractice() {
);
}
+function generateProblem() {
+ const ops = ["×", "÷"] as const;
+ const op = ops[Math.floor(Math.random() * ops.length)];
+
+ if (op === "×") {
+ const a = Math.floor(Math.random() * 25) - 12;
+ const b = Math.floor(Math.random() * 25) - 12;
+ return { a, b, op, answer: a * b };
+ } else {
+ // Generate division with clean integer results
+ const b = Math.floor(Math.random() * 11) - 5;
+ const nonZeroB = b === 0 ? 3 : b;
+ const answer = Math.floor(Math.random() * 21) - 10;
+ const a = answer * nonZeroB;
+ return { a, b: nonZeroB, op, answer };
+ }
+}
+
function MultiplyDividePractice() {
const [problem, setProblem] = useState(() => generateProblem());
const [userAnswer, setUserAnswer] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
- function generateProblem() {
- const ops = ["×", "÷"] as const;
- const op = ops[Math.floor(Math.random() * ops.length)];
-
- if (op === "×") {
- const a = Math.floor(Math.random() * 25) - 12;
- const b = Math.floor(Math.random() * 25) - 12;
- return { a, b, op, answer: a * b };
- } else {
- // Generate division with clean integer results
- const b = Math.floor(Math.random() * 11) - 5;
- const nonZeroB = b === 0 ? 3 : b;
- const answer = Math.floor(Math.random() * 21) - 10;
- const a = answer * nonZeroB;
- return { a, b: nonZeroB, op, answer };
- }
- }
-
function check() {
const parsed = parseInt(userAnswer);
if (isNaN(parsed)) return;
@@ -702,7 +701,9 @@ export function IntegerMultiplyDivideExplorer() {
{/* Practice sections */}
- {tab === "thermometer" ? : }
+ }>
+ {tab === "thermometer" ? : }
+