diff --git a/README.md b/README.md
index 009e817..30a3d39 100644
--- a/README.md
+++ b/README.md
@@ -17,10 +17,15 @@ modern conveniences belong in the workflow, not the output. See
## Run
```sh
-python3 -m http.server 8777 # from this directory
-# open http://127.0.0.1:8777
+python3 serve.py # from this directory, then open 127.0.0.1:8777
```
+Use `serve.py`, not `python3 -m http.server`. The latter sends `Last-Modified`
+and browsers cache ES modules on it hard enough that a reload serves a stale
+`js/app.js` against a fresh `index.html` — new knobs appear in the markup, nothing
+wires them, no error is raised, and the symptom reads as "the feature does not
+work". `serve.py` is the same server with `no-store`.
+
Static files and ES modules — no build step, no dependencies beyond MediaPipe's
wasm, which is fetched from a CDN on first use.
@@ -267,6 +272,40 @@ egg by construction, and no landmark precision fixes that. Hence the photo.
**Save frame 4x** writes the current registered composite as a 1280×800 PNG to
draw on.
+## Paint — background cels
+
+**A sketch.** It exists to test whether the aesthetic holds when a human draws
+the background instead of the tracker deriving it, and it is meant to be
+replaced by a real paint surface with onion skin and undo. It is one dependency-
+free module, `js/paint.js`, so throwing it away is a delete rather than surgery.
+
+Cels are drawn on the frames that get their own drawing and **hold until the
+next one** — the same rule the plate follows, and literally the same lookup. You
+can scrub anywhere and keep drawing on the cel you can see; the header says
+which one you are editing and how far it holds.
+
+- **pen** — click to place vertices, click the green box on the first one (or
+ Enter / double-click) to close.
+- **edit** — click a shape to select, drag a vertex or the whole shape,
+ Shift-click an edge to insert a vertex, Alt-click one to
+ remove it, Del to delete the layer.
+- **Layers** stack Photoshop-style, front at the top, with per-layer colour,
+ show/hide and reorder.
+- **Drag a frame** from the strip onto the canvas to seed this cel from that
+ one, every layer, as a deep copy. **Copy previous** does the same for the
+ previous kept frame, which is the case you reach for constantly.
+
+Two rules are enforced rather than left to discipline. Colours are **palette
+indices**, so you cannot pick one that is not in the ramp — sampling colour from
+the source is the one move `docs/design.md` says is irrecoverable. And vertices
+**snap to the 320×200 grid**, because on a hard-edged indexed rasteriser a shape
+nudged by 0.4px moves an edge by a whole pixel or not at all depending on where
+it lands, which shimmers instead of holding.
+
+Drawings autosave to `localStorage` per take name. They are the only thing in
+the tool a person made by hand; everything else regenerates. They are not in the
+`.take` export yet.
+
## Two kinds of sparseness
Sparseness has two unrelated causes, and conflating them was the original design
diff --git a/index.html b/index.html
index 9df6db8..579e043 100644
--- a/index.html
+++ b/index.html
@@ -52,6 +52,19 @@
.sw { display:flex; align-items:center; gap:5px; color:var(--dim); font-size:11px; }
.sw input { width:26px; height:20px; padding:0; border:1px solid var(--line); background:none; }
.legend { color:var(--dim); font-size:11px; margin-top:6px; }
+ /* paint: a sketch, see js/paint.js */
+ #cv-paint { border:1px solid var(--line); cursor:crosshair; touch-action:none; }
+ #cv-paint.drop { border-color:var(--ok); }
+ #paintlayers { flex:0 0 280px; max-height:600px; overflow:auto; }
+ .lay { display:flex; align-items:center; gap:5px; padding:3px 4px; border-radius:3px;
+ border:1px solid transparent; cursor:pointer; }
+ .lay.sel { border-color:var(--accent); background:#1c2130; }
+ .lay i { width:12px; height:12px; border-radius:2px; border:1px solid #0006; flex:0 0 auto; }
+ .lay b { flex:1; font-weight:400; color:var(--dim); font-size:11px; }
+ .lay.sel b { color:var(--fg); }
+ .lay select { font:inherit; font-size:10px; background:#1c2130; color:var(--dim);
+ border:1px solid var(--line); border-radius:2px; padding:1px 2px; max-width:88px; }
+ .lay button { padding:0 5px; font-size:11px; line-height:18px; }
kbd { background:#1c2130; border:1px solid var(--line); border-radius:2px;
padding:0 4px; color:var(--fg); font-size:11px; }
@@ -229,6 +242,36 @@
+
drawings needed — registered reference per kept frame, with the range it holds
diff --git a/js/app.js b/js/app.js
index 266ca86..ea4eb46 100644
--- a/js/app.js
+++ b/js/app.js
@@ -11,6 +11,7 @@ import { extractTeeth } from './interior.js';
import { applySim, offsetRing } from './mathutil.js';
import { writeTake } from './take.js';
import { synthDense } from './synth.js';
+import { PaintUI, drawCel, cloneCel, newCel } from './paint.js';
const RW = 320, RH = 200, ZOOM = 2, THUMB = 92;
@@ -53,6 +54,7 @@ const state = {
teeth: null, // resolved per-frame {show, t} after knobs
eyes: null, // resolved per-frame lid rings, shut flags, iris discs
brows: null, // resolved per-frame brow rings after quantised raise
+ cels: new Map(), // kept frame -> hand-painted background layers
eyeSig: null, // raw eye measurement, kept for the gaze readout
};
@@ -510,7 +512,14 @@ function renderFrame(f, mode = plateMode()) {
PALETTE.map((p) => p.hex));
} else {
r.clear(IDX.bg);
- if (mode === 'oval' || mode === 'oval+photo') r.fillPoly(state.plates[pf], IDX.base);
+ }
+ // Painted cels sit BEHIND the face and hold on the same frames the plate
+ // does - pf is already "the most recent kept frame at or before f", which is
+ // exactly the rule the user draws against: a cel holds until the next frame
+ // that has its own drawing.
+ drawCel(r, state.cels.get(pf), (i) => i);
+ if (mode !== 'posterize' && (mode === 'oval' || mode === 'oval+photo')) {
+ r.fillPoly(state.plates[pf], IDX.base);
}
// Eyes run on the CLOCK, not on the mouth lead. The lead is a lip-sync
@@ -641,6 +650,7 @@ function drawAll() {
drawStrip();
drawWorksheet();
drawReadout();
+ drawPaint();
}
function drawReadout() {
@@ -885,6 +895,8 @@ function drawStrip() {
const tag = document.createElement('span');
tag.textContent = f;
cell.append(cv, tag);
+ cell.draggable = true;
+ cell.ondragstart = (ev) => ev.dataTransfer.setData('text/plain', String(f));
cell.onclick = (ev) => {
seekTo(f);
if (ev.shiftKey) toggle(f);
@@ -1056,6 +1068,7 @@ async function runFrames() {
el('scrub').max = dense.length - 1;
state.frame = 0;
labelExposure();
+ loadCels();
rebuild(true);
const dur = (dense.length / state.fps).toFixed(2);
status(`${images.length} frames · ${images[0].naturalWidth}x${images[0].naturalHeight} · ` +
@@ -1078,6 +1091,7 @@ function runSynthetic() {
el('scrub').max = 71;
state.frame = 0;
labelExposure();
+ loadCels();
rebuild(true);
status('synthetic — exercises everything below detection', 'ok');
}
@@ -1254,6 +1268,92 @@ PALETTE.forEach((p) => {
el('palette').append(sw);
});
+/* ---------- paint ---------- */
+
+// The cel being edited is the one ON SCREEN, which is the most recent kept
+// frame at or before the playhead. You can scrub anywhere and keep drawing on
+// the cel you can see, rather than having to land exactly on a kept frame.
+const celFrame = () => (state.dense ? plateIndex(state.frame) : 0);
+
+const paint = new PaintUI({
+ canvas: el('cv-paint'), list: el('paintlayers'), info: el('paintinfo'),
+ zoom: 3, RW, RH, palette: PALETTE,
+ getCel: () => (state.dense ? (state.cels.get(celFrame()) || []) : []),
+ setCel: (cel) => { if (state.dense) state.cels.set(celFrame(), cel); },
+ celAt: (f) => (state.dense ? state.cels.get(heldFrame(keptSorted(), f)) : null),
+ backing: (cv, z) => {
+ if (!state.dense) {
+ cv.width = RW * z; cv.height = RH * z;
+ const g = cv.getContext('2d');
+ g.fillStyle = '#0d0f16'; g.fillRect(0, 0, cv.width, cv.height);
+ return;
+ }
+ compositeRender(cv, state.frame, z);
+ },
+ onChange: () => { saveCels(); drawPanes(); drawStrip(); drawWorksheet(); },
+});
+
+el('paintTool').addEventListener('change', () => {
+ paint.tool = el('paintTool').value;
+ paint.draft = null;
+ paint.render();
+});
+el('paintColor').addEventListener('change', () => { paint.color = +el('paintColor').value; });
+el('btn-celclear').onclick = () => {
+ if (!state.dense) return;
+ state.cels.set(celFrame(), newCel());
+ paint.sel = -1;
+ paint.commit();
+};
+el('btn-celprev').onclick = () => {
+ // Copy the previous kept frame's finished drawing onto this one. The same
+ // thing the drag-and-drop does, for the case you reach for constantly.
+ if (!state.dense) return;
+ const kept = keptSorted(), here = celFrame();
+ const prev = kept.filter((k) => k < here).pop();
+ if (prev === undefined) { paint.say('no earlier drawing to copy'); return; }
+ const src = state.cels.get(prev);
+ if (!src || !src.length) { paint.say(`f${prev} has nothing to copy`); return; }
+ state.cels.set(here, cloneCel(src));
+ paint.sel = -1;
+ paint.commit();
+ paint.say(`copied f${prev} — ${src.length} layers`);
+};
+
+PALETTE.forEach((p, i) => {
+ const o = document.createElement('option');
+ o.value = i; o.textContent = p.name;
+ el('paintColor').append(o);
+});
+el('paintColor').value = 1;
+paint.color = 1;
+
+// Drawings are the only thing here a person made by hand, so losing them to a
+// reload would be the worst failure in the tool. Everything else regenerates.
+const celKey = () => `arthur.cels.${el('takename').value || 'line_01'}`;
+function saveCels() {
+ try {
+ localStorage.setItem(celKey(), JSON.stringify([...state.cels]));
+ } catch { /* private window, quota - not worth failing a brush stroke over */ }
+}
+function loadCels() {
+ state.cels = new Map();
+ try {
+ const raw = localStorage.getItem(celKey());
+ if (raw) state.cels = new Map(JSON.parse(raw).map(([k, v]) => [+k, v]));
+ } catch { /* corrupt or absent: start empty */ }
+}
+
+function drawPaint() {
+ if (!state.dense) { paint.render(); return; }
+ const kept = keptSorted(), cf = celFrame();
+ const until = (kept[kept.indexOf(cf) + 1] ?? state.dense.length) - 1;
+ el('paintframe').textContent =
+ `drawing cel f${cf}` + (until > cf ? ` — holds to f${until}` : '') +
+ (state.frame !== cf ? ` (playhead f${state.frame})` : '');
+ paint.render();
+}
+
// #synth / #frames autorun, so the tool can be driven headlessly for smoke tests
// and deep-linked. Detection needs WebGL; the synthetic path does not.
window.addEventListener('error', (e) => {
diff --git a/js/paint.js b/js/paint.js
new file mode 100644
index 0000000..b50648a
--- /dev/null
+++ b/js/paint.js
@@ -0,0 +1,345 @@
+// Vector cel painting: flat polygons on the frames that get their own drawing.
+//
+// THIS IS A SKETCH. It exists to test whether the aesthetic holds when a human
+// draws the background rather than the tracker deriving it, and it is expected
+// to be replaced by a real paint surface with onion skin, undo and a proper
+// tool model. Deliberately kept to one module with no dependencies on the rest
+// of the pipeline so that throwing it away is a delete rather than a surgery.
+//
+// The data model is the part worth keeping:
+//
+// cel = Layer[] index 0 is the BACK of the stack, last is the front
+// Layer = { name, color, pts, hidden }
+//
+// `color` is a PALETTE INDEX, never an RGB value - the one rule from
+// docs/design.md that a paint tool could most easily break. You cannot pick a
+// colour here that is not already in the take's ramp.
+//
+// Points are integers in 320x200 raster space. Snapping is not a convenience:
+// sub-pixel vertices on a hard-edged indexed rasteriser move an edge by a whole
+// pixel or not at all depending on where the polygon happens to land, so a
+// shape nudged by 0.4px shimmers instead of holding still.
+
+export const newCel = () => [];
+
+export const cloneCel = (cel) =>
+ (cel || []).map((l) => ({
+ name: l.name, color: l.color, hidden: !!l.hidden,
+ pts: l.pts.map((p) => ({ x: p.x, y: p.y })),
+ }));
+
+// Fill every visible layer back to front. Same rasteriser the derived parts
+// use, so a painted shape and a traced one cannot look different.
+export function drawCel(raster, cel, idxOf) {
+ if (!cel) return;
+ for (const l of cel) {
+ if (l.hidden || l.pts.length < 3) continue;
+ raster.fillPoly(l.pts, idxOf(l.color));
+ }
+}
+
+const pointInPoly = (pts, x, y) => {
+ let inside = false;
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
+ if ((pts[i].y > y) !== (pts[j].y > y) &&
+ x < ((pts[j].x - pts[i].x) * (y - pts[i].y)) / (pts[j].y - pts[i].y) + pts[i].x) inside = !inside;
+ }
+ return inside;
+};
+
+// Distance from p to segment ab, and where along it the foot falls.
+function segDist(p, a, b) {
+ const vx = b.x - a.x, vy = b.y - a.y;
+ const len = vx * vx + vy * vy;
+ const t = len ? Math.max(0, Math.min(1, ((p.x - a.x) * vx + (p.y - a.y) * vy) / len)) : 0;
+ const cx = a.x + t * vx, cy = a.y + t * vy;
+ return { d: Math.hypot(p.x - cx, p.y - cy), t };
+}
+
+export class PaintUI {
+ constructor(opts) {
+ this.canvas = opts.canvas; // where the cel is drawn and edited
+ this.list = opts.list; // layer stack DOM host
+ this.info = opts.info; // status line
+ this.zoom = opts.zoom || 3;
+ this.RW = opts.RW; this.RH = opts.RH;
+ this.palette = opts.palette; // [{name,hex}], index is the colour
+ this.getCel = opts.getCel; // () => Layer[] for the frame being edited
+ this.setCel = opts.setCel; // (Layer[]) => void
+ this.celAt = opts.celAt; // (frame) => Layer[] for drag-to-seed
+ this.backing = opts.backing; // (canvas, zoom) => void, paints what is under
+ this.onChange = opts.onChange; // commit + redraw everything else
+
+ this.tool = 'pen';
+ this.color = 1;
+ this.sel = -1; // selected layer index
+ this.draft = null; // in-progress pen path
+ this.drag = null;
+
+ this.bind();
+ }
+
+ /* ---- geometry ---- */
+
+ at(ev) {
+ const r = this.canvas.getBoundingClientRect();
+ // Snap to the raster grid. See the note at the top of the file.
+ return {
+ x: Math.round(((ev.clientX - r.left) / r.width) * this.RW),
+ y: Math.round(((ev.clientY - r.top) / r.height) * this.RH),
+ };
+ }
+
+ hit(p) {
+ const cel = this.getCel();
+ const near = 3;
+ // Vertices of the SELECTED layer win over everything, so a vertex sitting
+ // under another shape stays grabbable instead of selecting the shape on top.
+ if (this.sel >= 0 && cel[this.sel]) {
+ const pts = cel[this.sel].pts;
+ for (let i = 0; i < pts.length; i++) {
+ if (Math.hypot(pts[i].x - p.x, pts[i].y - p.y) <= near) return { layer: this.sel, vertex: i };
+ }
+ for (let i = 0; i < pts.length; i++) {
+ const s = segDist(p, pts[i], pts[(i + 1) % pts.length]);
+ if (s.d <= near / 2) return { layer: this.sel, edge: i };
+ }
+ }
+ // Then shapes, front to back, so clicking picks what you can see.
+ for (let i = cel.length - 1; i >= 0; i--) {
+ if (!cel[i].hidden && cel[i].pts.length >= 3 && pointInPoly(cel[i].pts, p.x, p.y)) {
+ return { layer: i };
+ }
+ }
+ return null;
+ }
+
+ /* ---- editing ---- */
+
+ commit() { this.onChange(); this.render(); }
+
+ addLayer(pts) {
+ const cel = this.getCel().slice();
+ cel.push({ name: `shape ${cel.length + 1}`, color: this.color, hidden: false, pts });
+ this.setCel(cel);
+ this.sel = cel.length - 1;
+ this.commit();
+ }
+
+ mutate(fn) {
+ const cel = cloneCel(this.getCel());
+ fn(cel);
+ this.setCel(cel);
+ this.commit();
+ }
+
+ move(from, to) {
+ if (to < 0 || to >= this.getCel().length) return;
+ this.mutate((cel) => { cel.splice(to, 0, cel.splice(from, 1)[0]); });
+ this.sel = to;
+ this.render();
+ }
+
+ bind() {
+ const c = this.canvas;
+
+ c.addEventListener('mousedown', (ev) => {
+ ev.preventDefault();
+ const p = this.at(ev);
+
+ if (this.tool === 'pen') {
+ if (!this.draft) { this.draft = [p]; this.render(); return; }
+ const first = this.draft[0];
+ // Closing on the first point is how you finish; three points minimum,
+ // because a two-point "polygon" fills nothing and looks like a bug.
+ if (this.draft.length >= 3 && Math.hypot(first.x - p.x, first.y - p.y) <= 3) {
+ const pts = this.draft; this.draft = null; this.addLayer(pts); return;
+ }
+ this.draft.push(p); this.render(); return;
+ }
+
+ const h = this.hit(p);
+ if (!h) { this.sel = -1; this.render(); return; }
+ this.sel = h.layer;
+
+ if (h.vertex !== undefined) {
+ if (ev.altKey) {
+ // Never below a triangle.
+ if (this.getCel()[h.layer].pts.length > 3) {
+ this.mutate((cel) => cel[h.layer].pts.splice(h.vertex, 1));
+ }
+ return;
+ }
+ this.drag = { kind: 'vertex', layer: h.layer, vertex: h.vertex };
+ this.render(); return;
+ }
+ if (h.edge !== undefined && ev.shiftKey) {
+ this.mutate((cel) => cel[h.layer].pts.splice(h.edge + 1, 0, { x: p.x, y: p.y }));
+ this.drag = { kind: 'vertex', layer: h.layer, vertex: h.edge + 1 };
+ return;
+ }
+ this.drag = { kind: 'shape', layer: h.layer, from: p };
+ this.render();
+ });
+
+ window.addEventListener('mousemove', (ev) => {
+ if (!this.drag) return;
+ const p = this.at(ev);
+ const d = this.drag;
+ const cel = this.getCel();
+ if (d.kind === 'vertex') {
+ const v = cel[d.layer].pts[d.vertex];
+ if (v.x === p.x && v.y === p.y) return;
+ v.x = p.x; v.y = p.y;
+ } else {
+ const dx = p.x - d.from.x, dy = p.y - d.from.y;
+ if (!dx && !dy) return;
+ for (const q of cel[d.layer].pts) { q.x += dx; q.y += dy; }
+ d.from = p;
+ }
+ this.render();
+ });
+
+ window.addEventListener('mouseup', () => {
+ if (!this.drag) return;
+ this.drag = null;
+ this.commit();
+ });
+
+ c.addEventListener('dblclick', (ev) => {
+ ev.preventDefault();
+ if (this.tool === 'pen' && this.draft && this.draft.length >= 3) {
+ const pts = this.draft; this.draft = null; this.addLayer(pts);
+ }
+ });
+
+ // Seed from another frame: drag a strip thumbnail onto the canvas and take
+ // a copy of whatever that frame is showing, every layer. Copy, never
+ // reference - two cels sharing a layer object would edit each other and the
+ // reason would be invisible.
+ c.addEventListener('dragover', (ev) => { ev.preventDefault(); c.classList.add('drop'); });
+ c.addEventListener('dragleave', () => c.classList.remove('drop'));
+ c.addEventListener('drop', (ev) => {
+ ev.preventDefault();
+ c.classList.remove('drop');
+ const f = +ev.dataTransfer.getData('text/plain');
+ if (!Number.isFinite(f)) return;
+ const src = this.celAt(f);
+ if (!src || !src.length) { this.say(`f${f} has nothing to copy`); return; }
+ this.setCel(cloneCel(src));
+ this.sel = -1;
+ this.commit();
+ this.say(`seeded from f${f} — ${src.length} layers`);
+ });
+
+ window.addEventListener('keydown', (ev) => {
+ if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'SELECT') return;
+ if (!this.focused) return;
+ if (ev.key === 'Escape' && this.draft) { this.draft = null; this.render(); ev.preventDefault(); }
+ else if (ev.key === 'Enter' && this.draft && this.draft.length >= 3) {
+ const pts = this.draft; this.draft = null; this.addLayer(pts); ev.preventDefault();
+ } else if (ev.key === 'Backspace' && this.draft) {
+ this.draft.pop(); if (!this.draft.length) this.draft = null;
+ this.render(); ev.preventDefault();
+ } else if ((ev.key === 'Delete' || ev.key === 'Backspace') && this.sel >= 0) {
+ this.mutate((cel) => cel.splice(this.sel, 1));
+ this.sel = -1; ev.preventDefault();
+ }
+ });
+
+ // The paint canvas takes the keyboard only while the pointer is over it, so
+ // the main window's frame stepping keeps working everywhere else.
+ c.addEventListener('mouseenter', () => { this.focused = true; });
+ c.addEventListener('mouseleave', () => { this.focused = false; });
+ }
+
+ say(msg) { if (this.info) this.info.textContent = msg; }
+
+ /* ---- drawing ---- */
+
+ render() {
+ const z = this.zoom, c = this.canvas;
+ this.backing(c, z); // the composited frame, cels included
+ const g = c.getContext('2d');
+
+ const cel = this.getCel();
+ g.lineWidth = 1;
+ cel.forEach((l, i) => {
+ if (l.pts.length < 2) return;
+ const on = i === this.sel;
+ // Unselected outlines stay faint: they are there so you can find a shape
+ // to click, not so you can read them.
+ g.strokeStyle = l.hidden ? '#f8717166' : (on ? '#fbbf24' : '#ffffff33');
+ g.beginPath();
+ l.pts.forEach((p, k) => (k ? g.lineTo(p.x * z, p.y * z) : g.moveTo(p.x * z, p.y * z)));
+ g.closePath(); g.stroke();
+ if (!on) return;
+ g.fillStyle = '#fbbf24';
+ for (const p of l.pts) g.fillRect(p.x * z - 2, p.y * z - 2, 5, 5);
+ });
+
+ if (this.draft) {
+ g.strokeStyle = '#4ade80';
+ g.beginPath();
+ this.draft.forEach((p, k) => (k ? g.lineTo(p.x * z, p.y * z) : g.moveTo(p.x * z, p.y * z)));
+ g.stroke();
+ g.fillStyle = '#4ade80';
+ for (const p of this.draft) g.fillRect(p.x * z - 2, p.y * z - 2, 5, 5);
+ // The closing target, so "click here to finish" is visible rather than
+ // something you have to know.
+ if (this.draft.length >= 3) {
+ g.strokeStyle = '#4ade80';
+ g.strokeRect(this.draft[0].x * z - 4, this.draft[0].y * z - 4, 9, 9);
+ }
+ }
+ this.renderList();
+ }
+
+ renderList() {
+ const cel = this.getCel();
+ this.list.innerHTML = '';
+ // Top of the list is the FRONT of the stack, the way a layers panel reads.
+ for (let i = cel.length - 1; i >= 0; i--) {
+ const l = cel[i];
+ const row = document.createElement('div');
+ row.className = 'lay' + (i === this.sel ? ' sel' : '');
+
+ const sw = document.createElement('select');
+ this.palette.forEach((p, k) => {
+ const o = document.createElement('option');
+ o.value = k; o.textContent = p.name;
+ sw.append(o);
+ });
+ sw.value = l.color;
+ sw.onchange = () => this.mutate((cc) => { cc[i].color = +sw.value; });
+
+ const chip = document.createElement('i');
+ chip.style.background = this.palette[l.color] ? this.palette[l.color].hex : '#f0f';
+
+ const nm = document.createElement('b');
+ nm.textContent = l.name;
+ nm.onclick = () => { this.sel = i; this.render(); };
+
+ const btn = (txt, title, fn) => {
+ const b = document.createElement('button');
+ b.textContent = txt; b.title = title;
+ b.onclick = (e) => { e.stopPropagation(); fn(); };
+ return b;
+ };
+
+ row.append(chip, sw, nm,
+ btn('↑', 'forward', () => this.move(i, i + 1)),
+ btn('↓', 'back', () => this.move(i, i - 1)),
+ btn(l.hidden ? '◻' : '◼', 'show/hide', () => this.mutate((cc) => { cc[i].hidden = !cc[i].hidden; })),
+ btn('✕', 'delete', () => { this.mutate((cc) => cc.splice(i, 1)); this.sel = -1; }));
+ row.onclick = () => { this.sel = i; this.render(); };
+ this.list.append(row);
+ }
+ if (!cel.length) {
+ const e = document.createElement('div');
+ e.className = 'legend';
+ e.textContent = 'no layers — draw with the pen, or drag a frame here to copy its drawing';
+ this.list.append(e);
+ }
+ }
+}
diff --git a/serve.py b/serve.py
new file mode 100755
index 0000000..36139d3
--- /dev/null
+++ b/serve.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+"""Dev server that refuses to cache anything.
+
+`python3 -m http.server` sends Last-Modified, and browsers cache ES modules on
+it hard enough that a reload can serve a stale js/app.js against a fresh
+index.html. That failure is silent and points nowhere near its cause: the new
+knobs appear in the markup, nothing wires them, no error is raised, and the
+symptom reads as "the feature you just added does not work".
+"""
+import sys
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+
+
+class NoCache(SimpleHTTPRequestHandler):
+ def end_headers(self):
+ self.send_header('Cache-Control', 'no-store, must-revalidate')
+ self.send_header('Expires', '0')
+ super().end_headers()
+
+ def log_message(self, fmt, *args):
+ if '304' not in fmt % args:
+ super().log_message(fmt, *args)
+
+
+if __name__ == '__main__':
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 8777
+ print(f'http://127.0.0.1:{port} (no-store)')
+ ThreadingHTTPServer(('127.0.0.1', port), NoCache).serve_forever()