← Whiteboard results

Whiteboard

Claude haiku 5.5 · full

Initial view of Whiteboard

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · 6c6aa5b74f
# Task: Build a Collaborative Whiteboard UI Prototype

Build a whiteboard application prototype (vanilla JS, no external assets, no CDN, no libraries) in the spirit of Excalidraw, Miro, or tldraw. The top priority is **rock-solid interaction** — drawing, selecting, moving, and undoing must all behave exactly as a user expects.

## Core Specification

**Infinite Canvas & Viewport**
- A pan/zoom canvas: Space+drag (or middle-mouse drag) to pan, mouse wheel to zoom toward the cursor position
- Zoom range roughly 25%–400%, with the current zoom shown in the corner and a "reset view" button
- All objects live in *world coordinates*; rendering applies the viewport transform. Hit-testing must be done in world coordinates — selection must stay pixel-accurate at every zoom level
- Optional subtle dot or line grid that pans/zooms with the world

**Tools** (toolbar with clear active-tool indication)
- **Select (V):** click to select, drag to move, drag a corner handle to resize; Shift-click adds to selection; drag on empty space rubber-band selects multiple
- **Freehand pen (P):** pressure-smooth-ish stroke drawing (capture points, render as a smoothed polyline/path)
- **Rectangle (R), Ellipse (O), Line/Arrow (L):** drag to create, live preview while dragging
- **Text (T):** click to place a text box, type inline (contenteditable or textarea overlay), Enter/click-away commits
- **Eraser (E):** drag over objects to delete them

**Selection & Manipulation**
- Selected objects show a bounding box with 4 corner resize handles (resize preserves aspect with Shift)
- Arrow keys nudge the selection by 1px (10px with Shift)
- Delete/Backspace deletes the selection
- Hit-testing: freehand strokes use point-near-path testing; shapes use proper inside/on-border tests — clicking a filled ellipse's corner region outside the shape must NOT select it

**Undo / Redo**
- Full history: every create, move, resize, restyle, text edit, and erase is one undoable step (a multi-object drag is a single step, not one per object)
- Ctrl+Z / Ctrl+Shift+Z (plus toolbar buttons); history is lost on reload, but the *board itself* persists via `localStorage`

**Styling**
- Small properties bar: stroke color, fill color, stroke width — applies to the current selection or sets the default for new objects
- A tasteful hand-drawn-adjacent default palette

## Visual Style
- Minimal, professional tool UI: floating toolbar (top or left), properties bar, zoom indicator — everything else is canvas
- Light canvas on a slightly darker app chrome; crisp at any zoom
- No backend, no collaboration networking — this is a local prototype, but the UI should look like a product

## Technical Requirements
- Vanilla JS with HTML5 Canvas, with clear section comments
- A clean object model: every shape is a serializable object `{ id, type, ... }` in a single scene array — rendering, hit-testing, and persistence all derive from it
- Tunable constants block at the top (colors, zoom limits, hit tolerance, handle size)
- Target: smooth drawing and panning at 60 FPS with a few hundred objects

## Quality Bar (must hit all)
- Zoom toward cursor: the world point under the cursor stays under the cursor while zooming
- Selection and resize handles are accurate at 25% and at 400% zoom
- Undo after a multi-object rubber-band move restores all objects in one step
- Board content survives a page reload
- Drawing while zoomed in places the stroke exactly under the cursor

## Self-Check Before Finishing
Verify with the browser tools:
- Draw a rectangle, zoom in to 300%+, click it — it selects; click clearly outside it — it deselects
- Create 3 shapes, rubber-band select all, drag, Ctrl+Z — all three return in one undo
- Draw a freehand stroke while panned/zoomed and confirm it lands under the cursor
- Reload the page and confirm the board is restored

Inspect original source 40,913 bytes · SHA-256 44fa5cd4ce52
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Whiteboard Prototype</title>
<style>
:root{
  --chrome:#e9ebef; --panel:#ffffff; --ink:#1f2328; --muted:#6b7280;
  --accent:#4f46e5; --accent-soft:#e7e9ff; --border:#d5d9e0;
  --shadow:0 6px 20px rgba(20,25,40,.14);
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:var(--chrome);
  font:13px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:var(--ink);
  user-select:none;-webkit-user-select:none}
#board{position:fixed;left:0;top:0;width:100vw;height:100vh;display:block;touch-action:none}
.panel{position:fixed;background:var(--panel);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow)}
#tools{left:12px;top:50%;transform:translateY(-50%);display:flex;flex-direction:column;gap:3px;padding:6px}
#props{top:12px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:8px;padding:5px 10px;white-space:nowrap}
#hist{left:12px;bottom:12px;display:flex;gap:3px;padding:4px}
#zoom{right:12px;bottom:12px;display:flex;align-items:center;gap:4px;padding:4px}
.tb{width:34px;height:34px;border:0;border-radius:7px;background:transparent;cursor:pointer;
  display:flex;align-items:center;justify-content:center;color:var(--ink);position:relative;padding:0}
.tb:hover{background:#f1f3f7}
.tb.active{background:var(--accent);color:#fff}
.tb:disabled{opacity:.3;cursor:default;background:transparent}
.tb svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.tb .k{position:absolute;right:3px;bottom:1px;font-size:8px;opacity:.55}
.tb.wide{width:auto;padding:0 8px;font-size:12px}
.sepv{width:1px;height:22px;background:var(--border);margin:0 4px}
.sep{height:1px;background:var(--border);margin:3px 4px}
.grp{display:flex;align-items:center;gap:5px}
.lbl{font-size:11px;color:var(--muted);margin-right:4px}
.sw{width:18px;height:18px;border-radius:50%;border:1px solid rgba(0,0,0,.18);cursor:pointer;padding:0;flex:none}
.sw.none{background:linear-gradient(135deg,#fff 42%,#e03131 45%,#e03131 55%,#fff 58%)}
.sw.sel{box-shadow:0 0 0 2px #fff,0 0 0 3.5px var(--accent)}
.wd{width:28px;height:26px;border:0;border-radius:6px;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0}
.wd.sel{background:var(--accent-soft)}
.wd i{display:block;background:var(--ink);border-radius:4px}
#zVal{min-width:58px;font-variant-numeric:tabular-nums;font-size:12px;font-weight:600}
#zReset{width:auto;padding:0 10px;font-size:12px}
#textEdit{position:fixed;display:none;margin:0;padding:0;border:1px dashed var(--accent);outline:none;
  background:rgba(255,255,255,.6);resize:none;overflow:hidden;white-space:pre;font-family:"Segoe UI",system-ui,sans-serif;
  color:#1e1e1e;z-index:20;min-width:8px;min-height:1em}
</style>
</head>
<body>
<canvas id="board"></canvas>

<nav id="tools" class="panel">
  <button class="tb" data-tool="select" title="Select (V)"><svg viewBox="0 0 24 24"><path d="M5 3l13 8-6 1.5L9.5 19z"/></svg><span class="k">V</span></button>
  <button class="tb" data-tool="pen" title="Pen (P)"><svg viewBox="0 0 24 24"><path d="M4 20l1-4L16 5l3 3L8 19z"/></svg><span class="k">P</span></button>
  <button class="tb" data-tool="rect" title="Rectangle (R)"><svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="14" rx="1.5"/></svg><span class="k">R</span></button>
  <button class="tb" data-tool="ellipse" title="Ellipse (O)"><svg viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="8" ry="6.5"/></svg><span class="k">O</span></button>
  <button class="tb" data-tool="line" title="Line / Arrow (L)"><svg viewBox="0 0 24 24"><path d="M5 19L19 5"/><path d="M10 5h9v9"/></svg><span class="k">L</span></button>
  <button class="tb" data-tool="text" title="Text (T)"><svg viewBox="0 0 24 24"><path d="M6 6h12M12 6v13"/></svg><span class="k">T</span></button>
  <div class="sep"></div>
  <button class="tb" data-tool="eraser" title="Eraser (E)"><svg viewBox="0 0 24 24"><path d="M4 16l8-9 7 6-5 6H8z"/><path d="M9 20h11"/></svg><span class="k">E</span></button>
</nav>

<div id="props" class="panel">
  <div class="grp"><span class="lbl">Stroke</span><span id="strokeSw" class="grp"></span></div>
  <div class="sepv"></div>
  <div class="grp"><span class="lbl">Fill</span><span id="fillSw" class="grp"></span></div>
  <div class="sepv"></div>
  <div class="grp"><span class="lbl">Width</span><span id="widthBtns" class="grp"></span></div>
  <div class="sepv"></div>
  <button id="headBtn" class="tb" title="Toggle arrowhead on selected lines"><svg viewBox="0 0 24 24"><path d="M4 12h13"/><path d="M13 6l6 6-6 6"/></svg></button>
</div>

<div id="hist" class="panel">
  <button id="undoBtn" class="tb" title="Undo (Ctrl+Z)"><svg viewBox="0 0 24 24"><path d="M9 14L4 9l5-5"/><path d="M4 9h10a6 6 0 010 12h-3"/></svg></button>
  <button id="redoBtn" class="tb" title="Redo (Ctrl+Shift+Z)"><svg viewBox="0 0 24 24"><path d="M15 14l5-5-5-5"/><path d="M20 9H10a6 6 0 000 12h3"/></svg></button>
</div>

<div id="zoom" class="panel">
  <button id="zOut" class="tb" title="Zoom out">−</button>
  <button id="zVal" class="tb" title="Zoom to 100%">100%</button>
  <button id="zIn" class="tb" title="Zoom in">+</button>
  <div class="sepv"></div>
  <button id="zReset" class="tb wide" title="Reset view">Reset view</button>
</div>

<textarea id="textEdit" spellcheck="false"></textarea>
<script>
(() => {
'use strict';

/* =========================================================================
   1. TUNABLE CONSTANTS
   ========================================================================= */
const CONFIG = {
  zoomMin: 0.25,
  zoomMax: 4,
  zoomStep: 1.25,
  wheelSensitivity: 0.0015,
  hitTolPx: 6,            // pointer tolerance for hit-testing, in SCREEN pixels
  eraserPx: 10,           // eraser radius, screen pixels
  handlePx: 9,            // drawn handle size, screen pixels
  handleHitPx: 12,        // handle grab radius, screen pixels
  dragThresholdPx: 3,     // movement before a click becomes a drag
  minSize: 2,             // minimum object size in world units
  nudge: 1,               // arrow-key nudge (world px)
  nudgeShift: 10,
  nudgeCoalesceMs: 500,
  penMinDistPx: 1.5,      // pen sampling distance in screen px
  historyLimit: 300,
  fontFamily: '"Segoe UI", system-ui, -apple-system, sans-serif',
  fontSize: 20,
  lineHeight: 1.25,
  gridSize: 32,
  gridDot: '#c9ced8',
  canvasBg: '#fbfbfa',
  selectColor: '#4f46e5',
  marqueeFill: 'rgba(79,70,229,0.08)',
  storageKey: 'wb-board-v1',
  viewKey: 'wb-view-v1',
  strokeColors: ['#1e1e1e', '#e03131', '#2f9e44', '#1971c2', '#f08c00', '#7048e8'],
  fillColors:   ['none', '#ffc9c9', '#b2f2bb', '#a5d8ff', '#ffec99', '#eebefa'],
  strokeWidths: [1.5, 3, 5],
  strokeWidthLabels: ['S', 'M', 'L'],
  defaults: { stroke: '#1e1e1e', fill: 'none', sw: 3, head: true },
  doubleClickMs: 350,
};

/* =========================================================================
   2. STATE
   ========================================================================= */
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const measureCtx = document.createElement('canvas').getContext('2d');
const textEdit = document.getElementById('textEdit');

let scene = [];                 // ALL objects, in z-order (last = top)
let selection = new Set();      // ids
let tool = 'select';
let defaults = { ...CONFIG.defaults };
let view = { x: 0, y: 0, z: 1 };  // screen = world * z + (x, y)
let past = [];                  // JSON snapshots (undo)
let future = [];                // JSON snapshots (redo)
let gesture = null;             // active pointer gesture
let editing = null;             // { id, before, isNew }
let spaceDown = false;
let cw = 0, ch = 0, dpr = 1;
let dirty = true;
let nudgeBefore = null, nudgeTimer = null;
let lastClick = { t: 0, id: null };
let hoverCursor = 'default';
let rafId = 0;

/* =========================================================================
   3. UTILITIES: IDs, cloning, persistence, history
   ========================================================================= */
const uid = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
const clone = v => JSON.parse(JSON.stringify(v));
const snapshot = () => JSON.stringify(scene);
const byId = id => scene.find(o => o.id === id);
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));

function save() {
  try { localStorage.setItem(CONFIG.storageKey, JSON.stringify({ v: 1, scene })); }
  catch (err) { console.warn('Board save failed', err); }
}
function saveView() {
  try { localStorage.setItem(CONFIG.viewKey, JSON.stringify(view)); } catch (e) {}
}
function load() {
  try {
    const raw = JSON.parse(localStorage.getItem(CONFIG.storageKey) || 'null');
    if (raw && Array.isArray(raw.scene)) scene = raw.scene.filter(o => o && o.id && o.type);
  } catch (e) { scene = []; }
  try {
    const v = JSON.parse(localStorage.getItem(CONFIG.viewKey) || 'null');
    if (v && isFinite(v.x) && isFinite(v.y) && isFinite(v.z)) view = { x: v.x, y: v.y, z: clamp(v.z, CONFIG.zoomMin, CONFIG.zoomMax) };
  } catch (e) {}
}

/* Commit a finished user action. `before` is the snapshot taken when the action began.
   If the scene actually changed, the previous state becomes one undo step. */
function commit(before) {
  if (before == null) return;
  const now = snapshot();
  if (now === before) { updateUI(); return; }
  past.push(before);
  if (past.length > CONFIG.historyLimit) past.shift();
  future = [];
  save();
  updateUI();
  requestRender();
}

function undo() {
  flushNudge();
  if (gesture || editing) return;
  if (!past.length) return;
  future.push(snapshot());
  scene = JSON.parse(past.pop());
  pruneSelection();
  save(); updateUI(); requestRender();
}
function redo() {
  flushNudge();
  if (gesture || editing) return;
  if (!future.length) return;
  past.push(snapshot());
  scene = JSON.parse(future.pop());
  pruneSelection();
  save(); updateUI(); requestRender();
}
function pruneSelection() {
  for (const id of [...selection]) if (!byId(id)) selection.delete(id);
}

/* =========================================================================
   4. GEOMETRY: world<->screen, bounds, hit testing
   ========================================================================= */
function screenPt(e) {
  const r = canvas.getBoundingClientRect();
  return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function toWorld(s) { return { x: (s.x - view.x) / view.z, y: (s.y - view.y) / view.z }; }
function toScreen(p) { return { x: p.x * view.z + view.x, y: p.y * view.z + view.y }; }

/* Axis-aligned bounds of an object in world coordinates. */
function bbox(o) {
  if (o.pts) {
    let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
    for (const p of o.pts) {
      if (p.x < x0) x0 = p.x; if (p.y < y0) y0 = p.y;
      if (p.x > x1) x1 = p.x; if (p.y > y1) y1 = p.y;
    }
    if (x0 === Infinity) return { x: 0, y: 0, w: 0, h: 0 };
    return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
  }
  return { x: o.x, y: o.y, w: o.w, h: o.h };
}
function unionBounds(objs) {
  if (!objs.length) return null;
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  for (const o of objs) {
    const b = bbox(o);
    x0 = Math.min(x0, b.x); y0 = Math.min(y0, b.y);
    x1 = Math.max(x1, b.x + b.w); y1 = Math.max(y1, b.y + b.h);
  }
  return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
function selectedObjects() { return scene.filter(o => selection.has(o.id)); }
function selectionBounds() { return unionBounds(selectedObjects()); }

function distToSeg(p, a, b) {
  const dx = b.x - a.x, dy = b.y - a.y;
  const len2 = dx * dx + dy * dy;
  let t = len2 === 0 ? 0 : ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
  t = clamp(t, 0, 1);
  const ex = a.x + t * dx - p.x, ey = a.y + t * dy - p.y;
  return Math.hypot(ex, ey);
}
function filled(o) { return o.fill && o.fill !== 'none'; }

/* Hit test a single object. p is WORLD point, tol is WORLD tolerance. */
function hitObject(o, p, tol) {
  const half = (o.sw || 1) / 2;
  const t = tol + half;
  switch (o.type) {
    case 'rect': {
      const b = bbox(o);
      const inside = p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
      if (inside && filled(o)) return true;
      const c = [
        { x: b.x, y: b.y }, { x: b.x + b.w, y: b.y },
        { x: b.x + b.w, y: b.y + b.h }, { x: b.x, y: b.y + b.h },
      ];
      for (let i = 0; i < 4; i++) if (distToSeg(p, c[i], c[(i + 1) % 4]) <= t) return true;
      return false;
    }
    case 'ellipse': {
      const b = bbox(o);
      const rx = Math.max(b.w / 2, 0.5), ry = Math.max(b.h / 2, 0.5);
      const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
      const nx = (p.x - cx) / rx, ny = (p.y - cy) / ry;
      const r = Math.hypot(nx, ny);            // normalized radius: 1 = on the outline
      if (r <= 1 && filled(o)) return true;
      // Distance to the outline, approximated along the normalized radius.
      return Math.abs(r - 1) * Math.min(rx, ry) <= t;
    }
    case 'line': {
      const [a, b] = o.pts;
      return distToSeg(p, a, b) <= t;
    }
    case 'pen': {
      const pts = o.pts;
      if (pts.length === 1) return Math.hypot(p.x - pts[0].x, p.y - pts[0].y) <= t;
      for (let i = 0; i < pts.length - 1; i++) if (distToSeg(p, pts[i], pts[i + 1]) <= t) return true;
      return false;
    }
    case 'text': {
      const b = bbox(o);
      return p.x >= b.x - tol && p.x <= b.x + b.w + tol && p.y >= b.y - tol && p.y <= b.y + b.h + tol;
    }
  }
  return false;
}

/* Topmost object under a world point, or null. */
function hitTest(p) {
  const tol = CONFIG.hitTolPx / view.z;
  for (let i = scene.length - 1; i >= 0; i--) {
    if (hitObject(scene[i], p, tol)) return scene[i];
  }
  return null;
}

/* Corner handles of the selection, in SCREEN space. Index: 0 tl, 1 tr, 2 br, 3 bl. */
function handleScreenPoints(b) {
  const tl = toScreen({ x: b.x, y: b.y });
  const br = toScreen({ x: b.x + b.w, y: b.y + b.h });
  return [
    { x: tl.x, y: tl.y }, { x: br.x, y: tl.y },
    { x: br.x, y: br.y }, { x: tl.x, y: br.y },
  ];
}
function handleAt(s) {
  const b = selectionBounds();
  if (!b) return -1;
  const hs = handleScreenPoints(b);
  for (let i = 0; i < 4; i++) {
    if (Math.abs(hs[i].x - s.x) <= CONFIG.handleHitPx && Math.abs(hs[i].y - s.y) <= CONFIG.handleHitPx) return i;
  }
  return -1;
}

/* Text measurement (world units). Width/height are stored on the object. */
function measureText(text, fs) {
  measureCtx.font = `${fs}px ${CONFIG.fontFamily}`;
  const lines = text.split('\n');
  let w = 0;
  for (const l of lines) w = Math.max(w, measureCtx.measureText(l).width);
  return { w: Math.max(w, fs * 0.6), h: Math.max(lines.length * fs * CONFIG.lineHeight, fs * CONFIG.lineHeight) };
}

/* =========================================================================
   5. RENDERING
   ========================================================================= */
function requestRender() {
  dirty = true;
  if (!rafId) rafId = requestAnimationFrame(() => { rafId = 0; if (dirty) render(); });
}

function resizeCanvas() {
  dpr = window.devicePixelRatio || 1;
  cw = window.innerWidth; ch = window.innerHeight;
  canvas.width = Math.round(cw * dpr);
  canvas.height = Math.round(ch * dpr);
  requestRender();
}

function drawGrid() {
  const g0 = CONFIG.gridSize;
  let g = g0 * view.z;
  while (g < 10) g *= 2;
  const ox = ((view.x % g) + g) % g, oy = ((view.y % g) + g) % g;
  ctx.fillStyle = CONFIG.gridDot;
  const size = Math.max(1, dpr * 1.2);
  for (let x = ox; x < cw; x += g) {
    for (let y = oy; y < ch; y += g) {
      ctx.fillRect(Math.round(x * dpr) - size / 2, Math.round(y * dpr) - size / 2, size, size);
    }
  }
}

/* Draw one object in WORLD coordinates (ctx already transformed). */
function drawObject(c, o) {
  c.save();
  c.lineWidth = o.sw || 1;
  c.strokeStyle = o.stroke;
  c.fillStyle = o.fill && o.fill !== 'none' ? o.fill : 'transparent';
  c.lineJoin = 'round';
  c.lineCap = 'round';
  switch (o.type) {
    case 'pen': {
      const p = o.pts;
      c.beginPath();
      if (p.length === 1) {
        c.arc(p[0].x, p[0].y, c.lineWidth / 2, 0, Math.PI * 2);
        c.fillStyle = o.stroke; c.fill();
      } else {
        c.moveTo(p[0].x, p[0].y);
        for (let i = 1; i < p.length - 1; i++) {
          const mx = (p[i].x + p[i + 1].x) / 2, my = (p[i].y + p[i + 1].y) / 2;
          c.quadraticCurveTo(p[i].x, p[i].y, mx, my);   // smoothed through midpoints
        }
        const l = p[p.length - 1];
        c.lineTo(l.x, l.y);
        c.stroke();
      }
      break;
    }
    case 'rect': {
      const b = bbox(o);
      c.beginPath();
      c.rect(b.x, b.y, b.w, b.h);
      if (filled(o)) c.fill();
      c.stroke();
      break;
    }
    case 'ellipse': {
      const b = bbox(o);
      c.beginPath();
      c.ellipse(b.x + b.w / 2, b.y + b.h / 2, Math.max(b.w / 2, 0.01), Math.max(b.h / 2, 0.01), 0, 0, Math.PI * 2);
      if (filled(o)) c.fill();
      c.stroke();
      break;
    }
    case 'line': {
      const [a, b] = o.pts;
      c.beginPath();
      c.moveTo(a.x, a.y);
      c.lineTo(b.x, b.y);
      c.stroke();
      if (o.head !== false) {
        const ang = Math.atan2(b.y - a.y, b.x - a.x);
        const len = 10 + c.lineWidth * 2.5, spread = Math.PI / 7;
        c.beginPath();
        c.moveTo(b.x, b.y);
        c.lineTo(b.x - len * Math.cos(ang - spread), b.y - len * Math.sin(ang - spread));
        c.lineTo(b.x - len * Math.cos(ang + spread), b.y - len * Math.sin(ang + spread));
        c.closePath();
        c.fillStyle = o.stroke;
        c.fill();
      }
      break;
    }
    case 'text': {
      if (editing && editing.id === o.id) break;   // the DOM editor shows it instead
      c.fillStyle = o.stroke;
      c.font = `${o.fs}px ${CONFIG.fontFamily}`;
      c.textBaseline = 'top';
      const lines = o.text.split('\n');
      lines.forEach((l, i) => c.fillText(l, o.x, o.y + i * o.fs * CONFIG.lineHeight));
      break;
    }
  }
  c.restore();
}

function render() {
  dirty = false;
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.fillStyle = CONFIG.canvasBg;
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  drawGrid();

  // World layer
  ctx.setTransform(dpr * view.z, 0, 0, dpr * view.z, dpr * view.x, dpr * view.y);
  for (const o of scene) drawObject(ctx, o);

  // Screen-space overlays (selection chrome, marquee) — constant pixel size
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  const sel = selectionBounds();
  if (sel && selection.size) {
    const tl = toScreen({ x: sel.x, y: sel.y });
    const br = toScreen({ x: sel.x + sel.w, y: sel.y + sel.h });
    ctx.strokeStyle = CONFIG.selectColor;
    ctx.lineWidth = 1;
    ctx.setLineDash([5, 4]);
    ctx.strokeRect(Math.round(tl.x) + .5, Math.round(tl.y) + .5, Math.round(br.x - tl.x), Math.round(br.y - tl.y));
    ctx.setLineDash([]);
    const hs = handleScreenPoints(sel);
    const hp = CONFIG.handlePx;
    for (const h of hs) {
      ctx.fillStyle = '#fff';
      ctx.strokeStyle = CONFIG.selectColor;
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.rect(h.x - hp / 2, h.y - hp / 2, hp, hp);
      ctx.fill(); ctx.stroke();
    }
  }
  if (gesture && gesture.type === 'marquee') {
    const a = toScreen(gesture.start), b = toScreen(gesture.cur);
    const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
    const w = Math.abs(a.x - b.x), h = Math.abs(a.y - b.y);
    ctx.fillStyle = CONFIG.marqueeFill;
    ctx.fillRect(x, y, w, h);
    ctx.strokeStyle = CONFIG.selectColor;
    ctx.lineWidth = 1;
    ctx.strokeRect(Math.round(x) + .5, Math.round(y) + .5, Math.round(w), Math.round(h));
  }
  if (editing) positionEditor();
}

/* =========================================================================
   6. UI SYNC (toolbar, property bar, zoom label)
   ========================================================================= */
function buildSwatches() {
  const sEl = document.getElementById('strokeSw');
  const fEl = document.getElementById('fillSw');
  const wEl = document.getElementById('widthBtns');
  CONFIG.strokeColors.forEach(c => {
    const b = document.createElement('button');
    b.className = 'sw'; b.style.background = c; b.dataset.stroke = c; b.title = 'Stroke ' + c;
    b.onclick = () => applyStyle('stroke', c);
    sEl.appendChild(b);
  });
  CONFIG.fillColors.forEach(c => {
    const b = document.createElement('button');
    b.className = 'sw' + (c === 'none' ? ' none' : ''); b.dataset.fill = c;
    if (c !== 'none') b.style.background = c;
    b.title = c === 'none' ? 'No fill' : 'Fill ' + c;
    b.onclick = () => applyStyle('fill', c);
    fEl.appendChild(b);
  });
  CONFIG.strokeWidths.forEach((w, i) => {
    const b = document.createElement('button');
    b.className = 'wd'; b.dataset.sw = w; b.title = 'Stroke width ' + CONFIG.strokeWidthLabels[i];
    const bar = document.createElement('i');
    bar.style.width = '14px'; bar.style.height = (w + 1) + 'px';
    b.appendChild(bar);
    b.onclick = () => applyStyle('sw', w);
    wEl.appendChild(b);
  });
}

function setTool(t) {
  if (editing) finishEditing();
  tool = t;
  document.querySelectorAll('#tools [data-tool]').forEach(b => b.classList.toggle('active', b.dataset.tool === t));
  canvas.style.cursor = t === 'select' ? 'default' : (t === 'text' ? 'text' : 'crosshair');
  updateUI();
}

/* Reflect the selection (or defaults) in the property bar. */
function updateUI() {
  const objs = selectedObjects();
  const src = objs.length ? objs[objs.length - 1] : defaults;
  document.querySelectorAll('[data-stroke]').forEach(b => b.classList.toggle('sel', b.dataset.stroke === src.stroke));
  document.querySelectorAll('[data-fill]').forEach(b => b.classList.toggle('sel', b.dataset.fill === (src.fill || 'none')));
  document.querySelectorAll('[data-sw]').forEach(b => b.classList.toggle('sel', +b.dataset.sw === src.sw));
  const hasLine = objs.some(o => o.type === 'line');
  const headOn = objs.length ? (objs.find(o => o.type === 'line') || {}).head !== false : defaults.head;
  document.getElementById('headBtn').classList.toggle('active', !!headOn && hasLine);
  document.getElementById('headBtn').disabled = objs.length > 0 && !hasLine;
  document.getElementById('undoBtn').disabled = !past.length;
  document.getElementById('redoBtn').disabled = !future.length;
  document.getElementById('zVal').textContent = Math.round(view.z * 100) + '%';
}

/* Apply a style property to the selection (one undo step) and to defaults. */
function applyStyle(prop, value) {
  flushNudge();
  if (prop === 'sw' || prop === 'stroke' || prop === 'fill') defaults[prop] = value;
  const objs = selectedObjects();
  if (objs.length) {
    const before = snapshot();
    for (const o of objs) {
      if (prop === 'fill' && o.type === 'line') continue;
      if (prop === 'stroke' && o.type === 'pen' && false) continue;
      o[prop] = value;
    }
    commit(before);
  } else {
    updateUI();
  }
}
function toggleHead() {
  flushNudge();
  const lines = selectedObjects().filter(o => o.type === 'line');
  if (!lines.length) { defaults.head = !defaults.head; updateUI(); return; }
  const before = snapshot();
  const on = lines.some(o => o.head === false);   // if any is off, turn all on
  lines.forEach(o => o.head = on);
  commit(before);
}

/* Zoom around a screen point, keeping the world point under the cursor fixed. */
function zoomAt(sx, sy, factor) {
  const nz = clamp(view.z * factor, CONFIG.zoomMin, CONFIG.zoomMax);
  if (nz === view.z) return;
  const w = toWorld({ x: sx, y: sy });
  view.z = nz;
  view.x = sx - w.x * nz;
  view.y = sy - w.y * nz;
  saveView(); updateUI(); requestRender();
}
function resetView() {
  const b = unionBounds(scene);
  const cx = b ? b.x + b.w / 2 : 0, cy = b ? b.y + b.h / 2 : 0;
  view.z = 1;
  view.x = cw / 2 - cx;
  view.y = ch / 2 - cy;
  saveView(); updateUI(); requestRender();
}

/* =========================================================================
   7. TEXT EDITING (DOM overlay)
   ========================================================================= */
function positionEditor() {
  if (!editing) return;
  const o = byId(editing.id);
  if (!o) return;
  const s = toScreen({ x: o.x, y: o.y });
  const fs = o.fs * view.z;
  textEdit.style.left = s.x + 'px';
  textEdit.style.top = s.y + 'px';
  textEdit.style.fontSize = fs + 'px';
  textEdit.style.lineHeight = (fs * CONFIG.lineHeight) + 'px';
  textEdit.style.color = o.stroke;
  textEdit.style.fontFamily = CONFIG.fontFamily;
  const m = measureText(textEdit.value || ' ', fs);
  textEdit.style.width = Math.max(m.w + fs * 0.4, 12) + 'px';
  textEdit.style.height = Math.max(m.h, fs) + 'px';
  textEdit.rows = Math.max(1, textEdit.value.split('\n').length);
}

function openEditor(obj, before, isNew) {
  editing = { id: obj.id, before, isNew };
  textEdit.value = obj.text;
  textEdit.style.display = 'block';
  positionEditor();
  textEdit.focus();
  textEdit.setSelectionRange(textEdit.value.length, textEdit.value.length);
  requestRender();
}

function finishEditing() {
  if (!editing) return;
  const ed = editing;
  editing = null;
  const o = byId(ed.id);
  textEdit.style.display = 'none';
  if (o) {
    o.text = textEdit.value;
    if (!o.text.trim()) {
      scene = scene.filter(x => x.id !== o.id);
      selection.delete(o.id);
    } else {
      const m = measureText(o.text, o.fs);
      o.w = m.w; o.h = m.h;
      selection = new Set([o.id]);
    }
  }
  textEdit.value = '';
  commit(ed.before);
  requestRender();
}

textEdit.addEventListener('input', () => {
  if (!editing) return;
  const o = byId(editing.id);
  if (o) { o.text = textEdit.value; const m = measureText(o.text || ' ', o.fs); o.w = m.w; o.h = m.h; }
  positionEditor();
});
textEdit.addEventListener('keydown', e => {
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); finishEditing(); }
  else if (e.key === 'Escape') { e.preventDefault(); finishEditing(); }
  e.stopPropagation();
});
textEdit.addEventListener('blur', () => finishEditing());

/* =========================================================================
   8. GEOMETRY OPERATIONS ON OBJECTS (move / resize / nudge)
   ========================================================================= */
/* Map each original object (clone) from base bounds `ob` into new bounds `nb`.
   Writes results into the live scene objects, matched by id. */
function transformObjects(origs, ob, nb) {
  const fx = p => (ob.w > 0 ? (p.x - ob.x) / ob.w : 0.5);
  const fy = p => (ob.h > 0 ? (p.y - ob.y) / ob.h : 0.5);
  const mapPt = p => ({ x: nb.x + fx(p) * nb.w, y: nb.y + fy(p) * nb.h });
  const sx = ob.w > 0 ? nb.w / ob.w : 1;
  const sy = ob.h > 0 ? nb.h / ob.h : 1;
  for (const o of origs) {
    const cur = byId(o.id);
    if (!cur) continue;
    if (o.pts) {
      cur.pts = o.pts.map(mapPt);
    } else if (o.type === 'text') {
      const p = mapPt({ x: o.x, y: o.y });
      cur.x = p.x; cur.y = p.y;
      if (Math.abs(sx - 1) > 1e-9 || Math.abs(sy - 1) > 1e-9) {
        const s = (sx + sy) / 2;
        cur.fs = clamp(Math.round(o.fs * s * 10) / 10, 4, 400);
      }
      const m = measureText(cur.text || ' ', cur.fs);
      cur.w = m.w; cur.h = m.h;
    } else {
      const p = mapPt({ x: o.x, y: o.y });
      cur.x = p.x; cur.y = p.y;
      cur.w = o.w * sx; cur.h = o.h * sy;
    }
  }
}

/* Translate selected objects by (dx, dy) in world units (used by arrow nudge). */
function translateSelected(dx, dy) {
  for (const o of selectedObjects()) {
    if (o.pts) o.pts = o.pts.map(p => ({ x: p.x + dx, y: p.y + dy }));
    else { o.x += dx; o.y += dy; }
  }
}

/* Nudge with coalescing: a burst of arrow presses becomes one undo step. */
function nudge(dx, dy) {
  if (!selection.size) return;
  if (nudgeBefore == null) nudgeBefore = snapshot();
  translateSelected(dx, dy);
  clearTimeout(nudgeTimer);
  nudgeTimer = setTimeout(flushNudge, CONFIG.nudgeCoalesceMs);
  save(); requestRender();
}
function flushNudge() {
  clearTimeout(nudgeTimer);
  nudgeTimer = null;
  if (nudgeBefore != null) {
    const b = nudgeBefore;
    nudgeBefore = null;
    commit(b);
  }
}

function deleteSelection() {
  if (!selection.size) return;
  flushNudge();
  const before = snapshot();
  scene = scene.filter(o => !selection.has(o.id));
  selection = new Set();
  commit(before);
}

function createShape(type, p) {
  const o = {
    id: uid(), type,
    stroke: defaults.stroke,
    fill: defaults.fill,
    sw: defaults.sw,
  };
  if (type === 'rect' || type === 'ellipse') Object.assign(o, { x: p.x, y: p.y, w: 0, h: 0 });
  else if (type === 'line') Object.assign(o, { pts: [{ x: p.x, y: p.y }, { x: p.x, y: p.y }], head: defaults.head });
  else if (type === 'pen') Object.assign(o, { pts: [{ x: p.x, y: p.y }] });
  return o;
}

/* Round for compact storage. */
const r2 = v => Math.round(v * 100) / 100;

/* =========================================================================
   9. POINTER INTERACTION (state machine via `gesture`)
   ========================================================================= */
function rectOf(a, b) {
  return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(a.x - b.x), h: Math.abs(a.y - b.y) };
}
function intersects(a, b) {
  return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y;
}

function onPointerDown(e) {
  if (e.button !== 0 && e.button !== 1) return;
  if (editing) finishEditing();
  flushNudge();
  canvas.setPointerCapture(e.pointerId);
  const s = screenPt(e);
  const p = toWorld(s);

  // Pan: middle button, or Space + drag
  if (e.button === 1 || spaceDown) {
    gesture = { type: 'pan', sx: s.x, sy: s.y, vx: view.x, vy: view.y };
    canvas.style.cursor = 'grabbing';
    e.preventDefault();
    return;
  }

  if (tool === 'select') {
    const h = handleAt(s);
    if (h >= 0) {
      const b = selectionBounds();
      gesture = { type: 'resize', handle: h, before: snapshot(), ob: b, origs: selectedObjects().map(clone) };
      return;
    }
    const hit = hitTest(p);
    if (hit) {
      if (e.shiftKey) {
        if (selection.has(hit.id)) selection.delete(hit.id); else selection.add(hit.id);
        updateUI(); requestRender();
        gesture = null;
        return;
      }
      const wasSelected = selection.has(hit.id);
      if (!wasSelected) selection = new Set([hit.id]);
      gesture = {
        type: 'move', start: p, sStart: s, moved: false,
        clickId: wasSelected ? hit.id : null,   // collapse multi-selection on plain click
        before: snapshot(),
        ob: selectionBounds(), origs: selectedObjects().map(clone),
      };
      updateUI(); requestRender();
      return;
    }
    const base = e.shiftKey ? new Set(selection) : new Set();
    if (!e.shiftKey) selection = new Set();
    gesture = { type: 'marquee', start: p, cur: p, base };
    updateUI(); requestRender();
    return;
  }

  if (tool === 'eraser') {
    gesture = { type: 'erase', before: snapshot() };
    eraseAt(p);
    requestRender();
    return;
  }

  if (tool === 'text') {
    const hit = hitTest(p);
    if (hit && hit.type === 'text') {
      selection = new Set([hit.id]);
      openEditor(hit, snapshot(), false);
      return;
    }
    const before = snapshot();
    const o = { id: uid(), type: 'text', x: p.x, y: p.y, text: '', fs: CONFIG.fontSize, stroke: defaults.stroke, w: 0, h: 0 };
    scene.push(o);
    selection = new Set([o.id]);
    openEditor(o, before, true);
    return;
  }

  // Shapes, lines and pen: create immediately, grow while dragging
  const before = snapshot();
  const o = createShape(tool, p);
  scene.push(o);
  gesture = { type: 'draw', id: o.id, start: p, before, sStart: s };
  requestRender();
}

function eraseAt(p) {
  const tol = CONFIG.eraserPx / view.z;
  const before = scene.length;
  scene = scene.filter(o => !hitObject(o, p, tol));
  if (scene.length !== before) { pruneSelection(); updateUI(); requestRender(); }
}

function onPointerMove(e) {
  const s = screenPt(e);
  const p = toWorld(s);

  if (!gesture) {
    if (tool === 'select' && !spaceDown) {
      const h = handleAt(s);
      if (h >= 0) canvas.style.cursor = (h === 0 || h === 2) ? 'nwse-resize' : 'nesw-resize';
      else canvas.style.cursor = hitTest(p) ? 'move' : 'default';
    }
    return;
  }

  switch (gesture.type) {
    case 'pan': {
      view.x = gesture.vx + (s.x - gesture.sx);
      view.y = gesture.vy + (s.y - gesture.sy);
      requestRender();
      break;
    }
    case 'marquee': {
      gesture.cur = p;
      const r = rectOf(gesture.start, p);
      const next = new Set(gesture.base);
      for (const o of scene) if (intersects(bbox(o), r)) next.add(o.id);
      selection = next;
      updateUI(); requestRender();
      break;
    }
    case 'move': {
      if (!gesture.moved) {
        const d = Math.hypot(s.x - gesture.sStart.x, s.y - gesture.sStart.y);
        if (d < CONFIG.dragThresholdPx) break;
        gesture.moved = true;
      }
      const dx = p.x - gesture.start.x, dy = p.y - gesture.start.y;
      const ob = gesture.ob;
      transformObjects(gesture.origs, ob, { x: ob.x + dx, y: ob.y + dy, w: ob.w, h: ob.h });
      canvas.style.cursor = 'move';
      requestRender();
      break;
    }
    case 'resize': {
      const ob = gesture.ob;
      const corners = [
        { x: ob.x, y: ob.y }, { x: ob.x + ob.w, y: ob.y },
        { x: ob.x + ob.w, y: ob.y + ob.h }, { x: ob.x, y: ob.y + ob.h },
      ];
      const anchor = corners[(gesture.handle + 2) % 4];
      const dx = p.x - anchor.x, dy = p.y - anchor.y;
      let w = Math.abs(dx), h = Math.abs(dy);
      if (e.shiftKey && ob.w > 0 && ob.h > 0) {
        const k = ob.w / ob.h;                 // keep original aspect ratio
        if (w > h * k) h = w / k; else w = h * k;
      }
      w = Math.max(w, CONFIG.minSize);
      h = Math.max(h, CONFIG.minSize);
      const nb = {
        x: dx < 0 ? anchor.x - w : anchor.x,
        y: dy < 0 ? anchor.y - h : anchor.y,
        w, h,
      };
      transformObjects(gesture.origs, ob, nb);
      requestRender();
      break;
    }
    case 'erase': {
      eraseAt(p);
      break;
    }
    case 'draw': {
      const o = byId(gesture.id);
      if (!o) break;
      if (o.type === 'pen') {
        const last = o.pts[o.pts.length - 1];
        if (Math.hypot(p.x - last.x, p.y - last.y) * view.z >= CONFIG.penMinDistPx) {
          o.pts.push({ x: r2(p.x), y: r2(p.y) });
        }
      } else if (o.type === 'line') {
        let q = { x: p.x, y: p.y };
        if (e.shiftKey) {                       // snap to 45° increments
          const a = gesture.start;
          const ang = Math.round(Math.atan2(q.y - a.y, q.x - a.x) / (Math.PI / 4)) * (Math.PI / 4);
          const len = Math.hypot(q.x - a.x, q.y - a.y);
          q = { x: a.x + Math.cos(ang) * len, y: a.y + Math.sin(ang) * len };
        }
        o.pts[1] = q;
      } else {
        const a = gesture.start;
        let dx = p.x - a.x, dy = p.y - a.y;
        if (e.shiftKey) {                       // square / circle
          const m = Math.max(Math.abs(dx), Math.abs(dy));
          dx = Math.sign(dx || 1) * m; dy = Math.sign(dy || 1) * m;
        }
        o.x = Math.min(a.x, a.x + dx); o.y = Math.min(a.y, a.y + dy);
        o.w = Math.abs(dx); o.h = Math.abs(dy);
      }
      requestRender();
      break;
    }
  }
}

function onPointerUp(e) {
  if (!gesture) return;
  const g = gesture;
  gesture = null;
  try { canvas.releasePointerCapture(e.pointerId); } catch (_) {}
  switch (g.type) {
    case 'pan':
      saveView();
      canvas.style.cursor = spaceDown ? 'grab' : (tool === 'select' ? 'default' : 'crosshair');
      break;
    case 'marquee':
      updateUI(); requestRender();
      break;
    case 'move':
      if (!g.moved && g.clickId) selection = new Set([g.clickId]);
      if (g.moved) commit(g.before);
      else { updateUI(); requestRender(); }
      break;
    case 'resize':
    case 'erase':
      commit(g.before);
      break;
    case 'draw': {
      const o = byId(g.id);
      let keep = true;
      if (o) {
        if (o.type === 'rect' || o.type === 'ellipse') keep = o.w >= 1 || o.h >= 1;
        if (o.type === 'line') keep = Math.hypot(o.pts[1].x - o.pts[0].x, o.pts[1].y - o.pts[0].y) >= 1;
      }
      if (!keep) scene = scene.filter(x => x.id !== g.id);
      else if (o && o.type !== 'pen') selection = new Set([o.id]);
      commit(g.before);
      break;
    }
  }
  canvas.style.cursor = tool === 'select' ? 'default' : (tool === 'text' ? 'text' : 'crosshair');
  requestRender();
}

function onDblClick(e) {
  if (tool !== 'select') return;
  const p = toWorld(screenPt(e));
  const hit = hitTest(p);
  if (hit && hit.type === 'text') {
    selection = new Set([hit.id]);
    openEditor(hit, snapshot(), false);
  }
}

canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
canvas.addEventListener('dblclick', onDblClick);
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('wheel', e => {
  e.preventDefault();
  if (editing) return;
  const unit = e.deltaMode === 1 ? 33 : e.deltaMode === 2 ? 800 : 1;
  const factor = Math.exp(-e.deltaY * unit * CONFIG.wheelSensitivity);
  const s = screenPt(e);
  zoomAt(s.x, s.y, factor);
}, { passive: false });

/* =========================================================================
   10. KEYBOARD
   ========================================================================= */
window.addEventListener('keydown', e => {
  if (editing) return;                       // textarea handles its own keys
  const mod = e.ctrlKey || e.metaKey;
  const k = e.key.toLowerCase();

  if (e.code === 'Space') {
    if (!e.repeat) { spaceDown = true; if (!gesture) canvas.style.cursor = 'grab'; }
    e.preventDefault();
    return;
  }
  if (mod) {
    if (k === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
    if (k === 'y') { e.preventDefault(); redo(); return; }
    if (k === 'a') { e.preventDefault(); flushNudge(); selection = new Set(scene.map(o => o.id)); updateUI(); requestRender(); return; }
    return;
  }
  if (k === 'delete' || k === 'backspace') { e.preventDefault(); deleteSelection(); return; }
  if (k === 'escape') {
    flushNudge();
    selection = new Set(); updateUI(); requestRender(); return;
  }
  if (e.key.startsWith('Arrow')) {
    e.preventDefault();
    const d = e.shiftKey ? CONFIG.nudgeShift : CONFIG.nudge;
    const map = { ArrowLeft: [-d, 0], ArrowRight: [d, 0], ArrowUp: [0, -d], ArrowDown: [0, d] };
    nudge(...map[e.key]);
    return;
  }
  const toolKeys = { v: 'select', p: 'pen', r: 'rect', o: 'ellipse', l: 'line', t: 'text', e: 'eraser' };
  if (toolKeys[k] && !e.altKey) { setTool(toolKeys[k]); }
});
window.addEventListener('keyup', e => {
  if (e.code === 'Space') {
    spaceDown = false;
    if (!gesture) canvas.style.cursor = tool === 'select' ? 'default' : (tool === 'text' ? 'text' : 'crosshair');
  }
});
window.addEventListener('blur', () => { spaceDown = false; });

/* =========================================================================
   11. CHROME WIRING & INIT
   ========================================================================= */
document.querySelectorAll('#tools [data-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.tool)));
document.getElementById('undoBtn').onclick = undo;
document.getElementById('redoBtn').onclick = redo;
document.getElementById('headBtn').onclick = toggleHead;
document.getElementById('zIn').onclick = () => zoomAt(cw / 2, ch / 2, CONFIG.zoomStep);
document.getElementById('zOut').onclick = () => zoomAt(cw / 2, ch / 2, 1 / CONFIG.zoomStep);
document.getElementById('zVal').onclick = () => zoomAt(cw / 2, ch / 2, 1 / view.z);
document.getElementById('zReset').onclick = resetView;
window.addEventListener('resize', resizeCanvas);

buildSwatches();
load();
const hadView = !!localStorage.getItem(CONFIG.viewKey);
resizeCanvas();
if (!hadView) resetView();
setTool('select');
updateUI();
requestRender();

// Expose a small debugging handle (read-only snapshot helpers)
window.__wb = { get scene() { return scene; }, get view() { return view; }, toScreen, toWorld, undo, redo };
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"812a67c0-ad5a-4841-9b61-9a82fcdecd1b","tokensIn":243608,"tokensOut":123274,"tokensTotal":366882,"cost":0.068708655,"turns":9,"toolCalls":12,"failedToolCalls":1,"timestamp":"2026-10-08T03:01:18.748Z"} -->