# 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.html`** is the document — near-final static markup, A4-sized, authored by hand (with an agent's help) and readable on its own. - **`.data.json`** is 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.mjs`** applies the overlay to the template. It is per task type, deterministic, and the only thing that writes `.output.html`. - **`generate-pdf.mjs`** renders 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: ```js _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 1. Editor `POST`s `/api/save-edits` with `{ taskType, docId, data }` 2. Server writes `docs/{docId}.data.json` 3. Server diffs it against the previous state → `temp/{docId}.diff.json` 4. 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. 1. Copy the previous document's `.template.html` and `.md`, incrementing the number 2. Update the `.md` with the new problem patterns and page notes 3. Edit the template: new problems, different hero images, different footers 4. Add a card to the type's `index.html` 5. Run `generate.mjs`, then `generate-pdf.mjs` `.data.json` starts absent and is created only if the document is opened in the editor. --- ## Creating a new task type 1. `tasks/{type-name}/` 2. Pick a theme, record it in the type's `CLAUDE.md` as `**Theme:** {name}` 3. Write `CLAUDE.md` — layout rules, asset conventions, generation constraints 4. Write `scripts/generate.mjs` — template + data → output. **It must call `postGenerate()`**, which produces the per-page screenshots the rest of the workflow depends on 5. Write `editor.html` against `src/editor/editor-core.js` if the layout needs hand-tuning. `tasks/asteroid-splitting/editor.html` is the reference implementation — the smallest one 6. Add a card to `tasks/index.html` and create the type's own `index.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()` runs `take-screenshots.mjs`, writing one PNG per page to `temp/` - 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 ```bash 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/`. ```bash pnpm build:css # build Tailwind once, minified pnpm remove-bg -- # 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.