244 lines
10 KiB
Markdown
244 lines
10 KiB
Markdown
# 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.
|
||
|
||
<!-- GALLERY -->
|
||
|
||
---
|
||
|
||
## 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 -- <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.
|