diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..069f0d8
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+node_modules
+.next
+.git
+*.md
+term-2-unit.pdf
+.env*
+.claude
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b072522
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,30 @@
+FROM node:20-alpine AS deps
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci --ignore-scripts
+
+FROM node:20-alpine AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+ENV NEXT_TELEMETRY_DISABLED=1
+RUN npm run build
+
+FROM node:20-alpine AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+COPY --from=builder /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+EXPOSE 3000
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+CMD ["node", "server.js"]
diff --git a/Jenkinsfile b/Jenkinsfile
new file mode 100644
index 0000000..2d69862
--- /dev/null
+++ b/Jenkinsfile
@@ -0,0 +1,58 @@
+pipeline {
+ agent any
+
+ environment {
+ REGISTRY = 'registry.dwarrington.com'
+ IMAGE_NAME = 'cabrits'
+ IMAGE_TAG = "${env.BUILD_NUMBER}"
+ CREDENTIALS_ID = 'registry-creds'
+ SUBDIR = ''
+ COMPOSE_FILE = 'docker-compose.yml'
+ SERVICE_NAME = 'cabrits'
+ }
+
+ stages {
+ stage('Checkout') {
+ steps {
+ checkout scm
+ }
+ }
+
+ stage('Build & Push') {
+ steps {
+ script {
+ docker.withRegistry("https://${REGISTRY}", CREDENTIALS_ID) {
+ def buildArgs = (!env.SUBDIR || env.SUBDIR.trim() == '')
+ ? "-f Dockerfile ."
+ : "-f ${env.SUBDIR}/Dockerfile ${env.SUBDIR}"
+
+ def img = docker.build("${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}", buildArgs)
+ img.push()
+ img.push('latest')
+ }
+ }
+ }
+ }
+
+ stage('Deploy') {
+ steps {
+ withCredentials([usernamePassword(
+ credentialsId: CREDENTIALS_ID,
+ usernameVariable: 'DOCKER_USER',
+ passwordVariable: 'DOCKER_PASS'
+ )]) {
+ sh '''
+ export PATH=$PATH:/usr/libexec/docker/cli-plugins:/usr/bin:/usr/local/bin
+
+ echo "$DOCKER_PASS" | docker login ${REGISTRY} -u "$DOCKER_USER" --password-stdin
+
+ sed -i "/${SERVICE_NAME}:/,/image:/s|image:.*|image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}|" ${COMPOSE_FILE}
+
+ docker compose -f ${COMPOSE_FILE} pull ${SERVICE_NAME}
+ docker compose -f ${COMPOSE_FILE} up -d --force-recreate --remove-orphans
+ '''
+ }
+ }
+ }
+ }
+}
diff --git a/app/globals.css b/app/globals.css
index a2dc41e..4a4f9fc 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,26 +1,183 @@
@import "tailwindcss";
+@import "katex/dist/katex.min.css";
:root {
- --background: #ffffff;
- --foreground: #171717;
+ --background: #f4f8ff;
+ --foreground: #10223f;
+ --muted: #4f6588;
+ --border: #cbdaef;
+ --surface: #FFFFFF;
+ --surface-raised: #FFFFFF;
+
+ --unit-1: #2563eb;
+ --unit-1-light: #e8f1ff;
+ --unit-1-dark: #1d4ed8;
+
+ --unit-2: #0ea5a4;
+ --unit-2-light: #e6fffb;
+ --unit-2-dark: #0f766e;
+
+ --unit-3: #f97316;
+ --unit-3-light: #fff1e7;
+ --unit-3-dark: #c2410c;
+
+ --unit-4: #e11d48;
+ --unit-4-light: #ffe8ef;
+ --unit-4-dark: #be123c;
+
+ --correct: #16a34a;
+ --correct-light: #dcfce7;
+ --incorrect: #dc2626;
+ --incorrect-light: #fee2e2;
+ --hint: #d97706;
+ --hint-light: #ffedd5;
+
+ --shadow-sm: 0 1px 2px rgb(16 34 63 / 0.06), 0 2px 8px rgb(37 99 235 / 0.04);
+ --shadow-md: 0 8px 18px rgb(16 34 63 / 0.09), 0 2px 8px rgb(37 99 235 / 0.08);
+ --shadow-lg: 0 16px 30px rgb(16 34 63 / 0.11), 0 6px 20px rgb(37 99 235 / 0.1);
+ --shadow-xl: 0 24px 44px rgb(16 34 63 / 0.14), 0 12px 28px rgb(37 99 235 / 0.12);
+ --shadow-glow: 0 0 24px rgb(37 99 235 / 0.18);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
+ --color-muted: var(--muted);
+ --color-border: var(--border);
+ --color-surface: var(--surface);
+ --color-surface-raised: var(--surface-raised);
+
+ --color-unit-1: var(--unit-1);
+ --color-unit-1-light: var(--unit-1-light);
+ --color-unit-1-dark: var(--unit-1-dark);
+ --color-unit-2: var(--unit-2);
+ --color-unit-2-light: var(--unit-2-light);
+ --color-unit-2-dark: var(--unit-2-dark);
+ --color-unit-3: var(--unit-3);
+ --color-unit-3-light: var(--unit-3-light);
+ --color-unit-3-dark: var(--unit-3-dark);
+ --color-unit-4: var(--unit-4);
+ --color-unit-4-light: var(--unit-4-light);
+ --color-unit-4-dark: var(--unit-4-dark);
+
+ --color-correct: var(--correct);
+ --color-correct-light: var(--correct-light);
+ --color-incorrect: var(--incorrect);
+ --color-incorrect-light: var(--incorrect-light);
+ --color-hint: var(--hint);
+ --color-hint-light: var(--hint-light);
+
+ --font-sans: "Nunito", "Trebuchet MS", "Segoe UI", sans-serif;
+ --font-display: "Avenir Next", "Poppins", "Trebuchet MS", sans-serif;
+ --font-mono: "JetBrains Mono", "Fira Code", "Consolas", monospace;
}
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
+html {
+ scroll-behavior: smooth;
}
body {
- background: var(--background);
+ background:
+ radial-gradient(circle at 15% 0%, color-mix(in srgb, var(--unit-1) 9%, transparent), transparent 46%),
+ radial-gradient(circle at 90% 8%, color-mix(in srgb, var(--unit-3) 9%, transparent), transparent 34%),
+ radial-gradient(circle at 85% 88%, color-mix(in srgb, var(--unit-2) 7%, transparent), transparent 40%),
+ linear-gradient(180deg, #f7fbff 0%, var(--background) 100%);
color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ font-family: var(--font-sans);
+ text-wrap: pretty;
+}
+
+h1,
+h2,
+h3,
+h4 {
+ font-family: var(--font-display);
+ letter-spacing: -0.02em;
+}
+
+::selection {
+ background: color-mix(in srgb, var(--unit-1) 20%, transparent);
+}
+
+/* Subtle gradient background pattern */
+.hero-gradient {
+ background:
+ radial-gradient(ellipse 100% 70% at 55% -15%, color-mix(in srgb, var(--unit-1) 14%, transparent), transparent),
+ radial-gradient(ellipse 70% 40% at 95% 45%, color-mix(in srgb, var(--unit-3) 12%, transparent), transparent),
+ radial-gradient(ellipse 70% 50% at 10% 75%, color-mix(in srgb, var(--unit-2) 10%, transparent), transparent),
+ linear-gradient(180deg, rgb(255 255 255 / 0.94), rgb(255 255 255 / 0.82));
+}
+
+/* Dot pattern overlay */
+.dot-pattern {
+ background-image:
+ linear-gradient(to right, rgb(203 218 239 / 0.45) 1px, transparent 1px),
+ linear-gradient(to bottom, rgb(203 218 239 / 0.45) 1px, transparent 1px);
+ background-size: 22px 22px;
+}
+
+@keyframes float-card {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-3px);
+ }
+}
+
+.float-card {
+ animation: float-card 3.2s ease-in-out infinite;
+}
+
+.playground-bg {
+ background: linear-gradient(180deg, #2f65e8 0%, #2a5fdc 100%);
+}
+
+.playground-frame {
+ border-left: 4px solid #1e4fc6;
+ border-right: 4px solid #1e4fc6;
+ background: #ffffff;
+ box-shadow: 0 16px 40px rgb(15 35 86 / 0.22);
+}
+
+.section-ribbon {
+ border: 2px solid #1f4fbe;
+ background: linear-gradient(180deg, #2f65e8 0%, #2051c3 100%);
+ color: white;
+}
+
+.game-thumb-grid {
+ background-image:
+ linear-gradient(90deg, rgb(255 255 255 / 0.22) 1px, transparent 1px),
+ linear-gradient(0deg, rgb(255 255 255 / 0.22) 1px, transparent 1px);
+ background-size: 14px 14px;
+}
+
+/* Smooth scrollbar */
+::-webkit-scrollbar {
+ width: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--muted);
+}
+
+/* Focus ring utility */
+.focus-ring {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+}
+
+.focus-ring:focus-visible {
+ outline-color: var(--unit-1);
}
diff --git a/app/layout.tsx b/app/layout.tsx
index f7fa87e..d4f8262 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,20 +1,10 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "Cabrits Math Lab",
+ description:
+ "Build confidence in mathematics with interactive lessons and practice for secondary school students.",
};
export default function RootLayout({
@@ -24,9 +14,7 @@ export default function RootLayout({
}>) {
return (
-
+
{children}
diff --git a/app/lessons/layout.tsx b/app/lessons/layout.tsx
new file mode 100644
index 0000000..3bd86c8
--- /dev/null
+++ b/app/lessons/layout.tsx
@@ -0,0 +1,28 @@
+import { Header } from "@/components/layout/header";
+import { Footer } from "@/components/layout/footer";
+import { Sidebar } from "@/components/layout/sidebar";
+import { MobileNav } from "@/components/layout/mobile-nav";
+
+export default function LessonsLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/app/lessons/page.tsx b/app/lessons/page.tsx
new file mode 100644
index 0000000..15f8008
--- /dev/null
+++ b/app/lessons/page.tsx
@@ -0,0 +1,42 @@
+import Link from "next/link";
+import { curriculum } from "@/lib/curriculum";
+import { Card } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+
+export default function LessonsOverview() {
+ return (
+
+
All Topics
+
Form 1, Term 2 — Select a topic to explore
+
+ {curriculum.map((unit) => (
+
+
+ Unit {unit.number}
+
{unit.title}
+ {unit.weeks}
+
+
+ {unit.topics.map((topic) => (
+
+
+ {topic.title}
+ {topic.description}
+
+
Week {topic.week}
+
+ Explore
+
+
+
+
+
+ ))}
+
+
+ ))}
+
+ );
+}
diff --git a/app/lessons/unit-1-fractions/add-subtract/page.tsx b/app/lessons/unit-1-fractions/add-subtract/page.tsx
new file mode 100644
index 0000000..01f2b02
--- /dev/null
+++ b/app/lessons/unit-1-fractions/add-subtract/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { FractionOperationExplorer } from "@/components/explorers/fraction-operation-explorer";
+
+export default function AddSubtractPage() {
+ return (
+
+
+
+
Add and Subtract Fractions
+
+
+
+ );
+}
diff --git a/app/lessons/unit-1-fractions/divide/page.tsx b/app/lessons/unit-1-fractions/divide/page.tsx
new file mode 100644
index 0000000..836d530
--- /dev/null
+++ b/app/lessons/unit-1-fractions/divide/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { FractionOperationExplorer } from "@/components/explorers/fraction-operation-explorer";
+
+export default function DividePage() {
+ return (
+
+
+
+
Divide Fractions
+
+
+
+ );
+}
diff --git a/app/lessons/unit-1-fractions/fraction-of-quantity/page.tsx b/app/lessons/unit-1-fractions/fraction-of-quantity/page.tsx
new file mode 100644
index 0000000..9617d87
--- /dev/null
+++ b/app/lessons/unit-1-fractions/fraction-of-quantity/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { FractionQuantityExplorer } from "@/components/explorers/fraction-quantity-explorer";
+
+export default function FractionOfQuantityPage() {
+ return (
+
+
+
+
Fraction of a Quantity
+
+
+
+ );
+}
diff --git a/app/lessons/unit-1-fractions/mixed-operations/page.tsx b/app/lessons/unit-1-fractions/mixed-operations/page.tsx
new file mode 100644
index 0000000..0bb9d59
--- /dev/null
+++ b/app/lessons/unit-1-fractions/mixed-operations/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { BODMASExplorer } from "@/components/explorers/bodmas-explorer";
+
+export default function MixedOperationsPage() {
+ return (
+
+
+
+
Mixed Operations (BODMAS)
+
+
+
+ );
+}
diff --git a/app/lessons/unit-1-fractions/multiply/page.tsx b/app/lessons/unit-1-fractions/multiply/page.tsx
new file mode 100644
index 0000000..9e49ac2
--- /dev/null
+++ b/app/lessons/unit-1-fractions/multiply/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { FractionOperationExplorer } from "@/components/explorers/fraction-operation-explorer";
+
+export default function MultiplyPage() {
+ return (
+
+
+
+
Multiply Fractions
+
+
+
+ );
+}
diff --git a/app/lessons/unit-1-fractions/page.tsx b/app/lessons/unit-1-fractions/page.tsx
new file mode 100644
index 0000000..70d32cc
--- /dev/null
+++ b/app/lessons/unit-1-fractions/page.tsx
@@ -0,0 +1,41 @@
+import Link from "next/link";
+import { curriculum } from "@/lib/curriculum";
+import { Card } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+
+export default function Unit1Overview() {
+ const unit = curriculum[0];
+
+ return (
+
+
+
+
Unit 1 — {unit.weeks}
+
{unit.title}
+
{unit.description}
+
+
+ {unit.topics.map((topic, i) => (
+
+
+
+
+ {i + 1}
+
+ Week {topic.week}
+
+ {topic.title}
+ {topic.description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/lessons/unit-1-fractions/whole-from-fractions/page.tsx b/app/lessons/unit-1-fractions/whole-from-fractions/page.tsx
new file mode 100644
index 0000000..7d363b8
--- /dev/null
+++ b/app/lessons/unit-1-fractions/whole-from-fractions/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { FractionQuantityExplorer } from "@/components/explorers/fraction-quantity-explorer";
+
+export default function WholeFromFractionsPage() {
+ return (
+
+
+
+
Calculate the Whole from Fractions
+
+
+
+ );
+}
diff --git a/app/lessons/unit-2-decimals/approximate/page.tsx b/app/lessons/unit-2-decimals/approximate/page.tsx
new file mode 100644
index 0000000..46b815a
--- /dev/null
+++ b/app/lessons/unit-2-decimals/approximate/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { RoundingExplorer } from "@/components/explorers/rounding-explorer";
+
+export default function ApproximatePage() {
+ return (
+
+
+
+
Approximate Decimals
+
+
+
+ );
+}
diff --git a/app/lessons/unit-2-decimals/compare-order/page.tsx b/app/lessons/unit-2-decimals/compare-order/page.tsx
new file mode 100644
index 0000000..c6496cc
--- /dev/null
+++ b/app/lessons/unit-2-decimals/compare-order/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { DecimalOrderExplorer } from "@/components/explorers/decimal-order-explorer";
+
+export default function CompareOrderPage() {
+ return (
+
+
+
+
Compare and Order Decimals
+
+
+
+ );
+}
diff --git a/app/lessons/unit-2-decimals/page.tsx b/app/lessons/unit-2-decimals/page.tsx
new file mode 100644
index 0000000..174b144
--- /dev/null
+++ b/app/lessons/unit-2-decimals/page.tsx
@@ -0,0 +1,41 @@
+import Link from "next/link";
+import { curriculum } from "@/lib/curriculum";
+import { Card } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+
+export default function Unit2Overview() {
+ const unit = curriculum[1];
+
+ return (
+
+
+
+
Unit 2 — {unit.weeks}
+
{unit.title}
+
{unit.description}
+
+
+ {unit.topics.map((topic, i) => (
+
+
+
+
+ {i + 7}
+
+ Week {topic.week}
+
+ {topic.title}
+ {topic.description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/lessons/unit-2-decimals/standard-form/page.tsx b/app/lessons/unit-2-decimals/standard-form/page.tsx
new file mode 100644
index 0000000..7982f73
--- /dev/null
+++ b/app/lessons/unit-2-decimals/standard-form/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { StandardFormExplorer } from "@/components/explorers/standard-form-explorer";
+
+export default function StandardFormPage() {
+ return (
+
+
+
+
Standard Form (Scientific Notation)
+
+
+
+ );
+}
diff --git a/app/lessons/unit-3-decimal-operations/add-subtract/page.tsx b/app/lessons/unit-3-decimal-operations/add-subtract/page.tsx
new file mode 100644
index 0000000..2f71631
--- /dev/null
+++ b/app/lessons/unit-3-decimal-operations/add-subtract/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { DecimalArithmeticExplorer } from "@/components/explorers/decimal-arithmetic-explorer";
+
+export default function AddSubtractPage() {
+ return (
+
+
+
+
Add and Subtract Decimals
+
+
+
+ );
+}
diff --git a/app/lessons/unit-3-decimal-operations/convert/page.tsx b/app/lessons/unit-3-decimal-operations/convert/page.tsx
new file mode 100644
index 0000000..82d9b38
--- /dev/null
+++ b/app/lessons/unit-3-decimal-operations/convert/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { ConversionExplorer } from "@/components/explorers/conversion-explorer";
+
+export default function ConvertPage() {
+ return (
+
+
+
+
Convert Decimals and Fractions
+
+
+
+ );
+}
diff --git a/app/lessons/unit-3-decimal-operations/multiply-divide/page.tsx b/app/lessons/unit-3-decimal-operations/multiply-divide/page.tsx
new file mode 100644
index 0000000..47b4172
--- /dev/null
+++ b/app/lessons/unit-3-decimal-operations/multiply-divide/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { DecimalArithmeticExplorer } from "@/components/explorers/decimal-arithmetic-explorer";
+
+export default function MultiplyDividePage() {
+ return (
+
+
+
+
Multiply and Divide Decimals
+
+
+
+ );
+}
diff --git a/app/lessons/unit-3-decimal-operations/page.tsx b/app/lessons/unit-3-decimal-operations/page.tsx
new file mode 100644
index 0000000..c0d5c36
--- /dev/null
+++ b/app/lessons/unit-3-decimal-operations/page.tsx
@@ -0,0 +1,41 @@
+import Link from "next/link";
+import { curriculum } from "@/lib/curriculum";
+import { Card } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+
+export default function Unit3Overview() {
+ const unit = curriculum[2];
+
+ return (
+
+
+
+
Unit 3 — {unit.weeks}
+
{unit.title}
+
{unit.description}
+
+
+ {unit.topics.map((topic, i) => (
+
+
+
+
+ {i + 10}
+
+ Week {topic.week}
+
+ {topic.title}
+ {topic.description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/lessons/unit-4-ratio-proportion/define-ratio/page.tsx b/app/lessons/unit-4-ratio-proportion/define-ratio/page.tsx
new file mode 100644
index 0000000..b9a2f9c
--- /dev/null
+++ b/app/lessons/unit-4-ratio-proportion/define-ratio/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { RatioExplorer } from "@/components/explorers/ratio-explorer";
+
+export default function DefineRatioPage() {
+ return (
+
+
+
+
Define a Ratio
+
+
+
+ );
+}
diff --git a/app/lessons/unit-4-ratio-proportion/divide-in-ratio/page.tsx b/app/lessons/unit-4-ratio-proportion/divide-in-ratio/page.tsx
new file mode 100644
index 0000000..4070ed0
--- /dev/null
+++ b/app/lessons/unit-4-ratio-proportion/divide-in-ratio/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { RatioExplorer } from "@/components/explorers/ratio-explorer";
+
+export default function DivideInRatioPage() {
+ return (
+
+
+
+
Divide a Quantity in a Given Ratio
+
+
+
+ );
+}
diff --git a/app/lessons/unit-4-ratio-proportion/fractions-and-ratios/page.tsx b/app/lessons/unit-4-ratio-proportion/fractions-and-ratios/page.tsx
new file mode 100644
index 0000000..69c7d43
--- /dev/null
+++ b/app/lessons/unit-4-ratio-proportion/fractions-and-ratios/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { RatioExplorer } from "@/components/explorers/ratio-explorer";
+
+export default function FractionsAndRatiosPage() {
+ return (
+
+
+
+
Fractions and Ratios
+
+
+
+ );
+}
diff --git a/app/lessons/unit-4-ratio-proportion/page.tsx b/app/lessons/unit-4-ratio-proportion/page.tsx
new file mode 100644
index 0000000..c4d543f
--- /dev/null
+++ b/app/lessons/unit-4-ratio-proportion/page.tsx
@@ -0,0 +1,41 @@
+import Link from "next/link";
+import { curriculum } from "@/lib/curriculum";
+import { Card } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+
+export default function Unit4Overview() {
+ const unit = curriculum[3];
+
+ return (
+
+
+
+
Unit 4 — {unit.weeks}
+
{unit.title}
+
{unit.description}
+
+
+ {unit.topics.map((topic, i) => (
+
+
+
+
+ {String.fromCharCode(97 + i)}
+
+ Week {topic.week}
+
+ {topic.title}
+ {topic.description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/lessons/unit-4-ratio-proportion/simplify-ratios/page.tsx b/app/lessons/unit-4-ratio-proportion/simplify-ratios/page.tsx
new file mode 100644
index 0000000..bb8fc56
--- /dev/null
+++ b/app/lessons/unit-4-ratio-proportion/simplify-ratios/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { RatioExplorer } from "@/components/explorers/ratio-explorer";
+
+export default function SimplifyRatiosPage() {
+ return (
+
+
+
+
Simplify Ratios
+
+
+
+ );
+}
diff --git a/app/lessons/unit-4-ratio-proportion/word-problems/page.tsx b/app/lessons/unit-4-ratio-proportion/word-problems/page.tsx
new file mode 100644
index 0000000..8d581a7
--- /dev/null
+++ b/app/lessons/unit-4-ratio-proportion/word-problems/page.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/layout/breadcrumbs";
+import { RatioExplorer } from "@/components/explorers/ratio-explorer";
+
+export default function WordProblemsPage() {
+ return (
+
+
+
+
Proportional Parts Word Problems
+
+
+
+ );
+}
diff --git a/app/not-found.tsx b/app/not-found.tsx
new file mode 100644
index 0000000..0706160
--- /dev/null
+++ b/app/not-found.tsx
@@ -0,0 +1,37 @@
+import Link from "next/link";
+import { Header } from "@/components/layout/header";
+import { Footer } from "@/components/layout/footer";
+
+export default function NotFound() {
+ return (
+
+
+
+
+
+
+ Page not found
+
+
+
+ 404
+
+
+
+ This page doesn't exist or has been moved.
+
+
+
+ Back to Home
+
+
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 295f8fd..5ba59db 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,65 +1,133 @@
-import Image from "next/image";
+import Link from "next/link";
+import { Header } from "@/components/layout/header";
+import { Footer } from "@/components/layout/footer";
+import { curriculum } from "@/lib/curriculum";
+
+const unitStyles = {
+ "unit-1": {
+ tile: "from-[#4e95ff] to-[#1f63ea]",
+ unitCard: "border-[#8cb2ff] bg-[#eaf2ff]",
+ chip: "bg-[#2059cc]",
+ },
+ "unit-2": {
+ tile: "from-[#29c6c5] to-[#0f8d8c]",
+ unitCard: "border-[#89e5dd] bg-[#eafffb]",
+ chip: "bg-[#0f8d8c]",
+ },
+ "unit-3": {
+ tile: "from-[#ffab55] to-[#e97617]",
+ unitCard: "border-[#ffd2a3] bg-[#fff4e8]",
+ chip: "bg-[#d26a17]",
+ },
+ "unit-4": {
+ tile: "from-[#ff5f85] to-[#d11b56]",
+ unitCard: "border-[#ffb2c7] bg-[#fff0f4]",
+ chip: "bg-[#c6174e]",
+ },
+};
+
+const topicTiles = [
+ "from-[#4e95ff] via-[#357fe9] to-[#1f63ea]",
+ "from-[#2ac3be] via-[#1da8a3] to-[#0f8d8c]",
+ "from-[#ffb067] via-[#f2913a] to-[#d86e16]",
+ "from-[#ff6f98] via-[#ef3d72] to-[#cb1c54]",
+ "from-[#7d95ff] via-[#6078f2] to-[#3f56d9]",
+ "from-[#4ccf7b] via-[#2fb45f] to-[#1f944b]",
+ "from-[#ff8f8f] via-[#f26b6b] to-[#d94545]",
+ "from-[#5eb7ff] via-[#3f99ef] to-[#2a7fda]",
+];
export default function Home() {
+ const allTopics = curriculum.flatMap((unit) =>
+ unit.topics.map((topic) => ({
+ unit,
+ topic,
+ })),
+ );
+
return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
+
+
+
+
+
+ Interactive maths practice for secondary school students
-
-
+
+ Math Topics Students Love to Explore
+
+
+ Explore each topic with visual examples, short explanations, and guided lessons.
+ Pick a unit, jump into a topic, and start solving.
+
+
+
+ Browse Lessons
+
+
+ Start Practice
+
+
+
+
+
+
+ Unit Worlds
+
+
+ {curriculum.map((unit) => (
+
+
+
{unit.title}
+
+ Unit {unit.number}
+
+
+
{unit.description}
+
{unit.weeks}
+
+ ))}
+
+
+
+
+
+ Topic Explorer
+
+
+ {allTopics.map(({ unit, topic }, index) => (
+
+
+
+ Week {topic.week}
+
+
+ {topic.shortTitle}
+
+
+
+ {topic.title}
+
+
+ ))}
+
+
+
);
}
diff --git a/app/practice/page.tsx b/app/practice/page.tsx
new file mode 100644
index 0000000..8364b14
--- /dev/null
+++ b/app/practice/page.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { useState } from "react";
+import { Card } from "@/components/ui/card";
+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 type { Difficulty, MathProblem } from "@/lib/problems/types";
+
+import { generateFractionAddSubtract, generateFractionMultiply, generateFractionDivide, generateFractionOfQuantity, generateWholeFromFraction } 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";
+
+interface TopicGenerator {
+ unitSlug: string;
+ topicSlug: string;
+ label: string;
+ generator: (difficulty: Difficulty) => MathProblem;
+ unitColor: "unit-1" | "unit-2" | "unit-3" | "unit-4";
+}
+
+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",
+ },
+};
+
+export default function PracticePage() {
+ const [selectedTopic, setSelectedTopic] = useState(null);
+ const [filterUnit, setFilterUnit] = useState(null);
+
+ const filtered = filterUnit
+ ? TOPIC_GENERATORS.filter((t) => t.unitColor === filterUnit)
+ : TOPIC_GENERATORS;
+
+ 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) => (
+
+ ))}
+
+
+ {/* Topic grid */}
+ {!selectedTopic && (
+
+ {filtered.map((topic) => (
+
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
new file mode 100644
index 0000000..4fe9340
--- /dev/null
+++ b/components/explorers/bodmas-explorer.tsx
@@ -0,0 +1,338 @@
+"use client";
+
+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";
+
+interface FractionVal {
+ num: number;
+ den: number;
+}
+
+interface Expression {
+ fractions: FractionVal[];
+ operators: string[];
+}
+
+interface Step {
+ label: string;
+ math: string;
+ highlightIndex: number | null; // which operator is being evaluated
+}
+
+const OP_PRIORITY: Record = {
+ "×": 2,
+ "÷": 2,
+ "+": 1,
+ "−": 1,
+};
+
+function performOp(a: FractionVal, b: FractionVal, op: string): FractionVal {
+ let result: [number, number];
+ switch (op) {
+ case "+":
+ result = add(a.num, a.den, b.num, b.den);
+ break;
+ case "−":
+ result = subtract(a.num, a.den, b.num, b.den);
+ break;
+ case "×":
+ result = multiply(a.num, a.den, b.num, b.den);
+ break;
+ case "÷":
+ result = divide(a.num, a.den, b.num, b.den);
+ break;
+ default:
+ result = [0, 1];
+ }
+ return { num: result[0], den: result[1] };
+}
+
+function expressionToKatex(fracs: FractionVal[], ops: string[], highlightIdx: number | null): string {
+ const parts: string[] = [];
+ fracs.forEach((f, i) => {
+ parts.push(toKatex(f.num, f.den));
+ if (i < ops.length) {
+ if (i === highlightIdx) {
+ parts.push(`\\;\\boxed{${ops[i]}}\\;`);
+ } else {
+ parts.push(`\\;${ops[i]}\\;`);
+ }
+ }
+ });
+ return parts.join("");
+}
+
+function buildBodmasSteps(expr: Expression): Step[] {
+ const steps: Step[] = [];
+ let fracs = [...expr.fractions];
+ let ops = [...expr.operators];
+
+ // Step 0: Show the expression
+ steps.push({
+ label: "Start with the expression",
+ math: expressionToKatex(fracs, ops, null),
+ highlightIndex: null,
+ });
+
+ while (ops.length > 0) {
+ // Find highest priority operator (leftmost if tie)
+ let maxPri = 0;
+ let opIdx = 0;
+ ops.forEach((op, i) => {
+ if (OP_PRIORITY[op] > maxPri) {
+ maxPri = OP_PRIORITY[op];
+ opIdx = i;
+ }
+ });
+
+ // Highlight the operation to perform
+ steps.push({
+ label: `BODMAS: do ${ops[opIdx]} first (${OP_PRIORITY[ops[opIdx]] === 2 ? "multiplication/division" : "addition/subtraction"})`,
+ math: expressionToKatex(fracs, ops, opIdx),
+ highlightIndex: opIdx,
+ });
+
+ // Perform the operation
+ const result = performOp(fracs[opIdx], fracs[opIdx + 1], ops[opIdx]);
+ const [sn, sd] = simplify(result.num, result.den);
+
+ const newFracs = [...fracs];
+ newFracs.splice(opIdx, 2, { num: sn, den: sd });
+ const newOps = [...ops];
+ newOps.splice(opIdx, 1);
+
+ steps.push({
+ label: `${fracs[opIdx].num}/${fracs[opIdx].den} ${ops[opIdx]} ${fracs[opIdx + 1].num}/${fracs[opIdx + 1].den} = ${sn}/${sd}`,
+ math: expressionToKatex(newFracs, newOps, null),
+ highlightIndex: null,
+ });
+
+ fracs = newFracs;
+ ops = newOps;
+ }
+
+ return steps;
+}
+
+const PRESETS: { label: string; fracs: FractionVal[]; ops: string[] }[] = [
+ {
+ label: "½ + ¼ × ⅗",
+ fracs: [
+ { num: 1, den: 2 },
+ { num: 1, den: 4 },
+ { num: 3, den: 5 },
+ ],
+ ops: ["+", "×"],
+ },
+ {
+ label: "⅔ × ¾ − ½",
+ fracs: [
+ { num: 2, den: 3 },
+ { num: 3, den: 4 },
+ { num: 1, den: 2 },
+ ],
+ ops: ["×", "−"],
+ },
+ {
+ label: "⅗ ÷ ¼ + ⅔",
+ fracs: [
+ { num: 3, den: 5 },
+ { num: 1, den: 4 },
+ { num: 2, den: 3 },
+ ],
+ ops: ["÷", "+"],
+ },
+];
+
+export function BODMASExplorer() {
+ const [fracs, setFracs] = useState([
+ { num: 1, den: 2 },
+ { num: 1, den: 4 },
+ { num: 3, den: 5 },
+ ]);
+ const [ops, setOps] = useState(["+", "×"]);
+ 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;
+
+ function updateFrac(idx: number, field: "num" | "den", value: string) {
+ setFracs((prev) => {
+ const next = [...prev];
+ next[idx] = { ...next[idx], [field]: parseInt(value) || 0 };
+ return next;
+ });
+ }
+
+ function handleGo() {
+ setError("");
+ if (fracs.some((f) => f.den === 0)) {
+ setError("Denominators cannot be zero.");
+ return;
+ }
+ if (fracs.some((f) => Math.abs(f.num) > 99 || Math.abs(f.den) > 99)) {
+ setError("Keep numbers under 100.");
+ return;
+ }
+ try {
+ const s = buildBodmasSteps({ fractions: fracs, operators: ops });
+ setSteps(s);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ } catch {
+ setError("Could not compute. Check your inputs.");
+ }
+ }
+
+ function loadPreset(idx: number) {
+ const p = PRESETS[idx];
+ setFracs([...p.fracs]);
+ setOps([...p.ops]);
+ reset();
+ }
+
+ function stepForward() {
+ if (!steps || currentStep >= steps.length - 1) return;
+ setCurrentStep((s) => s + 1);
+ if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
+ }
+
+ function stepBack() {
+ if (currentStep <= 0) return;
+ setCurrentStep((s) => s - 1);
+ }
+
+ function togglePlay() {
+ setIsPlaying((p) => !p);
+ }
+
+ function reset() {
+ setSteps(null);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ }
+
+ const step = steps ? steps[currentStep] : null;
+
+ const opChoices = ["+", "−", "×", "÷"];
+
+ return (
+
+ {/* Presets */}
+
+ {PRESETS.map((p, i) => (
+
+ ))}
+
+
+ {/* Input */}
+
+
+ Enter a BODMAS expression with fractions
+
+
+ {fracs.map((f, i) => (
+
+
+ {i < ops.length && (
+
+ )}
+
+ ))}
+
+
+ {error && {error}
}
+
+
+ {/* Display */}
+
+ {!step ? (
+
+ Enter an expression above and click Go
+
+ ) : (
+ <>
+ {step.label}
+
+ >
+ )}
+
+
+ {/* Controls */}
+
+ 0}
+ />
+
+
+ {/* Result */}
+
+ {!done || !steps ? (
+ Result will appear here when steps are complete
+ ) : (
+ <>
+ Final Answer
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/components/explorers/conversion-explorer.tsx b/components/explorers/conversion-explorer.tsx
new file mode 100644
index 0000000..4923a65
--- /dev/null
+++ b/components/explorers/conversion-explorer.tsx
@@ -0,0 +1,345 @@
+"use client";
+
+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";
+
+type ConvertMode = "decToFrac" | "fracToDec";
+
+interface Step {
+ label: string;
+ math: string;
+ gridFilled?: number; // out of 100 for decimal grid
+ barFilled?: number;
+ barTotal?: number;
+}
+
+function buildDecToFracSteps(decimal: string): Step[] {
+ const steps: Step[] = [];
+ const val = parseFloat(decimal);
+
+ steps.push({
+ label: `Convert ${decimal} to a fraction`,
+ math: `${decimal} = \\;?`,
+ gridFilled: Math.round(val * 100),
+ });
+
+ // Count decimal places
+ const parts = decimal.split(".");
+ const decPlaces = parts[1]?.length || 0;
+ const denominator = Math.pow(10, decPlaces);
+ const numerator = Math.round(val * denominator);
+
+ steps.push({
+ label: `${decPlaces} decimal place${decPlaces !== 1 ? "s" : ""} → denominator is ${denominator}`,
+ math: `${decimal} = ${toKatex(numerator, denominator)}`,
+ gridFilled: Math.round(val * 100),
+ barFilled: numerator,
+ barTotal: denominator,
+ });
+
+ // Simplify
+ const [sn, sd] = simplify(numerator, denominator);
+ if (sn !== numerator || sd !== denominator) {
+ const g = gcd(numerator, denominator);
+ steps.push({
+ label: `Simplify by dividing by GCD = ${g}`,
+ math: `${toKatex(numerator, denominator)} = ${toKatex(sn, sd)}`,
+ gridFilled: Math.round(val * 100),
+ barFilled: sn,
+ barTotal: sd,
+ });
+ }
+
+ steps.push({
+ label: `Result: ${decimal} = ${sn}/${sd}`,
+ math: `${decimal} = ${toKatex(sn, sd)}`,
+ gridFilled: Math.round(val * 100),
+ barFilled: sn,
+ barTotal: sd,
+ });
+
+ return steps;
+}
+
+function buildFracToDecSteps(num: number, den: number): Step[] {
+ const steps: Step[] = [];
+
+ steps.push({
+ label: `Convert ${num}/${den} to a decimal`,
+ math: `${toKatex(num, den)} = \\;?`,
+ barFilled: num,
+ barTotal: den,
+ });
+
+ // Step: Perform the division
+ const result = num / den;
+ const resultStr = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/0+$/, "");
+
+ steps.push({
+ label: `Divide numerator by denominator`,
+ math: `${num} \\div ${den} = ${resultStr}`,
+ barFilled: num,
+ barTotal: den,
+ gridFilled: Math.min(100, Math.round(result * 100)),
+ });
+
+ // Check for terminating vs repeating
+ let tempDen = den;
+ const [, simpDen] = simplify(num, den);
+ tempDen = simpDen;
+ // Remove factors of 2 and 5
+ while (tempDen % 2 === 0) tempDen /= 2;
+ while (tempDen % 5 === 0) tempDen /= 5;
+ const isTerminating = tempDen === 1;
+
+ steps.push({
+ label: isTerminating
+ ? `This is a terminating decimal`
+ : `This is a recurring decimal`,
+ math: `${toKatex(num, den)} = ${resultStr}${!isTerminating ? "..." : ""}`,
+ barFilled: num,
+ barTotal: den,
+ gridFilled: Math.min(100, Math.round(result * 100)),
+ });
+
+ return steps;
+}
+
+function DecimalGrid({ filled }: { filled: number }) {
+ return (
+
+ {Array.from({ length: 100 }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+function FractionBarSmall({ filled, total }: { filled: number; total: number }) {
+ const segments = Math.min(total, 20);
+ const fillCount = Math.min(filled, segments);
+ return (
+
+ {Array.from({ length: segments }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+export function ConversionExplorer() {
+ const [mode, setMode] = useState("decToFrac");
+ const [decInput, setDecInput] = useState("0.75");
+ const [numInput, setNumInput] = useState("3");
+ const [denInput, setDenInput] = useState("8");
+ 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;
+
+ function handleGo() {
+ setError("");
+ try {
+ let s: Step[];
+ if (mode === "decToFrac") {
+ const val = parseFloat(decInput);
+ if (isNaN(val) || val < 0 || val >= 10) {
+ setError("Enter a valid decimal between 0 and 10.");
+ return;
+ }
+ s = buildDecToFracSteps(decInput.trim());
+ } else {
+ const n = parseInt(numInput);
+ const d = parseInt(denInput);
+ if (isNaN(n) || isNaN(d) || d === 0) {
+ setError("Enter a valid fraction (denominator ≠ 0).");
+ return;
+ }
+ s = buildFracToDecSteps(n, d);
+ }
+ setSteps(s);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ } catch {
+ setError("Could not compute. 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 reset = useCallback(() => {
+ setSteps(null);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ }, []);
+
+ const step = steps ? steps[currentStep] : null;
+
+ return (
+
+ {/* Mode tabs */}
+
+
+
+
+
+ {/* Input */}
+
+
+ {mode === "decToFrac" ? "Enter a decimal" : "Enter a fraction"}
+
+
+ {mode === "decToFrac" ? (
+
setDecInput(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleGo()}
+ placeholder="e.g. 0.75"
+ autoComplete="off"
+ className="max-w-[180px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3"
+ />
+ ) : (
+
+ )}
+
+
+ {error && {error}
}
+
+
+ {/* Display */}
+
+ {!step ? (
+
+ Enter a value above and click Convert
+
+ ) : (
+ <>
+ {step.label}
+
+
+ {step.gridFilled !== undefined && (
+
+
+ {step.gridFilled}/100
+
+ )}
+ {step.barFilled !== undefined && step.barTotal !== undefined && (
+
+
+
+ {step.barFilled}/{step.barTotal}
+
+
+ )}
+
+ >
+ )}
+
+
+ {/* Controls */}
+
+ 0}
+ />
+
+
+ {/* Result */}
+
+ {!done || !steps ? (
+ Result will appear here when steps are complete
+ ) : (
+ <>
+ Answer
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/components/explorers/decimal-arithmetic-explorer.tsx b/components/explorers/decimal-arithmetic-explorer.tsx
new file mode 100644
index 0000000..2efcc82
--- /dev/null
+++ b/components/explorers/decimal-arithmetic-explorer.tsx
@@ -0,0 +1,403 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { Card } from "@/components/ui/card";
+import { StepControls } from "./step-controls";
+
+type DecOp = "add" | "subtract" | "multiply" | "divide";
+
+interface Step {
+ label: string;
+ columns: ColumnDisplay[];
+ carry?: string;
+ resultRow?: string;
+}
+
+interface ColumnDisplay {
+ rows: string[];
+ highlight?: number; // which row is highlighted
+ separator?: boolean; // line above this row
+}
+
+function padAndAlign(a: string, b: string): { aStr: string; bStr: string; dotPos: number } {
+ const aParts = a.split(".");
+ const bParts = b.split(".");
+ const aInt = aParts[0] || "0";
+ const bInt = bParts[0] || "0";
+ const aDec = aParts[1] || "";
+ const bDec = bParts[1] || "";
+
+ const maxInt = Math.max(aInt.length, bInt.length);
+ const maxDec = Math.max(aDec.length, bDec.length);
+
+ const aAligned = aInt.padStart(maxInt, " ") + (maxDec > 0 ? "." + aDec.padEnd(maxDec, "0") : "");
+ const bAligned = bInt.padStart(maxInt, " ") + (maxDec > 0 ? "." + bDec.padEnd(maxDec, "0") : "");
+
+ return { aStr: aAligned, bStr: bAligned, dotPos: maxInt };
+}
+
+function buildAddSubSteps(a: number, b: number, op: DecOp): Step[] {
+ const steps: Step[] = [];
+ const aStr = a.toString();
+ const bStr = b.toString();
+ const { aStr: aAligned, bStr: bAligned } = padAndAlign(aStr, bStr);
+
+ const opSymbol = op === "add" ? "+" : "−";
+ const result = op === "add" ? a + b : a - b;
+ const resultStr = parseFloat(result.toFixed(10)).toString();
+
+ // Step 0: Show the problem
+ steps.push({
+ label: `${aStr} ${opSymbol} ${bStr}`,
+ columns: [],
+ });
+
+ // Step 1: Align decimal points
+ steps.push({
+ label: "Align the decimal points",
+ columns: [
+ { rows: [aAligned, `${opSymbol} ${bAligned}`] },
+ ],
+ });
+
+ // Step 2: Add trailing zeros
+ steps.push({
+ label: "Fill in zeros as placeholders",
+ columns: [
+ { rows: [aAligned, `${opSymbol} ${bAligned}`], separator: true },
+ ],
+ });
+
+ // Step 3: Compute
+ const { aStr: aFinal, bStr: bFinal } = padAndAlign(aStr, bStr);
+ const resultAligned = padAndAlign(resultStr, aStr).aStr;
+
+ steps.push({
+ label: `Compute column by column`,
+ columns: [
+ { rows: [aFinal, `${opSymbol} ${bFinal}`, resultAligned], separator: true, highlight: 2 },
+ ],
+ });
+
+ // Step 4: Result
+ steps.push({
+ label: `${aStr} ${opSymbol} ${bStr} = ${resultStr}`,
+ columns: [
+ { rows: [aFinal, `${opSymbol} ${bFinal}`, resultAligned], separator: true, highlight: 2 },
+ ],
+ resultRow: resultStr,
+ });
+
+ return steps;
+}
+
+function buildMultiplySteps(a: number, b: number): Step[] {
+ const steps: Step[] = [];
+ const aStr = a.toString();
+ const bStr = b.toString();
+ const aDec = (aStr.split(".")[1] || "").length;
+ const bDec = (bStr.split(".")[1] || "").length;
+ const totalDec = aDec + bDec;
+
+ // Remove decimals for integer multiplication
+ const aInt = Math.round(a * Math.pow(10, aDec));
+ const bInt = Math.round(b * Math.pow(10, bDec));
+ const result = a * b;
+ const resultStr = parseFloat(result.toFixed(10)).toString();
+
+ steps.push({
+ label: `${aStr} × ${bStr}`,
+ columns: [],
+ });
+
+ if (totalDec > 0) {
+ steps.push({
+ label: `Count decimal places: ${aDec} + ${bDec} = ${totalDec}`,
+ columns: [
+ { rows: [`${aStr} → ${aDec} d.p.`, `${bStr} → ${bDec} d.p.`, `Total: ${totalDec} d.p.`] },
+ ],
+ });
+
+ steps.push({
+ label: `Multiply as whole numbers: ${aInt} × ${bInt}`,
+ columns: [
+ { rows: [`${aInt}`, `× ${bInt}`, `${aInt * bInt}`], separator: true, highlight: 2 },
+ ],
+ });
+
+ steps.push({
+ label: `Place decimal point ${totalDec} place${totalDec !== 1 ? "s" : ""} from the right`,
+ columns: [
+ { rows: [`${aInt * bInt}`, `→ ${resultStr}`], highlight: 1 },
+ ],
+ resultRow: resultStr,
+ });
+ } else {
+ const intResult = a * b;
+ steps.push({
+ label: `Multiply: ${aStr} × ${bStr} = ${intResult}`,
+ columns: [
+ { rows: [`${aStr}`, `× ${bStr}`, `${intResult}`], separator: true, highlight: 2 },
+ ],
+ resultRow: intResult.toString(),
+ });
+ }
+
+ return steps;
+}
+
+function buildDivideSteps(a: number, b: number): Step[] {
+ const steps: Step[] = [];
+ const aStr = a.toString();
+ const bStr = b.toString();
+ const result = a / b;
+ const resultStr = parseFloat(result.toFixed(10)).toString();
+
+ steps.push({
+ label: `${aStr} ÷ ${bStr}`,
+ columns: [],
+ });
+
+ // Make divisor a whole number
+ const bDec = (bStr.split(".")[1] || "").length;
+ if (bDec > 0) {
+ const factor = Math.pow(10, bDec);
+ const newA = parseFloat((a * factor).toFixed(10));
+ const newB = parseFloat((b * factor).toFixed(10));
+ steps.push({
+ label: `Make divisor whole: multiply both by ${factor}`,
+ columns: [
+ { rows: [`${aStr} × ${factor} = ${newA}`, `${bStr} × ${factor} = ${newB}`] },
+ ],
+ });
+ steps.push({
+ label: `Now divide: ${newA} ÷ ${newB}`,
+ columns: [
+ { rows: [`${newA} ÷ ${newB}`, `= ${resultStr}`], highlight: 1 },
+ ],
+ });
+ } else {
+ steps.push({
+ label: `Divide: ${aStr} ÷ ${bStr}`,
+ columns: [
+ { rows: [`${aStr} ÷ ${bStr}`, `= ${resultStr}`], highlight: 1 },
+ ],
+ });
+ }
+
+ steps.push({
+ label: `${aStr} ÷ ${bStr} = ${resultStr}`,
+ columns: [
+ { rows: [`${aStr} ÷ ${bStr} = ${resultStr}`] },
+ ],
+ resultRow: resultStr,
+ });
+
+ return steps;
+}
+
+function ColumnArithmetic({
+ columns,
+}: {
+ columns: ColumnDisplay[];
+}) {
+ if (columns.length === 0) return null;
+
+ return (
+
+ {columns.map((col, ci) =>
+ col.rows.map((row, ri) => (
+
+ {col.separator && ri === col.rows.length - 1 && (
+
+ )}
+
+ {row}
+
+
+ )),
+ )}
+
+ );
+}
+
+export function DecimalArithmeticExplorer() {
+ const [op, setOp] = useState("add");
+ const [aInput, setAInput] = useState("12.45");
+ const [bInput, setBInput] = useState("3.7");
+ 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;
+
+ function handleGo() {
+ setError("");
+ const a = parseFloat(aInput);
+ const b = parseFloat(bInput);
+ if (isNaN(a) || isNaN(b)) {
+ setError("Enter valid decimal numbers.");
+ return;
+ }
+ if (op === "divide" && b === 0) {
+ setError("Cannot divide by zero.");
+ return;
+ }
+ try {
+ let s: Step[];
+ if (op === "add" || op === "subtract") {
+ s = buildAddSubSteps(a, b, op);
+ } else if (op === "multiply") {
+ s = buildMultiplySteps(a, b);
+ } else {
+ s = buildDivideSteps(a, b);
+ }
+ setSteps(s);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ } catch {
+ setError("Could not compute. Check your inputs.");
+ }
+ }
+
+ 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 reset = useCallback(() => {
+ setSteps(null);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ }, []);
+
+ const step = steps ? steps[currentStep] : null;
+
+ const opLabels: Record = {
+ add: "Add (+)",
+ subtract: "Subtract (−)",
+ multiply: "Multiply (×)",
+ divide: "Divide (÷)",
+ };
+
+ return (
+
+ {/* Operation tabs */}
+
+ {(["add", "subtract", "multiply", "divide"] as DecOp[]).map((o) => (
+
+ ))}
+
+
+ {/* Input */}
+
+
+ Enter two decimal numbers
+
+
+ setAInput(e.target.value)}
+ placeholder="e.g. 12.45"
+ autoComplete="off"
+ className="max-w-[140px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3"
+ aria-label="First number"
+ />
+
+ {{ add: "+", subtract: "−", multiply: "×", divide: "÷" }[op]}
+
+ setBInput(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleGo()}
+ placeholder="e.g. 3.7"
+ autoComplete="off"
+ className="max-w-[140px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3"
+ aria-label="Second number"
+ />
+
+
+ {error && {error}
}
+
+
+ {/* Display */}
+
+ {!step ? (
+
+ Enter numbers above and click Go
+
+ ) : (
+ <>
+ {step.label}
+
+ >
+ )}
+
+
+ {/* Controls */}
+
+ 0}
+ />
+
+
+ {/* Result */}
+
+ {!done || !step?.resultRow ? (
+ Result will appear here when steps are complete
+ ) : (
+ <>
+ Answer
+
+ {step.resultRow}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/components/explorers/decimal-order-explorer.tsx b/components/explorers/decimal-order-explorer.tsx
new file mode 100644
index 0000000..5549b0f
--- /dev/null
+++ b/components/explorers/decimal-order-explorer.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { Card } from "@/components/ui/card";
+import { StepControls } from "./step-controls";
+
+type SortDirection = "ascending" | "descending";
+
+interface Step {
+ label: string;
+ values: { value: number; str: string; sorted: boolean; comparing: boolean }[];
+ comparingPair?: [number, number];
+}
+
+function padDecimal(s: string, maxInt: number, maxDec: number): string {
+ const parts = s.split(".");
+ const intPart = (parts[0] || "0").padStart(maxInt, "\u00A0"); // nbsp padding
+ const decPart = (parts[1] || "").padEnd(maxDec, "0");
+ return maxDec > 0 ? `${intPart}.${decPart}` : intPart;
+}
+
+function buildSortSteps(values: number[], direction: SortDirection): Step[] {
+ const steps: Step[] = [];
+ const strs = values.map((v) => v.toString());
+
+ // Find max integer and decimal lengths for alignment
+ const maxInt = Math.max(...strs.map((s) => s.split(".")[0].length));
+ const maxDec = Math.max(...strs.map((s) => (s.split(".")[1] || "").length));
+
+ // Step 0: Show unsorted
+ steps.push({
+ label: `Sort ${values.length} decimals in ${direction} order`,
+ values: values.map((v, i) => ({
+ value: v,
+ str: padDecimal(strs[i], maxInt, maxDec),
+ sorted: false,
+ comparing: false,
+ })),
+ });
+
+ // Step 1: Show aligned place values
+ steps.push({
+ label: "Align decimal points to compare place values",
+ values: values.map((v, i) => ({
+ value: v,
+ str: padDecimal(strs[i], maxInt, maxDec),
+ sorted: false,
+ comparing: false,
+ })),
+ });
+
+ // Bubble sort with steps
+ const arr = [...values];
+ const arrStrs = [...strs];
+ const cmp = direction === "ascending" ? (a: number, b: number) => a > b : (a: number, b: number) => a < b;
+
+ for (let i = 0; i < arr.length - 1; i++) {
+ for (let j = 0; j < arr.length - 1 - i; j++) {
+ // Show comparison
+ steps.push({
+ label: `Compare ${arrStrs[j]} and ${arrStrs[j + 1]}`,
+ values: arr.map((v, k) => ({
+ value: v,
+ str: padDecimal(arrStrs[k], maxInt, maxDec),
+ sorted: k >= arr.length - i,
+ comparing: k === j || k === j + 1,
+ })),
+ comparingPair: [j, j + 1],
+ });
+
+ if (cmp(arr[j], arr[j + 1])) {
+ // Swap
+ [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
+ [arrStrs[j], arrStrs[j + 1]] = [arrStrs[j + 1], arrStrs[j]];
+
+ steps.push({
+ label: `Swap: ${arrStrs[j]} ${direction === "ascending" ? "<" : ">"} ${arrStrs[j + 1]}`,
+ values: arr.map((v, k) => ({
+ value: v,
+ str: padDecimal(arrStrs[k], maxInt, maxDec),
+ sorted: k >= arr.length - i,
+ comparing: k === j || k === j + 1,
+ })),
+ });
+ }
+ }
+ }
+
+ // Final step: Show sorted
+ steps.push({
+ label: `Sorted (${direction}): ${arrStrs.join(direction === "ascending" ? " < " : " > ")}`,
+ values: arr.map((v, k) => ({
+ value: v,
+ str: padDecimal(arrStrs[k], maxInt, maxDec),
+ sorted: true,
+ comparing: false,
+ })),
+ });
+
+ return steps;
+}
+
+function NumberLine({ values, min, max }: { values: { value: number; sorted: boolean; comparing: boolean }[]; min: number; max: number }) {
+ const range = max - min || 1;
+ return (
+
+
+ {values.map((v, i) => {
+ const pct = ((v.value - min) / range) * 100;
+ return (
+
+ {i + 1}
+
+ );
+ })}
+
+
+ {min}
+ {max}
+
+
+ );
+}
+
+export function DecimalOrderExplorer() {
+ const [input, setInput] = useState("3.14, 3.1, 3.141, 3.04, 3.4");
+ const [direction, setDirection] = useState("ascending");
+ 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;
+
+ function handleGo() {
+ setError("");
+ const parts = input
+ .split(/[,\s]+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+ if (parts.length < 2) {
+ setError("Enter at least 2 decimals separated by commas.");
+ return;
+ }
+ if (parts.length > 6) {
+ setError("Maximum 6 decimals.");
+ return;
+ }
+ const values = parts.map(Number);
+ if (values.some(isNaN)) {
+ setError("All values must be valid numbers.");
+ return;
+ }
+ try {
+ const s = buildSortSteps(values, direction);
+ setSteps(s);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ } catch {
+ setError("Could not process. 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 reset = useCallback(() => {
+ setSteps(null);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ }, []);
+
+ const step = steps ? steps[currentStep] : null;
+
+ return (
+
+ {/* Direction tabs */}
+
+ {(["ascending", "descending"] as SortDirection[]).map((d) => (
+
+ ))}
+
+
+ {/* Input */}
+
+
+ Enter 2-6 decimal numbers (comma separated)
+
+
+ setInput(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleGo()}
+ placeholder="e.g. 3.14, 3.1, 3.141"
+ autoComplete="off"
+ className="max-w-[320px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
+ />
+
+
+ {error && {error}
}
+
+
+ {/* Display */}
+
+ {!step ? (
+
+ Enter decimals above and click Sort
+
+ ) : (
+ <>
+ {step.label}
+ {/* Place value columns */}
+
+ {step.values.map((v, i) => (
+
+ {v.str}
+
+ ))}
+
+ {/* Number line */}
+ v.value)) - 0.1}
+ max={Math.max(...step.values.map((v) => v.value)) + 0.1}
+ />
+ >
+ )}
+
+
+ {/* Controls */}
+
+ 0}
+ />
+
+
+ {/* Result */}
+
+ {!done || !steps ? (
+ Result will appear here when steps are complete
+ ) : (
+ <>
+
+ Sorted ({direction})
+
+
+ {step!.values.map((v) => v.value).join(direction === "ascending" ? " < " : " > ")}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/components/explorers/fraction-operation-explorer.tsx b/components/explorers/fraction-operation-explorer.tsx
new file mode 100644
index 0000000..4c71fad
--- /dev/null
+++ b/components/explorers/fraction-operation-explorer.tsx
@@ -0,0 +1,389 @@
+"use client";
+
+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 { MathDisplay } from "@/components/math/math-display";
+
+type Operation = "add" | "subtract" | "multiply" | "divide";
+
+interface Step {
+ label: string;
+ math: string;
+ barA: { filled: number; total: number };
+ barB: { filled: number; total: number };
+ barResult?: { filled: number; total: number };
+}
+
+function buildSteps(
+ n1: number, d1: number,
+ n2: number, d2: number,
+ op: Operation,
+): Step[] {
+ const steps: Step[] = [];
+
+ // Step 0: Show the original fractions
+ const opSymbol = { add: "+", subtract: "-", multiply: "\\times", divide: "\\div" }[op];
+ steps.push({
+ label: "Start with the two fractions",
+ math: `${toKatex(n1, d1)} ${opSymbol} ${toKatex(n2, d2)}`,
+ barA: { filled: n1, total: d1 },
+ barB: { filled: n2, total: d2 },
+ });
+
+ if (op === "add" || op === "subtract") {
+ // Step 1: Find LCD
+ const lcd = lcm(d1, d2);
+ const mult1 = lcd / d1;
+ const mult2 = lcd / d2;
+ const cn1 = n1 * mult1;
+ const cn2 = n2 * mult2;
+
+ if (d1 !== d2) {
+ steps.push({
+ label: `Find the LCD of ${d1} and ${d2}`,
+ math: `\\text{LCD}(${d1}, ${d2}) = ${lcd}`,
+ barA: { filled: n1, total: d1 },
+ barB: { filled: n2, total: d2 },
+ });
+
+ // Step 2: Convert both fractions
+ steps.push({
+ label: "Convert to equivalent fractions with the LCD",
+ math: `${toKatex(n1, d1)} = ${toKatex(cn1, lcd)} \\quad ${toKatex(n2, d2)} = ${toKatex(cn2, lcd)}`,
+ barA: { filled: cn1, total: lcd },
+ barB: { filled: cn2, total: lcd },
+ });
+ }
+
+ // Step 3: Perform the operation
+ const resultNum = op === "add" ? cn1 + cn2 : cn1 - cn2;
+ steps.push({
+ label: op === "add" ? "Add the numerators" : "Subtract the numerators",
+ math: `${toKatex(cn1, lcd)} ${opSymbol} ${toKatex(cn2, lcd)} = ${toKatex(resultNum, lcd)}`,
+ barA: { filled: cn1, total: lcd },
+ barB: { filled: cn2, total: lcd },
+ barResult: { filled: Math.abs(resultNum), total: lcd },
+ });
+
+ // Step 4: Simplify if needed
+ const [sn, sd] = simplify(resultNum, lcd);
+ if (Math.abs(sn) !== Math.abs(resultNum) || sd !== lcd) {
+ const g = gcd(Math.abs(resultNum), lcd);
+ steps.push({
+ label: `Simplify by dividing by GCD = ${g}`,
+ math: `${toKatex(resultNum, lcd)} = ${toKatex(sn, sd)}`,
+ barA: { filled: cn1, total: lcd },
+ barB: { filled: cn2, total: lcd },
+ barResult: { filled: Math.abs(sn), total: sd },
+ });
+ }
+ } else if (op === "multiply") {
+ // Step 1: Multiply numerators and denominators
+ const rn = n1 * n2;
+ const rd = d1 * d2;
+ steps.push({
+ label: "Multiply numerators and denominators",
+ math: `\\frac{${n1} \\times ${n2}}{${d1} \\times ${d2}} = ${toKatex(rn, rd)}`,
+ barA: { filled: n1, total: d1 },
+ barB: { filled: n2, total: d2 },
+ barResult: { filled: rn, total: rd },
+ });
+
+ // Step 2: Simplify
+ const [sn, sd] = simplify(rn, rd);
+ if (sn !== rn || sd !== rd) {
+ const g = gcd(Math.abs(rn), rd);
+ steps.push({
+ label: `Simplify by dividing by GCD = ${g}`,
+ math: `${toKatex(rn, rd)} = ${toKatex(sn, sd)}`,
+ barA: { filled: n1, total: d1 },
+ barB: { filled: n2, total: d2 },
+ barResult: { filled: Math.abs(sn), total: sd },
+ });
+ }
+ } else {
+ // divide: invert and multiply
+ steps.push({
+ label: "Invert the second fraction (reciprocal)",
+ math: `${toKatex(n1, d1)} \\times ${toKatex(d2, n2)}`,
+ barA: { filled: n1, total: d1 },
+ barB: { filled: d2, total: n2 },
+ });
+
+ const rn = n1 * d2;
+ const rd = d1 * n2;
+ steps.push({
+ label: "Multiply numerators and denominators",
+ math: `\\frac{${n1} \\times ${d2}}{${d1} \\times ${n2}} = ${toKatex(rn, rd)}`,
+ barA: { filled: n1, total: d1 },
+ barB: { filled: d2, total: n2 },
+ barResult: { filled: rn, total: rd },
+ });
+
+ const [sn, sd] = simplify(rn, rd);
+ if (sn !== rn || sd !== rd) {
+ const g = gcd(Math.abs(rn), rd);
+ steps.push({
+ label: `Simplify by dividing by GCD = ${g}`,
+ math: `${toKatex(rn, rd)} = ${toKatex(sn, sd)}`,
+ barA: { filled: n1, total: d1 },
+ barB: { filled: d2, total: n2 },
+ barResult: { filled: Math.abs(sn), total: sd },
+ });
+ }
+ }
+
+ return steps;
+}
+
+function FractionBar({
+ filled,
+ total,
+ color,
+ label,
+}: {
+ filled: number;
+ total: number;
+ color: string;
+ label?: string;
+}) {
+ const maxSegments = Math.min(total, 24);
+ const fillCount = Math.min(filled, maxSegments);
+
+ return (
+
+ {label &&
{label}}
+
+ {Array.from({ length: maxSegments }, (_, i) => (
+
+ ))}
+
+
+ {filled}/{total}
+
+
+ );
+}
+
+export function FractionOperationExplorer() {
+ const [op, setOp] = useState("add");
+ const [n1, setN1] = useState("1");
+ const [d1, setD1] = useState("3");
+ const [n2, setN2] = useState("1");
+ const [d2, setD2] = useState("4");
+ 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;
+
+ function handleGo() {
+ setError("");
+ const nums = [parseInt(n1), parseInt(d1), parseInt(n2), parseInt(d2)];
+ if (nums.some(isNaN) || nums.some((n) => n === 0)) {
+ setError("Enter valid non-zero numbers.");
+ return;
+ }
+ if (nums[1] < 0 || nums[3] < 0) {
+ setError("Denominators must be positive.");
+ return;
+ }
+ if (nums.some((n) => Math.abs(n) > 99)) {
+ setError("Keep numbers under 100.");
+ return;
+ }
+ try {
+ const s = buildSteps(nums[0], nums[1], nums[2], nums[3], op);
+ setSteps(s);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ } catch {
+ setError("Could not compute. Check your inputs.");
+ }
+ }
+
+ 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 reset = useCallback(() => {
+ setSteps(null);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ }, []);
+
+ const step = steps ? steps[currentStep] : null;
+
+ const opLabels: Record = {
+ add: "Add (+)",
+ subtract: "Subtract (−)",
+ multiply: "Multiply (×)",
+ divide: "Divide (÷)",
+ };
+
+ return (
+
+ {/* Operation tabs */}
+
+ {(["add", "subtract", "multiply", "divide"] as Operation[]).map((o) => (
+
+ ))}
+
+
+ {/* Input Card */}
+
+
+ Enter two fractions
+
+
+
+
+ {{ add: "+", subtract: "−", multiply: "×", divide: "÷" }[op]}
+
+
+
+
+ {error && {error}
}
+
+
+ {/* Display Card */}
+
+ {!step ? (
+
+ Enter fractions above and click Go
+
+ ) : (
+ <>
+ {step.label}
+
+
+
+
+ {step.barResult && (
+
+ )}
+
+ >
+ )}
+
+
+ {/* Controls */}
+
+ 0}
+ />
+
+
+ {/* Result */}
+
+ {!done || !steps ? (
+ Result will appear here when steps are complete
+ ) : (
+ <>
+ Answer
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/components/explorers/fraction-quantity-explorer.tsx b/components/explorers/fraction-quantity-explorer.tsx
new file mode 100644
index 0000000..b2e80b9
--- /dev/null
+++ b/components/explorers/fraction-quantity-explorer.tsx
@@ -0,0 +1,305 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { Card } from "@/components/ui/card";
+import { StepControls } from "./step-controls";
+import { MathDisplay } from "@/components/math/math-display";
+import { toKatex } from "@/lib/math/fractions";
+
+type FQMode = "fractionOf" | "findWhole";
+
+interface Step {
+ label: string;
+ math: string;
+ barFilled: number; // 0–1 proportion
+ barLabel?: string;
+}
+
+function buildFractionOfSteps(num: number, den: number, quantity: number): Step[] {
+ const steps: Step[] = [];
+
+ steps.push({
+ label: `Find ${toKatexStr(num, den)} of ${quantity}`,
+ math: `${toKatex(num, den)} \\text{ of } ${quantity}`,
+ barFilled: 0,
+ });
+
+ // Step 1: Divide quantity by denominator
+ const onePart = quantity / den;
+ steps.push({
+ label: `Divide ${quantity} by ${den} to find one part`,
+ math: `${quantity} \\div ${den} = ${fmt(onePart)}`,
+ barFilled: 1 / den,
+ barLabel: fmt(onePart),
+ });
+
+ // Step 2: Multiply by numerator
+ const result = onePart * num;
+ steps.push({
+ label: `Multiply one part by ${num}`,
+ math: `${fmt(onePart)} \\times ${num} = ${fmt(result)}`,
+ barFilled: num / den,
+ barLabel: fmt(result),
+ });
+
+ steps.push({
+ label: `Answer: ${toKatexStr(num, den)} of ${quantity} = ${fmt(result)}`,
+ math: `${toKatex(num, den)} \\times ${quantity} = ${fmt(result)}`,
+ barFilled: num / den,
+ barLabel: fmt(result),
+ });
+
+ return steps;
+}
+
+function buildFindWholeSteps(num: number, den: number, part: number): Step[] {
+ const steps: Step[] = [];
+
+ steps.push({
+ label: `If ${toKatexStr(num, den)} of the whole = ${part}, find the whole`,
+ math: `${toKatex(num, den)} \\text{ of ? } = ${part}`,
+ barFilled: num / den,
+ barLabel: `${part}`,
+ });
+
+ // Step 1: Find one part
+ const onePart = part / num;
+ steps.push({
+ label: `Divide ${part} by ${num} to find one ${den}th`,
+ math: `${part} \\div ${num} = ${fmt(onePart)}`,
+ barFilled: 1 / den,
+ barLabel: fmt(onePart),
+ });
+
+ // Step 2: Multiply by denominator
+ const whole = onePart * den;
+ steps.push({
+ label: `Multiply by ${den} to find the whole`,
+ math: `${fmt(onePart)} \\times ${den} = ${fmt(whole)}`,
+ barFilled: 1,
+ barLabel: fmt(whole),
+ });
+
+ steps.push({
+ label: `The whole is ${fmt(whole)}`,
+ math: `\\text{Whole} = ${fmt(whole)}`,
+ barFilled: 1,
+ barLabel: fmt(whole),
+ });
+
+ return steps;
+}
+
+function toKatexStr(n: number, d: number): string {
+ return `${n}/${d}`;
+}
+
+function fmt(n: number): string {
+ return Number.isInteger(n) ? n.toString() : n.toFixed(2);
+}
+
+function QuantityBar({ filled, label }: { filled: number; label?: string }) {
+ const pct = Math.max(0, Math.min(1, filled)) * 100;
+ return (
+
+
+
+ {label && pct > 15 ? label : ""}
+
+
+ {label && pct <= 15 && (
+
{label}
+ )}
+
+ 0
+ {Math.round(pct)}%
+
+
+ );
+}
+
+export function FractionQuantityExplorer() {
+ const [mode, setMode] = useState("fractionOf");
+ const [num, setNum] = useState("3");
+ const [den, setDen] = useState("4");
+ const [quantity, setQuantity] = useState("80");
+ 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;
+
+ function handleGo() {
+ setError("");
+ const n = parseInt(num);
+ const d = parseInt(den);
+ const q = parseFloat(quantity);
+ if (isNaN(n) || isNaN(d) || d === 0) {
+ setError("Enter a valid fraction (denominator ≠ 0).");
+ return;
+ }
+ if (isNaN(q) || q <= 0) {
+ setError("Enter a valid positive quantity.");
+ return;
+ }
+ try {
+ const s =
+ mode === "fractionOf"
+ ? buildFractionOfSteps(n, d, q)
+ : buildFindWholeSteps(n, d, q);
+ setSteps(s);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ } catch {
+ setError("Could not compute. Check your inputs.");
+ }
+ }
+
+ 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 reset = useCallback(() => {
+ setSteps(null);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ }, []);
+
+ const step = steps ? steps[currentStep] : null;
+
+ return (
+
+ {/* Mode tabs */}
+
+
+
+
+
+ {/* Input */}
+
+
+ {mode === "fractionOf" ? "Enter a fraction and quantity" : "Enter a fraction and the part value"}
+
+
+
+
+ {mode === "fractionOf" ? "of" : "= "}
+
+
setQuantity(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleGo()}
+ className="w-24 rounded-lg border-2 border-border bg-surface px-3 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-1"
+ aria-label={mode === "fractionOf" ? "Quantity" : "Part value"}
+ />
+
+
+ {error && {error}
}
+
+
+ {/* Display */}
+
+ {!step ? (
+
+ Enter values above and click Go
+
+ ) : (
+ <>
+ {step.label}
+
+
+ >
+ )}
+
+
+ {/* Controls */}
+
+ 0}
+ />
+
+
+ {/* Result */}
+
+ {!done || !steps ? (
+ Result will appear here when steps are complete
+ ) : (
+ <>
+ Answer
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/components/explorers/ratio-explorer.tsx b/components/explorers/ratio-explorer.tsx
new file mode 100644
index 0000000..e0717f2
--- /dev/null
+++ b/components/explorers/ratio-explorer.tsx
@@ -0,0 +1,401 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { Card } from "@/components/ui/card";
+import { StepControls } from "./step-controls";
+import { MathDisplay } from "@/components/math/math-display";
+import { simplifyRatio } from "@/lib/math/ratios";
+import { gcd } from "@/lib/math/fractions";
+
+type RatioMode = "divide" | "simplify" | "equivalent";
+
+interface Step {
+ label: string;
+ math: string;
+ barSegments: { value: number; filled: boolean; label?: string }[];
+}
+
+const COLORS = ["var(--unit-4)", "var(--unit-1)", "var(--unit-3)", "var(--hint)"];
+
+function buildDivideSteps(total: number, parts: number[]): Step[] {
+ const steps: Step[] = [];
+ const sum = parts.reduce((a, b) => a + b, 0);
+
+ // Step 0: Show the ratio and total
+ steps.push({
+ label: `Divide ${total} in the ratio ${parts.join(" : ")}`,
+ math: `${total} \\div (${parts.join(" : ")})`,
+ barSegments: parts.map((p) => ({ value: p, filled: false })),
+ });
+
+ // Step 1: Sum the parts
+ steps.push({
+ label: `Add the ratio parts: ${parts.join(" + ")} = ${sum}`,
+ math: `${parts.join(" + ")} = ${sum} \\text{ total parts}`,
+ barSegments: parts.map((p) => ({ value: p, filled: false })),
+ });
+
+ // Step 2: Find one part
+ const onePart = total / sum;
+ steps.push({
+ label: `Find one part: ${total} ÷ ${sum} = ${onePart}`,
+ math: `\\text{One part} = ${total} \\div ${sum} = ${Number.isInteger(onePart) ? onePart : onePart.toFixed(2)}`,
+ barSegments: parts.map((p) => ({ value: p, filled: false })),
+ });
+
+ // Steps 3+: Calculate each share
+ const shares: number[] = [];
+ parts.forEach((p, i) => {
+ const share = p * onePart;
+ shares.push(share);
+ steps.push({
+ label: `Part ${i + 1}: ${p} × ${Number.isInteger(onePart) ? onePart : onePart.toFixed(2)} = ${Number.isInteger(share) ? share : share.toFixed(2)}`,
+ math: `${p} \\times ${Number.isInteger(onePart) ? onePart : onePart.toFixed(2)} = ${Number.isInteger(share) ? share : share.toFixed(2)}`,
+ barSegments: parts.map((pp, j) => ({
+ value: pp,
+ filled: j <= i,
+ label: j <= i ? `${Number.isInteger(shares[j]) ? shares[j] : shares[j].toFixed(2)}` : undefined,
+ })),
+ });
+ });
+
+ // Final step: Show all shares
+ const shareStrs = shares.map((s) => (Number.isInteger(s) ? s.toString() : s.toFixed(2)));
+ steps.push({
+ label: `Result: ${shareStrs.join(", ")}`,
+ math: `${total} = ${shareStrs.join(" + ")}`,
+ barSegments: parts.map((pp, j) => ({
+ value: pp,
+ filled: true,
+ label: shareStrs[j],
+ })),
+ });
+
+ return steps;
+}
+
+function buildSimplifySteps(parts: number[]): Step[] {
+ const steps: Step[] = [];
+
+ steps.push({
+ label: `Simplify the ratio ${parts.join(" : ")}`,
+ math: parts.join(" : "),
+ barSegments: parts.map((p) => ({ value: p, filled: true })),
+ });
+
+ // Find GCD
+ let g = parts[0];
+ for (let i = 1; i < parts.length; i++) g = gcd(g, parts[i]);
+
+ if (g === 1) {
+ steps.push({
+ label: "The ratio is already in its simplest form (GCD = 1)",
+ math: `\\text{GCD} = 1`,
+ barSegments: parts.map((p) => ({ value: p, filled: true })),
+ });
+ } else {
+ steps.push({
+ label: `Find the GCD of all parts: ${g}`,
+ math: `\\text{GCD}(${parts.join(", ")}) = ${g}`,
+ barSegments: parts.map((p) => ({ value: p, filled: true })),
+ });
+
+ const simplified = simplifyRatio(parts);
+ steps.push({
+ label: `Divide each part by ${g}`,
+ math: parts.map((p) => `${p} \\div ${g} = ${p / g}`).join(", \\quad "),
+ barSegments: simplified.map((p) => ({ value: p, filled: true })),
+ });
+
+ steps.push({
+ label: `Simplified ratio: ${simplified.join(" : ")}`,
+ math: `${parts.join(" : ")} = ${simplified.join(" : ")}`,
+ barSegments: simplified.map((p) => ({ value: p, filled: true })),
+ });
+ }
+
+ return steps;
+}
+
+function buildEquivalentSteps(parts: number[], multiplier: number): Step[] {
+ const steps: Step[] = [];
+
+ steps.push({
+ label: `Find an equivalent ratio: ${parts.join(" : ")} × ${multiplier}`,
+ math: `(${parts.join(" : ")}) \\times ${multiplier}`,
+ barSegments: parts.map((p) => ({ value: p, filled: true })),
+ });
+
+ const result = parts.map((p) => p * multiplier);
+ steps.push({
+ label: `Multiply each part by ${multiplier}`,
+ math: parts.map((p) => `${p} \\times ${multiplier} = ${p * multiplier}`).join(", \\quad "),
+ barSegments: result.map((p) => ({ value: p, filled: true })),
+ });
+
+ steps.push({
+ label: `Equivalent ratio: ${result.join(" : ")}`,
+ math: `${parts.join(" : ")} = ${result.join(" : ")}`,
+ barSegments: result.map((p) => ({ value: p, filled: true })),
+ });
+
+ return steps;
+}
+
+function RatioBar({
+ segments,
+}: {
+ segments: { value: number; filled: boolean; label?: string }[];
+}) {
+ const total = segments.reduce((s, seg) => s + seg.value, 0);
+ if (total === 0) return null;
+
+ return (
+
+
+ {segments.map((seg, i) => (
+
+ {seg.label || seg.value}
+
+ ))}
+
+
+ {segments.map((seg, i) => (
+
+ {seg.label && `(${seg.value} parts)`}
+
+ ))}
+
+
+ );
+}
+
+export function RatioExplorer() {
+ const [mode, setMode] = useState("divide");
+ const [partA, setPartA] = useState("2");
+ const [partB, setPartB] = useState("3");
+ const [partC, setPartC] = useState("");
+ const [total, setTotal] = useState("60");
+ const [multiplier, setMultiplier] = useState("4");
+ 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;
+
+ function getParts(): number[] {
+ const parts = [parseInt(partA), parseInt(partB)];
+ if (partC.trim()) parts.push(parseInt(partC));
+ return parts;
+ }
+
+ function handleGo() {
+ setError("");
+ const parts = getParts();
+ if (parts.some(isNaN) || parts.some((p) => p <= 0)) {
+ setError("Enter valid positive ratio parts.");
+ return;
+ }
+
+ try {
+ let s: Step[];
+ if (mode === "divide") {
+ const t = parseFloat(total);
+ if (isNaN(t) || t <= 0) {
+ setError("Enter a valid positive total.");
+ return;
+ }
+ s = buildDivideSteps(t, parts);
+ } else if (mode === "simplify") {
+ s = buildSimplifySteps(parts);
+ } else {
+ const m = parseInt(multiplier);
+ if (isNaN(m) || m <= 0) {
+ setError("Enter a valid positive multiplier.");
+ return;
+ }
+ s = buildEquivalentSteps(parts, m);
+ }
+ setSteps(s);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ } catch {
+ setError("Could not compute. Check your inputs.");
+ }
+ }
+
+ 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 reset = useCallback(() => {
+ setSteps(null);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ }, []);
+
+ const step = steps ? steps[currentStep] : null;
+
+ const modeLabels: Record = {
+ divide: "Divide in Ratio",
+ simplify: "Simplify Ratio",
+ equivalent: "Equivalent Ratio",
+ };
+
+ return (
+
+ {/* Mode tabs */}
+
+ {(["divide", "simplify", "equivalent"] as RatioMode[]).map((m) => (
+
+ ))}
+
+
+ {/* Input */}
+
+
+ {mode === "divide" ? "Enter ratio parts and total" : mode === "simplify" ? "Enter ratio parts" : "Enter ratio parts and multiplier"}
+
+
+ setPartA(e.target.value)}
+ className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
+ aria-label="Part A"
+ />
+ :
+ setPartB(e.target.value)}
+ className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
+ aria-label="Part B"
+ />
+ :
+ setPartC(e.target.value)}
+ placeholder="?"
+ className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
+ aria-label="Part C (optional)"
+ />
+
+ {mode === "divide" && (
+ <>
+ Total:
+ setTotal(e.target.value)}
+ className="w-20 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
+ aria-label="Total"
+ />
+ >
+ )}
+ {mode === "equivalent" && (
+ <>
+ ×
+ setMultiplier(e.target.value)}
+ className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
+ aria-label="Multiplier"
+ />
+ >
+ )}
+
+
+
+ {error && {error}
}
+
+
+ {/* Display */}
+
+ {!step ? (
+
+ Enter values above and click Go
+
+ ) : (
+ <>
+ {step.label}
+
+
+ >
+ )}
+
+
+ {/* Controls */}
+
+ 0}
+ />
+
+
+ {/* Result */}
+
+ {!done || !steps ? (
+ Result will appear here when steps are complete
+ ) : (
+ <>
+ Answer
+
+ {steps[steps.length - 1].label.replace("Result: ", "").replace("Simplified ratio: ", "").replace("Equivalent ratio: ", "")}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/components/explorers/rounding-explorer.tsx b/components/explorers/rounding-explorer.tsx
new file mode 100644
index 0000000..165b06d
--- /dev/null
+++ b/components/explorers/rounding-explorer.tsx
@@ -0,0 +1,559 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { Card } from "@/components/ui/card";
+import { StepControls } from "./step-controls";
+
+type RoundTarget = "whole" | "1dp" | "2dp" | "1sf" | "2sf" | "3sf";
+
+interface Step {
+ label: string;
+ digits: DigitInfo[];
+ resultText?: string;
+}
+
+interface DigitInfo {
+ char: string;
+ state: "normal" | "target" | "decider" | "dim" | "significant";
+}
+
+function getDigits(value: string): string[] {
+ return value.split("");
+}
+
+// ── Decimal place rounding steps ─────────────────────────────────────────────
+function buildDPSteps(rawValue: string, target: "whole" | "1dp" | "2dp"): Step[] {
+ const value = parseFloat(rawValue);
+ const digits = getDigits(rawValue);
+ const dotIndex = digits.indexOf(".");
+ const steps: Step[] = [];
+
+ function makeDigits(
+ targetIdx: number | null,
+ deciderIdx: number | null,
+ ): DigitInfo[] {
+ return digits.map((ch, i) => ({
+ char: ch,
+ state:
+ ch === "."
+ ? "normal"
+ : i === targetIdx
+ ? "target"
+ : i === deciderIdx
+ ? "decider"
+ : "normal",
+ }));
+ }
+
+ let targetDigitIdx: number;
+ let roundedValue: number;
+ let targetLabel: string;
+
+ if (target === "whole") {
+ targetDigitIdx = dotIndex === -1 ? digits.length - 1 : dotIndex - 1;
+ roundedValue = Math.round(value);
+ targetLabel = "ones (whole number)";
+ } else if (target === "1dp") {
+ targetDigitIdx = dotIndex + 1;
+ roundedValue = Math.round(value * 10) / 10;
+ targetLabel = "1 decimal place (tenths)";
+ } else {
+ targetDigitIdx = dotIndex + 2;
+ roundedValue = Math.round(value * 100) / 100;
+ targetLabel = "2 decimal places (hundredths)";
+ }
+
+ // Compute decider index (skip over decimal point if needed)
+ const nextIdx = targetDigitIdx + 1;
+ const deciderIdx =
+ nextIdx < digits.length && digits[nextIdx] === "."
+ ? nextIdx + 1
+ : nextIdx;
+
+ // Step 0: Show the number
+ steps.push({
+ label: `Rounding ${rawValue} to ${targetLabel}`,
+ digits: makeDigits(null, null),
+ });
+
+ // Step 1: Identify target
+ steps.push({
+ label: `Identify the target digit (${targetLabel})`,
+ digits: makeDigits(targetDigitIdx, null),
+ });
+
+ // Step 2: Identify decider
+ if (deciderIdx < digits.length) {
+ steps.push({
+ label: "Look at the digit to the right (the decider)",
+ digits: makeDigits(targetDigitIdx, deciderIdx),
+ });
+
+ const deciderDigit = parseInt(digits[deciderIdx]);
+ const roundsUp = deciderDigit >= 5;
+ steps.push({
+ label: roundsUp
+ ? `Decider is ${deciderDigit} (≥ 5) → round UP`
+ : `Decider is ${deciderDigit} (< 5) → round DOWN`,
+ digits: makeDigits(targetDigitIdx, deciderIdx),
+ });
+ } else {
+ steps.push({
+ label: "No digit to the right — no rounding needed",
+ digits: makeDigits(targetDigitIdx, null),
+ });
+ }
+
+ // Step 3: Result
+ const resultStr =
+ target === "whole"
+ ? roundedValue.toString()
+ : target === "1dp"
+ ? roundedValue.toFixed(1)
+ : roundedValue.toFixed(2);
+
+ const resultDigits = getDigits(resultStr);
+ steps.push({
+ label: `Result: ${resultStr}`,
+ digits: resultDigits.map((ch) => ({
+ char: ch,
+ state: "normal" as const,
+ })),
+ resultText: resultStr,
+ });
+
+ return steps;
+}
+
+// ── Significant figures rounding steps ───────────────────────────────────────
+function buildSFSteps(rawValue: string, sfCount: number): Step[] {
+ const value = parseFloat(rawValue);
+ const digits = getDigits(rawValue);
+ const steps: Step[] = [];
+
+ // Step 0: Show the number
+ steps.push({
+ label: `Rounding ${rawValue} to ${sfCount} significant figure${sfCount > 1 ? "s" : ""}`,
+ digits: digits.map((ch) => ({ char: ch, state: "normal" as const })),
+ });
+
+ // Step 1: Identify which digits are significant
+ // Rule: significant figures start at the first non-zero digit
+ const sigDigitIndices: number[] = [];
+ let foundFirstNonZero = false;
+ for (let i = 0; i < digits.length; i++) {
+ if (digits[i] === ".") continue;
+ if (!foundFirstNonZero && digits[i] === "0") continue;
+ foundFirstNonZero = true;
+ sigDigitIndices.push(i);
+ }
+
+ // Build display: highlight all significant digits found
+ const sigSet = new Set(sigDigitIndices);
+ steps.push({
+ label: `Identify significant figures — start from the first non-zero digit`,
+ digits: digits.map((ch, i) => ({
+ char: ch,
+ state:
+ ch === "."
+ ? "normal"
+ : sigSet.has(i)
+ ? "significant"
+ : "dim",
+ })),
+ });
+
+ // Count and label the sig figs we need
+ const sigLabel = sigDigitIndices.slice(0, sfCount).map((idx, n) => {
+ return `${ordinal(n + 1)} s.f. = ${digits[idx]}`;
+ });
+ const targetIdx = sigDigitIndices[sfCount - 1]; // the last sig fig we keep
+
+ if (targetIdx === undefined) {
+ // Not enough significant figures in the number
+ steps.push({
+ label: `The number only has ${sigDigitIndices.length} significant figure${sigDigitIndices.length !== 1 ? "s" : ""} — already rounded`,
+ digits: digits.map((ch, i) => ({
+ char: ch,
+ state:
+ ch === "."
+ ? "normal"
+ : sigSet.has(i)
+ ? "significant"
+ : "dim",
+ })),
+ resultText: rawValue,
+ });
+ return steps;
+ }
+
+ // Step 2: Show which sig figs we're keeping
+ const keepSet = new Set(sigDigitIndices.slice(0, sfCount));
+ steps.push({
+ label: `Keep ${sfCount} sig fig${sfCount > 1 ? "s" : ""}: ${sigLabel.join(", ")}`,
+ digits: digits.map((ch, i) => ({
+ char: ch,
+ state:
+ ch === "."
+ ? "normal"
+ : keepSet.has(i)
+ ? "target"
+ : sigSet.has(i)
+ ? "normal"
+ : "dim",
+ })),
+ });
+
+ // Step 3: Identify the decider
+ const deciderCandidateIdx = sigDigitIndices[sfCount]; // next sig fig after the ones we keep
+ // But the decider could also be after a decimal point
+ let deciderIdx: number | null = null;
+ if (deciderCandidateIdx !== undefined) {
+ deciderIdx = deciderCandidateIdx;
+ } else {
+ // Look for any digit after the target
+ for (let i = targetIdx + 1; i < digits.length; i++) {
+ if (digits[i] !== ".") {
+ deciderIdx = i;
+ break;
+ }
+ }
+ }
+
+ if (deciderIdx !== null && deciderIdx < digits.length) {
+ steps.push({
+ label: `Look at the next digit: ${digits[deciderIdx]} (the decider)`,
+ digits: digits.map((ch, i) => ({
+ char: ch,
+ state:
+ ch === "."
+ ? "normal"
+ : i === deciderIdx
+ ? "decider"
+ : keepSet.has(i)
+ ? "target"
+ : "dim",
+ })),
+ });
+
+ // Step 4: Round decision
+ const deciderDigit = parseInt(digits[deciderIdx]);
+ const roundsUp = deciderDigit >= 5;
+ steps.push({
+ label: roundsUp
+ ? `Decider is ${deciderDigit} (≥ 5) → round UP the last kept digit`
+ : `Decider is ${deciderDigit} (< 5) → round DOWN (keep it the same)`,
+ digits: digits.map((ch, i) => ({
+ char: ch,
+ state:
+ ch === "."
+ ? "normal"
+ : i === deciderIdx
+ ? "decider"
+ : keepSet.has(i)
+ ? "target"
+ : "dim",
+ })),
+ });
+ } else {
+ steps.push({
+ label: "No digit to the right — no rounding needed",
+ digits: digits.map((ch, i) => ({
+ char: ch,
+ state:
+ ch === "."
+ ? "normal"
+ : keepSet.has(i)
+ ? "target"
+ : "dim",
+ })),
+ });
+ }
+
+ // Step 5: Compute and show result
+ let roundedValue: number;
+ if (value === 0) {
+ roundedValue = 0;
+ } else {
+ const magnitude = Math.floor(Math.log10(Math.abs(value)));
+ const factor = Math.pow(10, sfCount - 1 - magnitude);
+ roundedValue = Math.round(value * factor) / factor;
+ }
+
+ // Format result with correct sig figs (preserve trailing zeros)
+ const resultStr = formatSigFigs(roundedValue, sfCount);
+ const resultDigits = getDigits(resultStr);
+
+ // Step: explain what happens to remaining digits
+ const dotIdx = rawValue.indexOf(".");
+ const hasIntegerPart = dotIdx === -1 || dotIdx > 0;
+ if (hasIntegerPart && sigDigitIndices.length > sfCount) {
+ // Some trailing digits in the integer part get replaced with zeros
+ steps.push({
+ label: "Replace remaining digits with zeros (to keep the place value)",
+ digits: resultDigits.map((ch) => ({
+ char: ch,
+ state: "normal" as const,
+ })),
+ });
+ }
+
+ steps.push({
+ label: `Result: ${resultStr} (${sfCount} significant figure${sfCount > 1 ? "s" : ""})`,
+ digits: resultDigits.map((ch) => ({
+ char: ch,
+ state: "normal" as const,
+ })),
+ resultText: resultStr,
+ });
+
+ return steps;
+}
+
+function formatSigFigs(value: number, sfCount: number): string {
+ if (value === 0) return "0";
+
+ const magnitude = Math.floor(Math.log10(Math.abs(value)));
+
+ if (magnitude >= sfCount - 1) {
+ // Integer result — no decimal point needed
+ return Math.round(value / Math.pow(10, magnitude - sfCount + 1))
+ * Math.pow(10, magnitude - sfCount + 1) + "";
+ }
+
+ // Decimal result — need to show enough places
+ const decimalPlaces = sfCount - 1 - magnitude;
+ return value.toFixed(decimalPlaces);
+}
+
+function ordinal(n: number): string {
+ if (n === 1) return "1st";
+ if (n === 2) return "2nd";
+ if (n === 3) return "3rd";
+ return `${n}th`;
+}
+
+// ── Main step builder ────────────────────────────────────────────────────────
+function buildSteps(rawValue: string, target: RoundTarget): Step[] {
+ if (target === "whole" || target === "1dp" || target === "2dp") {
+ return buildDPSteps(rawValue, target);
+ }
+ const sfCount = target === "1sf" ? 1 : target === "2sf" ? 2 : 3;
+ return buildSFSteps(rawValue, sfCount);
+}
+
+// ── Digit box component ─────────────────────────────────────────────────────
+function DigitBox({ char, state }: DigitInfo) {
+ if (char === ".") {
+ return (
+
+ );
+ }
+
+ const styles: Record = {
+ normal: "border-border bg-surface text-foreground",
+ target: "border-unit-2 bg-unit-2-light text-foreground ring-2 ring-unit-2/30",
+ decider: "border-hint bg-hint-light text-foreground ring-2 ring-hint/30",
+ significant: "border-unit-4 bg-unit-4-light text-foreground ring-2 ring-unit-4/30",
+ dim: "border-border/40 bg-background text-muted/30",
+ };
+
+ return (
+
+ {char}
+
+ );
+}
+
+// ── Explorer component ──────────────────────────────────────────────────────
+export function RoundingExplorer() {
+ const [input, setInput] = useState("3.4567");
+ const [target, setTarget] = useState("1dp");
+ 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;
+
+ function handleGo() {
+ setError("");
+ const trimmed = input.trim();
+ const val = parseFloat(trimmed);
+ if (isNaN(val)) {
+ setError("Enter a valid number.");
+ return;
+ }
+ if (val < 0) {
+ setError("Enter a positive number.");
+ return;
+ }
+ if (trimmed.length > 16) {
+ setError("Number too long.");
+ return;
+ }
+ if (!trimmed.includes(".") && (target === "1dp" || target === "2dp")) {
+ setError("Enter a decimal number for decimal place rounding.");
+ return;
+ }
+ try {
+ const s = buildSteps(trimmed, target);
+ setSteps(s);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ } catch {
+ setError("Could not process. 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 reset = useCallback(() => {
+ setSteps(null);
+ setCurrentStep(0);
+ setIsPlaying(false);
+ }, []);
+
+ const step = steps ? steps[currentStep] : null;
+
+ const targetLabels: Record = {
+ whole: "Whole Number",
+ "1dp": "1 d.p.",
+ "2dp": "2 d.p.",
+ "1sf": "1 s.f.",
+ "2sf": "2 s.f.",
+ "3sf": "3 s.f.",
+ };
+
+ return (
+
+ {/* Target tabs */}
+
+ {(["whole", "1dp", "2dp", "1sf", "2sf", "3sf"] as RoundTarget[]).map((t) => (
+
+ ))}
+
+
+ {/* Input */}
+
+
+ Enter a number
+
+
+ setInput(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleGo()}
+ placeholder="e.g. 3.4567 or 0.00456"
+ maxLength={18}
+ autoComplete="off"
+ className="max-w-[240px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
+ />
+
+
+ {error && {error}
}
+
+
+ {/* Display */}
+
+ {!step ? (
+
+ Enter a number above and click Round
+
+ ) : (
+ <>
+ {step.label}
+
+ {step.digits.map((d, i) => (
+
+ ))}
+
+ {/* Legend */}
+
+
+
+ Target digit
+
+
+
+ Decider digit
+
+
+
+ Significant
+
+
+
+ Not significant
+
+
+ >
+ )}
+
+
+ {/* Controls */}
+
+ 0}
+ />
+
+
+ {/* Result */}
+
+ {!done || !step?.resultText ? (
+ Result will appear here when steps are complete
+ ) : (
+ <>
+ Rounded Value
+
+ {step.resultText}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/components/explorers/standard-form-explorer.tsx b/components/explorers/standard-form-explorer.tsx
new file mode 100644
index 0000000..cbacb77
--- /dev/null
+++ b/components/explorers/standard-form-explorer.tsx
@@ -0,0 +1,445 @@
+"use client";
+
+import { useState, useCallback, useRef, useEffect } from "react";
+import { Card } from "@/components/ui/card";
+import { StepControls } from "./step-controls";
+
+type Mode = "toSF" | "toOrd";
+
+interface DisplayState {
+ digits: string[];
+ decimalPos: number;
+ initPos: number;
+ targetPos: number;
+ origPower: number;
+}
+
+function parseOrdinary(raw: string): { digits: string[]; decimalPos: number } {
+ const s = raw.trim().replace(/[\s,]/g, "");
+ if (s.startsWith("-")) throw new Error("Please enter a positive number.");
+ if (!/^\d*\.?\d+$/.test(s) || s === "")
+ throw new Error("Invalid number. Try something like 7438 or 0.0055");
+ if (s.length > 16) throw new Error("Number too long (max 16 digits).");
+
+ const dot = s.indexOf(".");
+ if (dot === -1) {
+ return { digits: s.split(""), decimalPos: s.length };
+ }
+ return {
+ digits: (s.slice(0, dot) + s.slice(dot + 1)).split(""),
+ decimalPos: dot,
+ };
+}
+
+function firstNonZero(digits: string[]): number {
+ return digits.findIndex((d) => d !== "0");
+}
+
+function sfTargetPos(digits: string[]): number {
+ const i = firstNonZero(digits);
+ return i === -1 ? 1 : i + 1;
+}
+
+function buildCoeff(sigDigs: string[]): string {
+ if (sigDigs.length === 0) return "0";
+ if (sigDigs.length === 1) return sigDigs[0] + ".0";
+ const rest = sigDigs.slice(1).join("").replace(/0+$/, "") || "0";
+ return sigDigs[0] + "." + rest;
+}
+
+function buildOrdinary(digits: string[], pos: number): string {
+ let s: string;
+ if (pos <= 0) {
+ s = "0." + "0".repeat(-pos) + digits.join("");
+ } else if (pos >= digits.length) {
+ s = digits.join("") + "0".repeat(pos - digits.length);
+ } else {
+ s = digits.slice(0, pos).join("") + "." + digits.slice(pos).join("");
+ }
+ s = s.replace(/^0+(?=[1-9])/, "");
+ if (s.startsWith(".")) s = "0" + s;
+ if (s.includes(".")) s = s.replace(/\.?0+$/, "");
+ return s || "0";
+}
+
+export function StandardFormExplorer() {
+ const [mode, setMode] = useState("toSF");
+ const [ordinaryInput, setOrdinaryInput] = useState("");
+ const [coeffInput, setCoeffInput] = useState("");
+ const [powerInput, setPowerInput] = useState("");
+ const [error, setError] = useState("");
+
+ const [display, setDisplay] = useState(null);
+ const [currentPos, setCurrentPos] = useState(0);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [done, setDone] = useState(false);
+
+ const numberRowRef = useRef(null);
+ const dotRef = useRef(null);
+ const [dotLeft, setDotLeft] = useState(0);
+ const [dotAnimate, setDotAnimate] = useState(false);
+ const [powerPop, setPowerPop] = useState(false);
+
+ const totalSteps = display ? Math.abs(display.targetPos - display.initPos) : 0;
+ const currentStep = display ? Math.abs(currentPos - display.initPos) : 0;
+
+ const currentPower = display
+ ? mode === "toSF"
+ ? display.initPos - currentPos
+ : display.origPower - (currentPos - display.initPos)
+ : 0;
+
+ // Position decimal dot
+ const placeDot = useCallback(
+ (animate: boolean) => {
+ if (!numberRowRef.current || !display) return;
+ const boxes = numberRowRef.current.querySelectorAll("[data-digit-box]");
+ if (!boxes.length) return;
+
+ let leftPx: number;
+ const dp = currentPos;
+ if (dp <= 0) {
+ leftPx = boxes[0].offsetLeft - 6;
+ } else if (dp >= boxes.length) {
+ const last = boxes[boxes.length - 1];
+ leftPx = last.offsetLeft + last.offsetWidth - 4;
+ } else {
+ const prev = boxes[dp - 1];
+ const next = boxes[dp];
+ leftPx = Math.round((prev.offsetLeft + prev.offsetWidth + next.offsetLeft) / 2);
+ }
+
+ setDotAnimate(animate);
+ setDotLeft(leftPx);
+ },
+ [currentPos, display],
+ );
+
+ useEffect(() => {
+ if (display) {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => placeDot(true));
+ });
+ }
+ }, [currentPos, display, placeDot]);
+
+ // Initial placement without animation
+ useEffect(() => {
+ if (display) {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => placeDot(false));
+ });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [display?.digits.join("")]);
+
+ function initDisplay(digits: string[], start: number, target: number, origPower: number) {
+ setIsPlaying(false);
+ setDone(start === target);
+ setDisplay({ digits, decimalPos: start, initPos: start, targetPos: target, origPower });
+ setCurrentPos(start);
+ setError("");
+ }
+
+ function handleConvertToSF() {
+ setError("");
+ try {
+ const { digits, decimalPos } = parseOrdinary(ordinaryInput);
+ const target = sfTargetPos(digits);
+ initDisplay(digits, decimalPos, target, 0);
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ }
+
+ function handleConvertToOrd() {
+ setError("");
+ try {
+ if (!coeffInput.trim()) throw new Error("Enter the coefficient A.");
+ const n = parseInt(powerInput, 10);
+ if (isNaN(n)) throw new Error("Enter the power n as a whole number.");
+ if (Math.abs(n) > 12) throw new Error("Power too large for display (max ±12).");
+ if (n === 0) throw new Error("Power is 0 — the number is already in ordinary form.");
+
+ const { digits } = parseOrdinary(coeffInput);
+ const A = parseFloat(coeffInput);
+ if (isNaN(A) || A < 1 || A >= 10)
+ throw new Error(`Coefficient must satisfy 1 ≤ A < 10 (got ${coeffInput}).`);
+
+ const decimalPos = 1;
+ const expandedDigits = [...digits];
+
+ let startPos: number;
+ let targetPos: number;
+ if (n > 0) {
+ targetPos = decimalPos + n;
+ startPos = decimalPos;
+ while (expandedDigits.length < targetPos) expandedDigits.push("0");
+ } else {
+ const absN = Math.abs(n);
+ const zeros = Array(absN).fill("0");
+ expandedDigits.unshift(...zeros);
+ startPos = decimalPos + absN;
+ targetPos = startPos + n;
+ }
+
+ initDisplay(expandedDigits, startPos, targetPos, n);
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ }
+
+ const stepForward = useCallback(() => {
+ if (!display || currentPos === display.targetPos) return;
+ const dir = display.targetPos > display.initPos ? 1 : -1;
+ const nextPos = currentPos + dir;
+ setCurrentPos(nextPos);
+ setPowerPop(true);
+ setTimeout(() => setPowerPop(false), 300);
+ if (nextPos === display.targetPos) {
+ setDone(true);
+ setIsPlaying(false);
+ }
+ }, [display, currentPos]);
+
+ const stepBack = useCallback(() => {
+ if (!display || currentPos === display.initPos) return;
+ if (done) setDone(false);
+ const dir = display.targetPos > display.initPos ? -1 : 1;
+ setCurrentPos((p) => p + dir);
+ setPowerPop(true);
+ setTimeout(() => setPowerPop(false), 300);
+ }, [display, currentPos, done]);
+
+ const togglePlay = useCallback(() => {
+ setIsPlaying((p) => !p);
+ }, []);
+
+ const reset = useCallback(() => {
+ setIsPlaying(false);
+ setDisplay(null);
+ setDone(false);
+ setError("");
+ }, []);
+
+ // Direction label
+ const remaining = display ? display.targetPos - currentPos : 0;
+ const dirText =
+ !display || done || remaining === 0
+ ? null
+ : remaining < 0
+ ? `← Moving left · ${Math.abs(remaining)} step${Math.abs(remaining) !== 1 ? "s" : ""} remaining`
+ : `→ Moving right · ${remaining} step${remaining !== 1 ? "s" : ""} remaining`;
+
+ // Result
+ const fz = display ? firstNonZero(display.digits) : 0;
+
+ return (
+
+ {/* Mode Tabs */}
+
+
+
+
+
+ {/* Input Card */}
+
+
+ {mode === "toSF" ? "Enter an ordinary number" : "Enter a number in standard form A × 10ⁿ"}
+
+ {mode === "toSF" ? (
+
+ setOrdinaryInput(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleConvertToSF()}
+ placeholder="e.g. 7438 or 0.0055"
+ maxLength={18}
+ autoComplete="off"
+ className="max-w-[220px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
+ />
+
+
+ ) : (
+
+ setCoeffInput(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleConvertToOrd()}
+ placeholder="A e.g. 3.6"
+ maxLength={12}
+ autoComplete="off"
+ className="max-w-[140px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
+ />
+
+ × 10n where n =
+
+ setPowerInput(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleConvertToOrd()}
+ placeholder="e.g. 4 or −3"
+ autoComplete="off"
+ className="max-w-[110px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
+ />
+
+
+ )}
+ {error && {error}
}
+
+
+ {/* Display Card */}
+
+ {!display ? (
+
+ Enter a number above and click Convert
+
+ ) : (
+ <>
+ {/* Step info */}
+
+ {totalSteps === 0
+ ? "Number is already in standard form position — power = 0"
+ : `Step ${currentStep} of ${totalSteps}`}
+
+
+ {/* Digit row */}
+
+ {display.digits.map((ch, i) => {
+ const isLeadingZero = i < fz && fz !== -1;
+ const isHighlight = i === display.targetPos - 1;
+ return (
+
+ {ch}
+
+ );
+ })}
+ {/* Decimal dot */}
+
= display.digits.length ? 0 : 1,
+ }}
+ />
+
+
+ {/* Direction label */}
+ {dirText && (
+
+ {dirText}
+
+ )}
+
+ {/* Power counter */}
+
+ ×
+ 10
+
+ {currentPower}
+
+
+ >
+ )}
+
+
+ {/* Controls */}
+
+
+
+
+ {/* Result Card */}
+
+ {!display || !done ? (
+ Result will appear here when steps are complete
+ ) : mode === "toSF" ? (
+ <>
+ Standard Form
+
+
+ {buildCoeff(display.digits.slice(fz === -1 ? 0 : fz))}
+
+ ×
+ 10
+ {display.initPos - display.targetPos}
+
+ >
+ ) : (
+ <>
+ Ordinary Form
+
+ {buildOrdinary(display.digits, display.targetPos)}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/components/explorers/step-controls.tsx b/components/explorers/step-controls.tsx
new file mode 100644
index 0000000..3a30e87
--- /dev/null
+++ b/components/explorers/step-controls.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { useEffect, useCallback, useRef } from "react";
+
+interface StepControlsProps {
+ currentStep: number;
+ totalSteps: number;
+ isPlaying: boolean;
+ onStepForward: () => void;
+ onStepBack: () => void;
+ onTogglePlay: () => void;
+ onReset: () => void;
+ canStepForward: boolean;
+ canStepBack: boolean;
+ playIntervalMs?: number;
+}
+
+export function StepControls({
+ currentStep,
+ totalSteps,
+ isPlaying,
+ onStepForward,
+ onStepBack,
+ onTogglePlay,
+ onReset,
+ canStepForward,
+ canStepBack,
+ playIntervalMs = 950,
+}: StepControlsProps) {
+ const timerRef = useRef | null>(null);
+
+ // Auto-play timer
+ useEffect(() => {
+ if (isPlaying && canStepForward) {
+ timerRef.current = setInterval(() => {
+ onStepForward();
+ }, playIntervalMs);
+ }
+ return () => {
+ if (timerRef.current) clearInterval(timerRef.current);
+ };
+ }, [isPlaying, canStepForward, onStepForward, playIntervalMs]);
+
+ // Keyboard shortcuts
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ const tag = (e.target as HTMLElement).tagName;
+ if (["INPUT", "TEXTAREA", "SELECT"].includes(tag)) return;
+
+ if (e.key === "ArrowRight" || e.key === " ") {
+ e.preventDefault();
+ if (canStepForward) onStepForward();
+ }
+ if (e.key === "ArrowLeft") {
+ e.preventDefault();
+ if (canStepBack) onStepBack();
+ }
+ if (e.key.toLowerCase() === "p") onTogglePlay();
+ if (e.key.toLowerCase() === "r") onReset();
+ },
+ [canStepForward, canStepBack, onStepForward, onStepBack, onTogglePlay, onReset],
+ );
+
+ useEffect(() => {
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [handleKeyDown]);
+
+ const progress = totalSteps > 0 ? (currentStep / totalSteps) * 100 : 0;
+
+ return (
+
+ {/* Progress bar */}
+ {totalSteps > 0 && (
+
+
+ Step {currentStep} of {totalSteps}
+ {Math.round(progress)}%
+
+
+
+ )}
+
+ {/* Controls */}
+
+
+
+
+
+
+
+ {/* Keyboard hint */}
+
+ Space{" / "}
+ →{" "}
+ next{" · "}
+ ←{" "}
+ back{" · "}
+ P{" "}
+ play{" · "}
+ R{" "}
+ reset
+
+
+ );
+}
diff --git a/components/layout/breadcrumbs.tsx b/components/layout/breadcrumbs.tsx
new file mode 100644
index 0000000..642d8b3
--- /dev/null
+++ b/components/layout/breadcrumbs.tsx
@@ -0,0 +1,38 @@
+import Link from "next/link";
+
+interface BreadcrumbItem {
+ label: string;
+ href?: string;
+}
+
+interface BreadcrumbsProps {
+ items: BreadcrumbItem[];
+}
+
+export function Breadcrumbs({ items }: BreadcrumbsProps) {
+ return (
+
+ );
+}
diff --git a/components/layout/footer.tsx b/components/layout/footer.tsx
new file mode 100644
index 0000000..9bba16c
--- /dev/null
+++ b/components/layout/footer.tsx
@@ -0,0 +1,32 @@
+import Link from "next/link";
+
+export function Footer() {
+ return (
+
+ );
+}
diff --git a/components/layout/header.tsx b/components/layout/header.tsx
new file mode 100644
index 0000000..5feb1d3
--- /dev/null
+++ b/components/layout/header.tsx
@@ -0,0 +1,42 @@
+import Link from "next/link";
+
+const navItems = [
+ { href: "/", label: "Home", className: "bg-[#e0483d] hover:bg-[#cb3a30]" },
+ { href: "/lessons", label: "Lessons", className: "bg-[#f08b24] hover:bg-[#d97706]" },
+ { href: "/practice", label: "Practice", className: "bg-[#1faa59] hover:bg-[#168845]" },
+];
+
+export function Header() {
+ return (
+
+
+
+
+
+ C
+
+
+
Cabrits
+
Math Playground
+
+
+
+ Ages 12-16
+ Form 1 Term 2
+
+
+
+
+
+ );
+}
diff --git a/components/layout/mobile-nav.tsx b/components/layout/mobile-nav.tsx
new file mode 100644
index 0000000..404c50b
--- /dev/null
+++ b/components/layout/mobile-nav.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { curriculum } from "@/lib/curriculum";
+import { cn } from "@/lib/utils";
+import { useState } from "react";
+
+const unitColorMap = {
+ "unit-1": "text-unit-1-dark",
+ "unit-2": "text-unit-2-dark",
+ "unit-3": "text-unit-3-dark",
+ "unit-4": "text-unit-4-dark",
+};
+
+const unitDotColor = {
+ "unit-1": "bg-unit-1",
+ "unit-2": "bg-unit-2",
+ "unit-3": "bg-unit-3",
+ "unit-4": "bg-unit-4",
+};
+
+export function MobileNav() {
+ const pathname = usePathname();
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+
+
+ {isOpen && (
+
+
setIsOpen(false)}
+ className="mb-4 flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-muted transition-colors hover:bg-background hover:text-foreground"
+ >
+
+ All Topics
+
+
+ {curriculum.map((unit) => (
+
+
+
+ Unit {unit.number}: {unit.title}
+
+
+ {unit.topics.map((topic) => {
+ const href = `/lessons/${unit.slug}/${topic.slug}`;
+ return (
+ setIsOpen(false)}
+ className={cn(
+ "block rounded-lg px-3 py-1.5 text-sm transition-colors",
+ pathname === href
+ ? "bg-foreground text-background font-medium"
+ : "text-muted hover:bg-background hover:text-foreground",
+ )}
+ >
+ {topic.shortTitle}
+
+ );
+ })}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
new file mode 100644
index 0000000..99fa031
--- /dev/null
+++ b/components/layout/sidebar.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { curriculum } from "@/lib/curriculum";
+import { cn } from "@/lib/utils";
+import { useState } from "react";
+
+const unitColorMap = {
+ "unit-1": {
+ active: "bg-unit-1-light text-unit-1-dark border-unit-1/20",
+ dot: "bg-unit-1",
+ heading: "text-unit-1-dark",
+ hoverBg: "hover:bg-unit-1-light/50",
+ },
+ "unit-2": {
+ active: "bg-unit-2-light text-unit-2-dark border-unit-2/20",
+ dot: "bg-unit-2",
+ heading: "text-unit-2-dark",
+ hoverBg: "hover:bg-unit-2-light/50",
+ },
+ "unit-3": {
+ active: "bg-unit-3-light text-unit-3-dark border-unit-3/20",
+ dot: "bg-unit-3",
+ heading: "text-unit-3-dark",
+ hoverBg: "hover:bg-unit-3-light/50",
+ },
+ "unit-4": {
+ active: "bg-unit-4-light text-unit-4-dark border-unit-4/20",
+ dot: "bg-unit-4",
+ heading: "text-unit-4-dark",
+ hoverBg: "hover:bg-unit-4-light/50",
+ },
+};
+
+export function Sidebar() {
+ const pathname = usePathname();
+ const [openUnits, setOpenUnits] = useState>(() => {
+ const open = new Set();
+ for (const unit of curriculum) {
+ if (pathname.includes(unit.slug)) {
+ open.add(unit.number);
+ }
+ }
+ if (open.size === 0) open.add(1);
+ return open;
+ });
+
+ function toggleUnit(num: number) {
+ setOpenUnits((prev) => {
+ const next = new Set(prev);
+ if (next.has(num)) next.delete(num);
+ else next.add(num);
+ return next;
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/components/math/math-display.tsx b/components/math/math-display.tsx
new file mode 100644
index 0000000..a023a32
--- /dev/null
+++ b/components/math/math-display.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import katex from "katex";
+
+interface MathDisplayProps {
+ math: string;
+ className?: string;
+}
+
+export function MathDisplay({ math, className }: MathDisplayProps) {
+ const html = katex.renderToString(math, {
+ displayMode: true,
+ throwOnError: false,
+ });
+
+ return (
+
+ );
+}
+
+export function MathInline({ math, className }: MathDisplayProps) {
+ const html = katex.renderToString(math, {
+ displayMode: false,
+ throwOnError: false,
+ });
+
+ return (
+
+ );
+}
diff --git a/components/practice/fraction-input.tsx b/components/practice/fraction-input.tsx
new file mode 100644
index 0000000..841f67a
--- /dev/null
+++ b/components/practice/fraction-input.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+
+interface FractionInputProps {
+ numerator: string;
+ denominator: string;
+ onNumeratorChange: (value: string) => void;
+ onDenominatorChange: (value: string) => void;
+ disabled?: boolean;
+ className?: string;
+}
+
+export function FractionInput({
+ numerator,
+ denominator,
+ onNumeratorChange,
+ onDenominatorChange,
+ disabled = false,
+ className,
+}: FractionInputProps) {
+ return (
+
+ );
+}
+
+interface DecimalInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ disabled?: boolean;
+ className?: string;
+}
+
+export function DecimalInput({ value, onChange, disabled, className }: DecimalInputProps) {
+ return (
+ onChange(e.target.value)}
+ disabled={disabled}
+ className={cn(
+ "w-28 rounded-lg border border-border bg-surface px-3 py-2 text-center text-lg font-bold focus:border-unit-2 focus:outline-none",
+ className,
+ )}
+ placeholder="?"
+ aria-label="Answer"
+ />
+ );
+}
+
+interface RatioInputProps {
+ parts: string[];
+ onChange: (index: number, value: string) => void;
+ disabled?: boolean;
+ className?: string;
+}
+
+export function RatioInput({ parts, onChange, disabled, className }: RatioInputProps) {
+ return (
+
+ {parts.map((p, i) => (
+
+ {i > 0 && :}
+ onChange(i, e.target.value)}
+ disabled={disabled}
+ className="w-14 rounded-lg border border-border bg-surface px-2 py-2 text-center text-lg font-bold focus:border-unit-4 focus:outline-none"
+ placeholder="?"
+ aria-label={`Part ${i + 1}`}
+ />
+
+ ))}
+
+ );
+}
diff --git a/components/practice/practice-section.tsx b/components/practice/practice-section.tsx
new file mode 100644
index 0000000..0950ea8
--- /dev/null
+++ b/components/practice/practice-section.tsx
@@ -0,0 +1,308 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import type { MathProblem, Difficulty } from "@/lib/problems/types";
+import { checkFractionAnswer, checkDecimalAnswer, checkRatioAnswer, checkIntegerAnswer, type AnswerResult } from "@/lib/math/validation";
+import { FractionInput, DecimalInput, RatioInput } from "./fraction-input";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import katex from "katex";
+
+interface PracticeSectionProps {
+ title: string;
+ generator: (difficulty: Difficulty) => MathProblem;
+ unitColor?: "unit-1" | "unit-2" | "unit-3" | "unit-4";
+}
+
+type UnitColor = NonNullable;
+
+const activeDifficultyStyle: Record = {
+ "unit-1": "bg-unit-1 text-white",
+ "unit-2": "bg-unit-2 text-white",
+ "unit-3": "bg-unit-3 text-white",
+ "unit-4": "bg-unit-4 text-white",
+};
+
+export function PracticeSection({ title, generator, unitColor = "unit-1" }: PracticeSectionProps) {
+ const [difficulty, setDifficulty] = useState(1);
+ const [problem, setProblem] = useState(() => generator(1));
+ const [userAnswer, setUserAnswer] = useState>({});
+ const [result, setResult] = useState(null);
+ const [showHint, setShowHint] = useState(false);
+ const [hintIndex, setHintIndex] = useState(0);
+ const [showSolution, setShowSolution] = useState(false);
+ const [score, setScore] = useState({ correct: 0, total: 0 });
+
+ const generateNew = useCallback((diff: Difficulty) => {
+ setProblem(generator(diff));
+ setUserAnswer({});
+ setResult(null);
+ setShowHint(false);
+ setHintIndex(0);
+ setShowSolution(false);
+ }, [generator]);
+
+ function checkAnswer() {
+ const answer = problem.answer;
+ let res: AnswerResult;
+
+ switch (answer.kind) {
+ case "fraction": {
+ const num = parseInt(userAnswer.numerator || "0");
+ const den = parseInt(userAnswer.denominator || "0");
+ if (isNaN(num) || isNaN(den)) {
+ res = { correct: false, message: "Enter valid numbers" };
+ } else {
+ res = checkFractionAnswer(num, den, answer.numerator, answer.denominator);
+ }
+ break;
+ }
+ case "decimal": {
+ const val = parseFloat(userAnswer.value || "");
+ if (isNaN(val)) {
+ res = { correct: false, message: "Enter a valid number" };
+ } else {
+ res = checkDecimalAnswer(val, answer.value);
+ }
+ break;
+ }
+ case "integer": {
+ const val = parseInt(userAnswer.value || "");
+ if (isNaN(val)) {
+ res = { correct: false, message: "Enter a valid number" };
+ } else {
+ res = checkIntegerAnswer(val, answer.value);
+ }
+ break;
+ }
+ case "ratio": {
+ const parts = (userAnswer.ratio || "").split(":").map((p) => parseInt(p.trim()));
+ if (parts.some(isNaN)) {
+ res = { correct: false, message: "Enter valid ratio parts" };
+ } else {
+ res = checkRatioAnswer(parts, answer.parts);
+ }
+ break;
+ }
+ case "standardForm": {
+ const coeff = parseFloat(userAnswer.coefficient || "");
+ const exp = parseInt(userAnswer.exponent || "");
+ if (isNaN(coeff) || isNaN(exp)) {
+ res = { correct: false, message: "Enter valid numbers" };
+ } else if (
+ Math.abs(coeff - answer.coefficient) < 0.01 &&
+ exp === answer.exponent
+ ) {
+ res = { correct: true, simplified: true };
+ } else {
+ res = { correct: false, message: "That's not quite right. Try again!" };
+ }
+ break;
+ }
+ }
+
+ setResult(res);
+ if (res.correct) {
+ setScore((s) => ({ correct: s.correct + 1, total: s.total + 1 }));
+ } else {
+ setScore((s) => ({ ...s, total: s.total + 1 }));
+ }
+ }
+
+ function nextHint() {
+ if (hintIndex < problem.hints.length - 1) {
+ setHintIndex((i) => i + 1);
+ }
+ setShowHint(true);
+ }
+
+ const promptHtml = katex.renderToString(problem.prompt, { throwOnError: false, displayMode: true });
+
+ return (
+
+
+
{title}
+
+
+ {score.correct}/{score.total}
+
+
+ {([1, 2, 3] as Difficulty[]).map((d) => (
+
+ ))}
+
+
+
+
+ {/* Problem */}
+
+
+ {/* Input */}
+
+
+ {/* Actions */}
+
+ {!result?.correct && (
+ <>
+
+
+
+ >
+ )}
+ {result?.correct && (
+
+ )}
+
+
+ {/* Feedback */}
+
+ {result && (
+
+ {result.correct
+ ? result.simplified
+ ? "Correct!"
+ : "Correct, but can you simplify further?"
+ : result.message}
+
+ )}
+
+
+ {/* Hint */}
+
+ {showHint && (
+
+ {problem.hints[hintIndex]}
+
+ )}
+
+
+ {/* Solution */}
+
+ {showSolution && (
+
+ Solution
+ {problem.steps.map((step, i) => (
+
+ {i + 1}.
+ {step.explanation}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000..94bd4fc
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,31 @@
+import { cn } from "@/lib/utils";
+import { type HTMLAttributes } from "react";
+
+type BadgeVariant = "default" | "unit-1" | "unit-2" | "unit-3" | "unit-4";
+
+interface BadgeProps extends HTMLAttributes {
+ variant?: BadgeVariant;
+}
+
+const variantStyles: Record = {
+ default: "border border-border/70 bg-background text-muted",
+ "unit-1": "border border-unit-1/20 bg-unit-1-light text-unit-1-dark",
+ "unit-2": "border border-unit-2/20 bg-unit-2-light text-unit-2-dark",
+ "unit-3": "border border-unit-3/20 bg-unit-3-light text-unit-3-dark",
+ "unit-4": "border border-unit-4/20 bg-unit-4-light text-unit-4-dark",
+};
+
+export function Badge({ variant = "default", className, children, ...props }: BadgeProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000..3b798ac
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,51 @@
+import { cn } from "@/lib/utils";
+import { type ButtonHTMLAttributes } from "react";
+
+type ButtonVariant = "primary" | "secondary" | "ghost" | "unit-1" | "unit-2" | "unit-3" | "unit-4";
+type ButtonSize = "sm" | "md" | "lg";
+
+interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+}
+
+const variantStyles: Record = {
+ primary: "bg-foreground text-background shadow-[var(--shadow-sm)] hover:bg-foreground/90 hover:shadow-[var(--shadow-lg)]",
+ secondary: "border-2 border-border/70 bg-surface text-foreground shadow-[var(--shadow-sm)] hover:bg-background hover:shadow-[var(--shadow-md)]",
+ ghost: "text-foreground hover:bg-border/30",
+ "unit-1": "bg-unit-1 text-white shadow-[var(--shadow-sm)] hover:bg-unit-1-dark hover:shadow-[var(--shadow-lg)]",
+ "unit-2": "bg-unit-2 text-white shadow-[var(--shadow-sm)] hover:bg-unit-2-dark hover:shadow-[var(--shadow-lg)]",
+ "unit-3": "bg-unit-3 text-white shadow-[var(--shadow-sm)] hover:bg-unit-3-dark hover:shadow-[var(--shadow-lg)]",
+ "unit-4": "bg-unit-4 text-white shadow-[var(--shadow-sm)] hover:bg-unit-4-dark hover:shadow-[var(--shadow-lg)]",
+};
+
+const sizeStyles: Record = {
+ sm: "px-4 py-2 text-sm",
+ md: "px-5.5 py-2.5 text-base",
+ lg: "px-8 py-3.5 text-lg",
+};
+
+export function Button({
+ variant = "primary",
+ size = "md",
+ className,
+ children,
+ ...props
+}: ButtonProps) {
+ return (
+
+ );
+}
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..c7ae94a
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,76 @@
+import { cn } from "@/lib/utils";
+import { type HTMLAttributes } from "react";
+
+interface CardProps extends HTMLAttributes {
+ accent?: "unit-1" | "unit-2" | "unit-3" | "unit-4";
+ hover?: boolean;
+}
+
+const accentStyles = {
+ "unit-1": "border-l-4 border-l-unit-1",
+ "unit-2": "border-l-4 border-l-unit-2",
+ "unit-3": "border-l-4 border-l-unit-3",
+ "unit-4": "border-l-4 border-l-unit-4",
+};
+
+export function Card({
+ accent,
+ hover = false,
+ className,
+ children,
+ ...props
+}: CardProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function CardHeader({
+ className,
+ children,
+ ...props
+}: HTMLAttributes) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function CardTitle({
+ className,
+ children,
+ ...props
+}: HTMLAttributes) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function CardDescription({
+ className,
+ children,
+ ...props
+}: HTMLAttributes) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..b77bb8e
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,16 @@
+services:
+ cabrits:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: registry.dwarrington.com/cabrits:${IMAGE_TAG}
+ container_name: cabrits
+ restart: always
+ ports:
+ - "5030:3000"
+ networks:
+ - caddy_network
+
+networks:
+ caddy_network:
+ external: true
diff --git a/lib/curriculum.ts b/lib/curriculum.ts
new file mode 100644
index 0000000..f23e888
--- /dev/null
+++ b/lib/curriculum.ts
@@ -0,0 +1,198 @@
+export interface Topic {
+ slug: string;
+ title: string;
+ shortTitle: string;
+ week: number;
+ description: string;
+}
+
+export interface Unit {
+ number: 1 | 2 | 3 | 4;
+ slug: string;
+ title: string;
+ description: string;
+ weeks: string;
+ color: "unit-1" | "unit-2" | "unit-3" | "unit-4";
+ topics: Topic[];
+}
+
+export const curriculum: Unit[] = [
+ {
+ number: 1,
+ slug: "unit-1-fractions",
+ title: "Fractions",
+ description: "Add, subtract, multiply, divide fractions and apply to quantities",
+ weeks: "Weeks 1-3",
+ color: "unit-1",
+ topics: [
+ {
+ slug: "add-subtract",
+ title: "Add and Subtract Fractions",
+ shortTitle: "Add & Subtract",
+ week: 1,
+ description: "Common and uncommon denominators, Butterfly Method",
+ },
+ {
+ slug: "multiply",
+ title: "Multiply Fractions",
+ shortTitle: "Multiply",
+ week: 1,
+ description: "Multiply numerators and denominators, simplify",
+ },
+ {
+ slug: "divide",
+ title: "Divide Fractions",
+ shortTitle: "Divide",
+ week: 1,
+ description: "Invert and multiply (reciprocal method)",
+ },
+ {
+ slug: "mixed-operations",
+ title: "Mixed Operations (BODMAS)",
+ shortTitle: "BODMAS",
+ week: 2,
+ description: "Order of operations with fractions",
+ },
+ {
+ slug: "fraction-of-quantity",
+ title: "Fraction of a Quantity",
+ shortTitle: "Of a Quantity",
+ week: 3,
+ description: "Calculate a fraction of a given amount",
+ },
+ {
+ slug: "whole-from-fractions",
+ title: "Calculate the Whole from Fractions",
+ shortTitle: "Find the Whole",
+ week: 3,
+ description: "Find the whole when given a part and its fraction",
+ },
+ ],
+ },
+ {
+ number: 2,
+ slug: "unit-2-decimals",
+ title: "Decimals",
+ description: "Compare, order, round decimals and express in standard form",
+ weeks: "Weeks 4-5",
+ color: "unit-2",
+ topics: [
+ {
+ slug: "compare-order",
+ title: "Compare and Order Decimals",
+ shortTitle: "Compare & Order",
+ week: 4,
+ description: "Place value, ascending, descending, greater/less than",
+ },
+ {
+ slug: "approximate",
+ title: "Approximate Decimals",
+ shortTitle: "Approximation",
+ week: 4,
+ description: "Round to whole numbers, decimal places, significant figures",
+ },
+ {
+ slug: "standard-form",
+ title: "Standard Form (Scientific Notation)",
+ shortTitle: "Standard Form",
+ week: 5,
+ description: "Express numbers as a × 10^n",
+ },
+ ],
+ },
+ {
+ number: 3,
+ slug: "unit-3-decimal-operations",
+ title: "Decimal Operations",
+ description: "Convert, add, subtract, multiply and divide decimals",
+ weeks: "Weeks 6-7",
+ color: "unit-3",
+ topics: [
+ {
+ slug: "convert",
+ title: "Convert Decimals and Fractions",
+ shortTitle: "Convert",
+ week: 6,
+ description: "Decimals to fractions and fractions to decimals",
+ },
+ {
+ slug: "add-subtract",
+ title: "Add and Subtract Decimals",
+ shortTitle: "Add & Subtract",
+ week: 6,
+ description: "Align decimal points, insert zeros as placeholders",
+ },
+ {
+ slug: "multiply-divide",
+ title: "Multiply and Divide Decimals",
+ shortTitle: "Multiply & Divide",
+ week: 7,
+ description: "By powers of 10, whole numbers, and decimals",
+ },
+ ],
+ },
+ {
+ number: 4,
+ slug: "unit-4-ratio-proportion",
+ title: "Ratio & Proportion",
+ description: "Define, simplify, and apply ratios to solve problems",
+ weeks: "Weeks 8-9",
+ color: "unit-4",
+ topics: [
+ {
+ slug: "define-ratio",
+ title: "Define a Ratio",
+ shortTitle: "Define Ratio",
+ week: 8,
+ description: "Relationship between two or more quantities",
+ },
+ {
+ slug: "fractions-and-ratios",
+ title: "Fractions and Ratios",
+ shortTitle: "Fractions & Ratios",
+ week: 8,
+ description: "Understand the relationship between fractions and ratios",
+ },
+ {
+ slug: "simplify-ratios",
+ title: "Simplify Ratios",
+ shortTitle: "Simplify",
+ week: 8,
+ description: "Write ratios in their simplest form",
+ },
+ {
+ slug: "divide-in-ratio",
+ title: "Divide a Quantity in a Given Ratio",
+ shortTitle: "Divide in Ratio",
+ week: 9,
+ description: "Share amounts using ratios",
+ },
+ {
+ slug: "word-problems",
+ title: "Proportional Parts Word Problems",
+ shortTitle: "Word Problems",
+ week: 9,
+ description: "Solve real-world problems with ratios",
+ },
+ ],
+ },
+];
+
+export function getUnit(slug: string): Unit | undefined {
+ return curriculum.find((u) => u.slug === slug);
+}
+
+export function getTopic(unitSlug: string, topicSlug: string): Topic | undefined {
+ const unit = getUnit(unitSlug);
+ return unit?.topics.find((t) => t.slug === topicSlug);
+}
+
+export function getUnitColor(unitNumber: 1 | 2 | 3 | 4): string {
+ const colors = {
+ 1: "unit-1",
+ 2: "unit-2",
+ 3: "unit-3",
+ 4: "unit-4",
+ };
+ return colors[unitNumber];
+}
diff --git a/lib/math/decimals.ts b/lib/math/decimals.ts
new file mode 100644
index 0000000..6592323
--- /dev/null
+++ b/lib/math/decimals.ts
@@ -0,0 +1,65 @@
+export function roundToWholeNumber(value: number): number {
+ return Math.round(value);
+}
+
+export function roundToDecimalPlaces(value: number, places: number): number {
+ const factor = Math.pow(10, places);
+ return Math.round(value * factor) / factor;
+}
+
+export function roundToSignificantFigures(value: number, sigFigs: number): number {
+ if (value === 0) return 0;
+ const magnitude = Math.floor(Math.log10(Math.abs(value)));
+ const factor = Math.pow(10, sigFigs - 1 - magnitude);
+ return Math.round(value * factor) / factor;
+}
+
+export function toStandardForm(value: number): { coefficient: number; exponent: number } {
+ if (value === 0) return { coefficient: 0, exponent: 0 };
+ const exponent = Math.floor(Math.log10(Math.abs(value)));
+ const coefficient = value / Math.pow(10, exponent);
+ return {
+ coefficient: roundToDecimalPlaces(coefficient, 10),
+ exponent,
+ };
+}
+
+export function fromStandardForm(coefficient: number, exponent: number): number {
+ return coefficient * Math.pow(10, exponent);
+}
+
+export function compareDecimals(a: number, b: number): -1 | 0 | 1 {
+ if (a > b) return 1;
+ if (a < b) return -1;
+ return 0;
+}
+
+export function getPlaceValue(value: number): { digit: number; place: string }[] {
+ const places = [
+ "thousands", "hundreds", "tens", "units", "tenths", "hundredths", "thousandths",
+ ];
+ const str = Math.abs(value).toFixed(3);
+ const [intPart, decPart] = str.split(".");
+ const paddedInt = intPart.padStart(4, "0");
+ const result: { digit: number; place: string }[] = [];
+
+ for (let i = 0; i < 4; i++) {
+ result.push({ digit: parseInt(paddedInt[i]), place: places[i] });
+ }
+ for (let i = 0; i < 3; i++) {
+ result.push({ digit: parseInt(decPart[i]), place: places[4 + i] });
+ }
+ return result;
+}
+
+export function multiplyByPowerOf10(value: number, power: number): number {
+ return roundToDecimalPlaces(value * Math.pow(10, power), 10);
+}
+
+export function divideByPowerOf10(value: number, power: number): number {
+ return roundToDecimalPlaces(value / Math.pow(10, power), 10);
+}
+
+export function standardFormToKatex(coefficient: number, exponent: number): string {
+ return `${coefficient} \\times 10^{${exponent}}`;
+}
diff --git a/lib/math/fractions.ts b/lib/math/fractions.ts
new file mode 100644
index 0000000..4c8cc2c
--- /dev/null
+++ b/lib/math/fractions.ts
@@ -0,0 +1,107 @@
+export function gcd(a: number, b: number): number {
+ a = Math.abs(a);
+ b = Math.abs(b);
+ while (b) {
+ [a, b] = [b, a % b];
+ }
+ return a;
+}
+
+export function lcm(a: number, b: number): number {
+ return Math.abs(a * b) / gcd(a, b);
+}
+
+export function simplify(num: number, den: number): [number, number] {
+ if (den === 0) return [num, den];
+ const g = gcd(Math.abs(num), Math.abs(den));
+ const sign = den < 0 ? -1 : 1;
+ return [(num / g) * sign, (den / g) * sign];
+}
+
+export function add(
+ n1: number, d1: number,
+ n2: number, d2: number,
+): [number, number] {
+ const num = n1 * d2 + n2 * d1;
+ const den = d1 * d2;
+ return simplify(num, den);
+}
+
+export function subtract(
+ n1: number, d1: number,
+ n2: number, d2: number,
+): [number, number] {
+ const num = n1 * d2 - n2 * d1;
+ const den = d1 * d2;
+ return simplify(num, den);
+}
+
+export function multiply(
+ n1: number, d1: number,
+ n2: number, d2: number,
+): [number, number] {
+ return simplify(n1 * n2, d1 * d2);
+}
+
+export function divide(
+ n1: number, d1: number,
+ n2: number, d2: number,
+): [number, number] {
+ return simplify(n1 * d2, d1 * n2);
+}
+
+export function toDecimal(num: number, den: number): number {
+ return num / den;
+}
+
+export function fromDecimal(decimal: number, precision: number = 6): [number, number] {
+ const str = decimal.toFixed(precision);
+ const parts = str.split(".");
+ if (!parts[1]) return [parseInt(parts[0]), 1];
+ const den = Math.pow(10, parts[1].length);
+ const num = Math.round(decimal * den);
+ return simplify(num, den);
+}
+
+export function compare(
+ n1: number, d1: number,
+ n2: number, d2: number,
+): -1 | 0 | 1 {
+ const diff = n1 * d2 - n2 * d1;
+ if (diff > 0) return 1;
+ if (diff < 0) return -1;
+ return 0;
+}
+
+export function fractionOfQuantity(num: number, den: number, quantity: number): number {
+ return (num / den) * quantity;
+}
+
+export function wholeFromFraction(num: number, den: number, part: number): number {
+ return (part * den) / num;
+}
+
+export function isProper(num: number, den: number): boolean {
+ return Math.abs(num) < Math.abs(den);
+}
+
+export function toMixed(num: number, den: number): [number, number, number] {
+ const whole = Math.floor(Math.abs(num) / Math.abs(den));
+ const remainder = Math.abs(num) % Math.abs(den);
+ const sign = (num < 0) !== (den < 0) ? -1 : 1;
+ return [whole * sign, remainder, Math.abs(den)];
+}
+
+export function fromMixed(whole: number, num: number, den: number): [number, number] {
+ const sign = whole < 0 ? -1 : 1;
+ return [sign * (Math.abs(whole) * den + num), den];
+}
+
+export function isSimplified(num: number, den: number): boolean {
+ return gcd(Math.abs(num), Math.abs(den)) === 1;
+}
+
+export function toKatex(num: number, den: number): string {
+ if (den === 1) return `${num}`;
+ return `\\frac{${num}}{${den}}`;
+}
diff --git a/lib/math/ratios.ts b/lib/math/ratios.ts
new file mode 100644
index 0000000..004b63e
--- /dev/null
+++ b/lib/math/ratios.ts
@@ -0,0 +1,35 @@
+import { gcd } from "./fractions";
+
+export function simplifyRatio(parts: number[]): number[] {
+ if (parts.length === 0) return [];
+ let g = parts[0];
+ for (let i = 1; i < parts.length; i++) {
+ g = gcd(g, parts[i]);
+ }
+ return parts.map((p) => p / g);
+}
+
+export function divideInRatio(total: number, parts: number[]): number[] {
+ const sum = parts.reduce((a, b) => a + b, 0);
+ const onePart = total / sum;
+ return parts.map((p) => p * onePart);
+}
+
+export function ratioToFraction(a: number, b: number): [number, number] {
+ return [a, a + b];
+}
+
+export function areEquivalent(ratio1: number[], ratio2: number[]): boolean {
+ if (ratio1.length !== ratio2.length) return false;
+ const s1 = simplifyRatio(ratio1);
+ const s2 = simplifyRatio(ratio2);
+ return s1.every((v, i) => v === s2[i]);
+}
+
+export function totalParts(parts: number[]): number {
+ return parts.reduce((a, b) => a + b, 0);
+}
+
+export function ratioToKatex(parts: number[]): string {
+ return parts.join(":");
+}
diff --git a/lib/math/validation.ts b/lib/math/validation.ts
new file mode 100644
index 0000000..df868f3
--- /dev/null
+++ b/lib/math/validation.ts
@@ -0,0 +1,94 @@
+import { gcd } from "./fractions";
+import { simplifyRatio } from "./ratios";
+
+export type AnswerResult =
+ | { correct: true; simplified: boolean }
+ | { correct: false; message: string };
+
+export function checkFractionAnswer(
+ userNum: number,
+ userDen: number,
+ expectedNum: number,
+ expectedDen: number,
+ requireSimplified: boolean = true,
+): AnswerResult {
+ if (userDen === 0) {
+ return { correct: false, message: "Denominator cannot be zero" };
+ }
+
+ // Check mathematical equivalence
+ const isEquivalent = userNum * expectedDen === expectedNum * userDen;
+
+ if (!isEquivalent) {
+ return { correct: false, message: "That's not quite right. Try again!" };
+ }
+
+ const isSimplified = gcd(Math.abs(userNum), Math.abs(userDen)) === 1;
+
+ if (requireSimplified && !isSimplified) {
+ return { correct: true, simplified: false };
+ }
+
+ return { correct: true, simplified: true };
+}
+
+export function checkDecimalAnswer(
+ userValue: number,
+ expectedValue: number,
+ tolerance: number = 0.001,
+): AnswerResult {
+ if (Math.abs(userValue - expectedValue) <= tolerance) {
+ return { correct: true, simplified: true };
+ }
+ return { correct: false, message: "That's not quite right. Try again!" };
+}
+
+export function checkRatioAnswer(
+ userParts: number[],
+ expectedParts: number[],
+ requireSimplified: boolean = true,
+): AnswerResult {
+ if (userParts.length !== expectedParts.length) {
+ return { correct: false, message: "Check the number of parts in your ratio" };
+ }
+
+ const userSimplified = simplifyRatio(userParts);
+ const expectedSimplified = simplifyRatio(expectedParts);
+
+ const isEquivalent = userSimplified.every((v, i) => v === expectedSimplified[i]);
+
+ if (!isEquivalent) {
+ return { correct: false, message: "That's not quite right. Try again!" };
+ }
+
+ if (requireSimplified) {
+ const isAlreadySimplified = userParts.every((v, i) => v === userSimplified[i]);
+ return { correct: true, simplified: isAlreadySimplified };
+ }
+
+ return { correct: true, simplified: true };
+}
+
+export function checkIntegerAnswer(
+ userValue: number,
+ expectedValue: number,
+): AnswerResult {
+ if (userValue === expectedValue) {
+ return { correct: true, simplified: true };
+ }
+ return { correct: false, message: "That's not quite right. Try again!" };
+}
+
+export function checkOrderingAnswer(
+ userOrder: number[],
+ expectedOrder: number[],
+): AnswerResult {
+ if (userOrder.length !== expectedOrder.length) {
+ return { correct: false, message: "Make sure you've ordered all the numbers" };
+ }
+ const isCorrect = userOrder.every((v, i) => v === expectedOrder[i]);
+ if (isCorrect) {
+ return { correct: true, simplified: true };
+ }
+ return { correct: false, message: "Check your ordering. Try again!" };
+}
diff --git a/lib/problems/generators/decimal-problems.ts b/lib/problems/generators/decimal-problems.ts
new file mode 100644
index 0000000..91e3c53
--- /dev/null
+++ b/lib/problems/generators/decimal-problems.ts
@@ -0,0 +1,287 @@
+import type { MathProblem, Difficulty } from "../types";
+import { randomInt, randomChoice, shuffle } from "@/lib/utils";
+import { roundToDecimalPlaces, roundToSignificantFigures, toStandardForm, standardFormToKatex } from "@/lib/math/decimals";
+
+let counter = 0;
+function nextId() {
+ return `dp-${++counter}-${Date.now()}`;
+}
+
+export function generateDecimalCompareOrder(difficulty: Difficulty): MathProblem {
+ const count = difficulty === 1 ? 3 : difficulty === 2 ? 4 : 5;
+ const values: number[] = [];
+
+ for (let i = 0; i < count; i++) {
+ const intPart = randomInt(0, difficulty === 1 ? 2 : 10);
+ const decPlaces = randomInt(1, difficulty === 1 ? 2 : 3);
+ const decPart = randomInt(1, Math.pow(10, decPlaces) - 1);
+ const val = intPart + decPart / Math.pow(10, decPlaces);
+ values.push(Math.round(val * 1000) / 1000);
+ }
+
+ const sorted = [...values].sort((a, b) => a - b);
+ const shuffled = shuffle(values);
+
+ return {
+ id: nextId(),
+ prompt: `\\text{Order from smallest to largest: } ${shuffled.join(", ")}`,
+ answer: { kind: "decimal", value: 0 },
+ hints: [
+ "Compare the whole number part first",
+ "If the whole parts are equal, compare tenths, then hundredths",
+ "Writing them with the same number of decimal places can help",
+ ],
+ steps: sorted.map((v, i) => ({
+ explanation: `Position ${i + 1}: ${v}`,
+ })),
+ };
+}
+
+export function generateDecimalRounding(difficulty: Difficulty): MathProblem {
+ const value = randomInt(100, 99999) / (difficulty === 1 ? 100 : 1000);
+ const roundType = randomChoice(
+ difficulty === 1
+ ? ["whole", "1dp"] as const
+ : difficulty === 2
+ ? ["1dp", "2dp", "whole"] as const
+ : ["1sf", "2sf", "3sf"] as const,
+ );
+
+ let answer: number;
+ let instruction: string;
+
+ switch (roundType) {
+ case "whole":
+ answer = Math.round(value);
+ instruction = "the nearest whole number";
+ break;
+ case "1dp":
+ answer = roundToDecimalPlaces(value, 1);
+ instruction = "1 decimal place";
+ break;
+ case "2dp":
+ answer = roundToDecimalPlaces(value, 2);
+ instruction = "2 decimal places";
+ break;
+ case "1sf":
+ answer = roundToSignificantFigures(value, 1);
+ instruction = "1 significant figure";
+ break;
+ case "2sf":
+ answer = roundToSignificantFigures(value, 2);
+ instruction = "2 significant figures";
+ break;
+ case "3sf":
+ answer = roundToSignificantFigures(value, 3);
+ instruction = "3 significant figures";
+ break;
+ }
+
+ return {
+ id: nextId(),
+ prompt: `\\text{Round } ${value} \\text{ to ${instruction}}`,
+ answer: { kind: "decimal", value: answer },
+ hints: [
+ "Look at the digit to the right of where you're rounding",
+ "If it's 5 or more, round up. If it's less than 5, round down",
+ ],
+ steps: [
+ { explanation: `Rounding ${value} to ${instruction}` },
+ { explanation: `Answer: ${answer}` },
+ ],
+ };
+}
+
+export function generateStandardForm(difficulty: Difficulty): MathProblem {
+ let value: number;
+ if (difficulty === 1) {
+ value = randomChoice([7438, 1578, 62, 93000, 5200, 340]);
+ } else if (difficulty === 2) {
+ value = randomChoice([0.086, 0.748, 0.0055, 0.34, 0.007]);
+ } else {
+ value = randomChoice([12436.3, 0.00092, 504000, 0.0000067, 83100]);
+ }
+
+ const sf = toStandardForm(value);
+
+ return {
+ id: nextId(),
+ prompt: `\\text{Express } ${value} \\text{ in standard form}`,
+ answer: { kind: "standardForm", coefficient: sf.coefficient, exponent: sf.exponent },
+ hints: [
+ "Move the decimal point so there's one non-zero digit before it",
+ "Count how many places you moved the decimal",
+ value >= 1
+ ? "Number is ≥ 1, so the power is positive"
+ : "Number is < 1, so the power is negative",
+ ],
+ steps: [
+ { explanation: `Move the decimal point to get a number between 1 and 10` },
+ { explanation: `Coefficient: ${sf.coefficient}` },
+ { explanation: `The decimal moved ${Math.abs(sf.exponent)} places, power = ${sf.exponent}` },
+ { explanation: "Answer", math: standardFormToKatex(sf.coefficient, sf.exponent) },
+ ],
+ };
+}
+
+export function generateDecimalAddSubtract(difficulty: Difficulty): MathProblem {
+ const operation = randomChoice(["add", "subtract"] as const);
+ const op = operation === "add" ? "+" : "-";
+ const places = difficulty === 1 ? 1 : difficulty === 2 ? 2 : 3;
+
+ const a = randomInt(100, 9999) / Math.pow(10, places);
+ const b = randomInt(100, 9999) / Math.pow(10, places);
+ const [first, second] = operation === "subtract" ? [Math.max(a, b), Math.min(a, b)] : [a, b];
+
+ const answer = roundToDecimalPlaces(
+ operation === "add" ? first + second : first - second,
+ places,
+ );
+
+ return {
+ id: nextId(),
+ prompt: `${first} ${op} ${second}`,
+ answer: { kind: "decimal", value: answer },
+ hints: [
+ "Line up the decimal points",
+ "Add zeros to fill empty places",
+ operation === "add" ? "Add column by column from right to left" : "Subtract column by column, borrow if needed",
+ ],
+ steps: [
+ { explanation: `Line up: ${first} ${op} ${second}` },
+ { explanation: `= ${answer}` },
+ ],
+ };
+}
+
+export function generateDecimalMultiplyDivide(difficulty: Difficulty): MathProblem {
+ const operation = randomChoice(["multiply", "divide"] as const);
+
+ if (difficulty === 1) {
+ // By powers of 10
+ const power = randomChoice([10, 100, 1000]);
+ const base = randomInt(1, 999) / 100;
+
+ if (operation === "multiply") {
+ const answer = roundToDecimalPlaces(base * power, 10);
+ return {
+ id: nextId(),
+ prompt: `${base} \\times ${power}`,
+ answer: { kind: "decimal", value: answer },
+ hints: [
+ `When multiplying by ${power}, move decimal ${Math.log10(power)} place(s) right`,
+ ],
+ steps: [
+ { explanation: `Move decimal point ${Math.log10(power)} place(s) to the right` },
+ { explanation: `= ${answer}` },
+ ],
+ };
+ } else {
+ const answer = roundToDecimalPlaces(base / power, 10);
+ return {
+ id: nextId(),
+ prompt: `${base} \\div ${power}`,
+ answer: { kind: "decimal", value: answer },
+ hints: [
+ `When dividing by ${power}, move decimal ${Math.log10(power)} place(s) left`,
+ ],
+ steps: [
+ { explanation: `Move decimal point ${Math.log10(power)} place(s) to the left` },
+ { explanation: `= ${answer}` },
+ ],
+ };
+ }
+ }
+
+ // By whole numbers or decimals
+ const a = randomInt(10, 999) / 10;
+ const b = difficulty === 2 ? randomInt(2, 12) : randomInt(10, 99) / 10;
+
+ if (operation === "multiply") {
+ const answer = roundToDecimalPlaces(a * b, 4);
+ return {
+ id: nextId(),
+ prompt: `${a} \\times ${b}`,
+ answer: { kind: "decimal", value: answer },
+ hints: [
+ "Multiply as if there are no decimal points",
+ "Count total decimal places in both numbers",
+ "Place the decimal point in your answer",
+ ],
+ steps: [
+ { explanation: `${a} × ${b}` },
+ { explanation: `= ${answer}` },
+ ],
+ };
+ } else {
+ const answer = roundToDecimalPlaces(a / b, 4);
+ return {
+ id: nextId(),
+ prompt: `${a} \\div ${b}`,
+ answer: { kind: "decimal", value: answer },
+ hints: [
+ "Place decimal point in answer directly above the one in the dividend",
+ "Divide as normal",
+ ],
+ steps: [
+ { explanation: `${a} ÷ ${b}` },
+ { explanation: `= ${answer}` },
+ ],
+ };
+ }
+}
+
+export function generateDecimalConversion(difficulty: Difficulty): MathProblem {
+ const direction = randomChoice(["toFraction", "toDecimal"] as const);
+
+ if (direction === "toFraction") {
+ const decimal = randomChoice(
+ difficulty === 1
+ ? [0.5, 0.25, 0.75, 0.1, 0.2, 0.4]
+ : [0.125, 0.375, 0.625, 0.875, 0.15, 0.35, 0.45],
+ );
+ const den = decimal === 0.5 ? 2 : decimal === 0.25 || decimal === 0.75 ? 4 : [0.125, 0.375, 0.625, 0.875].includes(decimal) ? 8 : 100;
+ const num = Math.round(decimal * den);
+ const g = gcdSimple(num, den);
+
+ return {
+ id: nextId(),
+ prompt: `\\text{Convert } ${decimal} \\text{ to a fraction}`,
+ answer: { kind: "fraction", numerator: num / g, denominator: den / g },
+ hints: [
+ `Write as ${num}/${den}`,
+ "Simplify by dividing numerator and denominator by their GCD",
+ ],
+ steps: [
+ { explanation: `${decimal} = ${num}/${den}` },
+ { explanation: `Simplified: ${num / g}/${den / g}` },
+ ],
+ };
+ } else {
+ const den = randomChoice(difficulty === 1 ? [2, 4, 5, 10] : [3, 8, 20, 25, 40, 50]);
+ const num = randomInt(1, den - 1);
+ const answer = roundToDecimalPlaces(num / den, 6);
+
+ return {
+ id: nextId(),
+ prompt: `\\text{Convert } \\frac{${num}}{${den}} \\text{ to a decimal}`,
+ answer: { kind: "decimal", value: answer },
+ hints: [
+ `Divide ${num} by ${den}`,
+ "Or find an equivalent fraction with denominator 10, 100, etc.",
+ ],
+ steps: [
+ { explanation: `${num} ÷ ${den} = ${answer}` },
+ ],
+ };
+ }
+}
+
+function gcdSimple(a: number, b: number): number {
+ a = Math.abs(a);
+ b = Math.abs(b);
+ while (b) {
+ [a, b] = [b, a % b];
+ }
+ return a;
+}
diff --git a/lib/problems/generators/fraction-problems.ts b/lib/problems/generators/fraction-problems.ts
new file mode 100644
index 0000000..bd38ef6
--- /dev/null
+++ b/lib/problems/generators/fraction-problems.ts
@@ -0,0 +1,163 @@
+import type { MathProblem, Difficulty } from "../types";
+import { randomInt, randomChoice } from "@/lib/utils";
+import * as frac from "@/lib/math/fractions";
+
+let counter = 0;
+function nextId() {
+ return `fp-${++counter}-${Date.now()}`;
+}
+
+export function generateFractionAddSubtract(difficulty: Difficulty): MathProblem {
+ const operation = randomChoice(["add", "subtract"] as const);
+ const op = operation === "add" ? "+" : "-";
+
+ let n1: number, d1: number, n2: number, d2: number;
+
+ if (difficulty === 1) {
+ // Common denominators
+ d1 = randomChoice([2, 3, 4, 5, 6, 8, 10, 12]);
+ d2 = d1;
+ n1 = randomInt(1, d1 - 1);
+ n2 = operation === "subtract" ? randomInt(1, n1) : randomInt(1, d1 - n1);
+ } else if (difficulty === 2) {
+ // One denominator is a multiple of the other
+ d1 = randomChoice([2, 3, 4, 5, 6]);
+ const multiplier = randomChoice([2, 3]);
+ d2 = d1 * multiplier;
+ n1 = randomInt(1, d1 - 1);
+ n2 = randomInt(1, d2 - 1);
+ } else {
+ // Unlike denominators
+ d1 = randomChoice([3, 4, 5, 7, 8, 9]);
+ d2 = randomChoice([3, 4, 5, 7, 8, 9].filter((d) => d !== d1));
+ n1 = randomInt(1, d1 - 1);
+ n2 = randomInt(1, d2 - 1);
+ }
+
+ const [ansNum, ansDen] = operation === "add"
+ ? frac.add(n1, d1, n2, d2)
+ : frac.subtract(n1, d1, n2, d2);
+
+ const prompt = `\\frac{${n1}}{${d1}} ${op} \\frac{${n2}}{${d2}}`;
+
+ const commonDen = frac.lcm(d1, d2);
+ const steps = d1 === d2
+ ? [
+ { explanation: "Denominators are the same, so just " + (operation === "add" ? "add" : "subtract") + " the numerators" },
+ { explanation: `${n1} ${op} ${n2} = ${operation === "add" ? n1 + n2 : n1 - n2}`, math: `\\frac{${operation === "add" ? n1 + n2 : n1 - n2}}{${d1}}` },
+ ]
+ : [
+ { explanation: `Find the common denominator: LCM(${d1}, ${d2}) = ${commonDen}` },
+ { explanation: `Convert: ${n1}×${commonDen / d1}/${d1}×${commonDen / d1} and ${n2}×${commonDen / d2}/${d2}×${commonDen / d2}` },
+ { explanation: `${op === "+" ? "Add" : "Subtract"} numerators`, math: `\\frac{${ansNum}}{${ansDen}}` },
+ ];
+
+ return {
+ id: nextId(),
+ prompt,
+ answer: { kind: "fraction", numerator: ansNum, denominator: ansDen },
+ hints: [
+ d1 === d2 ? "The denominators are already the same!" : "Find a common denominator first",
+ d1 !== d2 ? `Try using the Butterfly Method: cross multiply` : `Just ${operation} the numerators`,
+ ],
+ steps,
+ };
+}
+
+export function generateFractionMultiply(difficulty: Difficulty): MathProblem {
+ const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [2, 3, 4, 5, 6, 7, 8] : [3, 5, 7, 9, 11, 12, 15];
+ const d1 = randomChoice(denChoices);
+ const d2 = randomChoice(denChoices);
+ const n1 = randomInt(1, d1 - 1);
+ const n2 = randomInt(1, d2 - 1);
+
+ const [ansNum, ansDen] = frac.multiply(n1, d1, n2, d2);
+
+ return {
+ id: nextId(),
+ prompt: `\\frac{${n1}}{${d1}} \\times \\frac{${n2}}{${d2}}`,
+ answer: { kind: "fraction", numerator: ansNum, denominator: ansDen },
+ hints: [
+ "Multiply numerator × numerator",
+ "Multiply denominator × denominator",
+ "Simplify if possible",
+ ],
+ steps: [
+ { explanation: `Multiply numerators: ${n1} × ${n2} = ${n1 * n2}` },
+ { explanation: `Multiply denominators: ${d1} × ${d2} = ${d1 * d2}` },
+ { explanation: "Simplify", math: `\\frac{${ansNum}}{${ansDen}}` },
+ ],
+ };
+}
+
+export function generateFractionDivide(difficulty: Difficulty): MathProblem {
+ const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [2, 3, 4, 5, 6, 7, 8] : [3, 5, 7, 9, 11, 14, 15];
+ const d1 = randomChoice(denChoices);
+ const d2 = randomChoice(denChoices);
+ const n1 = randomInt(1, d1 - 1);
+ const n2 = randomInt(1, d2 - 1);
+
+ const [ansNum, ansDen] = frac.divide(n1, d1, n2, d2);
+
+ return {
+ id: nextId(),
+ prompt: `\\frac{${n1}}{${d1}} \\div \\frac{${n2}}{${d2}}`,
+ answer: { kind: "fraction", numerator: ansNum, denominator: ansDen },
+ hints: [
+ "Keep the first fraction the same",
+ "Flip the second fraction (reciprocal)",
+ "Then multiply",
+ ],
+ steps: [
+ { explanation: `Keep ${n1}/${d1}, flip ${n2}/${d2} to ${d2}/${n2}` },
+ { explanation: `Multiply: ${n1}×${d2} / ${d1}×${n2}` },
+ { explanation: "Simplify", math: `\\frac{${ansNum}}{${ansDen}}` },
+ ],
+ };
+}
+
+export function generateFractionOfQuantity(difficulty: Difficulty): MathProblem {
+ const denChoices = difficulty === 1 ? [2, 4, 5, 10] : difficulty === 2 ? [3, 4, 5, 6, 8] : [7, 8, 9, 12, 15];
+ const den = randomChoice(denChoices);
+ const num = randomInt(1, den - 1);
+ const multiplier = randomChoice(difficulty === 1 ? [2, 4, 5, 10] : [3, 6, 7, 8, 9, 12]);
+ const quantity = den * multiplier;
+ const answer = num * multiplier;
+
+ return {
+ id: nextId(),
+ prompt: `\\frac{${num}}{${den}} \\text{ of } ${quantity}`,
+ answer: { kind: "integer", value: answer },
+ hints: [
+ `Divide ${quantity} by ${den} first`,
+ `Then multiply by ${num}`,
+ ],
+ steps: [
+ { explanation: `Divide the quantity by the denominator: ${quantity} ÷ ${den} = ${quantity / den}` },
+ { explanation: `Multiply by the numerator: ${quantity / den} × ${num} = ${answer}` },
+ ],
+ };
+}
+
+export function generateWholeFromFraction(difficulty: Difficulty): MathProblem {
+ const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [3, 5, 6, 8] : [7, 8, 9, 12];
+ const den = randomChoice(denChoices);
+ const num = randomInt(1, den - 1);
+ const whole = den * randomChoice(difficulty === 1 ? [2, 3, 4, 5] : [3, 5, 6, 7, 8, 10]);
+ const part = (num / den) * whole;
+
+ return {
+ id: nextId(),
+ prompt: `\\frac{${num}}{${den}} \\text{ of a number is } ${part}\\text{. Find the number.}`,
+ answer: { kind: "integer", value: whole },
+ hints: [
+ `If ${num}/${den} of the number is ${part}...`,
+ `Find 1/${den} first by dividing ${part} by ${num}`,
+ `Then multiply by ${den} to get the whole`,
+ ],
+ steps: [
+ { explanation: `1/${den} of the number = ${part} ÷ ${num} = ${part / num}` },
+ { explanation: `The whole number = ${part / num} × ${den} = ${whole}` },
+ ],
+ };
+}
diff --git a/lib/problems/generators/ratio-problems.ts b/lib/problems/generators/ratio-problems.ts
new file mode 100644
index 0000000..69e5dac
--- /dev/null
+++ b/lib/problems/generators/ratio-problems.ts
@@ -0,0 +1,110 @@
+import type { MathProblem, Difficulty } from "../types";
+import { randomInt, randomChoice } from "@/lib/utils";
+import { simplifyRatio, divideInRatio } from "@/lib/math/ratios";
+
+let counter = 0;
+function nextId() {
+ return `rp-${++counter}-${Date.now()}`;
+}
+
+export function generateSimplifyRatio(difficulty: Difficulty): MathProblem {
+ const gcd = randomChoice(difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [3, 4, 5, 6, 7] : [4, 6, 8, 9, 12]);
+ const a = randomInt(1, difficulty === 1 ? 5 : 10) * gcd;
+ const b = randomInt(1, difficulty === 1 ? 5 : 10) * gcd;
+
+ const simplified = simplifyRatio([a, b]);
+
+ return {
+ id: nextId(),
+ prompt: `\\text{Simplify the ratio } ${a}:${b}`,
+ answer: { kind: "ratio", parts: simplified },
+ hints: [
+ `Find the GCD of ${a} and ${b}`,
+ `The GCD is ${gcd}`,
+ `Divide both parts by ${gcd}`,
+ ],
+ steps: [
+ { explanation: `GCD of ${a} and ${b} is ${gcd}` },
+ { explanation: `${a} ÷ ${gcd} = ${simplified[0]}, ${b} ÷ ${gcd} = ${simplified[1]}` },
+ { explanation: `Simplified: ${simplified.join(":")}` },
+ ],
+ };
+}
+
+export function generateDivideInRatio(difficulty: Difficulty): MathProblem {
+ const numParts = difficulty === 3 ? 3 : 2;
+ const parts: number[] = [];
+ for (let i = 0; i < numParts; i++) {
+ parts.push(randomInt(1, difficulty === 1 ? 5 : 9));
+ }
+
+ const sum = parts.reduce((a, b) => a + b, 0);
+ const multiplier = randomChoice(difficulty === 1 ? [2, 3, 4, 5] : [3, 4, 5, 6, 7, 8]);
+ const total = sum * multiplier;
+
+ const values = divideInRatio(total, parts);
+ const intValues = values.map(Math.round);
+
+ const names = ["first share", "second share", "third share"];
+
+ return {
+ id: nextId(),
+ prompt: `\\text{Share } ${total} \\text{ in the ratio } ${parts.join(":")}`,
+ answer: { kind: "ratio", parts: intValues },
+ hints: [
+ `Total parts: ${parts.join(" + ")} = ${sum}`,
+ `One part = ${total} ÷ ${sum} = ${multiplier}`,
+ `Multiply each ratio number by ${multiplier}`,
+ ],
+ steps: [
+ { explanation: `Total parts = ${parts.join(" + ")} = ${sum}` },
+ { explanation: `One part = ${total} ÷ ${sum} = ${multiplier}` },
+ ...parts.map((p, i) => ({
+ explanation: `${names[i]}: ${p} × ${multiplier} = ${intValues[i]}`,
+ })),
+ ],
+ };
+}
+
+export function generateRatioWordProblem(difficulty: Difficulty): MathProblem {
+ const a = randomInt(2, 7);
+ const b = randomInt(2, 7);
+ const diff = Math.abs(a - b);
+
+ if (diff === 0) {
+ return generateDivideInRatio(difficulty);
+ }
+
+ const contexts = [
+ { item: "sweets", nameA: "Josh", nameB: "Nathan" },
+ { item: "marbles", nameA: "Amy", nameB: "Ben" },
+ { item: "stickers", nameA: "Karen", nameB: "Natasha" },
+ ];
+ const ctx = randomChoice(contexts);
+
+ const onePart = randomChoice(difficulty === 1 ? [5, 10, 12] : [6, 8, 9, 15, 20]);
+ const extraAmount = diff * onePart;
+ const totalParts = a + b;
+ const total = totalParts * onePart;
+
+ const bigger = a > b ? ctx.nameA : ctx.nameB;
+ const biggerRatio = Math.max(a, b);
+ const smallerRatio = Math.min(a, b);
+
+ return {
+ id: nextId(),
+ prompt: `\\text{${ctx.item.charAt(0).toUpperCase() + ctx.item.slice(1)} were shared between ${ctx.nameA} and ${ctx.nameB} in the ratio ${a}:${b}. If ${bigger} received ${extraAmount} more ${ctx.item}, find the total shared.}`,
+ answer: { kind: "integer", value: total },
+ hints: [
+ `The difference in ratio parts is ${biggerRatio} - ${smallerRatio} = ${diff}`,
+ `${diff} parts = ${extraAmount}, so 1 part = ${onePart}`,
+ `Total parts = ${a} + ${b} = ${totalParts}`,
+ ],
+ steps: [
+ { explanation: `Difference in parts: ${biggerRatio} - ${smallerRatio} = ${diff}` },
+ { explanation: `${diff} parts = ${extraAmount}, so 1 part = ${extraAmount} ÷ ${diff} = ${onePart}` },
+ { explanation: `Total parts: ${a} + ${b} = ${totalParts}` },
+ { explanation: `Total ${ctx.item}: ${totalParts} × ${onePart} = ${total}` },
+ ],
+ };
+}
diff --git a/lib/problems/types.ts b/lib/problems/types.ts
new file mode 100644
index 0000000..d65a9e7
--- /dev/null
+++ b/lib/problems/types.ts
@@ -0,0 +1,21 @@
+export type Difficulty = 1 | 2 | 3;
+
+export type MathAnswer =
+ | { kind: "fraction"; numerator: number; denominator: number }
+ | { kind: "decimal"; value: number }
+ | { kind: "integer"; value: number }
+ | { kind: "ratio"; parts: number[] }
+ | { kind: "standardForm"; coefficient: number; exponent: number };
+
+export interface SolutionStep {
+ explanation: string;
+ math?: string;
+}
+
+export interface MathProblem {
+ id: string;
+ prompt: string;
+ answer: MathAnswer;
+ hints: string[];
+ steps: SolutionStep[];
+}
diff --git a/lib/utils.ts b/lib/utils.ts
new file mode 100644
index 0000000..d741864
--- /dev/null
+++ b/lib/utils.ts
@@ -0,0 +1,23 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function randomInt(min: number, max: number): number {
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+}
+
+export function randomChoice(arr: T[]): T {
+ return arr[Math.floor(Math.random() * arr.length)];
+}
+
+export function shuffle(arr: T[]): T[] {
+ const result = [...arr];
+ for (let i = result.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [result[i], result[j]] = [result[j], result[i]];
+ }
+ return result;
+}
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..68a6c64 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ output: "standalone",
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index 2d56f44..908cd38 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,12 +8,17 @@
"name": "cabrits",
"version": "0.1.0",
"dependencies": {
+ "clsx": "^2.1.1",
+ "framer-motion": "^12.34.3",
+ "katex": "^0.16.33",
"next": "16.1.6",
"react": "19.2.3",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/katex": "^0.16.8",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -1445,6 +1450,12 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
+ "node_modules/@types/katex": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
+ "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
+ "dev": true
+ },
"node_modules/@types/node": {
"version": "20.19.35",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz",
@@ -2436,6 +2447,14 @@
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2454,6 +2473,14 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
+ "node_modules/commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3388,6 +3415,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.34.3",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz",
+ "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==",
+ "dependencies": {
+ "motion-dom": "^12.34.3",
+ "motion-utils": "^12.29.2",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -4219,6 +4272,21 @@
"node": ">=4.0"
}
},
+ "node_modules/katex": {
+ "version": "0.16.33",
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.33.tgz",
+ "integrity": "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "dependencies": {
+ "commander": "^8.3.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4611,6 +4679,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/motion-dom": {
+ "version": "12.34.3",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
+ "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==",
+ "dependencies": {
+ "motion-utils": "^12.29.2"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.29.2",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
+ "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5676,6 +5757,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/tailwind-merge": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
+ "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
"node_modules/tailwindcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
diff --git a/package.json b/package.json
index bc52b82..8cb521f 100644
--- a/package.json
+++ b/package.json
@@ -9,12 +9,17 @@
"lint": "eslint"
},
"dependencies": {
+ "clsx": "^2.1.1",
+ "framer-motion": "^12.34.3",
+ "katex": "^0.16.33",
"next": "16.1.6",
"react": "19.2.3",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/katex": "^0.16.8",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/standard-form-explorer.html b/standard-form-explorer.html
new file mode 100644
index 0000000..9d17ab1
--- /dev/null
+++ b/standard-form-explorer.html
@@ -0,0 +1,836 @@
+
+
+
+
+
+ Standard Form Explorer
+
+
+
+
+
+
+
+
+
+
+
+
+
Enter an ordinary number
+
+
+
+
+
+
+
+
+
+
Enter a number in standard form A × 10n
+
+
+
+
+
+
+
+
+
+
+
+
Enter a number above and click Convert
+
+
+
+
+
+
+
+
+ ×
+ 10
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Result will appear here when steps are complete
+
+
+
+
+
+
+ Keyboard: Space or → next step ·
+ ← back · P play/pause · R reset
+
+
+
+
+
+
+
+
diff --git a/term-2-unit.pdf b/term-2-unit.pdf
new file mode 100644
index 0000000..15f3d65
Binary files /dev/null and b/term-2-unit.pdf differ
diff --git a/www.mathplayground.com_.png b/www.mathplayground.com_.png
new file mode 100644
index 0000000..952f7f0
Binary files /dev/null and b/www.mathplayground.com_.png differ