+ )
+}
diff --git a/src/manipulatives/balance-scale-equations.jsx b/src/manipulatives/balance-scale-equations.jsx
new file mode 100644
index 0000000..4e22d27
--- /dev/null
+++ b/src/manipulatives/balance-scale-equations.jsx
@@ -0,0 +1,413 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { cream, ink, muted, border, purple as xPurple, blue as unitBlue, green as balancedGreen, orange as offRed } from './shared/palette'
+import { useCanvasBox } from './shared/useCanvasBox'
+import { skipMotion } from './shared/motion'
+import GhostButton from './shared/GhostButton'
+
+const beamWood = '#B07A3C'
+
+// ax + b = cx + d, solvable by removing equal tiles (|a - c| = 1).
+const PROBLEMS = [
+ { left: { x: 1, u: 3 }, right: { x: 0, u: 5 } }, // x + 3 = 5 -> x = 2
+ { left: { x: 1, u: 5 }, right: { x: 0, u: 8 } }, // x + 5 = 8 -> x = 3
+ { left: { x: 2, u: 1 }, right: { x: 1, u: 4 } }, // 2x + 1 = x + 4 -> x = 3
+ { left: { x: 2, u: 3 }, right: { x: 1, u: 7 } }, // 2x + 3 = x + 7 -> x = 4
+]
+
+const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v))
+
+let tileSeq = 0
+const buildTiles = ({ x, u }) => [
+ ...Array.from({ length: x }, () => ({ id: (tileSeq += 1), type: 'x' })),
+ ...Array.from({ length: u }, () => ({ id: (tileSeq += 1), type: 'unit' })),
+]
+
+const countType = (tiles, type) => tiles.filter((t) => t.type === type).length
+
+function sideLabel(xc, uc) {
+ const parts = []
+ if (xc === 1) parts.push('x')
+ else if (xc > 1) parts.push(`${xc}x`)
+ if (uc > 0 || parts.length === 0) parts.push(String(uc))
+ return parts.join(' + ')
+}
+
+export default function BalanceScaleEquations() {
+ const canvasRef = useRef(null)
+ const wrapRef = useRef(null)
+ const { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 420 })
+ const [problemIndex, setProblemIndex] = useState(0)
+ const [left, setLeft] = useState(() => buildTiles(PROBLEMS[0].left))
+ const [right, setRight] = useState(() => buildTiles(PROBLEMS[0].right))
+ const [drag, setDrag] = useState(null) // { side, id, x, y, offX, offY }
+
+ const canvasHeight = 360
+
+ const leftX = countType(left, 'x')
+ const leftU = countType(left, 'unit')
+ const rightX = countType(right, 'x')
+ const rightU = countType(right, 'unit')
+ // x is whatever value makes the equation currently on the pans true, so any
+ // equation the teacher builds works — nothing is hardcoded to a preset.
+ // If both sides hold the same number of x's there is no unique x; a nominal 1
+ // then correctly shows an identity as level and a no-solution as permanently tipped.
+ const denom = leftX - rightX
+ const solution = denom !== 0 ? (rightU - leftU) / denom : 1
+ const leftW = leftX * solution + leftU
+ const rightW = rightX * solution + rightU
+ const balanced = leftW === rightW
+ const solved =
+ balanced &&
+ ((leftX === 1 && leftU === 0 && rightX === 0 && rightU >= 1) ||
+ (rightX === 1 && rightU === 0 && leftX === 0 && leftU >= 1))
+ const answer = solved ? (leftX === 1 ? rightU : leftU) : null
+
+ const addTile = (side, type) => {
+ const tile = { id: (tileSeq += 1), type }
+ if (side === 'left') setLeft((prev) => [...prev, tile])
+ else setRight((prev) => [...prev, tile])
+ }
+
+ const clearAll = () => {
+ setLeft([])
+ setRight([])
+ setDrag(null)
+ }
+
+ const loadProblem = (index) => {
+ setProblemIndex(index)
+ setLeft(buildTiles(PROBLEMS[index].left))
+ setRight(buildTiles(PROBLEMS[index].right))
+ setDrag(null)
+ }
+
+ const geo = useMemo(() => {
+ const W = canvasWidth
+ const cx = W / 2
+ const pivotY = 92
+ const L = Math.min(W * 0.33, 240)
+ const stringLen = 66
+ const panW = Math.min(L * 1.15, 190)
+ const baseY = 300
+ return { W, cx, pivotY, L, stringLen, panW, baseY }
+ }, [canvasWidth])
+
+ const targetAngle = clamp((leftW - rightW) * 0.03, -0.19, 0.19)
+ const angleRef = useRef(targetAngle)
+ const rafRef = useRef(null)
+
+ // Positions of every tile (used for drawing and hit-testing).
+ const layout = useMemo(() => {
+ const { cx, pivotY, L, stringLen, panW } = geo
+ const size = 26
+ const gap = 5
+ const perRow = Math.max(1, Math.floor(panW / (size + gap)))
+
+ const place = (tiles, angle, dir) => {
+ const endX = cx + dir * L * Math.cos(angle)
+ const endY = pivotY - dir * L * Math.sin(angle)
+ const panCX = endX
+ const panTopY = endY + stringLen
+ const positions = tiles.map((t, i) => {
+ const row = Math.floor(i / perRow)
+ const col = i % perRow
+ const rowCount = Math.min(perRow, tiles.length - row * perRow)
+ const rowW = rowCount * (size + gap) - gap
+ return {
+ ...t,
+ x: panCX - rowW / 2 + col * (size + gap) + size / 2,
+ y: panTopY - size / 2 - 6 - row * (size + gap),
+ size,
+ }
+ })
+ return { endX, endY, panCX, panTopY, positions }
+ }
+
+ return { size, place }
+ }, [geo])
+
+ const draw = useCallback(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+ const dpr = window.devicePixelRatio || 1
+ const cw = Math.round(canvasWidth * dpr)
+ const ch = Math.round(canvasHeight * dpr)
+ if (canvas.width !== cw || canvas.height !== ch) {
+ canvas.width = cw
+ canvas.height = ch
+ }
+ const ctx = canvas.getContext('2d')
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight)
+
+ const { cx, pivotY, baseY } = geo
+ const angle = angleRef.current
+
+ // Fulcrum (triangle) + stand.
+ ctx.fillStyle = '#9AA0AA'
+ ctx.beginPath()
+ ctx.moveTo(cx, pivotY)
+ ctx.lineTo(cx - 26, baseY)
+ ctx.lineTo(cx + 26, baseY)
+ ctx.closePath()
+ ctx.fill()
+ ctx.fillStyle = '#7C818C'
+ ctx.fillRect(cx - 46, baseY, 92, 8)
+
+ const leftInfo = layout.place(left, angle, -1)
+ const rightInfo = layout.place(right, angle, 1)
+
+ // Beam.
+ ctx.strokeStyle = beamWood
+ ctx.lineWidth = 9
+ ctx.lineCap = 'round'
+ ctx.beginPath()
+ ctx.moveTo(leftInfo.endX, leftInfo.endY)
+ ctx.lineTo(rightInfo.endX, rightInfo.endY)
+ ctx.stroke()
+ // Pivot cap.
+ ctx.fillStyle = ink
+ ctx.beginPath()
+ ctx.arc(cx, pivotY, 6, 0, Math.PI * 2)
+ ctx.fill()
+
+ // Strings + pans.
+ const drawPan = (info) => {
+ const hw = geo.panW / 2
+ // Hanging string.
+ ctx.strokeStyle = '#C9C3B6'
+ ctx.lineWidth = 1.5
+ ctx.beginPath()
+ ctx.moveTo(info.endX, info.endY)
+ ctx.lineTo(info.panCX, info.panTopY - 2)
+ ctx.stroke()
+ // Shallow bowl the tiles rest in.
+ ctx.strokeStyle = '#8A8478'
+ ctx.lineWidth = 4
+ ctx.lineCap = 'round'
+ ctx.beginPath()
+ ctx.moveTo(info.panCX - hw, info.panTopY - 2)
+ ctx.quadraticCurveTo(info.panCX, info.panTopY + 20, info.panCX + hw, info.panTopY - 2)
+ ctx.stroke()
+ }
+ drawPan(leftInfo)
+ drawPan(rightInfo)
+
+ // Tiles.
+ const dragId = drag?.id
+ const drawTile = (t) => {
+ const isX = t.type === 'x'
+ ctx.fillStyle = isX ? xPurple : unitBlue
+ ctx.strokeStyle = '#ffffff'
+ ctx.lineWidth = 2
+ roundRect(ctx, t.x - t.size / 2, t.y - t.size / 2, t.size, t.size, 6)
+ ctx.fill()
+ ctx.stroke()
+ ctx.fillStyle = '#ffffff'
+ ctx.font = `900 ${isX ? 15 : 12}px Inter, system-ui, sans-serif`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(isX ? 'x' : '1', t.x, t.y + 0.5)
+ }
+ ;[...leftInfo.positions, ...rightInfo.positions].forEach((t) => {
+ if (t.id === dragId) return
+ drawTile(t)
+ })
+ if (drag) {
+ drawTile({ x: drag.x, y: drag.y, size: layout.size, type: drag.type })
+ }
+ }, [canvasWidth, geo, layout, left, right, drag])
+
+ // Redraw on any state change (keeps the dragged tile following the pointer).
+ const drawRef = useRef(draw)
+ useEffect(() => {
+ drawRef.current = draw
+ draw()
+ }, [draw])
+
+ // Ease the tilt toward its target. Depends only on targetAngle, so a drag
+ // (which changes `draw` every frame) never restarts this loop — no shaking.
+ useEffect(() => {
+ if (rafRef.current) cancelAnimationFrame(rafRef.current)
+ // The beam still ends up at the right tilt, it just gets there at once.
+ if (skipMotion()) {
+ angleRef.current = targetAngle
+ drawRef.current()
+ return undefined
+ }
+ const tick = () => {
+ const cur = angleRef.current
+ const next = cur + (targetAngle - cur) * 0.2
+ angleRef.current = Math.abs(next - targetAngle) < 0.0006 ? targetAngle : next
+ drawRef.current()
+ if (angleRef.current !== targetAngle) rafRef.current = requestAnimationFrame(tick)
+ }
+ rafRef.current = requestAnimationFrame(tick)
+ return () => {
+ if (rafRef.current) cancelAnimationFrame(rafRef.current)
+ }
+ }, [targetAngle])
+
+ const getPoint = (event) => {
+ const rect = event.currentTarget.getBoundingClientRect()
+ return {
+ x: (event.clientX - rect.left) * (canvasWidth / rect.width),
+ y: (event.clientY - rect.top) * (canvasHeight / rect.height),
+ }
+ }
+
+ const handlePointerDown = (event) => {
+ const pt = getPoint(event)
+ const angle = angleRef.current
+ const leftInfo = layout.place(left, angle, -1)
+ const rightInfo = layout.place(right, angle, 1)
+ const all = [
+ ...leftInfo.positions.map((t) => ({ ...t, side: 'left' })),
+ ...rightInfo.positions.map((t) => ({ ...t, side: 'right' })),
+ ]
+ for (let i = all.length - 1; i >= 0; i -= 1) {
+ const t = all[i]
+ if (Math.abs(pt.x - t.x) <= t.size / 2 + 3 && Math.abs(pt.y - t.y) <= t.size / 2 + 3) {
+ event.currentTarget.setPointerCapture(event.pointerId)
+ setDrag({ side: t.side, id: t.id, type: t.type, x: t.x, y: t.y, offX: pt.x - t.x, offY: pt.y - t.y, homeX: t.x, homeY: t.y })
+ return
+ }
+ }
+ }
+
+ const handlePointerMove = (event) => {
+ if (!drag) return
+ const pt = getPoint(event)
+ setDrag((d) => (d ? { ...d, x: pt.x - d.offX, y: pt.y - d.offY } : d))
+ }
+
+ const handlePointerUp = () => {
+ if (!drag) return
+ // Removed if dragged well away from where it started (off the pan).
+ const movedOff = Math.hypot(drag.x - drag.homeX, drag.y - drag.homeY) > 46
+ if (movedOff) {
+ if (drag.side === 'left') setLeft((ts) => ts.filter((t) => t.id !== drag.id))
+ else setRight((ts) => ts.filter((t) => t.id !== drag.id))
+ }
+ setDrag(null)
+ }
+
+ const isEmpty = left.length === 0 && right.length === 0
+ const noUniqueX = denom === 0 && !isEmpty
+ const message = isEmpty
+ ? 'Add x and 1 tiles to either pan to build an equation.'
+ : solved
+ ? `Solved! x = ${answer}`
+ : noUniqueX
+ ? leftU === rightU
+ ? 'Both sides are identical — this is true for every value of x.'
+ : 'These sides can never balance — no value of x makes this true.'
+ : !balanced
+ ? 'Unbalanced! Take the same tile off the OTHER side to level it again.'
+ : 'Balanced ✓ Drag matching tiles off both sides until one x sits alone.'
+
+ const leftEq = sideLabel(leftX, leftU)
+ const rightEq = sideLabel(rightX, rightU)
+
+ // The canvas is the whole point of this manipulative, so it needs a text
+ // equivalent — otherwise a screen-reader user gets an unlabelled rectangle.
+ const summary = isEmpty
+ ? 'Empty balance scale. No tiles on either pan.'
+ : `Balance scale: left pan holds ${leftEq}, right pan holds ${rightEq}. ${
+ solved
+ ? `Solved — x equals ${answer}.`
+ : noUniqueX
+ ? leftU === rightU
+ ? 'Both sides are identical, true for every value of x.'
+ : 'These sides can never balance, no value of x works.'
+ : balanced
+ ? 'The scale is balanced.'
+ : 'The scale is tipped — the two sides are not equal.'
+ }`
+
+ return (
+
+ {/* Live equation */}
+
+ {leftEq}
+ =
+ {rightEq}
+
+
+
+
+
+
+
+ {message}
+
+
+
+ {/* Build any equation: add tiles to either pan. */}
+ {['left', 'right'].map((side) => (
+
+ {side}
+
+
+
+ ))}
+
+ loadProblem(problemIndex)} ariaLabel="Reset to the current problem's starting tiles">
+ Reset
+
+
+ Empty pans
+
+
+
+ )
+}
+
+function roundRect(ctx, x, y, w, h, r) {
+ ctx.beginPath()
+ ctx.moveTo(x + r, y)
+ ctx.arcTo(x + w, y, x + w, y + h, r)
+ ctx.arcTo(x + w, y + h, x, y + h, r)
+ ctx.arcTo(x, y + h, x, y, r)
+ ctx.arcTo(x, y, x + w, y, r)
+ ctx.closePath()
+}
diff --git a/src/manipulatives/index.js b/src/manipulatives/index.js
index fec55c1..682efed 100644
--- a/src/manipulatives/index.js
+++ b/src/manipulatives/index.js
@@ -1,11 +1,71 @@
+import AlgebraTiles from './algebra-tiles.jsx'
+import BalanceScaleEquations from './balance-scale-equations.jsx'
import CoordinateTreasureMap from './coordinate-treasure-map.jsx'
import FactorTree from './factor-tree.jsx'
+import InequalitiesNumberLine from './inequalities-number-line.jsx'
+import MeanAbsoluteDeviation from './mean-absolute-deviation.jsx'
import MeanBalancePoint from './mean-balance-point.jsx'
+import MixedNumbersImproper from './mixed-numbers-improper.jsx'
+import ProbabilityScale from './probability-scale.jsx'
+import SampleSpaceTree from './sample-space-tree.jsx'
+import MultiplyingFractionsArea from './multiplying-fractions-area.jsx'
import NumberLineExplorer from './number-line-explorer.jsx'
import ParallelogramArea from './parallelogram-area.jsx'
+import PizzaRemainder from './pizza-remainder.jsx'
+import RoundingNumberLine from './rounding-number-line.jsx'
import TwoFactorTrees from './two-factor-trees.jsx'
export const manipulatives = [
+ {
+ id: 'pizza-remainder',
+ name: 'Pizza Remainder',
+ component: PizzaRemainder,
+ },
+ {
+ id: 'multiplying-fractions-area',
+ name: 'Multiplying Fractions (Area)',
+ component: MultiplyingFractionsArea,
+ },
+ {
+ id: 'balance-scale-equations',
+ name: 'Balance Scale Equations',
+ component: BalanceScaleEquations,
+ },
+ {
+ id: 'rounding-number-line',
+ name: 'Rounding on a Number Line',
+ component: RoundingNumberLine,
+ },
+ {
+ id: 'mixed-numbers-improper',
+ name: 'Mixed Numbers & Improper Fractions',
+ component: MixedNumbersImproper,
+ },
+ {
+ id: 'inequalities-number-line',
+ name: 'Inequalities on a Number Line',
+ component: InequalitiesNumberLine,
+ },
+ {
+ id: 'algebra-tiles',
+ name: 'Algebra Tiles',
+ component: AlgebraTiles,
+ },
+ {
+ id: 'probability-scale',
+ name: 'Probability Scale Explorer',
+ component: ProbabilityScale,
+ },
+ {
+ id: 'sample-space-tree',
+ name: 'Sample Space & Tree Diagrams',
+ component: SampleSpaceTree,
+ },
+ {
+ id: 'mean-absolute-deviation',
+ name: 'Mean Absolute Deviation',
+ component: MeanAbsoluteDeviation,
+ },
{
id: 'coordinate-treasure-map',
name: 'Coordinate Treasure Map',
diff --git a/src/manipulatives/inequalities-number-line.jsx b/src/manipulatives/inequalities-number-line.jsx
new file mode 100644
index 0000000..aab12c5
--- /dev/null
+++ b/src/manipulatives/inequalities-number-line.jsx
@@ -0,0 +1,256 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { cream, ink, muted, border, green as solGreen, purple as boundPurple, blue as testBlue, red as noRed } from './shared/palette'
+import { useCanvasBox } from './shared/useCanvasBox'
+import ToggleChip from './shared/ToggleChip'
+import Stepper from './shared/Stepper'
+
+const axisColor = ink
+
+const MIN = -10
+const MAX = 10
+const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v))
+
+const OPS = [
+ { key: 'lt', sym: '<', inclusive: false, right: false, label: 'Less than' },
+ { key: 'le', sym: '≤', inclusive: true, right: false, label: 'Less than or equal to' },
+ { key: 'gt', sym: '>', inclusive: false, right: true, label: 'Greater than' },
+ { key: 'ge', sym: '≥', inclusive: true, right: true, label: 'Greater than or equal to' },
+]
+
+export default function InequalitiesNumberLine() {
+ const canvasRef = useRef(null)
+ const wrapRef = useRef(null)
+ const { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 420 })
+ const [opKey, setOpKey] = useState('gt')
+ const [bound, setBound] = useState(3)
+ const [test, setTest] = useState(6)
+ // Layered reveals: the teacher peels back one idea at a time.
+ const [show, setShow] = useState({ ray: true, check: true })
+ const toggle = (k) => setShow((s) => ({ ...s, [k]: !s[k] }))
+ const dragRef = useRef(null) // 'bound' | 'test'
+
+ const canvasHeight = 260
+ const op = OPS.find((o) => o.key === opKey)
+
+ const testOk = op.right ? (op.inclusive ? test >= bound : test > bound) : op.inclusive ? test <= bound : test < bound
+
+ // The canvas is the whole point of this manipulative, so it needs a text
+ // equivalent — otherwise a screen-reader user gets an unlabelled rectangle.
+ const summary =
+ `Number line from ${MIN} to ${MAX} showing x ${op.sym} ${bound}, meaning x is ${op.label.toLowerCase()} ${bound}. ` +
+ `The boundary at ${bound} is ${op.inclusive ? 'a closed dot, so it is included' : 'an open dot, so it is not included'}.` +
+ `${show.ray ? ` The solution ray is shaded ${op.right ? 'to the right' : 'to the left'} of the boundary.` : ''} ` +
+ `The test point at ${test} ${testOk ? 'satisfies' : 'does not satisfy'} the inequality${show.check ? ` and is marked with a ${testOk ? 'check' : 'cross'}` : ' (the check mark is hidden)'}.`
+
+ const geo = useMemo(() => {
+ const PAD = 54
+ const axisY = canvasHeight * 0.58
+ const spanX = canvasWidth - PAD * 2
+ const xFor = (v) => PAD + ((v - MIN) / (MAX - MIN)) * spanX
+ const vFor = (x) => clamp(Math.round(((x - PAD) / spanX) * (MAX - MIN) + MIN), MIN, MAX)
+ return { PAD, axisY, spanX, xFor, vFor }
+ }, [canvasWidth])
+
+ const draw = useCallback(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+ const dpr = window.devicePixelRatio || 1
+ const cw = Math.round(canvasWidth * dpr)
+ const ch = Math.round(canvasHeight * dpr)
+ if (canvas.width !== cw || canvas.height !== ch) {
+ canvas.width = cw
+ canvas.height = ch
+ }
+ const ctx = canvas.getContext('2d')
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight)
+
+ const { PAD, axisY, xFor } = geo
+ const bx = xFor(bound)
+
+ // Axis + arrowheads.
+ ctx.strokeStyle = axisColor
+ ctx.fillStyle = axisColor
+ ctx.lineWidth = 2.5
+ ctx.beginPath()
+ ctx.moveTo(PAD - 14, axisY)
+ ctx.lineTo(canvasWidth - PAD + 14, axisY)
+ ctx.stroke()
+ ;[[PAD - 14, -1], [canvasWidth - PAD + 14, 1]].forEach(([x, dir]) => {
+ ctx.beginPath()
+ ctx.moveTo(x, axisY)
+ ctx.lineTo(x - dir * 9, axisY - 5)
+ ctx.lineTo(x - dir * 9, axisY + 5)
+ ctx.closePath()
+ ctx.fill()
+ })
+
+ // Ticks + labels.
+ for (let v = MIN; v <= MAX; v += 1) {
+ const x = xFor(v)
+ ctx.strokeStyle = '#B9BDC6'
+ ctx.lineWidth = 1
+ ctx.beginPath()
+ ctx.moveTo(x, axisY - 6)
+ ctx.lineTo(x, axisY + 6)
+ ctx.stroke()
+ if (v % 2 === 0) {
+ ctx.fillStyle = '#8B8F99'
+ ctx.font = '600 12px Inter, system-ui, sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'top'
+ ctx.fillText(String(v), x, axisY + 12)
+ }
+ }
+
+ // Solution ray (shaded direction) + arrowhead.
+ if (show.ray) {
+ const endX = op.right ? canvasWidth - PAD + 8 : PAD - 8
+ ctx.strokeStyle = solGreen
+ ctx.lineWidth = 6
+ ctx.lineCap = 'round'
+ ctx.beginPath()
+ ctx.moveTo(bx, axisY)
+ ctx.lineTo(endX, axisY)
+ ctx.stroke()
+ const dir = op.right ? 1 : -1
+ ctx.fillStyle = solGreen
+ ctx.beginPath()
+ ctx.moveTo(endX + dir * 8, axisY)
+ ctx.lineTo(endX - dir * 6, axisY - 8)
+ ctx.lineTo(endX - dir * 6, axisY + 8)
+ ctx.closePath()
+ ctx.fill()
+ }
+
+ // Boundary dot: open (strict) or closed (inclusive).
+ ctx.lineWidth = 3.5
+ ctx.strokeStyle = boundPurple
+ ctx.beginPath()
+ ctx.arc(bx, axisY, 9, 0, Math.PI * 2)
+ if (op.inclusive) {
+ ctx.fillStyle = boundPurple
+ ctx.fill()
+ } else {
+ ctx.fillStyle = '#ffffff'
+ ctx.fill()
+ ctx.stroke()
+ }
+ ctx.fillStyle = boundPurple
+ ctx.font = '900 15px Inter, system-ui, sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'bottom'
+ ctx.fillText(String(bound), bx, axisY - 16)
+
+ // Test point above the line with a check / cross.
+ const tx = xFor(test)
+ const ty = axisY - 46
+ ctx.strokeStyle = testBlue
+ ctx.setLineDash([3, 4])
+ ctx.lineWidth = 1.5
+ ctx.beginPath()
+ ctx.moveTo(tx, ty + 10)
+ ctx.lineTo(tx, axisY - 2)
+ ctx.stroke()
+ ctx.setLineDash([])
+ ctx.fillStyle = testBlue
+ ctx.beginPath()
+ ctx.arc(tx, ty, 13, 0, Math.PI * 2)
+ ctx.fill()
+ ctx.fillStyle = '#ffffff'
+ ctx.font = '900 12px Inter, system-ui, sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(String(test), tx, ty)
+ // verdict badge (hideable, so the teacher can ask "is this a solution?")
+ if (show.check) {
+ ctx.fillStyle = testOk ? solGreen : noRed
+ ctx.font = '900 18px Inter, system-ui, sans-serif'
+ ctx.fillText(testOk ? '✓' : '✗', tx + 22, ty)
+ }
+ }, [canvasWidth, geo, op, bound, test, testOk, show])
+
+ useEffect(() => {
+ draw()
+ }, [draw])
+
+ const getPoint = (event) => {
+ const rect = event.currentTarget.getBoundingClientRect()
+ return {
+ x: (event.clientX - rect.left) * (canvasWidth / rect.width),
+ y: (event.clientY - rect.top) * (canvasHeight / rect.height),
+ }
+ }
+ const handlePointerDown = (event) => {
+ const pt = getPoint(event)
+ const bx = geo.xFor(bound)
+ const tx = geo.xFor(test)
+ const ty = geo.axisY - 46
+ event.currentTarget.setPointerCapture(event.pointerId)
+ if (Math.hypot(pt.x - tx, pt.y - ty) <= 20) {
+ dragRef.current = 'test'
+ setTest(geo.vFor(pt.x))
+ } else if (Math.abs(pt.x - bx) <= 18 || Math.abs(pt.y - geo.axisY) <= 22) {
+ dragRef.current = 'bound'
+ setBound(geo.vFor(pt.x))
+ }
+ }
+ const handlePointerMove = (event) => {
+ if (!dragRef.current) return
+ const v = geo.vFor(getPoint(event).x)
+ if (dragRef.current === 'test') setTest(v)
+ else setBound(v)
+ }
+ const handlePointerUp = () => {
+ dragRef.current = null
+ }
+
+ return (
+
+
+ x
+ {op.sym}
+ {bound}
+
+
+
+
+
+
+
+ The {op.inclusive ? 'closed' : 'open'} dot shows {bound} is {op.inclusive ? 'included' : 'not included'}. Drag the blue test number — the green ray is every value that works.
+
+ The tree lists every combined outcome exactly once — count them by multiplying the branches. Click outcomes to pick an event.
+
+
+
+
+ {/* Any experiment: type the outcomes for each stage. */}
+
+
+ setSelected(new Set())}>
+ Clear selection
+
+ toggle('count')} />
+ toggle('prob')} />
+
+
+ )
+}
diff --git a/src/manipulatives/shared/GhostButton.jsx b/src/manipulatives/shared/GhostButton.jsx
new file mode 100644
index 0000000..311f2f7
--- /dev/null
+++ b/src/manipulatives/shared/GhostButton.jsx
@@ -0,0 +1,18 @@
+import { border, muted } from './palette'
+
+// The neutral pill used for presets, resets and "clear" across the set.
+// Deliberately quiet: these are the controls that set a scene up, not the
+// ones a student is meant to reach for while thinking.
+export default function GhostButton({ children, onClick, ariaLabel, compact = false }) {
+ return (
+
+ )
+}
diff --git a/src/manipulatives/shared/Stepper.jsx b/src/manipulatives/shared/Stepper.jsx
new file mode 100644
index 0000000..94aef64
--- /dev/null
+++ b/src/manipulatives/shared/Stepper.jsx
@@ -0,0 +1,47 @@
+import { border, green, orange } from './palette'
+
+// −/value/+ control. Was written three times with cosmetic drift between the
+// copies (different column widths, `color` vs `accent`, different font sizes).
+//
+// decLabel/incLabel exist because the right screen-reader wording depends on
+// what is being counted: "More pizzas" reads naturally, "Increase pizzas"
+// does not, and for an abstract boundary value it is the other way round.
+export default function Stepper({ label, value, color, onDec, onInc, decLabel, incLabel }) {
+ return (
+
+
+ {label}
+
+
+
+
+ {value}
+
+
+
+
+ )
+}
diff --git a/src/manipulatives/shared/ToggleChip.jsx b/src/manipulatives/shared/ToggleChip.jsx
new file mode 100644
index 0000000..4e96750
--- /dev/null
+++ b/src/manipulatives/shared/ToggleChip.jsx
@@ -0,0 +1,26 @@
+import { border, hairline, muted } from './palette'
+
+// One reveal layer.
+//
+// The dot carries the layer's own colour, so the control and the thing it
+// reveals read as the same idea. A teacher peels ideas back one at a time
+// instead of flipping a single all-or-nothing "show answer".
+//
+// aria-pressed (not just visual colour) is what tells a screen reader this is
+// a two-state control rather than a button that does something.
+export default function ToggleChip({ label, color, on, onClick, compact = false }) {
+ return (
+
+ )
+}
diff --git a/src/manipulatives/shared/motion.js b/src/manipulatives/shared/motion.js
new file mode 100644
index 0000000..485d1d8
--- /dev/null
+++ b/src/manipulatives/shared/motion.js
@@ -0,0 +1,15 @@
+// Should this frame animate, or just arrive?
+//
+// Two reasons to skip the motion and jump straight to the end state:
+//
+// - the viewer asked for reduced motion, and a manipulative that slides
+// things around is exactly what that setting is for;
+// - the document is hidden, where requestAnimationFrame does not fire at
+// all. Animating a tab nobody is looking at buys nothing, and easing must
+// never be the only path to a correct frame — otherwise a manipulative
+// built while hidden is still mid-flight when it is first shown.
+export function skipMotion() {
+ if (typeof window === 'undefined') return true
+ if (typeof document !== 'undefined' && document.hidden) return true
+ return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
+}
diff --git a/src/manipulatives/shared/palette.js b/src/manipulatives/shared/palette.js
new file mode 100644
index 0000000..8235e0c
--- /dev/null
+++ b/src/manipulatives/shared/palette.js
@@ -0,0 +1,29 @@
+// Shared palette for the mjones manipulatives.
+//
+// The three neutrals were declared identically in all ten files. The accents
+// were too, but under a different name each time (solGreen, meanGreen, posX,
+// roundGreen... all #1D9E75). Import and alias at the point of use so each
+// manipulative still names a colour for what it MEANS there:
+//
+// import { green as solGreen, blue as testBlue } from './shared/palette'
+//
+// Colour carries meaning in these manipulatives, so a colour should keep its
+// job across the set: green = the true/solved thing, purple = the unknown or
+// the total, blue = the thing you are testing or have selected, orange/red =
+// the leftover, the distance, the negative.
+//
+// Domain colours (pizza cheese, balance-beam wood) stay in their own file —
+// they mean nothing outside it.
+
+export const cream = '#F8F6F0'
+export const ink = '#1A1A2E'
+export const muted = '#5F5E5A'
+export const border = '#E0DDD6'
+export const hairline = '#C9CDD6'
+
+export const green = '#1D9E75'
+export const purple = '#7C3AED'
+export const blue = '#2563EB'
+export const orange = '#D85A30'
+export const amber = '#D97706'
+export const red = '#D8402F'
diff --git a/src/manipulatives/shared/useCanvasBox.js b/src/manipulatives/shared/useCanvasBox.js
new file mode 100644
index 0000000..6304dd8
--- /dev/null
+++ b/src/manipulatives/shared/useCanvasBox.js
@@ -0,0 +1,46 @@
+import { useLayoutEffect, useState } from 'react'
+
+// Measures the element `ref` points at and keeps a canvas-sized box in state.
+//
+// This was copy-pasted into all ten manipulatives, because getting it right
+// took three separate bug fixes and each one had to be applied everywhere:
+//
+// 1. useLayoutEffect, not useEffect — measure before first paint, or the
+// canvas draws at its default 300x150 and visibly snaps to size.
+// 2. contentRect, not getBoundingClientRect, inside the observer — the
+// shared ManipulativeCanvas frame scales its children with a CSS
+// transform, and contentRect is the unscaled box. getBoundingClientRect
+// returns the scaled one, which feeds back into the observer and bounces.
+// 3. rAF-defer the callback and only commit whole-pixel changes — a
+// sub-pixel width change must not be able to retrigger the observer.
+//
+// Returns a { w, h } object whose identity is stable until a dimension
+// actually changes, so it is safe to put straight into a deps array.
+export function useCanvasBox(ref, { minW = 420, minH = 220 } = {}) {
+ const [box, setBox] = useState({ w: 720, h: minH })
+
+ useLayoutEffect(() => {
+ const node = ref.current
+ if (!node) return undefined
+ let raf = 0
+ const commit = (rect) => {
+ const w = Math.max(minW, Math.round(rect.width))
+ const h = Math.max(minH, Math.round(rect.height))
+ setBox((prev) => (Math.abs(prev.w - w) >= 1 || Math.abs(prev.h - h) >= 1 ? { w, h } : prev))
+ }
+ commit(node.getBoundingClientRect())
+ const observer = new ResizeObserver((entries) => {
+ const cr = entries[0]?.contentRect
+ if (!cr) return
+ cancelAnimationFrame(raf)
+ raf = requestAnimationFrame(() => commit(cr))
+ })
+ observer.observe(node)
+ return () => {
+ cancelAnimationFrame(raf)
+ observer.disconnect()
+ }
+ }, [ref, minW, minH])
+
+ return box
+}