---
name: worksheet-layout
description: >
Unified layout system for printable A4 math worksheets. Use this skill when
creating or editing template.html files, building editors (tune or main),
writing generate.mjs scripts, or working with data.json for any task type.
Covers: page structure, coordinate system, units, element hierarchy, grouping,
z-index layering, data.json delta format, merge operations, editor helpers API,
and mandatory e2e testing protocols. Does NOT apply to existing task types
(space-exploration, collecting-asteroids, asteroid-splitting, space-route) —
only to newly created task types.
---
# Worksheet Layout System
Unified rules for structuring, positioning, and editing elements in printable A4 math worksheets. This skill is the single source of truth for layout decisions in new task types.
## When to Use
- Creating a new task type (`tasks/{type}/`)
- Writing or editing `*.template.html` files for new task types
- Building tune-editor or main-editor for a new task type
- Writing `generate.mjs` for a new task type
- Performing a merge operation on any task type using this system
- Reviewing or debugging layout/editor issues in new task types
## Does NOT Apply To
Legacy task types created before this system: `space-exploration`, `collecting-asteroids`, `asteroid-splitting`, `space-route`. Those keep their existing patterns.
---
## 1. Units
| What | Unit | Notes |
|------|------|-------|
| All layout dimensions | `mm` | widths, heights, positions, margins, gaps, paddings |
| Font sizes | `rem` | Only exception. Never mm for fonts |
| Borders, shadows | `px` | Thin decorative lines only (1-2px) |
**Banned in layout context:** `%`, `em`, `vw`, `vh`, `calc()` with mixed units.
**Why mm:** These are print documents (A4 = 210×297mm). Millimeters map 1:1 to physical output. No ambiguity, no container-relative surprises, no calc() fragility.
**Conversion reference:** At 96 DPI, 1mm ≈ 3.78px. Editor helpers handle mm↔px conversion via `mmToPx` ratio from EditorCore.
---
## 2. Page Structure
Every page follows a fixed three-layer structure:
```
┌─────────────────────────────┐
│ Page (210×297mm) │
│ ┌─────────────────────────┐ │
│ │ Header │ │ ← hero image, title, subtitle
│ ├─────────────────────────┤ │
│ │ │ │
│ │ Content Area │ │ ← fills remaining space
│ │ ┌───────┐ ┌───────┐ │ │
│ │ │Section│ │Section│ │ │ ← sections arranged by layout
│ │ └───────┘ └───────┘ │ │
│ │ ┌───────┐ ┌───────┐ │ │
│ │ │Section│ │Section│ │ │
│ │ └───────┘ └───────┘ │ │
│ │ │ │
│ ├─────────────────────────┤ │
│ │ Footer │ │ ← decorative (planet, wave, etc.)
│ └─────────────────────────┘ │
└─────────────────────────────┘
```
### HTML pattern
```html
```
| Attribute | Purpose |
|-----------|---------|
| `data-edit` | Unique ID within the section. Used as key in data.json |
| `data-edit-props` | Comma-separated list of allowed delta properties |
**Naming convention:** kebab-case. Examples: `ship-group`, `space-asteroid`, `badge-label`, `inner-cargo`.
**Allowed props:**
| Prop | Type | Unit | Meaning |
|------|------|------|---------|
| `dx` | number | mm | Horizontal offset from base position |
| `dy` | number | mm | Vertical offset from base position |
| `scale` | number | multiplier | Size multiplier (1.0 = original) |
| `rotate` | number | degrees | Rotation angle |
Additional props can be added ad-hoc per task type (e.g., `opacity`, `color`). Base props are always dx, dy, scale, rotate.
---
## 6. data.json Format
Stores **deltas only** for elements marked with `data-edit`. Created by the main editor.
```json
{
"pages": [
{
"page": 1,
"sections": [
{
"index": 0,
"elements": {
"ship-group": { "dx": 3.5, "dy": -1.0 },
"badge": { "scale": 1.2 },
"space-asteroid": { "dx": -2, "dy": 1, "rotate": 15 }
}
},
{
"index": 1,
"elements": {
"space-asteroid": { "scale": 1.3 }
}
}
]
}
]
}
```
**Rules:**
- Only elements with actual changes appear (sparse, not exhaustive)
- Zero-deltas are omitted (`dx: 0` → don't include)
- Scale default is 1.0 — only include if ≠ 1.0
- Rotate default is 0 — only include if ≠ 0
- Element IDs match `data-edit` values in template HTML
- Section index is 0-based within the page
---
## 7. generate.mjs — Applying Deltas
Each task type has its own `scripts/generate.mjs`. The delta application logic is standardized:
```javascript
// Pseudocode for applying deltas to a section element
function applyDelta(element, delta) {
const style = parseInlineStyle(element);
if (delta.dx != null || delta.dy != null) {
const baseLeft = parseFloat(style.left); // mm
const baseTop = parseFloat(style.top); // mm
style.left = `${baseLeft + (delta.dx || 0)}mm`;
style.top = `${baseTop + (delta.dy || 0)}mm`;
}
if (delta.scale != null) {
addTransform(style, `scale(${delta.scale})`);
}
if (delta.rotate != null) {
addTransform(style, `rotate(${delta.rotate}deg)`);
}
element.setAttribute('style', serializeStyle(style));
}
```
**Key principles:**
- dx/dy are ADDITIVE to base position (base 50mm + dx 3.5 = 53.5mm)
- scale is ABSOLUTE (scale 1.3 means 1.3×, not base × 1.3)
- rotate is ABSOLUTE (rotate 15 means 15°, not base + 15°)
- Find elements by `[data-edit="id"]` selector, scoped to section index
- Never chain scripts. generate.mjs reads template + data.json, writes output.html
### Shared helper for generate scripts
Task-type generate.mjs scripts should use a shared helper for delta application. This helper lives at `src/scripts/apply-deltas.mjs`:
```javascript
// src/scripts/apply-deltas.mjs
// Shared logic for applying data.json deltas to template HTML
//
// Usage in generate.mjs:
// import { applyDeltas } from '../../src/scripts/apply-deltas.mjs';
// const outputHtml = applyDeltas(templateHtml, dataJson);
export function applyDeltas(html, data) {
// Parse HTML with JSDOM or cheerio
// For each page in data.pages:
// Find page container (nth .page element)
// For each section in page.sections:
// Find section container (nth .section within page)
// For each [elementId, delta] in section.elements:
// Find element by [data-edit="elementId"]
// Apply delta props to inline style
// Return modified HTML string
}
```
This helper is created once and reused by all new task types. Task-specific generate.mjs handles any custom logic (problem generation, SVG creation, etc.) and calls `applyDeltas()` for the standard positioning step.
---
## 8. Two Editors
### 8.1 Tune Editor (Stage 1 — Layout Calibration)
**Purpose:** Adjust the base layout of ONE section. Changes propagate to all sections.
**When to use:** Early stage, before per-section fine-tuning. Setting up the initial element positions, sizes, and grouping within a section.
**Flow:**
```
Tune Editor UI
→ User adjusts element positions in a single section
→ Save → POST /api/save-tune → writes tune-data.json
→ Claude reads tune-data.json
→ Claude reviews changes, decides what to apply
→ Claude edits template.html directly (applies to all sections)
→ Claude deletes tune-data.json
```
**tune-data.json** is NOT automatically applied. It is a proposal for Claude. Claude is the arbiter — it reads the data, validates it makes sense, and manually updates the template. No script chaining.
**tune-data.json format:**
```json
{
"section": {
"elements": {
"ship-group": { "left": 52.5, "top": 8.0 },
"badge": { "left": 16.0, "top": 9.5, "scale": 1.1 },
"space-asteroid": { "left": 4.0, "top": 14.0, "rotate": 10 }
}
},
"hierarchy": {
"ship-group": {
"editable": true,
"children": {
"badge": { "editable": true },
"inner-asteroid": { "editable": true }
}
},
"space-asteroid": { "editable": true },
"arrows-svg": { "editable": false },
"formula": { "editable": false }
}
}
```
**Note:** `hierarchy.*.editable` marks which elements will be available in the main editor. Tune editor sets this; Claude writes the corresponding `data-edit` attributes into the template.
**Sidebar panel:** The tune editor has a sidebar showing the element hierarchy tree, auto-built from DOM by scanning elements with `data-edit` attributes within the section. Each element shows:
- Name (from `data-edit` value)
- Nesting level (indentation)
- Checkbox: editable in main editor (yes/no)
- Current position summary (left, top in mm)
### 8.2 Main Editor (Stage 2 — Per-Section Fine-Tuning)
**Purpose:** Make per-section, per-page adjustments. Only elements marked as editable (via `data-edit` + `data-edit-props`) can be modified.
**Flow:**
```
Main Editor UI
→ User selects a section, clicks an editable element
→ Drag / keyboard adjustments
→ Save → POST /api/save-edits → writes data.json
→ Server runs generate.mjs → output.html + screenshots
```
**This is the standard editor described in the project CLAUDE.md.** It saves to data.json, and generate.mjs applies deltas to produce output.html.
### Editor Helpers
Common functionality lives in shared modules alongside `editor-core.js`:
**`src/editor/editor-elements.js`** — Element interaction for BOTH editors:
```javascript
// Register an editable element
EditorElements.register(el, {
props: ['dx', 'dy', 'scale', 'rotate'], // allowed operations
mmToPx: ratio, // from EditorCore
onSelect: (el, info) => {}, // callback
onChange: (el, prop, value) => {} // callback
});
// Built-in behaviors (automatic after register):
// • Click → thin rounded outline (selection highlight)
// • Drag → updates left/top, converts px movement to mm
// • Arrow keys → ±0.5mm nudge (dx/dy)
// • +/- keys → ±0.05 scale
// • [/] keys → ±5° rotate
// • Orange circle indicator on changed elements
// • Tracks original state for change detection + reset
// Serialize all changes (for save)
EditorElements.serialize({ changesOnly: true })
// → { elements: { "ship-group": { dx: 3.5, dy: -1 }, ... } }
// Reset element to original state
EditorElements.reset(el)
// Reset all elements on current page
EditorElements.resetAll()
```
**`src/editor/editor-tune.js`** — Tune-editor specific (sidebar, hierarchy):
```javascript
// Build hierarchy tree from DOM
EditorTune.buildTree(sectionEl)
// Scans [data-edit] elements, returns nested structure
// Render sidebar panel
EditorTune.renderSidebar(treeData, containerEl)
// Shows hierarchy with checkboxes, position info
// Serialize for tune-data.json
EditorTune.serialize()
// → { section: { elements: {...} }, hierarchy: {...} }
```
**These helpers are implemented once and imported by all new editors.** Task-specific editors only need to define which elements exist and any custom behavior.
---
## 9. Merge Operation
**What:** Bake current data.json deltas into the template, reset data.json, start fresh.
**Who:** Claude only. Never automated.
**When:** User wants to lock in editor adjustments and start a new round of editing (or make further AI-driven changes to the template).
**Steps:**
```
1. Verify output.html exists and is up-to-date
→ Run generate.mjs if needed
2. Copy output.html → template.html (overwrite)
→ This bakes all deltas into the base template
3. Delete data.json (or write empty {})
→ Fresh start for the editor
4. Verify: run generate.mjs again
→ output.html should be identical to template.html (no deltas to apply)
→ Compare file sizes or diff to confirm
5. Update .md if new patterns were established
6. Test: open main editor, verify elements show at their new base positions
→ No orange indicators (no deltas)
→ Drag/scale/rotate creates new deltas from the merged baseline
```
**After merge, deltas are absolute, not multiplicative:**
- Pre-merge: template has `scale(1.0)`, data.json has `scale: 1.5` → output: `scale(1.5)`
- Post-merge: template has `scale(1.5)`, data.json empty
- New edit: data.json `scale: 1.2` → output: `scale(1.2)` (replaces, not multiplies)
---
## 10. Template HTML Conventions
### Page container
```html
```
### Section container
```html
```
### Editable element
```html

```
### Editable group
```html
10
```
### What NOT to do
```html
```
### Inline styles vs Tailwind
For positions and dimensions of editable elements: **always inline `style=""`**. This is what editors read/write and generate.mjs modifies.
Tailwind classes are fine for:
- Page container (`w-[210mm] h-[297mm]`)
- Non-editable decorative elements
- Typography, colors, borders
- Flex/grid layout of the section grid
**Rule of thumb:** If generate.mjs or an editor might touch it → inline style. If it's static structure → Tailwind is fine.
---
## 11. Mandatory Editor Testing Protocol
**BLOCKING REQUIREMENT.** Before presenting any editor (tune or main) to the user, Claude MUST complete ALL of the following steps using Chrome DevTools MCP. If any step fails, fix and re-test. Never skip.
### 11.1 Tune Editor Testing
```
Step 1: LOAD
→ navigate_page to tune editor URL
→ take_screenshot → verify section renders, sidebar shows hierarchy tree
→ verify element count in sidebar matches data-edit elements in DOM
Step 2: SIDEBAR VALIDATION
→ verify hierarchy nesting matches DOM structure
→ verify editable checkboxes reflect data-edit-props presence
→ verify position values shown match inline styles in HTML
Step 3: SELECT
→ click on an editable element
→ take_screenshot → verify thin rounded selection outline appears
→ verify sidebar highlights the selected element
→ verify status bar shows element ID and current position
Step 4: MOVE
→ press Arrow key 4 times (should move 2mm total)
→ take_screenshot → verify element visually moved
→ verify sidebar position values updated
Step 5: SCALE
→ press + key 3 times (should scale by 0.15 total)
→ take_screenshot → verify element visually scaled
→ verify status shows new scale value
Step 6: ROTATE
→ press ] key 2 times (should rotate 10° total)
→ take_screenshot → verify element visually rotated
Step 7: SAVE
→ click Save button
→ wait for save completion (toast "Saved!")
→ take_screenshot → verify no page reload occurred
→ read tune-data.json → verify it contains the changes made in steps 4-6
→ verify element positions in tune-data.json match what was shown in sidebar
Step 8: VERIFY TUNE-DATA CONTENT
→ read tune-data.json with Read tool
→ verify hierarchy section is present with correct nesting
→ verify editable flags match checkbox states
→ verify position values are in mm (not px, not %)
```
### 11.2 Main Editor Testing
```
Step 1: LOAD
→ navigate_page to main editor URL (?file=docId)
→ take_screenshot → verify all pages load with sections visible
→ verify only data-edit elements have hover/click affordance
Step 2: SELECT
→ click on an editable element in section 0
→ take_screenshot → verify selection highlight (thin rounded outline)
→ verify status bar shows element ID, section index, page number
Step 3: DRAG
→ mouse drag the element ~5mm to the right
→ take_screenshot → verify element moved
→ verify orange changed indicator appears on the element
Step 4: KEYBOARD
→ select another element
→ press Arrow-Right 4× (2mm), press + 2× (scale +0.1), press ] 1× (rotate 5°)
→ take_screenshot → verify all three modifications visible
→ verify orange indicators on both modified elements
Step 5: SAVE
→ click Save button
→ wait for toast "Saved!" and no page reload
→ take_screenshot after save
Step 6: VERIFY DATA.JSON
→ read data.json with Read tool
→ verify it contains entries for ONLY the two modified elements
→ verify section indices are correct
→ verify dx/dy values are in mm (reasonable magnitude, not px)
→ verify scale and rotate values match what was applied
Step 7: VERIFY GENERATE
→ run generate.mjs via Bash
→ read output.html — verify data-edit elements have modified inline styles
→ compare: base position + delta = output position (arithmetic check)
Step 8: RELOAD PERSISTENCE
→ navigate_page to same editor URL (fresh load)
→ take_screenshot → verify saved state is restored
→ verify orange indicators still show on previously modified elements
→ verify element positions match the saved state, not the original template
Step 9: VISUAL COMPARISON
→ take_screenshot of editor view (specific section)
→ read output page screenshot from temp/ (generated by postGenerate)
→ compare: element positions in editor must match output screenshots
→ if mismatch → editor or generate.mjs has a bug, fix before proceeding
```
### 11.3 Post-Merge Testing
```
Step 1: Verify output.html ≈ template.html (after merge, no deltas)
Step 2: Open main editor → no orange indicators visible
Step 3: Make a small edit → save → verify new data.json has only the new change
Step 4: Run generate.mjs → verify only the new change appears in output
```
---
## 12. Checklist: Creating a New Task Type
Use this checklist when setting up a new task type that follows the worksheet-layout system.
```
□ Create tasks/{type}/ folder structure
□ Create tasks/{type}/CLAUDE.md with type-specific rules
□ Define section layout in template (grid/flex/stack)
□ All layout dimensions in mm, fonts in rem
□ All editable elements have data-edit + data-edit-props
□ No % positioning, no calc() with mixed units, no negative margins
□ Sections have position: relative + overflow: hidden
□ Groups max 2 levels deep (section → group → element)
□ Z-index 1-6 within sections, semantically ordered
□ Create generate.mjs using shared applyDeltas() helper
□ Create tune-editor.html with sidebar hierarchy panel
□ Create editor.html (main editor) for per-section adjustments
□ Run tune editor e2e test (Section 11.1) — all steps pass
□ Run main editor e2e test (Section 11.2) — all steps pass
□ Add card to tasks/{type}/index.html
□ Add card to tasks/index.html
□ Verify all navigation links in browser (Chrome DevTools)
```
---
## 13. File Reference
```
src/editor/
editor-core.js — Shared editor framework (existing)
editor-elements.js — Element interaction helpers (NEW — this system)
editor-tune.js — Tune editor sidebar + hierarchy (NEW — this system)
src/scripts/
apply-deltas.mjs — Shared delta application logic (NEW — this system)
generate-pdf.mjs — PDF generation (existing)
post-generate.mjs — Post-generate screenshots (existing)
```
Helper files (`editor-elements.js`, `editor-tune.js`, `apply-deltas.mjs`) are created when implementing the first task type that uses this system. Their API is defined in this skill; implementation follows from the first real use case.