10 KiB
FunPen
Print-ready A4 math worksheets for children aged 6–10, built as HTML and rendered to PDF.
Every worksheet is a themed scene — spaceships, asteroids, cargo bays — where solving a problem does something in the story: you fill a cargo hold, you charge a battery, you plot a route. The math is ordinary; the wrapping is what makes a child pick the page up.
This repository is the authoring system behind them: a template pipeline, a set of in-browser visual editors, and a deterministic HTML → PDF renderer.
How it works
.template.html + .data.json → generate.mjs → .output.html → generate-pdf.mjs → PDF
(authored) (editor) (per type) (+ page PNGs) (Puppeteer)
Two inputs, one output, no hidden state:
.template.htmlis the document — near-final static markup, A4-sized, authored by hand (with an agent's help) and readable on its own..data.jsonis a thin overlay produced by the visual editor: positions, rotations, scales, values. It may not exist — and when it doesn't, the template is the output.generate.mjsapplies the overlay to the template. It is per task type, deterministic, and the only thing that writes.output.html.generate-pdf.mjsrenders that HTML with Puppeteer: A4,printBackground, zero margins,preferCSSPageSize.
The same pipeline serves both paths — a human dragging things around in the editor, and an agent regenerating the document from source. There is no second code path for "manual" edits.
Why the output is static HTML
Templates contain no runtime scripting. Nothing computes, generates or lays out content when the page loads. A worksheet must look identical in the browser, in the screenshot and in the PDF, so everything is resolved before render time — otherwise the print artifact and the preview quietly drift apart.
Document anatomy
Each document is up to four files in tasks/{type}/docs/:
| File | Purpose | Written by |
|---|---|---|
{docId}.md |
Problem patterns, page notes, non-obvious details | authoring (human + agent) |
{docId}.template.html |
The document itself — static A4 markup | authoring (human + agent) |
{docId}.data.json |
Editor overlay: positions, values, transforms | the editor's save endpoint |
{docId}.output.html |
Template with overlay applied | generate.mjs (not committed) |
The .md file never drives generation. It is context — why this page looks the way it
does, which patterns repeat, what must not be broken next time.
Task types
Seven types, fourteen documents. Each type owns its folder, its rules, its generator and — where the layout is hand-tuned — its editor.
| Type | What the child does | Editor |
|---|---|---|
space-exploration |
Solves problem cards decorated with icons, over a hero scene | — |
collecting-asteroids |
Matches asteroids to cargo ships so weights sum to capacity | ✓ |
asteroid-splitting |
Splits an asteroid of weight N into two unequal parts | ✓ |
cargo-filling |
Splits an asteroid to fill a hold to exactly 10 — addition across ten | ✓ |
space-route |
Follows a hex-graph route under arithmetic constraints between nodes | ✓ |
crystal-battery |
Two characters take turns filling a 10-cell battery — complements of 10 | — |
space-coloring |
Colours a full-page line-art scene | — |
Every type folder carries its own CLAUDE.md with layout rules, asset conventions and
generation constraints. Type-specific rules live there, never in the root file — that
separation is what keeps one type's quirks from leaking into another.
tasks/{type}/
CLAUDE.md — rules for this type
editor.html — visual editor (if the type needs one)
scripts/ — generate.mjs + validators
docs/ — the documents
temp/ — diffs and page screenshots (not committed)
The editor
Four of the seven types have a visual editor at
http://localhost:3300/tasks/{type}/editor.html?file={docId}.
They exist because some things cannot be authored blind: whether an asteroid overlaps a ship's nose, whether a row breathes, whether the page is balanced. Those are seen, not specified.
All four share src/editor/editor-core.js — worksheet loading, page navigation, selection,
toolbar, toast, save, export. Type-specific logic (what is draggable, what a keypress means,
how state serialises) stays in each editor.html.
Keyboard
Precision work is done from the keyboard, not the mouse. In collecting-asteroids:
| Key | Effect |
|---|---|
← → ↑ ↓ |
Move by 1 |
Shift + arrows |
Move by 5 |
[ ] |
Rotate (5°, or 1° with Shift) |
- + |
Change the asteroid's value — the sprite rescales to match |
, . |
Change the base scale for this asteroid type on this page |
The last one matters: it edits a class of objects, not one object. Tuning one asteroid is fiddling; tuning every asteroid of that kind on the page is design.
Millimetres, not pixels
The worksheet is a physical object. The editor establishes the scale from the rendered page:
_mmToPx = pages[0].getBoundingClientRect().width / 210; // 210mm = A4 width
Positions are reasoned about in millimetres and rendered in pixels, so what is nudged on screen is what comes out of the printer.
Save
- Editor
POSTs/api/save-editswith{ taskType, docId, data } - Server writes
docs/{docId}.data.json - Server diffs it against the previous state →
temp/{docId}.diff.json - Server runs
generate.mjs→ fresh.output.html+ per-page PNGs
The diff is the point. After a session of dragging, the question "what actually changed?" has a file for an answer instead of a guess — reviewable by a human, readable by an agent.
Two details that stop the editor from fighting its own tooling: live-reload is disconnected inside editor pages (the page holds unsaved DOM state, a reload would discard it), and the save endpoint de-duplicates rapid repeat POSTs.
Creating a new document of an existing type
No script — this is deliberate. Documents differ in ways a generator cannot guess.
- Copy the previous document's
.template.htmland.md, incrementing the number - Update the
.mdwith the new problem patterns and page notes - Edit the template: new problems, different hero images, different footers
- Add a card to the type's
index.html - Run
generate.mjs, thengenerate-pdf.mjs
.data.json starts absent and is created only if the document is opened in the editor.
Creating a new task type
tasks/{type-name}/- Pick a theme, record it in the type's
CLAUDE.mdas**Theme:** {name} - Write
CLAUDE.md— layout rules, asset conventions, generation constraints - Write
scripts/generate.mjs— template + data → output. It must callpostGenerate(), which produces the per-page screenshots the rest of the workflow depends on - Write
editor.htmlagainstsrc/editor/editor-core.jsif the layout needs hand-tuning.tasks/asteroid-splitting/editor.htmlis the reference implementation — the smallest one - Add a card to
tasks/index.htmland create the type's ownindex.html
Verification
Worksheets fail visually — an overlap, a clipped footer, a number sitting off its asteroid — and no assertion catches that. So every generation ends in a picture:
postGenerate()runstake-screenshots.mjs, writing one PNG per page totemp/- Stale screenshots are deleted first, so what is on disk always matches the current output
- Those PNGs are read back and checked; Chrome DevTools MCP covers interactive checks
- Editors are verified end to end — load, edit, save, regenerate — not just eyeballed
Scripts are tools, not actors
Every script in this repository reads input, computes, and either prints JSON to stdout or writes one specific file. Scripts do not chain, do not modify documents, and do not decide anything.
run → node tasks/collecting-asteroids/scripts/verify.mjs --ships 10,10,14 --asteroids 3,2,1,4,5
stdout ← { "valid": false, "errors": ["ship 2 overflow by 3"] }
then → read the output, decide, edit the template
Four writers are allowed, and the list is closed: generate.mjs (template + data → output),
generate-pdf.mjs (the final artifact), take-screenshots.mjs (page PNGs), and the editor's
save endpoint. Everything else is read-only.
This is the boundary the project is built on. Generation, layout and PDF rendering are fully deterministic and reproducible; judgement — what a worksheet should teach, how a page should feel, whether an image is right — stays outside that line, where it can be reviewed.
Themes and assets
Assets live in assets/themes/{name}/, each theme with its own CLAUDE.md listing available
images, palettes and usage rules. The space theme (assets/themes/nms/) carries the current
task types: spaceships, asteroids, planets, cargo bays, footer panoramas.
Images are generated through the Banatie API via a /gen-image skill, using existing images
from the same folder as references so a new asteroid looks like it belongs beside the old
ones. Newly generated files land in raw/ and move up a level once processed — usually
after remove-bg.mjs flood-fills the white background away.
Running it
pnpm install
pnpm dev # Tailwind watch + live-reload server on :3300
pnpm pdf -- tasks/{type}/docs/{docId}.output.html
Preview index: http://localhost:3300/tasks/index.html → type page → document.
Generated PDFs are committed under output/pdf/.
pnpm build:css # build Tailwind once, minified
pnpm remove-bg -- <file|dir> # strip white background from generated PNGs
Stack
Plain HTML and CSS for the documents, Tailwind for the utility layer, vanilla JavaScript for the editors, Node for the scripts. Puppeteer renders the PDFs, sharp processes the images. No framework, no build step for the worksheets themselves — a document is a file you can open.