Created
March 12, 2026 00:45
-
-
Save statico/b04b5cd82a85258184100afa08cce665 to your computer and use it in GitHub Desktop.
Math Tutor - single page HTML app for 9-year-old level math practice
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Math Tutor</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: 'Segoe UI', system-ui, sans-serif; | |
| background: #f0f4ff; | |
| color: #1a1a2e; | |
| min-height: 100vh; | |
| padding: 20px; | |
| } | |
| h1 { | |
| text-align: center; | |
| font-size: 2rem; | |
| margin-bottom: 20px; | |
| color: #2d3a8c; | |
| } | |
| .container { | |
| max-width: 700px; | |
| margin: 0 auto; | |
| } | |
| /* Settings panel */ | |
| .settings { | |
| background: #fff; | |
| border-radius: 16px; | |
| padding: 24px; | |
| box-shadow: 0 2px 12px rgba(0,0,0,0.08); | |
| margin-bottom: 20px; | |
| } | |
| .settings h2 { | |
| font-size: 1.1rem; | |
| margin-bottom: 14px; | |
| color: #444; | |
| } | |
| .ops { | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| margin-bottom: 16px; | |
| } | |
| .ops button { | |
| font-size: 1.2rem; | |
| padding: 10px 22px; | |
| border: 2px solid #c5cae9; | |
| border-radius: 10px; | |
| background: #e8eaf6; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| font-weight: 600; | |
| } | |
| .ops button.active { | |
| background: #3f51b5; | |
| color: #fff; | |
| border-color: #3f51b5; | |
| } | |
| .ops button:hover:not(.active) { | |
| background: #d1d5f0; | |
| } | |
| .size-control { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| } | |
| .size-control label { | |
| font-weight: 600; | |
| min-width: 110px; | |
| } | |
| .size-control input[type=range] { | |
| flex: 1; | |
| min-width: 120px; | |
| accent-color: #3f51b5; | |
| } | |
| .size-control .size-val { | |
| font-weight: 700; | |
| min-width: 36px; | |
| text-align: center; | |
| font-size: 1.1rem; | |
| color: #3f51b5; | |
| } | |
| .size-hint { | |
| font-size: 0.82rem; | |
| color: #888; | |
| margin-top: 4px; | |
| } | |
| /* Problem area */ | |
| .problem-area { | |
| background: #fff; | |
| border-radius: 16px; | |
| padding: 28px 24px; | |
| box-shadow: 0 2px 12px rgba(0,0,0,0.08); | |
| text-align: center; | |
| margin-bottom: 20px; | |
| } | |
| .problem { | |
| font-size: 2.4rem; | |
| font-weight: 700; | |
| letter-spacing: 2px; | |
| margin-bottom: 20px; | |
| font-family: 'Courier New', monospace; | |
| } | |
| .problem .op-sym { | |
| color: #3f51b5; | |
| margin: 0 10px; | |
| } | |
| .answer-row { | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| gap: 12px; | |
| margin-bottom: 16px; | |
| } | |
| #answer { | |
| font-size: 1.8rem; | |
| padding: 8px 16px; | |
| border: 2px solid #c5cae9; | |
| border-radius: 10px; | |
| width: 180px; | |
| text-align: center; | |
| font-family: 'Courier New', monospace; | |
| outline: none; | |
| transition: border 0.2s; | |
| } | |
| #answer:focus { border-color: #3f51b5; } | |
| .btn { | |
| font-size: 1.1rem; | |
| padding: 10px 24px; | |
| border: none; | |
| border-radius: 10px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| transition: background 0.15s; | |
| } | |
| .btn-check { | |
| background: #43a047; | |
| color: #fff; | |
| } | |
| .btn-check:hover { background: #388e3c; } | |
| .btn-new { | |
| background: #3f51b5; | |
| color: #fff; | |
| } | |
| .btn-new:hover { background: #303f9f; } | |
| .btn-show { | |
| background: #ff9800; | |
| color: #fff; | |
| } | |
| .btn-show:hover { background: #ef6c00; } | |
| .buttons { | |
| display: flex; | |
| justify-content: center; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| } | |
| /* Result feedback */ | |
| .feedback { | |
| font-size: 1.4rem; | |
| font-weight: 700; | |
| margin: 14px 0 6px; | |
| min-height: 36px; | |
| } | |
| .feedback.correct { color: #2e7d32; } | |
| .feedback.wrong { color: #c62828; } | |
| /* Explanation area */ | |
| .explanation { | |
| background: #fffde7; | |
| border: 2px solid #ffe082; | |
| border-radius: 14px; | |
| padding: 22px 20px; | |
| margin-top: 20px; | |
| text-align: left; | |
| font-family: 'Courier New', monospace; | |
| font-size: 1.05rem; | |
| line-height: 1.7; | |
| white-space: pre; | |
| overflow-x: auto; | |
| display: none; | |
| } | |
| .explanation h3 { | |
| font-family: 'Segoe UI', system-ui, sans-serif; | |
| font-size: 1.1rem; | |
| color: #e65100; | |
| margin-bottom: 10px; | |
| white-space: normal; | |
| } | |
| .explanation .step { | |
| white-space: pre; | |
| margin-bottom: 2px; | |
| } | |
| .explanation .note { | |
| font-family: 'Segoe UI', system-ui, sans-serif; | |
| color: #5d4037; | |
| font-size: 0.95rem; | |
| white-space: normal; | |
| margin: 6px 0; | |
| padding-left: 4px; | |
| font-style: italic; | |
| } | |
| /* Score */ | |
| .score-bar { | |
| text-align: center; | |
| font-size: 0.95rem; | |
| color: #666; | |
| margin-bottom: 10px; | |
| } | |
| .score-bar span { font-weight: 700; color: #3f51b5; } | |
| /* Responsive */ | |
| @media (max-width: 500px) { | |
| .problem { font-size: 1.7rem; } | |
| #answer { font-size: 1.4rem; width: 140px; } | |
| .explanation { font-size: 0.92rem; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Math Tutor</h1> | |
| <div class="score-bar">Score: <span id="score">0</span> / <span id="total">0</span></div> | |
| <div class="settings"> | |
| <h2>Operation</h2> | |
| <div class="ops"> | |
| <button class="active" data-op="add">+ Add</button> | |
| <button data-op="sub">− Subtract</button> | |
| <button data-op="mul">× Multiply</button> | |
| <button data-op="div">÷ Divide</button> | |
| </div> | |
| <h2>Number Size</h2> | |
| <div class="size-control"> | |
| <label for="sizeRange">Difficulty:</label> | |
| <input type="range" id="sizeRange" min="1" max="4" step="0.5" value="2"> | |
| <div class="size-val" id="sizeVal">2</div> | |
| </div> | |
| <div class="size-hint" id="sizeHint">Two 2-digit numbers</div> | |
| </div> | |
| <div class="problem-area"> | |
| <div class="problem" id="problem"></div> | |
| <div class="answer-row"> | |
| <input type="text" id="answer" placeholder="?" autocomplete="off" inputmode="numeric"> | |
| <button class="btn btn-check" id="checkBtn" onclick="checkAnswer()">Check</button> | |
| </div> | |
| <div class="buttons"> | |
| <button class="btn btn-show" onclick="showExplanation()">Show How</button> | |
| <button class="btn btn-new" onclick="newProblem()">New Problem</button> | |
| </div> | |
| <div class="feedback" id="feedback"></div> | |
| <div class="explanation" id="explanation"></div> | |
| </div> | |
| </div> | |
| <script> | |
| let state = { a: 0, b: 0, op: 'add', answer: 0, checked: false }; | |
| let score = 0, total = 0; | |
| // --- Operation buttons --- | |
| document.querySelectorAll('.ops button').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| document.querySelectorAll('.ops button').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| state.op = btn.dataset.op; | |
| newProblem(); | |
| }); | |
| }); | |
| // --- Size slider --- | |
| const sizeRange = document.getElementById('sizeRange'); | |
| const sizeVal = document.getElementById('sizeVal'); | |
| const sizeHint = document.getElementById('sizeHint'); | |
| sizeRange.addEventListener('input', () => { | |
| sizeVal.textContent = sizeRange.value; | |
| updateSizeHint(); | |
| newProblem(); | |
| }); | |
| function updateSizeHint() { | |
| const v = parseFloat(sizeRange.value); | |
| const d1 = Math.ceil(v), d2 = Math.floor(v); | |
| if (d1 === d2) { | |
| sizeHint.textContent = `Two ${d1}-digit number${d1>1?'s':''}`; | |
| } else { | |
| sizeHint.textContent = `One ${d1}-digit number and one ${d2}-digit number`; | |
| } | |
| } | |
| function randDigits(n) { | |
| if (n <= 0) return 1; | |
| const lo = n === 1 ? 1 : Math.pow(10, n - 1); | |
| const hi = Math.pow(10, n) - 1; | |
| return Math.floor(Math.random() * (hi - lo + 1)) + lo; | |
| } | |
| function newProblem() { | |
| const v = parseFloat(sizeRange.value); | |
| const d1 = Math.ceil(v), d2 = Math.floor(v); | |
| // Randomly assign bigger/smaller digit count | |
| let a, b; | |
| if (Math.random() < 0.5) { a = randDigits(d1); b = randDigits(d2); } | |
| else { a = randDigits(d2); b = randDigits(d1); } | |
| // For subtraction, ensure a >= b (no negatives for a 9-year-old) | |
| if (state.op === 'sub' && a < b) [a, b] = [b, a]; | |
| // For division, generate a problem that divides evenly | |
| if (state.op === 'div') { | |
| // b is divisor, result is quotient with digits based on size | |
| b = randDigits(d2 || 1); | |
| if (b === 0) b = 1; | |
| const quotient = randDigits(d1); | |
| a = b * quotient; | |
| } | |
| state.a = a; | |
| state.b = b; | |
| state.checked = false; | |
| switch (state.op) { | |
| case 'add': state.answer = a + b; break; | |
| case 'sub': state.answer = a - b; break; | |
| case 'mul': state.answer = a * b; break; | |
| case 'div': state.answer = a / b; break; | |
| } | |
| const opSymbols = { add: '+', sub: '−', mul: '×', div: '÷' }; | |
| document.getElementById('problem').innerHTML = | |
| `${a} <span class="op-sym">${opSymbols[state.op]}</span> ${b}`; | |
| document.getElementById('answer').value = ''; | |
| document.getElementById('feedback').textContent = ''; | |
| document.getElementById('feedback').className = 'feedback'; | |
| document.getElementById('explanation').style.display = 'none'; | |
| document.getElementById('answer').focus(); | |
| } | |
| function checkAnswer() { | |
| const input = document.getElementById('answer').value.trim(); | |
| if (input === '') return; | |
| const userAns = parseFloat(input); | |
| const fb = document.getElementById('feedback'); | |
| if (!state.checked) { total++; } | |
| if (Math.abs(userAns - state.answer) < 0.0001) { | |
| if (!state.checked) score++; | |
| fb.textContent = 'Correct!'; | |
| fb.className = 'feedback correct'; | |
| } else { | |
| fb.textContent = `Not quite — try again or click "Show How"`; | |
| fb.className = 'feedback wrong'; | |
| } | |
| state.checked = true; | |
| document.getElementById('score').textContent = score; | |
| document.getElementById('total').textContent = total; | |
| } | |
| // Enter key submits | |
| document.getElementById('answer').addEventListener('keydown', e => { | |
| if (e.key === 'Enter') checkAnswer(); | |
| }); | |
| // --- Explanation generators --- | |
| function showExplanation() { | |
| const el = document.getElementById('explanation'); | |
| let html = ''; | |
| switch (state.op) { | |
| case 'add': html = explainAdd(state.a, state.b); break; | |
| case 'sub': html = explainSub(state.a, state.b); break; | |
| case 'mul': html = explainMul(state.a, state.b); break; | |
| case 'div': html = explainDiv(state.a, state.b); break; | |
| } | |
| el.innerHTML = html; | |
| el.style.display = 'block'; | |
| // Also reveal correct answer | |
| const fb = document.getElementById('feedback'); | |
| fb.textContent = `Answer: ${state.answer}`; | |
| fb.className = 'feedback correct'; | |
| if (!state.checked) { total++; state.checked = true; } | |
| document.getElementById('total').textContent = total; | |
| } | |
| function pad(s, n) { return s.toString().padStart(n, ' '); } | |
| function explainAdd(a, b) { | |
| const sum = a + b; | |
| const w = Math.max(a.toString().length, b.toString().length, sum.toString().length) + 2; | |
| // Column-by-column carry explanation | |
| const sa = a.toString(), sb = b.toString(); | |
| const maxLen = Math.max(sa.length, sb.length); | |
| const pa = sa.padStart(maxLen, '0'); | |
| const pb = sb.padStart(maxLen, '0'); | |
| let steps = []; | |
| let carry = 0; | |
| for (let i = maxLen - 1; i >= 0; i--) { | |
| const da = parseInt(pa[i]), db = parseInt(pb[i]); | |
| const col = da + db + carry; | |
| const newCarry = Math.floor(col / 10); | |
| const digit = col % 10; | |
| const pos = maxLen - i; | |
| let note = `Column ${pos} (from right): ${da} + ${db}`; | |
| if (carry) note += ` + ${carry} (carry)`; | |
| note += ` = ${col}`; | |
| if (newCarry) note += ` → write ${digit}, carry ${newCarry}`; | |
| steps.push(note); | |
| carry = newCarry; | |
| } | |
| if (carry) steps.push(`Carry the ${carry} to the front`); | |
| let out = `<h3>Column Addition</h3>`; | |
| out += `<div class="step">${pad(a, w)}</div>`; | |
| out += `<div class="step">+${pad(b, w - 1)}</div>`; | |
| out += `<div class="step">${'─'.repeat(w)}</div>`; | |
| out += `<div class="step">${pad(sum, w)}</div>\n`; | |
| steps.forEach(s => { out += `<div class="note">${s}</div>`; }); | |
| return out; | |
| } | |
| function explainSub(a, b) { | |
| const diff = a - b; | |
| const w = Math.max(a.toString().length, b.toString().length, diff.toString().length) + 2; | |
| const sa = a.toString(), sb = b.toString(); | |
| const maxLen = Math.max(sa.length, sb.length); | |
| const pa = sa.padStart(maxLen, '0'); | |
| const pb = sb.padStart(maxLen, '0'); | |
| let steps = []; | |
| let borrow = 0; | |
| for (let i = maxLen - 1; i >= 0; i--) { | |
| let da = parseInt(pa[i]), db = parseInt(pb[i]) + borrow; | |
| const pos = maxLen - i; | |
| let note = `Column ${pos}: ${parseInt(pa[i])}`; | |
| if (borrow) note += ` (after borrowing: ${da} )`; | |
| note += ` − ${parseInt(pb[i])}`; | |
| if (borrow) note += ` (+ ${borrow} borrow = ${db})`; | |
| if (da < db) { | |
| note += ` → ${da} is less than ${db}, so borrow 10 → ${da+10} − ${db} = ${da + 10 - db}`; | |
| borrow = 1; | |
| } else { | |
| note += ` = ${da - db}`; | |
| borrow = 0; | |
| } | |
| steps.push(note); | |
| } | |
| let out = `<h3>Column Subtraction</h3>`; | |
| out += `<div class="step">${pad(a, w)}</div>`; | |
| out += `<div class="step">−${pad(b, w - 1)}</div>`; | |
| out += `<div class="step">${'─'.repeat(w)}</div>`; | |
| out += `<div class="step">${pad(diff, w)}</div>\n`; | |
| steps.forEach(s => { out += `<div class="note">${s}</div>`; }); | |
| return out; | |
| } | |
| function explainMul(a, b) { | |
| const product = a * b; | |
| // Use the smaller number as the multiplier for partial products | |
| let top = a, bottom = b; | |
| if (b.toString().length > a.toString().length) { top = b; bottom = a; } | |
| const sb = bottom.toString(); | |
| const w = Math.max(top.toString().length + sb.length + 2, product.toString().length + 2); | |
| let out = `<h3>Long Multiplication</h3>`; | |
| out += `<div class="step">${pad(top, w)}</div>`; | |
| out += `<div class="step">×${pad(bottom, w - 1)}</div>`; | |
| out += `<div class="step">${'─'.repeat(w)}</div>`; | |
| let partials = []; | |
| for (let i = sb.length - 1; i >= 0; i--) { | |
| const d = parseInt(sb[i]); | |
| const placeVal = Math.pow(10, sb.length - 1 - i); | |
| const partial = top * d; | |
| const shifted = partial * placeVal; | |
| partials.push({ digit: d, partial, shifted, placeVal, placeIdx: sb.length - 1 - i }); | |
| const label = placeVal > 1 ? ` (${d} × ${top} = ${partial}, shifted ${sb.length-1-i} place${sb.length-1-i>1?'s':''})` : ` (${d} × ${top})`; | |
| out += `<div class="step">${pad(shifted, w)}</div>`; | |
| out += `<div class="note">${label}</div>`; | |
| } | |
| if (partials.length > 1) { | |
| out += `<div class="step">${'─'.repeat(w)}</div>`; | |
| out += `<div class="step">${pad(product, w)}</div>`; | |
| out += `<div class="note">Add the partial products: ${partials.map(p => p.shifted).join(' + ')} = ${product}</div>`; | |
| } | |
| return out; | |
| } | |
| function explainDiv(a, b) { | |
| const quotient = Math.floor(a / b); | |
| const sq = quotient.toString(); | |
| const sa = a.toString(); | |
| let out = `<h3>Long Division</h3>`; | |
| // Draw the bracket | |
| const totalW = sa.length + 4; | |
| out += `<div class="step"> ${sq}</div>`; | |
| out += `<div class="step"> ${'─'.repeat(sa.length + 1)}</div>`; | |
| out += `<div class="step"> ${b} │ ${a}</div>`; | |
| // Step through long division | |
| let remainder = 0; | |
| let steps = []; | |
| for (let i = 0; i < sa.length; i++) { | |
| const bring = parseInt(sa[i]); | |
| const current = remainder * 10 + bring; | |
| const d = Math.floor(current / b); | |
| const sub = d * b; | |
| const newRem = current - sub; | |
| let note = ''; | |
| if (i === 0) { | |
| note = `Bring down ${bring} → ${current}. How many times does ${b} go into ${current}? ${d} time${d!==1?'s':''}. ${d} × ${b} = ${sub}. Remainder: ${current} − ${sub} = ${newRem}`; | |
| } else { | |
| note = `Bring down ${bring} → ${current}. ${b} goes into ${current} ${d} time${d!==1?'s':''}. ${d} × ${b} = ${sub}. Remainder: ${current} − ${sub} = ${newRem}`; | |
| } | |
| steps.push(note); | |
| remainder = newRem; | |
| } | |
| out += `\n`; | |
| steps.forEach((s, i) => { | |
| out += `<div class="note">Step ${i + 1}: ${s}</div>`; | |
| }); | |
| out += `\n<div class="note">Result: ${a} ÷ ${b} = ${quotient}</div>`; | |
| return out; | |
| } | |
| // Init | |
| updateSizeHint(); | |
| newProblem(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment