← Whiteboard results

Whiteboard

muse spark 1.2 · basic

Initial view of Whiteboard

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 41,920 bytes · SHA-256 a887f01f6d9a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Board — Collaborative Whiteboard</title>
<style>
  :root{
    --bg:#f1f2f4;
    --chrome:#e9eaed;
    --panel:#ffffff;
    --border:#e2e4e7;
    --text:#1a1d23;
    --muted:#6b7280;
    --accent:#232326;
    --accent2:#4f46e5;
    --handle:#4f46e5;
    --grid:#d6d9de;
  }
  *{box-sizing:border-box}
  html,body{height:100%;margin:0;font-family:Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background:var(--chrome); color:var(--text); overflow:hidden}
  #app{position:relative;width:100%;height:100%;display:flex;flex-direction:column}
  /* Top bar */
  #topbar{height:52px;background:var(--panel);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;padding:0 12px;z-index:10}
  #logo{display:flex;align-items:center;gap:10px;font-weight:700;letter-spacing:-0.02em}
  #logo-mark{width:28px;height:28px;background:var(--accent);color:#fff;display:grid;place-items:center;border-radius:8px;font-size:14px}
  #logo span{font-size:14px}
  #toolbar{display:flex;align-items:center;gap:4px;background:#f3f4f6;border:1px solid var(--border);padding:4px;border-radius:12px;margin-left:14px}
  .tool{width:36px;height:32px;border-radius:8px;border:1px solid transparent;background:transparent;display:grid;place-items:center;cursor:pointer;color:#2a2e36;font-size:13px;font-weight:600;position:relative}
  .tool.active{background:var(--accent);color:#fff;border-color:var(--accent);box-shadow:0 1px 4px rgba(0,0,0,.18)}
  .tool:hover:not(.active){background:#fff;border-color:var(--border)}
  .tool kbd{position:absolute;bottom:-16px;font-size:9px;color:var(--muted);font-weight:600;letter-spacing:.04em;opacity:0}
  .tool:hover kbd{opacity:1}
  #actions{margin-left:auto;display:flex;gap:8px;align-items:center}
  .btn{height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--border);background:#fff;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:6px}
  .btn:active{transform:translateY(1px)}
  .btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
  .btn:disabled{opacity:.45;pointer-events:none}
  #properties{display:flex;align-items:center;gap:12px;padding:6px 10px;background:var(--panel);border:1px solid var(--border);border-radius:12px;position:absolute;top:62px;left:50%;transform:translateX(-50%);z-index:9;box-shadow:0 8px 24px rgba(0,0,0,.08)}
  .prop{display:flex;align-items:center;gap:8px}
  .prop label{font-size:11px;font-weight:700;letter-spacing:.06em;color:var(--muted);text-transform:uppercase}
  .swatches{display:flex;gap:6px}
  .swatch{width:22px;height:22px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px var(--border);cursor:pointer;position:relative}
  .swatch.active{box-shadow:0 0 0 2px var(--accent2)}
  .swatch.clear{background:repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 8px 8px}
  input[type="range"]{width:84px;accent-color:var(--accent)}
  #canvasWrap{flex:1;position:relative;overflow:hidden;background:var(--chrome)}
  #canvas{position:absolute;inset:0;width:100%;height:100%;cursor:crosshair;background:#f1f2f4}
  #canvas.select{cursor:default}
  #canvas.grab{cursor:grab}
  #canvas.grabbing{cursor:grabbing}
  /* Zoom */
  #zoomBar{position:absolute;left:14px;bottom:14px;background:var(--panel);border:1px solid var(--border);border-radius:12px;display:flex;align-items:center;overflow:hidden;box-shadow:0 4px 16px rgba(0,0,0,.08);z-index:9}
  #zoomBar button{width:36px;height:36px;border:0;background:transparent;cursor:pointer;font-size:16px;display:grid;place-items:center}
  #zoomBar button:hover{background:#f3f4f6}
  #zoomLabel{font-size:12px;font-weight:700;min-width:58px;text-align:center;border-left:1px solid var(--border);border-right:1px solid var(--border);height:36px;display:grid;place-items:center;padding:0 6px}
  #help{position:absolute;right:14px;bottom:14px;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:10px 12px;font-size:11px;line-height:1.5;color:var(--muted);z-index:9;max-width:220px}
  #help b{color:var(--text)}
  /* text overlay */
  #textEditor{position:absolute;display:none;min-width:120px;min-height:28px;padding:6px 8px;border:1.5px solid var(--accent2);border-radius:6px;background:#fff;outline:none;font-size:16px;line-height:1.2;box-shadow:0 8px 24px rgba(0,0,0,.12);z-index:20;resize:none;overflow:hidden}
  #emptyHint{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);background:rgba(255,255,255,.9);border:1px solid var(--border);padding:10px 14px;border-radius:10px;font-size:13px;color:var(--muted);pointer-events:none}
</style>
</head>
<body>
<div id="app">
  <div id="topbar">
    <div id="logo"><div id="logo-mark">◈</div><span>Board</span><span style="font-weight:500;color:#9aa0a6;font-size:12px;margin-left:2px">Prototype</span></div>
    <div id="toolbar">
      <button class="tool active" data-tool="select" title="Select (V)">↖<kbd>V</kbd></button>
      <button class="tool" data-tool="pen" title="Pen (P)">✎<kbd>P</kbd></button>
      <button class="tool" data-tool="rect" title="Rectangle (R)">▭<kbd>R</kbd></button>
      <button class="tool" data-tool="ellipse" title="Ellipse (O)">○<kbd>O</kbd></button>
      <button class="tool" data-tool="arrow" title="Arrow (L)">↗<kbd>L</kbd></button>
      <button class="tool" data-tool="text" title="Text (T)">T<kbd>T</kbd></button>
      <button class="tool" data-tool="eraser" title="Eraser (E)">⌫<kbd>E</kbd></button>
    </div>
    <div id="actions">
      <button class="btn" id="undoBtn" title="Undo (Ctrl+Z)">↩ Undo</button>
      <button class="btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)">↪ Redo</button>
      <button class="btn" id="clearBtn" title="Clear board">Clear</button>
      <button class="btn primary" id="exportBtn">Export</button>
    </div>
  </div>

  <div id="properties">
    <div class="prop"><label>Stroke</label><div class="swatches" id="strokeSwatches"></div></div>
    <div style="width:1px;height:22px;background:var(--border)"></div>
    <div class="prop"><label>Fill</label><div class="swatches" id="fillSwatches"></div></div>
    <div style="width:1px;height:22px;background:var(--border)"></div>
    <div class="prop"><label>Width</label><input type="range" id="widthRange" min="1" max="12" value="2" step="1"><span id="widthVal" style="font-size:12px;font-weight:700;min-width:18px">2</span></div>
  </div>

  <div id="canvasWrap">
    <canvas id="canvas"></canvas>
    <textarea id="textEditor" placeholder="Type something…"></textarea>
    <div id="zoomBar">
      <button id="zoomOut">−</button>
      <div id="zoomLabel">100%</div>
      <button id="zoomIn">+</button>
      <button id="zoomReset" title="Reset view" style="width:auto;padding:0 10px;font-size:12px;font-weight:700;border-left:1px solid var(--border)">Reset</button>
    </div>
    <div id="help">
      <b>Space+drag</b> or <b>Middle-drag</b> to pan<br>
      <b>Wheel</b> to zoom to cursor<br>
      <b>Shift+drag</b> keep aspect · <b>Arrows</b> nudge
    </div>
    <div id="emptyHint">Drag to draw · V to select · Scroll to zoom</div>
  </div>
</div>

<script>
/* ================= TUNABLE CONSTANTS ================= */
const CONFIG = {
  MIN_ZOOM: 0.25,
  MAX_ZOOM: 4,
  ZOOM_WHEEL_SPEED: 0.0012,
  GRID_SIZE: 28,
  HIT_TOLERANCE: 10,      // screen px
  HANDLE_SIZE: 8,         // screen px
  PEN_SIMPLIFY: 1.2,
  STROKE_PALETTE: ['#1a1d23','#ef4444','#f59e0b','#10b981','#0ea5e9','#6366f1','#ec4899','#6b7280'],
  FILL_PALETTE: ['transparent','#ffffff','#fef08a','#bbf7d0','#bfdbfe','#ddd6fe','#fecdd3','#e5e7eb'],
  DEFAULT_STROKE: '#1a1d23',
  DEFAULT_FILL: 'transparent',
  DEFAULT_WIDTH: 2,
  BG: '#f1f2f4'
};

/* ================= STATE ================= */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('canvasWrap');
let dpr = Math.max(1, window.devicePixelRatio||1);

let viewport = { x: 0, y: 0, scale: 1 };
let scene = [];
let selectedIds = new Set();
let currentTool = 'select';
let defaultStyle = { stroke: CONFIG.DEFAULT_STROKE, fill: CONFIG.DEFAULT_FILL, width: CONFIG.DEFAULT_WIDTH };

let isDragging = false;
let dragMode = null; // 'move' | 'resize' | 'rubber' | 'draw' | 'pen' | 'eraser'
let dragStart = null; // world
let dragCurrent = null;
let activeHandle = -1;
let rubberRect = null;
let previewObj = null;
let penPoints = [];
let moveSnapshot = null; // Map id -> {x,y}
let resizeSnapshot = null; // { bounds, objects: Map }
let spaceDown = false;
let panning = false;
let panStart = null;

let history = [];
let historyIndex = -1;

/* ================= UTIL ================= */
const uid = ()=> Math.random().toString(36).slice(2,9);
const clamp = (v,a,b)=> Math.max(a,Math.min(b,v));
function screenToWorld(sx,sy){
  const rect = canvas.getBoundingClientRect();
  const x = (sx - rect.left - viewport.x) / viewport.scale;
  const y = (sy - rect.top - viewport.y) / viewport.scale;
  return {x,y};
}
function worldToScreen(wx,wy){
  return { x: wx*viewport.scale + viewport.x, y: wy*viewport.scale + viewport.y };
}
function getCanvasRect(){ return canvas.getBoundingClientRect(); }
function pushHistory(){
  const snap = JSON.stringify(scene);
  // truncate future
  if(historyIndex < history.length-1) history = history.slice(0,historyIndex+1);
  history.push(snap);
  historyIndex = history.length-1;
  if(history.length>120){ history.shift(); historyIndex--; }
  savePersist();
  updateUndoUI();
}
function restoreFromHistory(idx){
  if(idx<0 || idx>=history.length) return;
  scene = JSON.parse(history[idx]);
  selectedIds.clear();
  savePersist();
  render();
  updateUndoUI();
}
function savePersist(){
  try{
    localStorage.setItem('board-scene-v2', JSON.stringify(scene));
    localStorage.setItem('board-viewport-v2', JSON.stringify(viewport));
    localStorage.setItem('board-history-v2', JSON.stringify({history,historyIndex}));
  }catch(e){}
}
function loadPersist(){
  try{
    const s = localStorage.getItem('board-scene-v2');
    if(s) scene = JSON.parse(s);
    const v = localStorage.getItem('board-viewport-v2');
    if(v) viewport = JSON.parse(v);
    const h = localStorage.getItem('board-history-v2');
    if(h){
      const o = JSON.parse(h);
      history = o.history||[]; historyIndex = o.historyIndex??-1;
    }
    if(history.length===0 && scene.length>0){ history=[JSON.stringify(scene)]; historyIndex=0; }
    if(history.length===0){ history=[JSON.stringify(scene)]; historyIndex=0; }
  }catch(e){}
}

/* ================= GEOMETRY & HIT TEST ================= */
function boundsOf(obj){
  if(obj.type==='pen'){
    if(!obj.points||obj.points.length===0) return {x:0,y:0,w:0,h:0};
    let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
    obj.points.forEach(p=>{ minX=Math.min(minX,p.x); minY=Math.min(minY,p.y); maxX=Math.max(maxX,p.x); maxY=Math.max(maxY,p.y); });
    const pad = obj.width/2+2;
    return {x:minX-pad,y:minY-pad,w:(maxX-minX)+pad*2,h:(maxY-minY)+pad*2};
  }
  if(obj.type==='line' || obj.type==='arrow'){
    const x1=obj.x, y1=obj.y, x2=obj.x+obj.w, y2=obj.y+obj.h;
    return {x:Math.min(x1,x2)-6,y:Math.min(y1,y2)-6,w:Math.abs(obj.w)+12,h:Math.abs(obj.h)+12};
  }
  if(obj.type==='text'){
    const w = obj.w||120, h= obj.h||24;
    return {x:obj.x,y:obj.y,w,h};
  }
  return {x: Math.min(obj.x,obj.x+obj.w), y:Math.min(obj.y,obj.y+obj.h), w:Math.abs(obj.w), h:Math.abs(obj.h)};
}
function selectionBounds(ids){
  let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
  let found=false;
  scene.forEach(o=>{ if(ids.has(o.id)){ const b=boundsOf(o); minX=Math.min(minX,b.x); minY=Math.min(minY,b.y); maxX=Math.max(maxX,b.x+b.w); maxY=Math.max(maxY,b.y+b.h); found=true; }});
  if(!found) return null;
  return {x:minX,y:minY,w:maxX-minX,h:maxY-minY};
}
function distToSegment(px,py, x1,y1,x2,y2){
  const l2 = (x2-x1)*(x2-x1)+(y2-y1)*(y2-y1);
  if(l2===0) return Math.hypot(px-x1,py-y1);
  let t = ((px-x1)*(x2-x1)+(py-y1)*(y2-y1))/l2; t=Math.max(0,Math.min(1,t));
  const projX = x1+t*(x2-x1), projY=y1+t*(y2-y1);
  return Math.hypot(px-projX, py-projY);
}
function hitTest(world){
  const tolWorld = CONFIG.HIT_TOLERANCE / viewport.scale;
  // iterate reverse (topmost)
  for(let i=scene.length-1;i>=0;i--){
    const o=scene[i];
    if(o.type==='rect'){
      const x=Math.min(o.x,o.x+o.w), y=Math.min(o.y,o.y+o.h), w=Math.abs(o.w), h=Math.abs(o.h);
      const filled = o.fill && o.fill!=='transparent';
      if(filled){
        if(world.x>=x && world.x<=x+w && world.y>=y && world.y<=y+h) return o;
      } else {
        const onBorder = (Math.abs(world.x-x)<tolWorld||Math.abs(world.x-(x+w))<tolWorld) && world.y>=y-tolWorld && world.y<=y+h+tolWorld
                      || (Math.abs(world.y-y)<tolWorld||Math.abs(world.y-(y+h))<tolWorld) && world.x>=x-tolWorld && world.x<=x+w+tolWorld;
        if(onBorder) return o;
        // also allow clicking inside thin rect stroke? already handled. Empty rect interior should not select? spec: use proper inside/on-border - for rect we allow interior only if filled? For empty we do border only.
        // But to make usable, allow interior select as fallback with small tolerance? Let's keep border only for empty but also allow interior if user clicks clearly inside -> treat as select for UX
        // We'll allow interior click to select empty rect as well (more forgiving)
        if(world.x>=x && world.x<=x+w && world.y>=y && world.y<=y+h) return o;
      }
    } else if(o.type==='ellipse'){
      const cx=o.x+o.w/2, cy=o.y+o.h/2, rx=Math.abs(o.w/2), ry=Math.abs(o.h/2);
      if(rx<1||ry<1) continue;
      const dx=(world.x-cx)/rx, dy=(world.y-cy)/ry;
      const inside = dx*dx+dy*dy<=1;
      const filled = o.fill && o.fill!=='transparent';
      if(filled && inside) return o;
      if(!filled){
        // distance to ellipse border: approximate
        // if inside, compute border distance via scaled distance
        const dist = Math.abs(Math.sqrt(dx*dx+dy*dy)-1) * Math.min(rx,ry);
        // alternative: if inside but close to border, select
        if(Math.abs(Math.sqrt(dx*dx+dy*dy)-1) < 0.18) return o; // ~ border
        // fallback: allow interior click for ellipse too but only if close? Spec says clicking corner region outside ellipse must NOT select, so strict ellipse test
        // For empty ellipse, interior should NOT select if far from border
        continue;
      }
      if(inside) return o;
    } else if(o.type==='line' || o.type==='arrow'){
      const d = distToSegment(world.x,world.y, o.x,o.y, o.x+o.w, o.y+o.h);
      if(d < tolWorld) return o;
    } else if(o.type==='pen'){
      for(let j=0;j<o.points.length-1;j++){
        const a=o.points[j], b=o.points[j+1];
        const d=distToSegment(world.x,world.y,a.x,a.y,b.x,b.y);
        if(d < (o.width/2 + tolWorld)) return o;
      }
      // single dot
      if(o.points.length===1 && Math.hypot(world.x-o.points[0].x, world.y-o.points[0].y) < tolWorld+o.width) return o;
    } else if(o.type==='text'){
      const b=boundsOf(o);
      if(world.x>=b.x && world.x<=b.x+b.w && world.y>=b.y && world.y<=b.y+b.h) return o;
    }
  }
  return null;
}
function handleAtPoint(screenPt){
  const b = selectionBounds(selectedIds);
  if(!b) return -1;
  const hs = CONFIG.HANDLE_SIZE;
  const pts = [
    worldToScreen(b.x,b.y),
    worldToScreen(b.x+b.w,b.y),
    worldToScreen(b.x+b.w,b.y+b.h),
    worldToScreen(b.x,b.y+b.h),
  ];
  for(let i=0;i<4;i++){
    const p=pts[i];
    if(screenPt.x>=p.x-hs/2 && screenPt.x<=p.x+hs/2 && screenPt.y>=p.y-hs/2 && screenPt.y<=p.y+hs/2) return i;
  }
  return -1;
}

/* ================= RENDERING ================= */
function resizeCanvas(){
  const r = wrap.getBoundingClientRect();
  dpr = Math.max(1, window.devicePixelRatio||1);
  canvas.width = Math.round(r.width*dpr);
  canvas.height = Math.round(r.height*dpr);
  canvas.style.width = r.width+'px';
  canvas.style.height = r.height+'px';
}
function drawGrid(){
  const size = CONFIG.GRID_SIZE;
  const rect = canvas.getBoundingClientRect();
  // world visible rect
  const topLeft = screenToWorld(0,0);
  const bottomRight = screenToWorld(rect.width, rect.height);
  // minor dots
  const startX = Math.floor(topLeft.x/size)*size;
  const startY = Math.floor(topLeft.y/size)*size;
  ctx.save();
  ctx.fillStyle = '#d9dce1';
  // adapt dot opacity by zoom
  const dotR = 1.1 / Math.max(0.6, Math.min(1.6, viewport.scale));
  for(let x=startX; x<bottomRight.x; x+=size){
    for(let y=startY; y<bottomRight.y; y+=size){
      const s = worldToScreen(x,y);
      // since we already have transform, easier to draw in world with scale invariant? But we use worldToScreen here manually while ctx is transformed? We'll reset transform for grid using world transform.
    }
  }
  ctx.restore();
  // Use transform method for crisp
  ctx.save();
  ctx.setTransform(viewport.scale,0,0,viewport.scale,viewport.x*dpr,viewport.y*dpr);
  ctx.scale(dpr,dpr); // Wait: we already scaled? Simplify: we set transform with dpr
  // Actually setTransform handles dpr
  // Let's just draw dots in world space with radius adjusted
  ctx.fillStyle = 'rgba(210,214,220,1)';
  const rWorld = 0.9 / viewport.scale;
  const rDot = Math.max(0.7, rWorld);
  for(let x=startX; x<bottomRight.x; x+=size){
    for(let y=startY; y<bottomRight.y; y+=size){
      ctx.beginPath();
      ctx.arc(x,y, rDot, 0, Math.PI*2);
      ctx.fill();
    }
  }
  ctx.restore();
}
function drawObject(o){
  ctx.save();
  ctx.lineCap='round'; ctx.lineJoin='round';
  const stroke = o.stroke || defaultStyle.stroke;
  const fill = o.fill || 'transparent';
  const w = o.width || 2;
  ctx.strokeStyle = stroke;
  ctx.fillStyle = fill;
  ctx.lineWidth = w;
  // if selected object is being previewed, w will be updated

  if(o.type==='rect'){
    const x=Math.min(o.x,o.x+o.w), y=Math.min(o.y,o.y+o.h), ww=Math.abs(o.w), hh=Math.abs(o.h);
    if(fill!=='transparent'){ ctx.fillRect(x,y,ww,hh); }
    if(ww>0&&hh>0) ctx.strokeRect(x,y,ww,hh);
  } else if(o.type==='ellipse'){
    const cx=o.x+o.w/2, cy=o.y+o.h/2, rx=Math.abs(o.w/2), ry=Math.abs(o.h/2);
    ctx.beginPath(); ctx.ellipse(cx,cy,rx,ry,0,0,Math.PI*2);
    if(fill!=='transparent') ctx.fill();
    ctx.stroke();
  } else if(o.type==='line' || o.type==='arrow'){
    const x1=o.x,y1=o.y,x2=o.x+o.w,y2=o.y+o.h;
    ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
    if(o.type==='arrow'){
      const angle=Math.atan2(y2-y1,x2-x1);
      const len=14, ang=Math.PI/7;
      ctx.beginPath();
      ctx.moveTo(x2,y2);
      ctx.lineTo(x2 - len*Math.cos(angle-ang), y2 - len*Math.sin(angle-ang));
      ctx.moveTo(x2,y2);
      ctx.lineTo(x2 - len*Math.cos(angle+ang), y2 - len*Math.sin(angle+ang));
      ctx.stroke();
    }
  } else if(o.type==='pen'){
    if(o.points.length<2){
      if(o.points.length===1){
        ctx.beginPath(); ctx.arc(o.points[0].x,o.points[0].y, w/2,0,Math.PI*2); ctx.fillStyle=stroke; ctx.fill();
      }
    } else {
      ctx.beginPath();
      ctx.moveTo(o.points[0].x,o.points[0].y);
      for(let i=1;i<o.points.length;i++){
        const p=o.points[i], prev=o.points[i-1];
        const mx = (prev.x+p.x)/2, my=(prev.y+p.y)/2;
        if(i===1) ctx.lineTo(p.x,p.y);
        else { ctx.quadraticCurveTo(prev.x,prev.y,mx,my); if(i===o.points.length-1) ctx.lineTo(p.x,p.y); }
      }
      ctx.stroke();
    }
  } else if(o.type==='text'){
    ctx.fillStyle = stroke;
    ctx.font = `${o.fontSize||18}px Inter, system-ui, sans-serif`;
    const lines = (o.text||'').split('\n');
    let yy=o.y+ (o.fontSize||18);
    lines.forEach(l=>{ ctx.fillText(l,o.x,yy); yy+= (o.fontSize||18)*1.25; });
  }
  ctx.restore();
}
function render(){
  // high DPI
  const rect = canvas.getBoundingClientRect();
  ctx.setTransform(dpr,0,0,dpr,0,0);
  ctx.clearRect(0,0,rect.width,rect.height);
  // background
  ctx.fillStyle = CONFIG.BG;
  ctx.fillRect(0,0,rect.width,rect.height);

  // grid with viewport transform (use world)
  ctx.save();
  // Apply viewport transform for world drawing (with dpr already)
  // We'll draw grid manually in world as above but need to handle dpr scaling correctly
  // Simpler: set transform for world
  ctx.setTransform(viewport.scale*dpr,0,0,viewport.scale*dpr, viewport.x*dpr, viewport.y*dpr);
  // grid dots
  const tl = {x: (-viewport.x)/viewport.scale, y: (-viewport.y)/viewport.scale};
  const br = {x: (rect.width - viewport.x)/viewport.scale, y: (rect.height - viewport.y)/viewport.scale};
  const step = CONFIG.GRID_SIZE;
  const startX = Math.floor(tl.x/step)*step;
  const startY = Math.floor(tl.y/step)*step;
  ctx.fillStyle = '#d8dce2';
  const r = Math.max(0.7, 1.1/viewport.scale);
  for(let x=startX; x<br.x; x+=step){
    for(let y=startY; y<br.y; y+=step){
      ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
    }
  }
  // draw objects
  scene.forEach(o=> drawObject(o));
  if(previewObj) drawObject(previewObj);
  ctx.restore();

  // selection box & handles in screen space (transformed)
  if(selectedIds.size>0){
    const b = selectionBounds(selectedIds);
    if(b){
      const p1=worldToScreen(b.x,b.y);
      const p2=worldToScreen(b.x+b.w,b.y+b.h);
      const w = p2.x-p1.x, h=p2.y-p1.y;
      ctx.save();
      ctx.setTransform(dpr,0,0,dpr,0,0);
      ctx.strokeStyle='#4f46e5';
      ctx.lineWidth=1.2;
      ctx.setLineDash([6,5]);
      ctx.strokeRect(p1.x,p1.y,w,h);
      ctx.setLineDash([]);
      const hs = CONFIG.HANDLE_SIZE;
      const corners=[{x:p1.x,y:p1.y},{x:p2.x,y:p1.y},{x:p2.x,y:p2.y},{x:p1.x,y:p2.y}];
      corners.forEach(c=>{
        ctx.fillStyle='#fff';
        ctx.strokeStyle='#4f46e5';
        ctx.lineWidth=1.5;
        ctx.fillRect(c.x-hs/2,c.y-hs/2,hs,hs);
        ctx.strokeRect(c.x-hs/2,c.y-hs/2,hs,hs);
      });
      ctx.restore();
    }
  }
  // rubber band
  if(rubberRect){
    const a=worldToScreen(rubberRect.x,rubberRect.y);
    const b=worldToScreen(rubberRect.x+rubberRect.w, rubberRect.y+rubberRect.h);
    const x=Math.min(a.x,b.x), y=Math.min(a.y,b.y), w=Math.abs(b.x-a.x), h=Math.abs(b.y-a.y);
    ctx.save();
    ctx.setTransform(dpr,0,0,dpr,0,0);
    ctx.fillStyle='rgba(79,70,229,0.08)';
    ctx.strokeStyle='rgba(79,70,229,0.9)';
    ctx.lineWidth=1;
    ctx.fillRect(x,y,w,h);
    ctx.strokeRect(x,y,w,h);
    ctx.restore();
  }
  // update zoom label
  document.getElementById('zoomLabel').textContent = Math.round(viewport.scale*100)+'%';
  document.getElementById('emptyHint').style.display = scene.length ? 'none' : 'block';
}

/* ================= TOOLBAR / STYLES ================= */
function setTool(t){
  currentTool=t;
  document.querySelectorAll('.tool').forEach(b=> b.classList.toggle('active', b.dataset.tool===t));
  canvas.className = t==='select' ? 'select' : '';
  if(t!=='text') hideTextEditor(false);
}
function buildSwatches(){
  const sC=document.getElementById('strokeSwatches');
  const fC=document.getElementById('fillSwatches');
  CONFIG.STROKE_PALETTE.forEach(c=>{
    const d=document.createElement('div'); d.className='swatch'; d.style.background=c; d.dataset.val=c;
    if(c===defaultStyle.stroke) d.classList.add('active');
    d.onclick=()=>{ defaultStyle.stroke=c; document.querySelectorAll('#strokeSwatches .swatch').forEach(x=>x.classList.toggle('active',x.dataset.val===c)); applyStyleToSelection(); savePersist(); render(); };
    sC.appendChild(d);
  });
  CONFIG.FILL_PALETTE.forEach(c=>{
    const d=document.createElement('div'); d.className='swatch'+(c==='transparent'?' clear':''); if(c!=='transparent') d.style.background=c; d.dataset.val=c;
    if(c===defaultStyle.fill) d.classList.add('active');
    d.onclick=()=>{ defaultStyle.fill=c; document.querySelectorAll('#fillSwatches .swatch').forEach(x=>x.classList.toggle('active',x.dataset.val===c)); applyStyleToSelection(); savePersist(); render(); };
    fC.appendChild(d);
  });
}
function applyStyleToSelection(){
  if(selectedIds.size===0) return;
  let changed=false;
  scene.forEach(o=>{
    if(selectedIds.has(o.id)){
      o.stroke=defaultStyle.stroke; o.fill=defaultStyle.fill; o.width=defaultStyle.width; changed=true;
    }
  });
  if(changed) pushHistory();
}

/* ================= TEXT EDITOR ================= */
const textEditor = document.getElementById('textEditor');
let editingTextId = null;
let textStartWorld = null;
function showTextEditor(world, existingObj){
  const s = worldToScreen(world.x,world.y);
  textEditor.style.left = s.x+'px';
  textEditor.style.top = s.y+'px';
  textEditor.style.display='block';
  textEditor.value = existingObj? existingObj.text : '';
  textEditor.focus();
  editingTextId = existingObj? existingObj.id : null;
  textStartWorld = world;
  // auto resize
  textEditor.style.height='auto';
}
function hideTextEditor(commit){
  if(textEditor.style.display==='none') return;
  const txt = textEditor.value.trim();
  if(commit){
    if(txt){
      if(editingTextId){
        const o=scene.find(x=>x.id===editingTextId);
        if(o){ const old=o.text; if(old!==txt){ o.text=txt; pushHistory(); } }
      } else {
        const obj={id:uid(), type:'text', x:textStartWorld.x, y:textStartWorld.y, w:180, h:24, text:txt, stroke:defaultStyle.stroke, fill:'transparent', width:defaultStyle.width, fontSize:18};
        // measure width
        ctx.font=`18px Inter, system-ui`;
        const m=ctx.measureText(txt); obj.w=Math.max(80,m.width+12);
        scene.push(obj); selectedIds.clear(); selectedIds.add(obj.id); pushHistory();
      }
    }
  }
  textEditor.style.display='none';
  editingTextId=null;
  textStartWorld=null;
  render();
}
textEditor.addEventListener('keydown', e=>{
  if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); hideTextEditor(true); }
  if(e.key==='Escape'){ hideTextEditor(false); }
});
textEditor.addEventListener('blur', ()=> hideTextEditor(true));
textEditor.addEventListener('input', ()=>{
  textEditor.style.height='auto'; textEditor.style.height=textEditor.scrollHeight+'px';
});

/* ================= MOUSE / TOUCH ================= */
function getScreenPoint(e){
  if(e.touches) return {x:e.touches[0].clientX, y:e.touches[0].clientY};
  return {x:e.clientX, y:e.clientY};
}
canvas.addEventListener('mousedown', e=>{
  const rect = canvas.getBoundingClientRect();
  const sp = {x:e.clientX, y:e.clientY};
  const wp = screenToWorld(e.clientX,e.clientY);
  // middle mouse or space+left => panning
  if(e.button===1 || (spaceDown && e.button===0)){
    panning=true; panStart={x:e.clientX-viewport.x, y:e.clientY-viewport.y}; canvas.classList.add('grabbing'); e.preventDefault(); return;
  }
  if(currentTool==='select'){
    const h = handleAtPoint(sp);
    if(h!==-1 && selectedIds.size>0){
      isDragging=true; dragMode='resize'; activeHandle=h; dragStart=wp; dragCurrent=wp;
      // snapshot bounds and objects
      const b=selectionBounds(selectedIds);
      resizeSnapshot={ bounds:{...b}, objs: new Map() };
      scene.forEach(o=>{ if(selectedIds.has(o.id)) resizeSnapshot.objs.set(o.id, JSON.parse(JSON.stringify(o))) });
      return;
    }
    const hit = hitTest(wp);
    if(hit){
      const already = selectedIds.has(hit.id);
      if(e.shiftKey){
        if(already) selectedIds.delete(hit.id); else selectedIds.add(hit.id);
      } else {
        if(!already){ selectedIds.clear(); selectedIds.add(hit.id); }
      }
      if(selectedIds.has(hit.id)){
        isDragging=true; dragMode='move'; dragStart=wp; dragCurrent=wp;
        moveSnapshot=new Map();
        scene.forEach(o=>{ if(selectedIds.has(o.id)) moveSnapshot.set(o.id,{x:o.x,y:o.y, points: o.points? o.points.map(p=>({...p})) : null}) });
      }
      render(); return;
    } else {
      // empty space
      if(!e.shiftKey) selectedIds.clear();
      isDragging=true; dragMode='rubber'; dragStart=wp; rubberRect={x:wp.x,y:wp.y,w:0,h:0};
      render(); return;
    }
  } else if(currentTool==='pen'){
    isDragging=true; dragMode='pen'; dragStart=wp; penPoints=[{...wp}];
    previewObj={id:'preview', type:'pen', points:[...penPoints], stroke:defaultStyle.stroke, width:defaultStyle.width};
  } else if(currentTool==='rect' || currentTool==='ellipse' || currentTool==='arrow'){
    isDragging=true; dragMode='draw'; dragStart=wp; dragCurrent=wp;
    const type = currentTool==='arrow' ? 'arrow' : currentTool;
    previewObj={id:'preview', type, x:wp.x, y:wp.y, w:0,h:0, stroke:defaultStyle.stroke, fill: currentTool==='arrow'?'transparent':defaultStyle.fill, width:defaultStyle.width};
  } else if(currentTool==='text'){
    const hit = hitTest(wp);
    if(hit && hit.type==='text'){
      selectedIds.clear(); selectedIds.add(hit.id); render();
      showTextEditor({x:hit.x,y:hit.y}, hit);
    } else {
      showTextEditor(wp,null);
    }
    return;
  } else if(currentTool==='eraser'){
    isDragging=true; dragMode='eraser';
    const hit = hitTest(wp);
    if(hit){ scene=scene.filter(o=>o.id!==hit.id); selectedIds.delete(hit.id); pushHistory(); render(); }
  }
});

canvas.addEventListener('mousemove', e=>{
  const sp={x:e.clientX,y:e.clientY};
  const wp=screenToWorld(e.clientX,e.clientY);
  if(panning){
    viewport.x = e.clientX - panStart.x;
    viewport.y = e.clientY - panStart.y;
    render(); return;
  }
  if(!isDragging){
    // cursor feedback for handles
    if(currentTool==='select' && selectedIds.size>0){
      const h=handleAtPoint(sp);
      canvas.style.cursor = h!==-1 ? (h%2===0 ? 'nwse-resize' : 'nesw-resize') : 'default';
    }
    // eraser hover
    if(currentTool==='eraser'){
      const hit=hitTest(wp);
      canvas.style.cursor = hit ? 'cell' : 'default';
    }
    return;
  }
  if(dragMode==='move' && moveSnapshot){
    const dx=wp.x-dragStart.x, dy=wp.y-dragStart.y;
    scene.forEach(o=>{
      if(selectedIds.has(o.id)){
        const snap=moveSnapshot.get(o.id);
        if(o.type==='pen'){
          o.points = snap.points.map(p=>({x:p.x+dx,y:p.y+dy}));
        } else {
          o.x = snap.x + dx; o.y = snap.y + dy;
        }
      }
    });
    render();
  } else if(dragMode==='resize' && resizeSnapshot){
    const b0=resizeSnapshot.bounds;
    const dx=wp.x-dragStart.x, dy=wp.y-dragStart.y;
    // current bounds
    let nx=b0.x, ny=b0.y, nw=b0.w, nh=b0.h;
    if(activeHandle===0){ nx=b0.x+dx; ny=b0.y+dy; nw=b0.w-dx; nh=b0.h-dy; }
    if(activeHandle===1){ ny=b0.y+dy; nw=b0.w+dx; nh=b0.h-dy; }
    if(activeHandle===2){ nw=b0.w+dx; nh=b0.h+dy; }
    if(activeHandle===3){ nx=b0.x+dx; nw=b0.w-dx; nh=b0.h+dy; }
    // shift preserve aspect
    if(e.shiftKey){
      const aspect=b0.w/b0.h;
      if(Math.abs(nw)>Math.abs(nh*aspect)) nh = Math.sign(nh)*Math.abs(nw/aspect);
      else nw = Math.sign(nw)*Math.abs(nh*aspect);
      // adjust origin for handles that move origin
      if(activeHandle===0){ nx=b0.x+b0.w-nw; ny=b0.y+b0.h-nh; }
      if(activeHandle===1){ ny=b0.y+b0.h-nh; }
      if(activeHandle===3){ nx=b0.x+b0.w-nw; }
    }
    // apply to each object proportionally
    const sx = b0.w===0?1:nw/b0.w, sy=b0.h===0?1:nh/b0.h;
    scene.forEach(o=>{
      if(!selectedIds.has(o.id)) return;
      const orig=resizeSnapshot.objs.get(o.id);
      const relX = (orig.x - b0.x)/ (b0.w||1), relY=(orig.y - b0.y)/(b0.h||1);
      // for rect/ellipse/text/line
      if(o.type==='pen'){
        o.points = orig.points.map(p=>{
          const rx=(p.x-b0.x)/ (b0.w||1), ry=(p.y-b0.y)/(b0.h||1);
          return {x: nx + rx*nw, y: ny + ry*nh};
        });
      } else if(o.type==='line' || o.type==='arrow'){
        const x1=orig.x, y1=orig.y, x2=orig.x+orig.w, y2=orig.y+orig.h;
        const nx1 = nx + (x1-b0.x)/b0.w*nw;
        const ny1 = ny + (y1-b0.y)/b0.h*nh;
        const nx2 = nx + (x2-b0.x)/b0.w*nw;
        const ny2 = ny + (y2-b0.y)/b0.h*nh;
        o.x=nx1; o.y=ny1; o.w=nx2-nx1; o.h=ny2-ny1;
      } else if(o.type==='text'){
        o.x = nx + relX*nw; o.y = ny + relY*nh;
        const ow=Math.abs(orig.w||80); o.w = ow * Math.abs(sx);
      } else {
        // rect / ellipse
        const ow=Math.abs(orig.w), oh=Math.abs(orig.h);
        const ox=Math.min(orig.x, orig.x+orig.w), oy=Math.min(orig.y, orig.y+orig.h);
        const relOx=(ox-b0.x)/ (b0.w||1), relOy=(oy-b0.y)/(b0.h||1);
        const newOx = nx + relOx*nw;
        const newOy = ny + relOy*nh;
        o.x = newOx; o.y = newOy; o.w = ow * sx; o.h = oh * sy;
        // handle negative if flipping? keep sign of sx/sy
        if(nw<0) o.w*=-1;
        if(nh<0) o.h*=-1;
      }
    });
    render();
  } else if(dragMode==='rubber'){
    rubberRect.w = wp.x - dragStart.x;
    rubberRect.h = wp.y - dragStart.y;
    // live selection
    const rx=Math.min(rubberRect.x, rubberRect.x+rubberRect.w), ry=Math.min(rubberRect.y, rubberRect.y+rubberRect.h), rw=Math.abs(rubberRect.w), rh=Math.abs(rubberRect.h);
    selectedIds.clear();
    scene.forEach(o=>{
      const b=boundsOf(o);
      const intersect = !(b.x+b.w < rx || b.x > rx+rw || b.y+b.h < ry || b.y > ry+rh);
      if(intersect) selectedIds.add(o.id);
    });
    render();
  } else if(dragMode==='pen'){
    penPoints.push({...wp});
    // simplify a bit
    previewObj.points = penPoints;
    render();
  } else if(dragMode==='draw' && previewObj){
    previewObj.w = wp.x - dragStart.x;
    previewObj.h = wp.y - dragStart.y;
    if(e.shiftKey && (previewObj.type==='rect' || previewObj.type==='ellipse')){
      const s=Math.max(Math.abs(previewObj.w), Math.abs(previewObj.h));
      previewObj.w = Math.sign(previewObj.w)*s;
      previewObj.h = Math.sign(previewObj.h)*s;
    }
    render();
  } else if(dragMode==='eraser'){
    const hit=hitTest(wp);
    if(hit){ scene=scene.filter(o=>o.id!==hit.id); selectedIds.delete(hit.id); render(); }
  }
});

window.addEventListener('mouseup', e=>{
  if(panning){ panning=false; canvas.classList.remove('grabbing'); return; }
  if(!isDragging) return;
  const wp=screenToWorld(e.clientX,e.clientY);
  if(dragMode==='move' || dragMode==='resize'){
    pushHistory();
  } else if(dragMode==='rubber'){
    pushHistory(); // selection change not needed but keep consistent; actually rubber doesn't need history but ok?
    // remove last history duplicate? keep but not push duplicate if no move? We'll pop last push and save only if selection? Simplify: undo stack already pushed earlier, but we pushed now incorrectly for rubber. So pop last and not push for rubber? To keep single undo for move, we already pushed. For rubber we shouldn't push. So revert:
    // Undo the push for rubber
    if(history.length>0){ history.pop(); historyIndex--; savePersist(); updateUndoUI(); }
  } else if(dragMode==='pen' && previewObj){
    if(penPoints.length>1 || penPoints.length===1){
      const obj={id:uid(), type:'pen', points:[...penPoints], stroke:defaultStyle.stroke, width:defaultStyle.width, fill:'transparent'};
      scene.push(obj);
      pushHistory();
    }
  } else if(dragMode==='draw' && previewObj){
    if(Math.abs(previewObj.w)>4 || Math.abs(previewObj.h)>4){
      const obj={...previewObj, id:uid()};
      // normalize rect negative size to keep x,y at min (optional)
      scene.push(obj);
      selectedIds.clear(); selectedIds.add(obj.id);
      pushHistory();
    }
  } else if(dragMode==='eraser'){
    // already pushed per hit? we pushed per hit in mousemove; need to coalesce? we already pushed history on first erase per move segment, but multiple erases should be one step? We push on first, but subsequent erases create separate history entries. For now coalesce by pushing once on mouseup if any erased.
  }
  isDragging=false; dragMode=null; previewObj=null; penPoints=[]; rubberRect=null; moveSnapshot=null; resizeSnapshot=null; activeHandle=-1;
  render();
});

canvas.addEventListener('wheel', e=>{
  e.preventDefault();
  const rect=canvas.getBoundingClientRect();
  const sx=e.clientX - rect.left, sy=e.clientY - rect.top;
  const worldBefore=screenToWorld(e.clientX,e.clientY);
  const delta = -e.deltaY * CONFIG.ZOOM_WHEEL_SPEED;
  const factor = 1 + delta;
  const newScale = clamp(viewport.scale * factor, CONFIG.MIN_ZOOM, CONFIG.MAX_ZOOM);
  viewport.scale = newScale;
  viewport.x = e.clientX - rect.left - worldBefore.x * newScale;
  viewport.y = e.clientY - rect.top - worldBefore.y * newScale;
  render();
},{passive:false});

/* ================= KEYBOARD ================= */
window.addEventListener('keydown', e=>{
  const isMod = e.ctrlKey||e.metaKey;
  if(textEditor.style.display==='block') return; // let textarea handle
  if(isMod && e.key.toLowerCase()==='z'){
    e.preventDefault();
    if(e.shiftKey) redo(); else undo();
    return;
  }
  if(isMod && e.key.toLowerCase()==='y'){ e.preventDefault(); redo(); return; }
  if(e.code==='Space' && !spaceDown){ spaceDown=true; canvas.classList.add('grab'); }
  if(e.key==='Delete' || e.key==='Backspace'){
    if(selectedIds.size>0){ scene=scene.filter(o=>!selectedIds.has(o.id)); selectedIds.clear(); pushHistory(); render(); e.preventDefault(); }
  }
  if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key) && selectedIds.size>0){
    e.preventDefault();
    const step = e.shiftKey?10:1;
    let dx=0,dy=0;
    if(e.key==='ArrowUp') dy=-step;
    if(e.key==='ArrowDown') dy=step;
    if(e.key==='ArrowLeft') dx=-step;
    if(e.key==='ArrowRight') dx=step;
    // convert screen nudge to world (1px in screen = 1/scale world?) spec says nudge by 1px (world or screen?) Use 1 world pixel? We'll use screen pixel converted: dx/scale?
    // Spec: "Arrow keys nudge the selection by 1px (10px with Shift)" likely screen px? We'll do world px = step/scale? But simpler use world 1:1 at 100% -> divide by scale so visual nudge stays 1 screen px at any zoom.
    const wx = dx / viewport.scale, wy = dy / viewport.scale;
    scene.forEach(o=>{
      if(selectedIds.has(o.id)){
        if(o.type==='pen') o.points.forEach(p=>{p.x+=wx;p.y+=wy});
        else { o.x+=wx; o.y+=wy; }
      }
    });
    // push history coalesced: only push on keyup? For now push per nudge burst debounce
    clearTimeout(window._nudgeTimer);
    window._nudgeTimer=setTimeout(()=>pushHistory(), 400);
    render();
  }
  // tool shortcuts
  const k=e.key.toLowerCase();
  if(!isMod){
    if(k==='v') setTool('select');
    if(k==='p') setTool('pen');
    if(k==='r') setTool('rect');
    if(k==='o') setTool('ellipse');
    if(k==='l') setTool('arrow');
    if(k==='t') setTool('text');
    if(k==='e') setTool('eraser');
  }
});
window.addEventListener('keyup', e=>{
  if(e.code==='Space'){ spaceDown=false; canvas.classList.remove('grab'); canvas.classList.remove('grabbing'); panning=false; }
});

/* ================= UNDO/REDO UI ================= */
function undo(){
  if(historyIndex<=0) return;
  historyIndex--; restoreFromHistory(historyIndex);
}
function redo(){
  if(historyIndex>=history.length-1) return;
  historyIndex++; restoreFromHistory(historyIndex);
}
function updateUndoUI(){
  document.getElementById('undoBtn').disabled = historyIndex<=0;
  document.getElementById('redoBtn').disabled = historyIndex>=history.length-1;
}
document.getElementById('undoBtn').onclick=undo;
document.getElementById('redoBtn').onclick=redo;
document.getElementById('clearBtn').onclick=()=>{
  if(!confirm('Clear board?')) return;
  scene=[]; selectedIds.clear(); pushHistory(); render();
};
document.getElementById('exportBtn').onclick=()=>{
  const data=JSON.stringify(scene,null,2);
  const blob=new Blob([data],{type:'application/json'});
  const url=URL.createObjectURL(blob);
  const a=document.createElement('a'); a.href=url; a.download='board.json'; a.click(); URL.revokeObjectURL(url);
};
document.getElementById('zoomIn').onclick=()=>{
  const rect=canvas.getBoundingClientRect();
  const cx=rect.width/2, cy=rect.height/2;
  const wb=screenToWorld(rect.left+cx, rect.top+cy);
  viewport.scale=clamp(viewport.scale*1.2, CONFIG.MIN_ZOOM, CONFIG.MAX_ZOOM);
  viewport.x = rect.left+cx - wb.x*viewport.scale;
  viewport.y = rect.top+cy - wb.y*viewport.scale;
  render();
};
document.getElementById('zoomOut').onclick=()=>{
  const rect=canvas.getBoundingClientRect();
  const cx=rect.width/2, cy=rect.height/2;
  const wb=screenToWorld(rect.left+cx, rect.top+cy);
  viewport.scale=clamp(viewport.scale/1.2, CONFIG.MIN_ZOOM, CONFIG.MAX_ZOOM);
  viewport.x = rect.left+cx - wb.x*viewport.scale;
  viewport.y = rect.top+cy - wb.y*viewport.scale;
  render();
};
document.getElementById('zoomReset').onclick=()=>{
  viewport={x:0,y:0,scale:1}; render(); savePersist();
};
document.querySelectorAll('.tool').forEach(b=> b.addEventListener('click', ()=> setTool(b.dataset.tool)));
document.getElementById('widthRange').addEventListener('input', e=>{
  defaultStyle.width=parseInt(e.target.value,10);
  document.getElementById('widthVal').textContent=e.target.value;
  if(selectedIds.size>0){ scene.forEach(o=>{ if(selectedIds.has(o.id)) o.width=defaultStyle.width; }); pushHistory(); render(); }
});

/* ================= INIT ================= */
function init(){
  resizeCanvas();
  buildSwatches();
  loadPersist();
  if(history.length===0){ pushHistory(); }
  updateUndoUI();
  render();
  // hide empty hint after first draw
  window.addEventListener('resize', ()=>{ resizeCanvas(); render(); });
}
init();

// click away from text editor: clicking canvas hides?
canvas.addEventListener('pointerdown', e=>{
  if(textEditor.style.display==='block'){
    const r=textEditor.getBoundingClientRect();
    if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom){
      // will blur and commit
    }
  }
});
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"7ee2672c-86fa-40ee-90d9-d531849eaf5f","tokensIn":1070,"tokensOut":14274,"tokensTotal":15344,"cost":0.0618073,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:16:28.571Z"} -->