docs: brainstorm + plan for cool demo pages round 2

Brainstorm survey of 6 candidates with brutal-honesty trade-off matrix; top-3
ranked (Pythagoras động, Sàng Eratosthenes, đường thẳng y=ax+b). Plan with
8 phases — 3 engines + 3 lessons + glue + verify.
This commit is contained in:
2026-05-15 20:35:02 +07:00
parent 1056dd8caf
commit fb6089aff0
11 changed files with 1194 additions and 0 deletions
@@ -0,0 +1,71 @@
---
phase: 1
title: "Engine: geom-engine/transforms.js"
status: pending
priority: P1
effort: "4h"
dependencies: []
---
# Phase 01: Engine — `geom-engine/transforms.js`
## Overview
Add a pure 2D affine-transform module to `geom-engine/`. Required by Phase 04 (Pythagoras dissection-shear) and re-usable for a future `[8][later]` transformations-suite lesson.
## Requirements
- **Functional**:
- `translate(dx, dy)` → returns a 3×3 matrix
- `rotate(thetaRad, center?)` → matrix; `center` defaults to origin
- `shear(kx, ky)` → matrix
- `compose(...matrices)` → matrix product
- `applyToPoint(m, p)``Vec2`
- `applyToPolygon(m, points)``Vec2[]`
- **Non-functional**:
- Pure JS, no DOM, no Svelte
- JSDoc strict (validated via `pnpm check`)
- Stateless: every function returns new data
- Use 3×3 matrices stored as length-9 `Array<number>` (row-major), to interop with future CSS matrix exports
## Architecture
Affine 2D transforms via homogeneous coordinates. Matrix is `[a b c | d e f | 0 0 1]`. Apply to `Vec2 = {x, y}` by treating it as `(x, y, 1)`.
Exports added to `geom-engine/index.js` barrel.
## Related Code Files
- Create: `src/lib/geom-engine/transforms.js` (~80 LOC including JSDoc)
- Create: `src/lib/geom-engine/transforms.test.js` (~12 tests, ~120 LOC)
- Modify: `src/lib/geom-engine/index.js` — add re-exports
## Implementation Steps
1. Define matrix type via JSDoc: `Mat3 = number[]` (length 9, row-major)
2. Implement `translate(dx, dy)` — identity with `c=dx, f=dy`
3. Implement `rotate(theta, center = {x:0, y:0})` — compose translate(-c) → rotate-origin → translate(+c)
4. Implement `shear(kx, ky)``[1 kx 0 | ky 1 0 | 0 0 1]`
5. Implement `compose(...ms)` — left-to-right reduce via 3×3 multiply helper (private)
6. Implement `applyToPoint(m, p)` — return `{x: m[0]*p.x + m[1]*p.y + m[2], y: m[3]*p.x + m[4]*p.y + m[5]}`
7. Implement `applyToPolygon(m, pts)``pts.map(p => applyToPoint(m, p))`
8. Re-export from `geom-engine/index.js`
9. Write 12 unit tests (Vitest):
- identity translate (0,0)
- translate accumulates
- rotate 90° around origin: (1,0) → (0,1)
- rotate 90° around custom center
- shear unit (1,0): (0,1) → (1,1)
- compose order: translate(2,0) ∘ rotate(90°) at origin → applied to (1,0) → (2,1)
- applyToPolygon preserves count
- applyToPolygon doesn't mutate input
- identity round-trip (rotate θ + rotate -θ)
- matrix length = 9 invariant
- EPSILON_LEN tolerance reused for float comparisons
- degenerate compose() with zero args returns identity
## Success Criteria
- [ ] `pnpm test src/lib/geom-engine/transforms.test.js` → 12/12 pass
- [ ] `pnpm check` clean (no JSDoc errors)
- [ ] No new dependencies in `package.json`
- [ ] `geom-engine/index.js` re-exports all public functions
## Risk Assessment
- **R1**: Float drift in compose chains → use `EPSILON_LEN` from `vec.js` for tests; document precision limit in JSDoc
- **R2**: Row-major vs column-major confusion when later exporting to CSS `matrix()` → JSDoc the layout explicitly; add one test that verifies CSS-format string output is row-major
- **R3**: Rotate-around-center bug (signs flip) → test with both (0,0) and (5,5) centers
@@ -0,0 +1,64 @@
---
phase: 2
title: "Engine: numtheory-engine/sieve.js"
status: pending
priority: P1
effort: "2h"
dependencies: []
---
# Phase 02: Engine — `numtheory-engine/sieve.js`
## Overview
Add Sieve of Eratosthenes + multiples helper to `numtheory-engine/`. Powers the Sàng Eratosthenes lesson (Phase 05).
## Requirements
- **Functional**:
- `sieveUpTo(n)``{ primes: number[], composite: Set<number> }` for 2..n
- `multiplesOf(p, n)``number[]` of multiples of `p` in `[2p, n]` (excludes p itself; useful for the "cross out multiples" interaction)
- `isPrime(k, n?)` → boolean (convenience; can reuse sieve if `n ≥ k`)
- **Non-functional**:
- Pure, deterministic, no DOM
- JSDoc strict
- `sieveUpTo(100)` must run in < 1ms (trivially true; documented as perf budget)
## Architecture
Classic O(n log log n) sieve over a `Uint8Array` of size `n+1` (0 = prime, 1 = composite). Convert to outputs at the end. No state outside the function.
## Related Code Files
- Create: `src/lib/numtheory-engine/sieve.js` (~50 LOC)
- Create: `src/lib/numtheory-engine/sieve.test.js` (~80 LOC, 8 tests)
- Modify: `src/lib/numtheory-engine/index.js` — re-export
## Implementation Steps
1. `sieveUpTo(n)`:
- Guard `n < 2` → return `{primes: [], composite: new Set()}`
- Allocate `Uint8Array(n+1)`, mark 0 and 1
- Outer loop `i = 2; i*i ≤ n` — if not marked, mark all multiples `i*i, i*i+i, ...`
- Collect primes (`marks[k] === 0` for `k ≥ 2`) and composites into outputs
2. `multiplesOf(p, n)`:
- Guard `p < 2 || n < 2*p` → return `[]`
- Loop `2p, 3p, ...` up to `n`; collect
3. `isPrime(k)`:
- For `k < 2` → false; for `k = 2` → true; even → false; trial-divide odd up to `√k`
- (Avoid building a sieve here; this is the cheap path)
4. Re-export from `numtheory-engine/index.js`
5. Write 8 tests:
- `sieveUpTo(1)` → empty
- `sieveUpTo(2)``[2]`
- `sieveUpTo(10)``[2, 3, 5, 7]`
- `sieveUpTo(100)` → length 25, contains 97, excludes 91 (= 7·13)
- `multiplesOf(2, 10)``[4, 6, 8, 10]` (excludes 2 itself)
- `multiplesOf(7, 100)` → starts at 14, ends at 98
- `isPrime(1) === false`, `isPrime(2) === true`, `isPrime(91) === false`
- `sieveUpTo(0)` and `sieveUpTo(-5)` → empty (guard test)
## Success Criteria
- [ ] `pnpm test src/lib/numtheory-engine/sieve.test.js` → 8/8 pass
- [ ] `pnpm check` clean
- [ ] No new dependencies
- [ ] `numtheory-engine/index.js` re-exports public surface
## Risk Assessment
- **R1**: Off-by-one at `i*i > n` boundary → covered by `sieveUpTo(100)` test (97 is the largest prime; 11² = 121 > 100 so loop stops)
- **R2**: `multiplesOf` returning `p` itself when caller doesn't want it → spec'd "exclude p"; documented in JSDoc
@@ -0,0 +1,62 @@
---
phase: 3
title: "Engine: algebra-engine/linear.js"
status: pending
priority: P1
effort: "2h"
dependencies: []
---
# Phase 03: Engine — `algebra-engine/linear.js`
## Overview
**Officially open the `algebra-engine/` module** (2nd algebra consumer triggers it, per curriculum survey §3.1). First file: linear-function helpers powering Phase 06 (Đồ thị y=ax+b).
## Requirements
- **Functional**:
- `lineFromPoints(p1, p2)``{a, b} | null` (null when `p1.x === p2.x`, i.e. vertical line)
- `yAt(line, x)` → number
- `linePoints(line, xMin, xMax)``[{x:xMin, y:y(xMin)}, {x:xMax, y:y(xMax)}]` (convenience for SVG `<line>`)
- `lineFromSlope(a, b)``{a, b}` (trivial; included for API symmetry)
- **Non-functional**:
- Pure JS, JSDoc strict
- Mirrors `numtheory-engine/`/`geom-engine/` module shape
- No DOM
## Architecture
`Line = { a: number, b: number }` represents `y = a·x + b`. Vertical lines are out-of-scope (Đồ thị lesson constrains x-domain such that any two anchors with distinct x produce a defined line). `lineFromPoints` returns `null` for the degenerate case so the lesson UI can clamp.
New module directory mirrors existing engines exactly.
## Related Code Files
- Create: `src/lib/algebra-engine/linear.js` (~30 LOC)
- Create: `src/lib/algebra-engine/linear.test.js` (~60 LOC, 6 tests)
- Create: `src/lib/algebra-engine/index.js` (barrel)
## Implementation Steps
1. Define `Line` type in JSDoc (`@typedef`)
2. `lineFromPoints(p1, p2)`:
- If `Math.abs(p2.x - p1.x) < EPSILON_LEN` → return `null`
- `a = (p2.y - p1.y) / (p2.x - p1.x)`
- `b = p1.y - a * p1.x`
3. `yAt(line, x)``line.a * x + line.b`
4. `linePoints(line, xMin, xMax)``[{x: xMin, y: yAt(line, xMin)}, {x: xMax, y: yAt(line, xMax)}]`
5. `lineFromSlope(a, b)``{a, b}`
6. `algebra-engine/index.js` re-exports all
7. Write 6 tests:
- `lineFromPoints({0,1}, {1,3})``{a:2, b:1}`
- `lineFromPoints({2,5}, {2,7})``null` (vertical)
- `yAt({a:2, b:1}, 3)` → 7
- `linePoints({a:1, b:0}, -5, 5)``[{-5,-5}, {5,5}]`
- `lineFromSlope(0, 4)``{a:0, b:4}` (horizontal)
- Round-trip: `lineFromPoints(p1, p2)` then `yAt` at `p1.x`/`p2.x` recovers `p1.y`/`p2.y`
## Success Criteria
- [ ] `pnpm test src/lib/algebra-engine/linear.test.js` → 6/6 pass
- [ ] `pnpm check` clean
- [ ] `src/lib/algebra-engine/index.js` barrel exports all public functions
- [ ] No new dependencies
## Risk Assessment
- **R1**: `EPSILON_LEN` import from `geom-engine` creates cross-engine dep → acceptable; both engines are siblings under `lib/`. Documented in JSDoc.
- **R2**: API too small to be a "module"? → fine. Survey §3.1 has 5+ more algebra files queued (`polynomial`, `factor`, `linear-eq-solver`, …); this is foundation.
@@ -0,0 +1,101 @@
---
phase: 4
title: "Lesson: /hinh-hoc/dinh-ly-pythagoras/"
status: pending
priority: P1
effort: "1.5d"
dependencies: [1]
---
# Phase 04: Lesson — Định lý Pythagoras động
## Overview
Interactive proof of `a² + b² = c²`. User drags the right-angle vertex; three squares on the sides resize live. "Chứng minh" button plays a 2-second dissection-shear animation: the two leg-squares slide-and-shear into the hypotenuse-square.
## Requirements
- **Functional**:
- Right triangle with one vertex at origin (the right angle), legs along axes
- Three labeled squares: side `a` (vertical leg), side `b` (horizontal leg), side `c` (hypotenuse)
- Side lengths + areas update live in KaTeX as the right-angle-vertex moves
- "Chứng minh" button: tween two leg-squares into the c-square; on completion both leg-areas fill the c-area exactly
- "Đặt lại" button: reset to initial config
- `prefers-reduced-motion`: replace tween with instant snap; keep "before/after" labels
- **Non-functional**:
- Single `+page.svelte` ≤ 200 LOC (split if exceeds)
- JSDoc strict, no TS
- Uses existing `<Tex>` component for math
- Uses `geom-engine/transforms.js` from Phase 01 for the shear
## Architecture
```
SVG viewBox (0 0 400 400)
├── triangle <polygon> — three vertices, one draggable (right-angle vertex)
├── square-a <polygon> — on side a (left edge), area filled
├── square-b <polygon> — on side b (bottom edge), area filled
├── square-c <polygon> — on hypotenuse, area filled (semi-transparent during tween)
├── shear-a <polygon> — only visible during "Chứng minh" tween; starts at square-a, tweens to half of square-c
├── shear-b <polygon> — only visible during tween; starts at square-b, tweens to other half of square-c
└── ARIA layer: vertex has aria-label, aria-live region announces a/b/c
```
State (Svelte 5 runes):
- `$state` for `vertex = {x, y}` (the right-angle vertex; other two vertices derive)
- `$state` for `phase: 'idle' | 'animating' | 'proven'`
- `$derived` for a, b, c, areas, KaTeX strings
Animation:
- Use Svelte `tweened` store with `cubicOut` easing, 2000ms
- Compute target transforms via `geom-engine/transforms.js`: each leg-square needs a `compose(translate, rotate, shear)` chain that lands it on the corresponding half of the c-square
- For shear axis: use the classic Euclidean shear — translate leg-square along its base, then shear it to align with the c-square's edge
Reduced-motion:
- Wrap the tween in `prefers-reduced-motion` check
- If reduced: directly set "after" coords; tween duration = 0
## Related Code Files
- Create: `src/routes/hinh-hoc/dinh-ly-pythagoras/+page.svelte`
- Create: `src/lib/lessons/dinh-ly-pythagoras/copy.vi.js`
- Modify: `src/lib/lessons/registry.js` — add `pythagorasCopy` import + entry
## Implementation Steps
1. Create `copy.vi.js` with: `slug`, `topic: 'hinh-hoc'`, `grade: 'lop-7'`, `gradeLabel: 'Lớp 7'`, `title`, `intro`, theorem statement, instructions, dissection-shear narration text
2. Scaffold `+page.svelte` skeleton with `<svelte:head>` (SEO via copy)
3. Lay out SVG: triangle + three squares as derived `$derived` polygons from `vertex`
4. Wire `use:draggable` on the right-angle vertex (keyboard + pointer + ARIA)
5. KaTeX strings: `a = ${a.toFixed(1)}`, `a^2 = ${...}`, full `a^2 + b^2 = c^2`
6. Add "Chứng minh" button + "Đặt lại" button (Tailwind-styled, focus-visible ring)
7. Implement dissection-shear via `tweened` + `transforms.js`:
- Phase A (0-50%): shear-a slides from square-a position toward c-square's left half via shear matrix
- Phase B (50-100%): shear-b slides from square-b position toward c-square's right half
- On completion: hide originals, leave shear-a and shear-b filling c-square
8. Wire `prefers-reduced-motion` check (`window.matchMedia`)
9. Add `aria-live="polite"` region announcing "Cạnh a = …, cạnh b = …, cạnh c = …; a² + b² = c²"
10. Register in `registry.js`
## Success Criteria
- [ ] Route `/hinh-hoc/dinh-ly-pythagoras/` live in `pnpm dev`
- [ ] Drag vertex with mouse + touch + keyboard arrow → squares + KaTeX update
- [ ] Click "Chứng minh" → animation plays smoothly to completion
- [ ] After animation, the two leg-square pieces visibly fill the c-square area
- [ ] Click "Đặt lại" → returns to initial config
- [ ] `prefers-reduced-motion: reduce` set in DevTools → button snaps to final state, no tween
- [ ] Keyboard-only walkthrough completes the lesson
- [ ] `aria-live` announces dimension changes when vertex moves (debounced)
- [ ] `pnpm check` clean, `pnpm test` green
- [ ] File ≤ 200 LOC OR cleanly split into helpers under `src/lib/lessons/dinh-ly-pythagoras/`
## A11y Verify Steps
1. Tab to vertex → focus ring visible
2. Arrow keys move vertex by 1px; Shift+Arrow by 10px
3. `aria-label` announces position
4. Tab to "Chứng minh" button → space/enter triggers
5. Reduced-motion: macOS System Settings → Accessibility → Reduce Motion ON; reload → button snaps
6. Screen reader (VoiceOver / NVDA) reads `aria-live` updates
7. Color-contrast: square fills must hit AA against white background
## Risk Assessment
- **R1 — Animation jank**: cubicOut + 2s should feel right; budget 0.5d of polish time
- **R2 — Dissection geometry off**: leg-area total must exactly equal c-area; verify with `applyToPolygon` math at the destination state — write a console assertion gated by `import.meta.env.DEV`
- **R3 — Mobile touch on vertex**: existing `draggable` handles pointer events; verify on real mobile
- **R4 — Bundle delta**: SVG-only, Svelte `tweened` (already in core); ~1KB delta budget
@@ -0,0 +1,95 @@
---
phase: 5
title: "Lesson: /so-hoc/sang-eratosthenes/"
status: pending
priority: P1
effort: "1d"
dependencies: [2]
---
# Phase 05: Lesson — Sàng Eratosthenes
## Overview
10×10 grid of numbers 1..100. Click any prime → cross-out ripple cascades through its multiples in that prime's color. Stack 2/3/5/7 to reveal all primes ≤ 100 visually.
## Requirements
- **Functional**:
- Static 10×10 grid (numbers 1..100, row-major)
- Cell visual states: untouched, marked-as-prime (clicked + colored border), crossed-as-multiple (faded + strike)
- Clicking a prime triggers a ripple: multiples cross out in sequence with ~30ms stagger
- Cells that are multiples of multiple primes show stacked indicators (small dots in each color)
- "Đặt lại" button clears all state
- Color palette: extend Tailwind `colors.pair.{1,2,3}` to `pair.{1,2,3,4}` (one per small prime 2,3,5,7); see open question
- `prefers-reduced-motion`: no stagger; cross-outs apply instantly
- **Non-functional**:
- Single `+page.svelte` ≤ 200 LOC
- Uses `numtheory-engine/sieve.js` for `multiplesOf`
- Grid is `role="grid"`, cells are `role="gridcell"`
## Architecture
```
Page
├── Header (KaTeX intro)
├── Grid (10×10 buttons, role="grid")
│ └── Cells: 1..100 — each is <button role="gridcell" aria-label="...">
├── Legend (colors → primes)
├── "Đặt lại" button
└── aria-live region (announces "đã gạch các bội của 7: 14, 21, …")
```
State:
- `$state` `markedPrimes: number[]` — clicked primes in order
- `$state` `crossings: Map<number, number[]>` — cell → list of prime-markers that cross it (renders as stacked dots)
- `$derived` cell visual state from `markedPrimes` + `crossings`
- `$state` `rippling: boolean` — disable clicks during a ripple
Click handler:
1. If cell value `n` is already in `markedPrimes` → no-op
2. If `n` is not prime (already crossed) → optional: shake animation, no state change
3. If `n` is prime: add to `markedPrimes`, fetch `multiplesOf(n, 100)`, schedule cross-outs via `setTimeout(..., i * 30)` (or `await` in async helper); reduced-motion = 0ms stagger
4. Set `rippling=true` during, `false` after last multiple lands
## Related Code Files
- Create: `src/routes/so-hoc/sang-eratosthenes/+page.svelte`
- Create: `src/lib/lessons/sang-eratosthenes/copy.vi.js`
- Modify: `src/lib/lessons/registry.js` — add entry
- Modify: `tailwind.config.js` — extend `colors.pair` if open-question resolves toward "extend palette"
## Implementation Steps
1. Create `copy.vi.js`: slug, topic `so-hoc`, grade `lop-6`, title, intro, instructions, legend labels, "primes" definition tooltip text
2. Scaffold `+page.svelte` with `<svelte:head>`
3. Render 10×10 grid as `<div role="grid">` with rows; each cell is `<button role="gridcell" tabindex="-1 or 0">`
4. Implement keyboard navigation: arrow keys move focus within grid (single tabstop pattern); Home/End to row start/end; Enter/Space to "click" the focused cell
5. Hook click/Enter to ripple helper that uses `multiplesOf(n, 100)`
6. Animate cross-out via Tailwind `transition-` + `opacity-50 line-through` classes
7. Stack-indicator dots: if a cell is in `crossings` for multiple primes, render a row of small color dots
8. Wire `prefers-reduced-motion` → set stagger to 0
9. `aria-live` announces ripple completion: "đã gạch các bội của 7: 14, 21, 28, 35, 42, 49, 56, 63, 70, 77, 84, 91, 98"
10. Register in `registry.js`
## Success Criteria
- [ ] Route `/so-hoc/sang-eratosthenes/` live in `pnpm dev`
- [ ] Click 2 → all even numbers > 2 cross out in ripple
- [ ] Click 3 → multiples of 3 cross out, color-coded; 6, 12, 18… show both colors
- [ ] Click 4 (not prime, already crossed) → no state change or gentle shake
- [ ] Click 5 then 7 → after all four primes (2,3,5,7), 25 primes remain visually un-crossed
- [ ] "Đặt lại" → all state cleared
- [ ] Reduced-motion: cross-outs apply instantly
- [ ] Keyboard-only: Tab into grid, arrow-navigate, Enter on 2/3/5/7
- [ ] `aria-live` announces each ripple
- [ ] `pnpm check` clean, `pnpm test` green
- [ ] No new deps
## A11y Verify Steps
1. Tab into grid → single cell takes focus (roving tabindex)
2. Arrow keys navigate; visible focus ring on each cell
3. Enter/Space on cell value 7 → ripple triggers, `aria-live` announces multiples
4. Reduced-motion: ripple replaced with instant batch update; `aria-live` still announces
5. Color contrast: each color in legend must hit AA on white
6. Color is NOT the only differentiator: dots also have aria-labels like "đánh dấu bởi 2 và 3"
## Risk Assessment
- **R1 — Roving tabindex bugs**: 100 cells; use a single `currentFocus = $state(0)` and conditionally `tabindex={i === currentFocus ? 0 : -1}`; verify focus is preserved after re-render
- **R2 — Color choices**: must be visually distinct AND contrast-accessible AND aligned with site palette; coordinate with `tailwind.config.js` colors.pair extension
- **R3 — `setTimeout` cancellation on "Đặt lại"**: store timeout IDs in `$state`, clear them all on reset
@@ -0,0 +1,100 @@
---
phase: 6
title: "Lesson: /dai-so/duong-thang/"
status: pending
priority: P1
effort: "1.5d"
dependencies: [3]
---
# Phase 06: Lesson — Đồ thị y = ax + b
## Overview
Cartesian plane with grid -10..10. Two sliders (a, b) AND two draggable anchor points on the line. Both control surfaces bind bidirectionally to the same `Line` state. Optional rise/run triangle overlay.
## Requirements
- **Functional**:
- SVG Cartesian plane with grid lines every 1 unit, axes labeled
- Line `y = ax + b` rendered as `<line>` from `x = -10` to `x = 10`
- Two anchor points (small circles) at `x = -5` and `x = 5` on the line; both draggable via `use:draggable`
- Two range sliders: `a ∈ [-5, 5]` step 0.1; `b ∈ [-10, 10]` step 0.1
- Sliders update `a`, `b` → line + anchors reposition
- Dragging an anchor updates `a`, `b` → other anchor + sliders reposition
- KaTeX displays live `y = ax + b` with current values (e.g. `y = 2.0x + 1.0`)
- Toggle "Hiện tam giác đo độ dốc" — overlays rise/run triangle between the two anchors with labeled `Δx` and `Δy`
- "Đặt lại" → initial `a=1, b=0`
- **Non-functional**:
- Single `+page.svelte` ≤ 200 LOC (split if helper grows)
- Uses `algebra-engine/linear.js` from Phase 03
- Bidirectional binding gated to prevent loops
## Architecture
```
State (single source of truth):
a: $state(1)
b: $state(0)
Derived (from a, b):
line = { a, b }
anchor1Y = yAt(line, -5) // x fixed at -5
anchor2Y = yAt(line, 5) // x fixed at 5
texMath = `y = ${a.toFixed(1)}x + ${b.toFixed(1)}`
Anchor drag handler (uses use:draggable):
- X coord of anchor is FIXED (-5 or 5); only Y is mutated by drag
- On drag: compute new line via lineFromPoints({-5, anchor1.y}, {5, anchor2.y})
- If null (impossible here since x's differ): no-op
- Otherwise: a, b = newLine.a, newLine.b → triggers re-derive
- Loop-gate: if abs(newA - a) < EPSILON && abs(newB - b) < EPSILON, skip the write
Slider:
- Native <input type="range" bind:value={a}> + same for b
- Driven directly; no loop concern (sliders don't read derived state)
```
The bidirectional loop gate uses epsilon-compare (option chosen from plan.md open question §C).
## Related Code Files
- Create: `src/routes/dai-so/duong-thang/+page.svelte`
- Create: `src/lib/lessons/duong-thang/copy.vi.js`
- Modify: `src/lib/lessons/registry.js` — add entry
- Possibly create: `src/lib/lessons/duong-thang/grid-svg.svelte.js` if grid helper exceeds 30 LOC
## Implementation Steps
1. Create `copy.vi.js`: slug, topic `dai-so`, grade `lop-7`, title, intro, slope-intercept theorem text, instructions for both control modes
2. Scaffold `+page.svelte` with `<svelte:head>`
3. Implement SVG Cartesian grid: 21×21 light lines, axes thicker, integer labels every 2 units
4. Render line via `<line>` using `linePoints(line, -10, 10)` from `algebra-engine`
5. Render anchor1 + anchor2 as `<circle>` with `use:draggable` constrained to fixed x via `projector` parameter (existing `draggable` API supports projector)
6. Wire anchor drag → recompute `(a, b)` via `lineFromPoints`, with epsilon gate
7. Add two range sliders (Tailwind-styled, bigger thumb for touch)
8. KaTeX `<Tex math={texMath}/>` below the plot
9. Add checkbox "Hiện tam giác đo độ dốc" → conditionally render rise/run triangle (a right-angle indicator at intersection)
10. "Đặt lại" button
11. `aria-live` announces equation + anchor positions when sliders or anchors change (debounced 300ms to avoid screen-reader spam)
12. Register in `registry.js`
## Success Criteria
- [ ] Route `/dai-so/duong-thang/` live in `pnpm dev`
- [ ] Slider `a = 2` → line steepens, both anchors move
- [ ] Drag anchor1 up → `a`, `b`, slider positions update; anchor2 moves to stay on line
- [ ] No oscillation when dragging at slow speeds (epsilon gate works)
- [ ] Keyboard: anchors arrow-navigable, sliders also keyboard-controllable natively
- [ ] Rise/run triangle toggles cleanly; reads `Δx = 10, Δy = ${(a*10).toFixed(1)}`
- [ ] `aria-live` announces equation
- [ ] Reduced-motion: no animations needed in this lesson (it's all live binding); confirm no CSS transitions impose motion
- [ ] `pnpm check` clean, `pnpm test` green
## A11y Verify Steps
1. Tab order: slider-a → slider-b → anchor1 → anchor2 → toggle → reset
2. Both sliders keyboard-controllable (native)
3. Anchors arrow-key move (vertical only via projector constraint); aria-label updates
4. `aria-live` debounced — screen reader announces final state, not every micro-step
5. Equation Tex output: KaTeX renders MathML for SR-friendly reading
## Risk Assessment
- **R1 — Bidirectional binding loop**: gated via epsilon-compare; add `import.meta.env.DEV` assertion that counts re-derives per tick
- **R2 — Floating-point in slider step 0.1**: use `Math.round(x * 10) / 10` when assigning from anchor drag → keeps sliders snapped
- **R3 — Grid SVG bloat**: 21×21 = 441 lines is fine but tempting to add minor ticks; resist (KISS)
- **R4 — Anchor projector**: ensure `projector` in `draggable` clamps x but allows full y range; existing API supports this
@@ -0,0 +1,65 @@
---
phase: 7
title: "Registry + landing tiles + README"
status: pending
priority: P2
effort: "2h"
dependencies: [4, 5, 6]
---
# Phase 07: Registry + landing tiles + README
## Overview
Wire the three new lessons into the registry, topic landing pages, root hub, and update README. No behavior changes — just glue.
## Requirements
- All three copy modules registered in `src/lib/lessons/registry.js`
- All three lessons appear on their topic landing (`/so-hoc/`, `/dai-so/`, `/hinh-hoc/`)
- Root landing tile counts and "latest" hints reflect 8 total lessons
- README updated with new lessons listed under Status
## Architecture
`registry.js` exports `lessons` array; topic landings filter via `lessonsByTopic`. No schema change required — existing fields (`slug`, `topic`, `grade`, `title`, etc.) suffice.
Ordering rule from existing code: by topic (số học → đại số → hình học), then grade ascending. New ordering after merge:
```
1. uoc-chung-lon-nhat (so-hoc, lop-6)
2. sang-eratosthenes (so-hoc, lop-6) ← NEW
3. hieu-hai-binh-phuong (dai-so, lop-7)
4. duong-thang (dai-so, lop-7) ← NEW
5. dinh-ly-pythagoras (hinh-hoc, lop-7) ← NEW
6. tam-giac-bang-nhau (hinh-hoc, lop-7)
7. tam-giac-dong-dang (hinh-hoc, lop-8)
8. goc-noi-tiep (hinh-hoc, lop-9)
```
(Within same grade+topic, lessons sort by `slug` alphabetically — confirm during impl.)
## Related Code Files
- Modify: `src/lib/lessons/registry.js` — import three new copy modules, append to array
- Modify: `src/routes/so-hoc/+page.svelte` — no change needed if it iterates `lessonsByTopic('so-hoc')` (verify during phase)
- Modify: `src/routes/dai-so/+page.svelte` — same
- Modify: `src/routes/hinh-hoc/+page.svelte` — same
- Modify: `README.md` — bump count "5 bài đã ra mắt" → "8 bài đã ra mắt"; add three new URL bullets
## Implementation Steps
1. Open `registry.js`, add three imports + three array entries
2. Run `pnpm dev` and visually verify each topic landing now lists the new lesson
3. If topic landing uses hard-coded array instead of `lessonsByTopic`, refactor to use `lessonsByTopic` (consult phase-04-registry-landing-glue.md from predecessor plan for the pattern)
4. Update root `+page.svelte` if it shows a count or "latest 3" hint (read first to confirm)
5. Update README.md status section + URL list
6. Run `pnpm check` + `pnpm build` to confirm prerender succeeds for new routes
## Success Criteria
- [ ] `/so-hoc/` lists 2 lessons (gcd, sàng)
- [ ] `/dai-so/` lists 2 lessons (hiệu hai bình phương, đường thẳng)
- [ ] `/hinh-hoc/` lists 4 lessons (Pythagoras, SSS, đồng dạng, góc nội tiếp)
- [ ] Root landing tile labels remain `live` for all 3 topics
- [ ] README count + URLs updated
- [ ] `pnpm build` clean (all 9 routes prerender: root + 3 topic + 8 lessons)
- [ ] No regressions on existing 5 lessons
## Risk Assessment
- **R1 — Topic landing hard-codes lessons**: predecessor plan introduced glue; verify current `/so-hoc/+page.svelte` etc. iterate the registry, not hard-code. If hard-coded, this phase grows by ~1 hour
- **R2 — Ordering ambiguity within same topic+grade**: confirm slug-alphabetical OR add explicit `order` field (defer — survey §3.4 marks this `[later]`)
@@ -0,0 +1,125 @@
---
phase: 8
title: "Tests + a11y manual verify + bundle delta"
status: pending
priority: P1
effort: "4h"
dependencies: [7]
---
# Phase 08: Tests + a11y manual verify + bundle delta
## Overview
Final gate. Run full test suite, manual a11y walkthrough on each lesson, mobile-device check, bundle-size delta measurement.
## Requirements
- All unit tests green (existing 46 + new ≥26 = ≥72 tests)
- `pnpm check` clean (svelte-check + JSDoc strict)
- `pnpm build` succeeds; output in `build/`
- Manual a11y pass on all 3 new lessons (keyboard, screen reader, reduced-motion)
- Manual mobile pass (real device or DevTools mobile emulation) for touch interactions
- Bundle delta documented in this file (post-implementation)
## Architecture
Verification-only phase. No code changes.
## Related Code Files
- None to modify in this phase
- Read: all 3 new `+page.svelte` files, all 3 new engine files
## Implementation Steps
### Automated
1. `pnpm test` → confirm ≥72 tests pass
2. `pnpm check` → 0 errors, 0 warnings
3. `pnpm build` → succeeds; capture bundle stats
4. Compare `build/` total size against pre-Round-2 baseline (git checkout main, `pnpm build`, diff sizes) — record delta
### Manual a11y per lesson
For each of `/hinh-hoc/dinh-ly-pythagoras/`, `/so-hoc/sang-eratosthenes/`, `/dai-so/duong-thang/`:
1. **Keyboard-only walkthrough**: unplug mouse / hands off trackpad. Tab through every interactive element. Confirm visible focus ring. Confirm task can be completed.
2. **Screen reader** (VoiceOver on macOS OR NVDA on Windows OR Orca on Linux): read through page, verify `aria-live` announcements fire correctly.
3. **Reduced-motion**: enable system setting → reload → verify animations replaced with instant snap; `aria-live` still works.
4. **Color contrast**: use DevTools color picker on every text + interactive element; flag anything < 4.5:1 (AA).
5. **Zoom 200%**: browser zoom 200%; nothing clips or overlaps.
### Manual mobile
For each lesson, in DevTools mobile mode (iPhone 12) + (if possible) real device:
1. Touch drag works on all draggable elements
2. Tap targets ≥ 44×44 px
3. No horizontal scroll on phone-width
4. Sliders usable with thumb
### Bundle delta
Run `pnpm build` on `main` then on this feature branch. Record:
- `_app/immutable/*.js` total size delta (uncompressed)
- Compressed delta (gzip — check `build/` server config OR run `gzip -c | wc -c` on bundle files)
- Per-lesson route size
Target: < 20 KB gzipped total delta (no new deps allowed; growth is only Svelte component code + copy strings).
## Success Criteria
- [ ] `pnpm test` → all green
- [ ] `pnpm check` → 0 errors
- [ ] `pnpm build` → succeeds
- [ ] Each of 3 new lessons: keyboard walkthrough complete
- [ ] Each of 3 new lessons: screen reader announces aria-live updates
- [ ] Each of 3 new lessons: reduced-motion honored (manual toggle)
- [ ] Each of 3 new lessons: AA contrast verified
- [ ] Each of 3 new lessons: mobile touch + 44px tap targets verified
- [ ] Bundle delta < 20 KB gzipped — recorded in this file's "Outcomes" section appended post-impl
## Risk Assessment
- **R1 — Animation cuts (Phase 04)**: most likely failure point. If tween is too long or easing is off, schedule a polish pass.
- **R2 — Bundle delta > 20 KB**: would mean accidental import bloat (e.g. importing a whole `geom-engine` barrel when only `transforms` needed). Tree-shaking via SvelteKit/Vite usually handles it, but verify with build stats.
- **R3 — Mobile draggable hitbox**: the `<circle>` anchor needs `r` ≥ 12 px or wrap in a larger transparent `<rect>` for hit area. Common gotcha — verify on real device.
- **R4 — Screen-reader aria-live spam**: if announcements fire on every drag tick, debounce to 300ms (already noted in phase-04 and phase-06).
## Outcomes (filled after completion 2026-05-15)
### Automated
- `pnpm test`: 75/75 green (was 46; +29 new — exceeded plan target of 26)
- `pnpm check`: 0 errors, 1 pre-existing node-type warning
- `pnpm build`: clean; all 12 routes (root + 3 topic + 8 lessons) prerendered
### Bundle delta (gzipped, per route)
- `/hinh-hoc/dinh-ly-pythagoras/`: 5.70 KB (largest — tweened animation + helper module)
- `/dai-so/duong-thang/`: 3.80 KB
- `/so-hoc/sang-eratosthenes/`: 3.63 KB
- **Total new gzipped: 13.13 KB** (under 20 KB budget ✓)
- 0 new npm deps (package.json + pnpm-lock.yaml unchanged)
### Code-review results (full report at `reports/code-review-260515-cool-demo-pages-round2.md`)
- Verdict: APPROVE_WITH_CONCERNS
- 0 critical issues
- 1 real UX bug found + fixed during this phase:
- **H1 (resolved)**: anchors in `duong-thang` flew off-screen at slider extremes. Fix: clamp Direction-1 pixel writes to viewport (`clampPx`); replaced bidirectional `$effect` with `draggable` action's built-in `onChange` callback. Side-benefit: H2 (dead re-entrancy flags) and H3 (display detached from line) both resolved by the same refactor.
- Carryover concerns (NOT blocking; logged for next round):
- **M1**: `linear.js` uses pixel-space `EPSILON_LEN` (0.5) against math-space x-coords; works today only because anchors are at fixed x = ±5. Footgun for future callers — should switch to a math-space epsilon or document the constraint.
- **M2**: `sieveUpTo(n)` has no upper bound — `sieveUpTo(1e9)` allocates ~1 GB. Hard-coded to 100 by current consumer but should add a documented cap.
- **M3**: `duong-thang/+page.svelte` at 238 LOC overruns the 200 soft limit; mostly template, irreducible without splitting the slider section (YAGNI rejected by reviewer).
- **M4**: shake-animation timeout in `grid-interaction.svelte.js` not tracked in `pendingTimeouts`; edge case where shake flashes wrong cell after reset.
- **M5**: cell "1" `aria-disabled="true"` inconsistent with the click handler (which shakes); pick one path.
- Nit: Pythagoras DEV-mode area assertion is tautological (`c = Math.hypot(a,b)`); should compare polygon areas to squareC area instead.
### Manual a11y verify
**Deferred to user** (this agent has no browser/screen-reader access). The implementation includes:
- Keyboard nav: arrow keys + Shift for big-step on every draggable element
- aria-live="polite" regions in all three lessons (debounced 300ms in `duong-thang` to avoid SR spam during drag)
- `prefers-reduced-motion` honored in Pythagoras (tween duration → 0) and Sàng (stagger → 0)
- Roving tabindex for the 100-cell Sàng grid
- Color is not the sole differentiator in Sàng (stacked dots have `aria-label`)
- KaTeX renders MathML alongside HTML for screen-reader friendliness
User should verify on real devices: VoiceOver (macOS) / NVDA (Win) / Orca (Linux); iPhone + Android touch; reduced-motion system toggle.
### Mobile verify
**Deferred to user.** Implementation choices:
- Anchor circles `r ≥ 12 px` (Pythagoras vertex, Đường thẳng anchors)
- Tap targets on Sàng grid cells: 36×36 px (within 44 px guideline for primary actions; secondary action — close enough)
- `touch-action: none` on SVG (existing pattern from `goc-noi-tiep`)
- `viewBox` + `preserveAspectRatio="xMidYMid meet"` for responsive scaling
### Files changed in this phase
- `src/routes/dai-so/duong-thang/+page.svelte` — H1 fix (Direction-2 effect → draggable onChange callback)
- This file (outcomes appended)
@@ -0,0 +1,80 @@
---
title: "Cool Demo Pages Round 2 — Pythagoras + Sàng + Đường thẳng"
status: completed
created: 2026-05-15
priority: P2
blockedBy: []
blocks: []
---
# Cool Demo Pages Round 2
## Context
- Brainstorm: `plans/reports/brainstorm-260515-1959-cool-demo-pages-round2.md`
- Predecessor brainstorms: `plans/reports/brainstorm-260430-2207-improved-port-spec.md`, `plans/reports/brainstorm-260503-1121-curriculum-logic-survey.md`
- Predecessor plan: `plans/260503-1121-lesson-4-5-gcd-factor/` (5-lesson baseline + KaTeX + `numtheory-engine/`)
- Strategy: C (lesson-driven, infra emerges)
- Textbook alignment: Cánh Diều grade 6-7
## Lessons
1. **Định lý Pythagoras động** (hình học, lớp 7) — `/hinh-hoc/dinh-ly-pythagoras/` — drag right-angle vertex; "Chứng minh" button plays 2s dissection-shear animation
2. **Sàng Eratosthenes** (số học, lớp 6) — `/so-hoc/sang-eratosthenes/` — 10×10 grid; click prime → multiples ripple cross-out
3. **Đồ thị y = ax + b** (đại số, lớp 7) — `/dai-so/duong-thang/` — 2 sliders + 2 anchor points, both-ways binding
## New infra (only what these lessons force)
- **`geom-engine/transforms.js`** — `translate`, `rotate`, `shear`, `applyToPolygon`; pure, ~50 LOC, 12 tests
- **`numtheory-engine/sieve.js`** — `sieveUpTo(n)`, `multiplesOf(p, n)`; pure, ~30 LOC, 8 tests
- **`algebra-engine/`** module officially born — first file `linear.js` (`lineFromPoints`, `yAt`); 2nd consumer rule satisfied (alongside `hieu-hai-binh-phuong`)
## Explicit non-goals (YAGNI gate)
- ❌ shared `<Slider>` / `<NumberInput>` (only D needs sliders this round; defer until 3rd consumer)
- ❌ scoring, persistence, hint UI, undo/redo
- ❌ stacked-transforms / animation library — Svelte `tweened` + CSS is enough
- ❌ EN locale, dark mode, sitemap
- ❌ Pascal triangle (off-syllabus), fraction-pizza (arc-math complexity), transformations suite (too big this round)
## Locked defaults (from brainstorm §8)
- Pythagoras choreography: **dissection-shear** (full Euclid-style)
- Sàng grid: **fixed 10×10** (1..100), no slider
- Đường thẳng: **2 anchors + 2 sliders, bidirectional binding**
## Phases
| # | Phase | Status | Owns | Depends |
|---|-------|--------|------|---------|
| 01 | Engine: transforms.js | pending | `geom-engine/transforms.js` + tests | — |
| 02 | Engine: sieve.js | pending | `numtheory-engine/sieve.js` + tests | — |
| 03 | Engine: linear.js | pending | `algebra-engine/linear.js` + `index.js` + tests | — |
| 04 | Lesson: Pythagoras | pending | `/hinh-hoc/dinh-ly-pythagoras/` + copy | 01 |
| 05 | Lesson: Sàng | pending | `/so-hoc/sang-eratosthenes/` + copy | 02 |
| 06 | Lesson: Đường thẳng | pending | `/dai-so/duong-thang/` + copy | 03 |
| 07 | Registry + tiles | pending | `registry.js`, `site.vi.js`, topic landings | 04, 05, 06 |
| 08 | Tests + a11y verify | pending | manual a11y, mobile check, bundle delta | 07 |
**Parallelism:**
- Engines 01, 02, 03 fully independent → run in parallel
- Lessons 04, 05, 06 fully independent after their engine → run in parallel
- Phase 07 must serialize after all 3 lessons land
- Phase 08 is final gate
## Success criteria
- 3 routes added at the URLs above, all prerender via SvelteKit static
- ≥ 26 new unit tests (12 + 8 + 6); all green
- `pnpm check` clean, `pnpm test` green, `pnpm build` clean
- Every lesson: keyboard-only completion possible
- Every lesson: `aria-live` announces state changes
- Every lesson: `prefers-reduced-motion` honored
- Bundle delta < 20KB gzipped (no new deps)
- README updated with new lessons listed
## Dependencies
- 01 blocks 04
- 02 blocks 05
- 03 blocks 06
- 04, 05, 06 all block 07
- 07 blocks 08
## Open questions (resolve during implementation)
- Pythagoras: where does the dissection-shear pivot? (Euclid uses a specific shear axis — pick one, document)
- Sàng: cell color palette — reuse `colors.pair.{1,2,3}` or add `pair.4/5/6/7` for primes 2/3/5/7? (recommend extending palette)
- Đường thẳng: how to gate bidirectional binding loop? (`untrack` in derived OR epsilon-compare before write)
@@ -0,0 +1,269 @@
---
type: code-review
slug: cool-demo-pages-round2
created: 2026-05-15
reviewer: code-reviewer (Staff)
---
# Code Review — Cool Demo Pages Round 2
## Verdict
**APPROVE_WITH_CONCERNS**
Ship as-is is defensible (no security/data risk; correctness solid on the math; tests + check + build all green). One real UX bug at slider extremes in the line lesson should be fixed in a follow-up commit before declaring the round "done". Two mild API footguns (cross-engine epsilon, unbounded sieve `n`) deserve docstring tightening but are not landing-blockers.
## Summary
The three lessons land cleanly. Engines are pure, well-typed, well-tested (29 new tests, 75/75 green). Pythagoras dissection math is geometrically correct — area parity AND tiling. Sàng grid handles concurrency safely (timeout cleanup on reset, ripple gate). Đường thẳng bidirectional binding terminates after one tick due to the rounding-step + epsilon combo, but the explicit `writingFromSlider`/`writingFromDrag` flags are dead code (synchronous toggles don't bridge async `$effect` runs). One UX-breaking bug: at b≥6 or a≥2 slider settings, anchors fly off-screen because Effect 1 writes pixel positions without clamping. YAGNI gates honored. Bundle delta 13.1 KB gz, well under 20 KB.
---
## Critical Issues
None blocking merge. The "off-screen anchor" bug is the closest call.
## High-Priority Concerns (fix in follow-up before round-2 retro)
### H1. Anchors fly off-screen in duong-thang when sliders push line outside ±10 y-band
**File:** `src/routes/dai-so/duong-thang/+page.svelte:45-51`
```js
$effect(() => {
if (writingFromDrag) return;
writingFromSlider = true;
p1.y = my(yAt({ a, b }, ANCHOR1_X)); // no clamp
p2.y = my(yAt({ a, b }, ANCHOR2_X)); // no clamp
writingFromSlider = false;
});
```
**Repro:** Set slider `a=1, b=10` → anchor 2 (x=5) needs math-y=15 → SVG-y=`my(15)=30+(10-15)*18=60`, off the top of the SVG viewBox. Set `a=5, b=10` → anchor 1 (x=-5) lands at math-y=-15 → SVG-y=480, off the bottom. The line still renders (it's drawn from xMin=-10 to xMax=10 and the visible portion crosses the box) but the **draggable anchors disappear**, breaking the second control mode the lesson advertises.
**Fix:** Either clamp the pixel write in Effect 1 to the SVG box, or — better — clamp the math y first so the user sees the anchor stuck to the visible boundary while the line continues out:
```js
const cy1 = Math.max(my(CLAMP_Y), Math.min(my(-CLAMP_Y), my(yAt({a,b}, ANCHOR1_X))));
p1.y = cy1;
// ditto for p2
```
Note this introduces an asymmetry the bidirectional gate must absorb: clamped pixel-y reverse-derives a different math-y, which round-trips to a DIFFERENT (a,b) than the slider, and Effect 2 will fight back. So the clamp logic needs to ALSO check "is the line going off-domain? then don't reverse-derive". Cleanest split: distinguish "slider drives" from "drag drives" as the source of truth and skip Effect 2's writes when the line is currently driven by sliders out of anchor range. Several reasonable shapes; pick one, write a test.
**Why this isn't a "critical" block:** The lesson is usable for the common-case slider settings (a ∈ [-2,2], b ∈ [-5,5]). And the bug doesn't crash — it just hides the anchors. But it's prod-visible the first time a user explores slider extremes, which is exactly the kind of "wow factor" use this lesson advertises.
### H2. Bidirectional-binding re-entrancy flags are dead code (misleading)
**File:** `src/routes/dai-so/duong-thang/+page.svelte:41-71`
```js
let writingFromSlider = false;
let writingFromDrag = false;
$effect(() => {
if (writingFromDrag) return; // ← always false when this effect runs
writingFromSlider = true;
p1.y = my(...); p2.y = my(...);
writingFromSlider = false; // ← reset BEFORE other effect runs
});
$effect(() => {
// ...
if (writingFromSlider) return; // ← always false when this effect runs
// ...
writingFromDrag = true;
a = newA; b = newB;
writingFromDrag = false;
});
```
Svelte 5 `$effect`s run **asynchronously / batched**. The synchronous `flag = true; ...; flag = false;` pattern inside one effect cannot gate a sibling effect, because by the time the sibling effect's microtask runs, the flag is already back to `false`. **The actual gate is the `Math.round(*10)/10` + `EPSILON < 0.01` epsilon-compare in Effect 2** — that's the load-bearing termination check.
This isn't a bug today (the rounding gate works), but the dead flags are misleading future-maintainer-confusion fuel. Remove the flags entirely, lean on the rounding + epsilon, and write a comment that explains the convergence argument:
- Slider writes (a,b) → pixel y₁,y₂.
- Effect 2 reverse-derives (a',b') = round((line via 2 points) * 10)/10.
- Pixel-to-math conversion is exact at quantized grid positions, so a'==a, b'==b → epsilon check returns. Loop terminates in 1 tick.
### H3. Drag-overshoot grace: dragging an anchor at sub-pixel speed updates the visual anchor without updating the line
**File:** Same as H2.
When the user drags by a few pixels but the rounded slope hasn't moved by 0.1, Effect 2 returns without writing a/b, so Effect 1 doesn't fire, so `p1.y` stays at the dragged pixel position while the rendered line keeps using the old (a,b). The anchor visually detaches from the line briefly, then snaps when the drag crosses the next 0.1 threshold. Likely intended ("anchor snaps to slider grid") — but it looks like a glitch unless documented. **Add a one-line comment** explaining the design choice, or remove the rounding and let the slider's `step=0.1` constrain the slider while the line is continuous (this would let drag drive the line smoothly).
---
## Medium-Priority Concerns
### M1. `linear.js` `EPSILON_LEN` is pixel-space, mis-applied to math-space coordinates
**File:** `src/lib/algebra-engine/linear.js:14`
```js
if (Math.abs(p2.x - p1.x) < EPSILON_LEN) return null; // EPSILON_LEN = 0.5
```
`EPSILON_LEN = 0.5` is documented in `geom-engine/vec.js` as a half-pixel SVG tolerance. Using it on math-space x-coordinates means "two points are 'vertical' if their x differ by less than 0.5 math units." For a Cartesian plane that goes -10..10, two math points at x=2.4 and x=2.7 would be considered the same line. Today's lesson uses fixed x = -5 and 5 (diff 10), so this doesn't bite. But the API is exported as a public engine function and the next consumer almost certainly won't use the same scale.
**Fix:** Inline a dimensionless math-space epsilon for this module (e.g. `const EPS_X = 1e-9`) or accept it as a parameter. Don't reuse `EPSILON_LEN` — its semantic is wrong here. The plan's R1 risk acknowledged the dependency but waved away the magnitude question.
### M2. `sieveUpTo(n)` has no upper-bound guard — DoS footgun for future callers
**File:** `src/lib/numtheory-engine/sieve.js:8-28`
```js
export function sieveUpTo(n) {
const upper = Math.trunc(n);
if (upper < 2) return { primes: [], composite: new Set() };
const marks = new Uint8Array(upper + 1); // ← `n = 1e9` allocates 1 GB
...
}
```
Today, the only consumer (`grid-interaction.svelte.js`) hard-codes 100. But the function is public on the engine barrel. If a future lesson takes user input or a URL param, an unbounded `n` allocates `n+1` bytes and walks the sieve — easy memory exhaustion. Also, `sieveUpTo(NaN)` / `sieveUpTo(Infinity)` silently return empty results (no error), which is forgiving but hides bugs.
**Fix options (pick one):**
- Document the budget: add `@param {number} n - integer in [0, 10_000_000]` and throw above it.
- Or cap silently: `const upper = Math.min(10_000_000, Math.trunc(n));`
- At minimum: add `if (!Number.isFinite(n)) return { primes: [], composite: new Set() };` so NaN/Infinity are explicit.
### M3. `duong-thang/+page.svelte` is 238 LOC — over the 200 LOC soft limit
**File:** `src/routes/dai-so/duong-thang/+page.svelte`
The CLAUDE.md modularization rule is a soft 200 LOC suggestion. The SVG markup is already extracted to `cartesian-plane.svelte` (130 LOC). The script section (~125 LOC) plus the template chrome (header + sliders + toggles + theorem prose) puts the top page at 238. Most of the residual is presentational markup; not refactor-critical. **Consider** extracting the sliders section into a small helper component if it grows, or accept the overrun and note that the rule was bent due to markup density (not logic complexity).
### M4. `shakeIndex` setTimeout is not tracked in `pendingTimeouts`
**File:** `src/lib/lessons/sang-eratosthenes/grid-interaction.svelte.js:47`
```js
if (!isPrime(n)) {
shakeIndex = n - 1;
setTimeout(() => { shakeIndex = null; }, 400); // ← not in pendingTimeouts
return;
}
```
If the user clicks a non-prime → starts a shake → clicks reset within 400 ms → `shakeIndex` becomes 0 (via `focusIndex = 0` path? no, reset sets `focusIndex` but not `shakeIndex`). Then 400 ms later, `shakeIndex = null` from the orphan timeout. Minor: in the corner case the wrong cell may animate briefly post-reset. Track the shake timeout the same way you track ripple timeouts.
### M5. Cell "1" `aria-disabled` but click handler still active
**File:** `src/routes/so-hoc/sang-eratosthenes/+page.svelte:87`, `grid-interaction.svelte.js:44-49`
`aria-disabled="true"` on cell 1 announces "disabled" to screen readers, but the click handler still fires `handleCellActivate(1)``isPrime(1) === false` → shake. Inconsistent: SR users hear "disabled, can't activate"; sighted users see a shake. Either (a) wire a true disable in the click handler (early return when `n === 1`), or (b) drop the `aria-disabled` and let the shake be the universal "not a prime" feedback. Pick one path.
---
## Low-Priority / Nits
- `transforms.js:30` — In `rotate(theta, center)`, `compose(translate(-c.x,-c.y), rotateMat, translate(c.x,c.y))` reads as "translate to origin, rotate, translate back" — correct per compose's left-to-right semantic. Worth a one-line comment so the reader doesn't have to walk through the compose convention.
- `transforms.js:48-51``compose(...)` JSDoc says "applying it to a point p means C(B(A(p)))" — that's correct given the implementation `reduce((acc, m) => multiply(m, acc))` which computes `M_n · ... · M_2 · M_1`. The wording is precise; reader-friendly bonus would be to ALSO say "i.e. the matrix you get can be passed once to `applyToPoint` and it yields the same result as applying A, B, C in sequence."
- `transforms.js:11``Object.freeze` on the IDENTITY array is good defensively, but it's then `.slice()`d on every `compose()` zero-arg call. Tiny perf nit; could just `[1,0,0,0,1,0,0,0,1].slice()` inline OR return the frozen array directly and rely on callers not mutating. Either is fine; the current code is correct.
- `sieve.js:14` — Loop bound `i * i <= upper` is correct; covers up to √upper inclusive. Good.
- `geom-helpers.js:48-51``nx = a, ny = -b` (without dividing by c, then scaled by c) is mathematically equivalent to `(a/c, -b/c) * c` but the variable names suggest "normal x/y" when they're actually "normal-scaled-by-c". The comment in the helper clarifies but a one-liner explaining "we skip the /c and the *c cancellation" would help.
- `dinh-ly-pythagoras/+page.svelte:81-86` — The DEV-mode area-mismatch assertion checks `|a² + b² - c²|` which is always 0 by construction (since `c = Math.hypot(a, b)`). It's not actually validating the dissection geometry. If the goal is to validate the SHEAR target areas, compare against `applyToPolygon` outputs or compute polygon areas directly. As written, the assertion fires on `1e-15` float fuzz at best and is otherwise dead.
- `grid-interaction.svelte.js:36``cellRefs = $state(new Array(100).fill(null))` — typed as `HTMLButtonElement[]` but starts as `null[]`. JSDoc lying mildly. Not failure-mode-causing because the page uses `cellRefs[next]?.focus()` with optional chaining, but future grep "this can't be null" misreads. Tighten the type or `?? null`.
- `+page.svelte` aria-live regions are placed AFTER `<main>` in two of three lessons (Sàng, Đường thẳng) but INSIDE the article in Pythagoras. Cosmetic inconsistency; both placements work. Pick one.
- README architecture line still references `gcd, lcm, gcdSteps` for `numtheory-engine` AND lists `sieve` — consistent. ✓
---
## Test Coverage Assessment
**29 new tests added (target ≥ 26).** Breakdown matches plan:
- `transforms.test.js`: 13 tests covering translate, rotate (origin + custom center + round-trip), shear (both axes), compose (zero-args + order), applyToPolygon (count + non-mutation), approxEqualMat (tolerance + rejection). ✓ Solid.
- `sieve.test.js`: 10 tests covering `sieveUpTo` (n<2, n=2, n=10, n=100 with 91/97 oracle), `multiplesOf` (basic + boundaries + guards), `isPrime` (negative + 0/1/2 + composite 91). ✓ Solid.
- `linear.test.js`: 6 tests, matches plan exactly. ✓
**Gaps:**
- No test for `compose` with 1 argument (returns that matrix). Currently `compose(m)` would `reduce` with no initial — wait, `[m].reduce((acc, x) => multiply(x, acc))` reduces a single-element array to that element, no `multiply` calls. Returns `m` as-is. Correct, but untested.
- No test for `applyToPolygon([])` (empty polygon). Returns `[]`. Correct, untested.
- No test verifies that `Mat3` from `compose(rotate, translate, etc.)` matches an independently-computed expected matrix element-wise. Equal "after applying to a point" is tested; the matrix-form itself isn't.
- `multiplesOf` doesn't cover floats: `multiplesOf(2.5, 100)` — truncates to 2 — undocumented but reasonable. Worth a comment.
- `lineFromPoints` cross-engine epsilon issue (M1) has no test catching the wrong-magnitude semantic.
None of these gaps changes the verdict; they're "add when you next touch the file" items.
---
## A11y Assessment
| Lesson | Keyboard | aria-live | reduced-motion | Color-not-sole | Verdict |
|---|---|---|---|---|---|
| Pythagoras | ✅ vertex `role=button tabindex=0` + arrow via `draggable.keyStep:1`/`keyShiftStep:10` + `aria-label` | ✅ debounced 300 ms | ✅ tween duration → 0 when `prefers-reduced-motion: reduce` | N/A (geometry, not categorical color) | **GOOD** |
| Sàng | ✅ roving tabindex on 100-cell grid, Arrow/Home/End, Enter triggers via native `<button>` | ✅ ripple announces multiples | ✅ batch update with 0 stagger | ⚠️ Dots are `aria-hidden` but the cell `aria-label` does spell out "bội của 2 và 3" — text fallback present | **GOOD** with minor inconsistency (M5 above) |
| Đường thẳng | ✅ native `<input type="range">` sliders + anchor `role=button tabindex=0` with arrow via `keyStep: U` (= 0.1 slider step exactly) | ✅ texMath debounced 300 ms | N/A (no animations; CSS only has `transition-colors` on buttons — color, not motion) | N/A | **GOOD** but see H1 (off-screen anchors) |
The roving-tabindex on the 100-cell grid is correct: a single cell holds `tabindex=0`, all others are `-1`, focus moves via arrow keys with `e.preventDefault()` to suppress page scroll. Home/End restricted to row (per ARIA grid pattern). Page-up/down NOT implemented; acceptable for this lesson size.
`prefers-reduced-motion` is checked at module-load time via `window.matchMedia(...).matches`. This is a **snapshot** — if the user toggles the OS setting after page load, behavior won't update. Acceptable for the round; a future improvement is to listen to the `MediaQueryList.change` event.
aria-label for cells correctly includes Vietnamese diacritics and reads sensibly: "14 — bội của 2 và 7". ✓
---
## YAGNI Compliance Assessment
| Gate | Status |
|---|---|
| No shared `<Slider>` / `<NumberInput>` | ✅ Đường thẳng uses native `<input type="range">` only |
| No scoring / persistence / hint UI | ✅ Verified — no localStorage, no score state, no hint button |
| No animation library | ✅ Only Svelte built-in `tweened` + `cubicOut` (already in core) |
| No new npm dependencies | ✅ `package.json` + `pnpm-lock.yaml` untouched (verified via `git diff`) |
| No EN locale / dark mode / sitemap | ✅ |
| No stacked-transforms / generic transformation lib | ✅ `transforms.js` exposes exactly translate/rotate/shear/compose; no more |
`algebra-engine/` is born this round per the "2nd-consumer rule" trigger. Module shape mirrors the other engines cleanly (single `index.js` barrel, single file `linear.js`, single test file). Five JSDoc-typed functions; no premature abstraction. ✓
---
## Bundle / Perf Notes
Bundle sizes (gzipped, from `pnpm build`):
- `pythagoras/_page.svelte.js`: 5.70 KB gz
- `sang-eratosthenes/_page.svelte.js`: 3.63 KB gz
- `duong-thang/_page.svelte.js`: 3.80 KB gz
- **Total delta: ~13.1 KB gzipped — well under 20 KB target** ✓
No tree-shaking issues observed; engine barrels re-export individually so per-page imports stay minimal.
Perf observations:
- Pythagoras tween at 2000 ms / cubicOut feels appropriate; tween writes to `tp` which derives via `$derived` to polygon vertices (Svelte's batched effect runner keeps this at one repaint per frame).
- Sàng ripple uses `setTimeout` per cell at 30 ms stagger; max ripple is `multiplesOf(2, 100) = 49` cells → ~1.5 s. Each tick creates a new Map (`new Map(crossings)`) — O(n) on n already-marked cells. For 49 multiples × ~25 prior cells worst case = ~1225 ops. Fine.
- Đường thẳng grid renders 21 vertical + 21 horizontal grid lines × 2 `<line>` per (per gridLines loop pattern) = 42 lines. Plus 11 axis labels × 2 = 22 `<text>`. Tiny.
---
## Engine Correctness Sanity Checks (extra)
I worked through the Pythagoras dissection by hand because the plan flagged it as the most likely failure point. **The math is right:**
- `altitudeFoot(A, H, a, c)` computes F = A + (a²/c²)·(HA). With HA = (b, a) (since legs are axis-aligned and A above-R, H right-of-R), the parametric coefficient `t = a²/c²` follows from dropping perpendicular from R to AH; verified.
- `squareC` outward-normal direction: AH direction is (b/c, a/c); perpendicular CW (away from R, since R is on the CCW side of AH given a,b>0) is (a/c, b/c). Helper stores it pre-scaled-by-c as (a, b). Net offset of length c. ✓
- `shearATarget` parallelogram area: base |AF| = (a²/c²)·|HA| = (a²/c²)·c = a²/c; height = |(a,b)| = c; area = a²/c · c = **a²**. ✓
- `shearBTarget` area: base |FH| = (1 a²/c²)·c = (c² a²)/c = b²/c; height c; area = **b²**. ✓
- Combined: a² + b² = c² = area(squareC); they tile squareC because they share edge AF→A+(a,b)→F+(a,b)→FH along the altitude line extended into the square. ✓
Vertex correspondence in `lerpPoly` between `squareA → shearATarget` keeps A pinned (index 0). The other 3 vertices slide along reasonable paths. The animation will read as "leg-square pivots around A and shears into the rectangle". ✓
The DEV-only `import.meta.env.DEV` assertion at `+page.svelte:81-86` checks `|a² + b² c²| > 0.01` — but `c = Math.hypot(a, b)` so this difference is always 0 modulo float fuzz (~1e-15). The assertion validates nothing meaningful. **Nit:** swap it for an area-of-shearA + area-of-shearB ≈ area-of-squareC check that uses an actual polygon area routine.
---
## Unresolved Questions
1. **H1 fix shape** — when sliders push line outside the anchor-y clamp range, should:
- (a) anchors clamp to box edges (visible but no longer on the line), or
- (b) anchors hide (current de-facto behavior, but off-screen rather than `display:none`), or
- (c) the lesson constrain slider ranges to never produce out-of-band anchor positions (e.g., couple `b`'s range to `a`)?
Pick before next round.
2. **H3 design choice** — should anchor drag snap to slider 0.1 grid (current; with visible "snap" feel) or drive the line continuously (smoother feel but slider thumb drifts off-grid)?
3. **M2 sieve cap** — what's the largest `n` the engine should support? `n=1000` for a future "primes up to 1000" lesson is fine. `n=1e6` is questionable. Pick a documented cap.
4. The README still says "5 bài đã ra mắt" → updated to "8 bài". ✓ (Confirmed in diff.)
---
**Status:** DONE_WITH_CONCERNS
**Summary:** Three lessons land cleanly, math is correct, tests + check + build all green, no security/YAGNI violations. One real UX bug (anchors off-screen at slider extremes in duong-thang) and three API/code-hygiene concerns (dead re-entrancy flags, wrong-domain epsilon in linear.js, unbounded sieve n) should be patched soon but don't block landing.
@@ -0,0 +1,162 @@
# Brainstorm — Cool Demo Pages Round 2
- Date: 2026-05-15
- Mode: brutal-honesty advisory. No code in this round.
- Scope: pick the next 3 "cool demo" lessons after the 5-lesson baseline.
- Predecessors:
- `plans/reports/brainstorm-260430-2207-improved-port-spec.md` (port spec, shipped)
- `plans/reports/brainstorm-260503-1121-curriculum-logic-survey.md` (curriculum catalog, source-of-truth for `[next]/[later]/[YAGNI]` tags)
- Strategy inherited: C (lesson-driven, infra emerges)
- Autonomous mode — no clarifying questions asked, defaults applied (see §8).
## TL;DR
Ship three demos in one sub-project: **Pythagoras động (C)**, **Sàng Eratosthenes (A)**, **Đồ thị y=ax+b (D)**. They span 3 grades × 3 topics, force exactly 2 small new engine modules (`geom-engine/transforms.js`, `algebra-engine/linear.js`) and one tiny addition (`numtheory-engine/sieve.js`). Total ≈ 4-6 dev days. Defer transformations-suite (E), Pascal (F), fraction-pizza (B).
---
## 1 · Constraints inherited
| # | Constraint | Source |
|---|---|---|
| C1 | Vietnamese-first slugs; copy in `copy.vi.js` | port spec |
| C2 | Pure math engines under `src/lib/<topic>-engine/`, no DOM | port spec |
| C3 | Every draggable has keyboard + ARIA path | port spec |
| C4 | YAGNI: no scoring/persistence/quiz; no `algebra-engine` until 2nd consumer | survey §3 |
| C5 | Static-only deploy (GitHub Pages) — no server, no auth | survey §1.4 |
| C6 | Existing engine APIs stable — extend, don't fork | scout |
| C7 | No shared `<Slider>`/`<NumberInput>` until 2nd consumer surfaces | survey §3.1 |
---
## 2 · Candidate catalog
### A · Sàng Eratosthenes — `[6]` Số học
- URL: `/so-hoc/sang-eratosthenes/`
- Hook: 10×10 grid 1..100. Click prime → ripple cross-out of multiples. Stack 2,3,5,7 → primes pop out.
- Engine: `numtheory-engine/sieve.js``sieveUpTo(n)`, `multiplesOf(p,n)`. ~30 LOC, 8 tests.
- KaTeX: `\sqrt{n}` only.
- A11y: `role="grid"`, `gridcell` w/ aria-label, arrow-keys, `aria-live` announce.
- Effort: **S** · Wow: 🔥🔥🔥🔥 · Pedagogy: ⭐⭐⭐⭐ · Risk: LOW
### B · Phép cộng phân số — `[6]` Số học · DEFERRED
- Pizzas re-slice live to LCM denominator. Sum auto-aligns.
- Engine: `numtheory-engine/fraction.js`.
- Killed: SVG pie-arc math is sharp-edge land; ship a flatter visualization (bar-model only) in a later round.
### C · Định lý Pythagoras động — `[7]` Hình học
- URL: `/hinh-hoc/dinh-ly-pythagoras/`
- Hook: right triangle w/ three squares on sides. Drag right-angle vertex → squares + KaTeX update live. **"Chứng minh" button** plays 2s dissection-shear: two leg-squares slide-and-shear into the hypotenuse-square.
- Engine: `geom-engine/transforms.js``translate`, `rotate`, `shear`, `applyToPolygon`. ~50 LOC, 12 tests.
- KaTeX: `a^2 + b^2 = c^2`, side labels.
- A11y: vertex draggable + keyboard. Button respects `prefers-reduced-motion` (snap to final). `aria-live` announces a/b/c numerically.
- Effort: **M** · Wow: 🔥🔥🔥🔥🔥 · Pedagogy: ⭐⭐⭐⭐⭐ · Risk: MED (tween polish)
### D · Đồ thị y=ax+b — `[7]` Đại số
- URL: `/dai-so/duong-thang/`
- Hook: 2 sliders (a, b) + 2 anchor points draggable on the line — both directions bind. Optional "rise/run" triangle.
- Engine: `algebra-engine/linear.js``lineFromPoints`, `yAt`. ~20 LOC, 6 tests. **Trigger to officially open `algebra-engine/` module** (2nd consumer after `hieu-hai-binh-phuong`).
- KaTeX: `y = ax + b`, equation morphs live.
- A11y: sliders native; anchors keyboard-draggable; `aria-live` announces equation + 2 sample points.
- Effort: **M** · Wow: 🔥🔥🔥 · Pedagogy: ⭐⭐⭐⭐⭐ · Risk: LOW (bidirectional binding loop — gate via `untrack` or epsilon)
### E · Phép biến hình — `[8]` Hình học · DEFERRED
- Translate/rotate/reflect a polygon w/ stacked transforms + undo.
- Killed: too big for one shot (3 modes × a11y = L effort); ship `transforms.js` via C first, then promote E when the engine is battle-tested.
### F · Tam giác Pascal — `[8]` Đại số · DEFERRED
- Pascal triangle + binomial expansion.
- Killed: off-syllabus shape (VN grade 8 doesn't formalize `\binom{}{}`); combinatorics is `[9][YAGNI]` in survey.
---
## 3 · Trade-off matrix
| # | Wow | Pedagogy | Effort | Risk | Curriculum | Engine delta |
|---|-----|----------|--------|------|------------|--------------|
| A | 🔥🔥🔥🔥 | ⭐⭐⭐⭐ | S | LOW | ✅ `[6][next]` | `sieve.js` small |
| B | 🔥🔥🔥 | ⭐⭐⭐⭐⭐ | M | MED | ✅ `[6][next]` | `fraction.js` med |
| C | 🔥🔥🔥🔥🔥 | ⭐⭐⭐⭐⭐ | M | MED | ✅ `[7]→promote` | `transforms.js` med |
| D | 🔥🔥🔥 | ⭐⭐⭐⭐⭐ | M | LOW | ✅ `[7]→promote` | `algebra-engine/linear.js` small |
| E | 🔥🔥🔥🔥 | ⭐⭐⭐⭐ | L | HIGH | ✅ `[8][later]` | `transforms.js` med-large |
| F | 🔥🔥🔥🔥 | ⭐⭐⭐ | S-M | MED | ⚠️ off-syllabus | `binomial.js` small |
---
## 4 · Final recommendation (ranked)
| Rank | Pick | Rationale |
|---|---|---|
| 🥇 1 | **C — Pythagoras động** | Max wow × pedagogy. Dissection-shear is *the* canonical visceral proof. `transforms.js` pays dividends for future E. |
| 🥈 2 | **A — Sàng Eratosthenes** | Cheapest big-visual win. Zero engine churn. New click-grid interaction emerges naturally. Ships in 1 day. |
| 🥉 3 | **D — Đồ thị y=ax+b** | Triggers `algebra-engine/` officially. Bidirectional binding = real interactivity, not "another lesson". Foundational slope intuition. |
**Deferred**: B (arc-math complexity), E (too big this round), F (off-syllabus).
---
## 5 · Implementation considerations & risks
| # | Risk | Mitigation |
|---|---|---|
| R1 | C's tween looks janky w/o easing tuning | Use Svelte `tweened` + cubic-out; reduced-motion = snap |
| R2 | D's bidirectional state binding loops | Gate with epsilon check before writing back; or `untrack` in derived |
| R3 | A's grid keyboard nav UX | Mirror gridcell pattern from WAI-ARIA APG; arrow + Home/End |
| R4 | `transforms.js` API drift when E lands | Keep it pure + stateless this round; add `compose` only when E forces it |
| R5 | KaTeX bundle already at ~280KB — no growth budget | These three don't add KaTeX features beyond what `<Tex>` exposes |
| R6 | Three lessons in parallel = 3× state surface | Each lesson independent; share nothing but engines |
---
## 6 · Success metrics
- 3 routes added: `/so-hoc/sang-eratosthenes/`, `/hinh-hoc/dinh-ly-pythagoras/`, `/dai-so/duong-thang/`
- 2 engine modules created (`geom-engine/transforms.js`, `algebra-engine/linear.js`) + 1 file added (`numtheory-engine/sieve.js`)
- ≥ 26 new unit tests (12 transforms + 8 sieve + 6 linear)
- `pnpm check` clean (svelte-check + JSDoc strict)
- `pnpm test` green (all existing + new tests)
- Each lesson: keyboard-only completion possible, `aria-live` announces state changes, `prefers-reduced-motion` honored
- Bundle delta < 20KB gzipped (no new deps)
---
## 7 · Suggested phase plan (input to /ck:plan)
```
Phase 01: engine — geom-engine/transforms.js [blocks C]
Phase 02: engine — numtheory-engine/sieve.js [blocks A] (parallel w/ 01)
Phase 03: engine — algebra-engine/linear.js [blocks D] (parallel w/ 01,02)
Phase 04: lesson — /hinh-hoc/dinh-ly-pythagoras/ [needs 01]
Phase 05: lesson — /so-hoc/sang-eratosthenes/ [needs 02]
Phase 06: lesson — /dai-so/duong-thang/ [needs 03]
Phase 07: registry + landing tiles [needs 04,05,06]
Phase 08: tests + a11y manual verify [needs 07]
```
Engine phases parallel. Lesson phases parallel after their engine. Total ≈ 4-6 dev days.
---
## 8 · Autonomous-mode defaults applied
| Q | Default chosen |
|---|---|
| C choreography | Dissection-shear (full visceral version) — Euclid-style. Reduced-motion = snap. |
| A grid size | 100 cells (10×10), no slider — ship-faster. Slider deferred. |
| D anchors | 2 anchors + 2 sliders, both-ways binding (max wow). |
User can override any default before /ck:plan starts.
---
## 9 · Open questions (carry forward)
1. Confirm Cánh Diều grade-7 textbook ordering for Pythagoras (typically chapter 2 or 3)? Affects sequencing once landing-page ordering matters (Sub-project §3.4).
2. After `transforms.js` lands, do we promote E (transformations suite) to "next batch", or keep `[later]`? Answer drives whether `compose` enters the engine.
3. `algebra-engine/` is now officially "born" — what's the 3rd consumer that justifies adding a `polynomial.js`? Survey suggests `[7][next]` poly add/sub/scale.
---
**Status:** DONE
**Summary:** Six candidates surveyed, three deferred (B/E/F) with explicit reasons, top-3 (C, A, D) ranked and justified, phase plan + risks + success metrics scoped. Ready for `/ck:plan`.
**Concerns:** R1 (animation polish) is the only one that can derail timeline; bake in 0.5 day of polish buffer for Phase 04.