← Whiteboard results

Whiteboard

Qwen 3.8 27B GGUF XL · full

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 49,006 bytes · SHA-256 64063dbad1e1
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Slate — Whiteboard</title>
<style>
/* ============================ APP CHROME ============================ */
:root{
  --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --font-text: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --canvas-bg: #f7f6f3;
  --panel: #f1f0ed;
  --panel-border: rgba(0,0,0,.07);
  --ink: #37352f;
  --ink-soft: #8a867d;
  --accent: #1971c2;
  --accent-soft: #dbeafc;
  --shadow: 0 6px 20px rgba(30,28,24,.14), 0 1px 3px rgba(30,28,24,.10);
}
*{ box-sizing: border-box; }
html, body{ height: 100%; margin: 0; overflow: hidden; }
body{
  font-family: var(--font-ui);
  background: var(--canvas-bg);
  color: var(--ink);
  -webkit-user-select: none; user-select: none;
}
button:focus-visible{ outline: 2px solid var(--accent); outline-offset: 1px; }

#board{ position: fixed; inset: 0; display: block; touch-action: none; }

.panel{
  position: fixed;
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 12px;
  box-shadow: var(--shadow);
  z-index: 10;
}

/* ---------------- logo ---------------- */
#logo{
  top: 14px; left: 14px;
  display: flex; align-items: center; gap: 9px;
  padding: 9px 13px;
  font-size: 14px; font-weight: 650; letter-spacing: .2px;
}
#logo .mark{
  width: 12px; height: 12px; border-radius: 4px;
  background: var(--accent);
  box-shadow: inset 0 0 0 2.5px rgba(255,255,255,.35);
}
#logo .tag{
  font-size: 10px; font-weight: 500; color: var(--ink-soft);
  background: rgba(0,0,0,.05); padding: 2px 7px; border-radius: 20px; letter-spacing: .5px;
}

/* ---------------- toolbar ---------------- */
#toolbar{
  left: 14px; top: 50%; transform: translateY(-50%);
  display: flex; flex-direction: column; gap: 2px; padding: 6px;
}
#toolbar button{
  width: 38px; height: 38px;
  border: 0; background: transparent; border-radius: 9px;
  display: grid; place-items: center; cursor: pointer;
  color: #57544c; padding: 0;
}
#toolbar button:hover{ background: rgba(0,0,0,.06); }
#toolbar button.active{ background: var(--accent-soft); color: var(--accent); }
#toolbar button:disabled{ opacity: .35; cursor: default; }
#toolbar button:disabled:hover{ background: transparent; }
#toolbar .sep{ height: 1px; background: var(--panel-border); margin: 4px 5px; }
#toolbar svg{ display: block; }

/* ---------------- properties bar ---------------- */
#props{
  top: 14px; left: 50%; transform: translateX(-50%);
  display: flex; align-items: center; gap: 10px;
  padding: 8px 12px;
}
.pgroup{ display: flex; align-items: center; gap: 6px; }
.psep{ width: 1px; height: 22px; background: var(--panel-border); }
.swatch{
  width: 20px; height: 20px; border-radius: 50%;
  border: 1px solid rgba(0,0,0,.15);
  cursor: pointer; padding: 0; position: relative;
  transition: transform .08s ease;
}
.swatch:hover{ transform: scale(1.12); }
.swatch.active{ outline: 2px solid var(--accent); outline-offset: 2px; }
.swatch.none{ background: #fff; }
.swatch.none::after{
  content: ""; position: absolute; left: -2px; top: 9px;
  width: 24px; height: 2px; background: #d9480f;
  transform: rotate(-45deg); border-radius: 2px;
}
.wbtn{
  width: 26px; height: 26px; border: 0; background: transparent;
  border-radius: 7px; display: grid; place-items: center; cursor: pointer;
}
.wbtn:hover{ background: rgba(0,0,0,.06); }
.wbtn.active{ background: var(--accent-soft); }
.wbtn i{ display: block; border-radius: 50%; background: #57544c; }
.wbtn.active i{ background: var(--accent); }

/* ---------------- zoom bar ---------------- */
#zoombar{
  right: 14px; bottom: 14px;
  display: flex; align-items: center; gap: 2px; padding: 4px;
}
#zoombar button{
  border: 0; background: transparent; border-radius: 8px; cursor: pointer;
  color: #57544c; height: 30px; display: grid; place-items: center;
  padding: 0 8px; font-family: inherit; font-size: 12.5px; font-weight: 600;
}
#zoombar button:hover{ background: rgba(0,0,0,.06); }
#zoombar button.icon{ width: 30px; padding: 0; }
#zoombar .zsep{ width: 1px; height: 18px; background: var(--panel-border); margin: 0 3px; }

/* ---------------- hint ---------------- */
#hint{
  position: fixed; left: 14px; bottom: 16px; z-index: 10;
  font-size: 12px; color: var(--ink-soft); pointer-events: none;
}
kbd{
  font-family: inherit; font-size: 10.5px;
  background: rgba(0,0,0,.06); border: 1px solid rgba(0,0,0,.08);
  border-bottom-width: 2px; border-radius: 5px; padding: 1px 5px; margin: 0 1px;
}

/* ---------------- inline text editor ---------------- */
#text-editor{
  position: fixed; display: none;
  white-space: pre; overflow: hidden; resize: none;
  border: 1.5px solid var(--accent); border-radius: 6px;
  padding: 4px 8px;
  background: rgba(255,255,255,.96);
  z-index: 20; outline: none;
  font-family: var(--font-text); line-height: 1.25;
  box-shadow: 0 4px 14px rgba(25,113,194,.18);
  -webkit-user-select: text; user-select: text;
}

/* ---------------- eraser cursor ---------------- */
#eraser-cursor{
  position: fixed; width: 22px; height: 22px;
  border: 1.5px solid var(--accent); border-radius: 50%;
  transform: translate(-50%,-50%);
  pointer-events: none; z-index: 15; display: none;
  background: rgba(255,255,255,.35);
}
</style>
</head>
<body>

<canvas id="board"></canvas>

<div id="logo" class="panel"><span class="mark"></span>Slate<span class="tag">LOCAL PROTOTYPE</span></div>

<div id="toolbar" class="panel">
  <button data-tool="select" title="Select (V)" aria-label="Select">
    <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M5.2 2.5 19.8 11.4 12.9 12.9 9.5 19.8 Z"/></svg>
  </button>
  <button data-tool="pen" title="Pen (P)" aria-label="Pen">
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M17.2 3.3a2.6 2.6 0 0 1 3.6 3.6L8 19.7 3 21l1.3-5z"/><path d="M14.5 6 18 9.5"/></svg>
  </button>
  <button data-tool="rect" title="Rectangle (R)" aria-label="Rectangle">
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><rect x="4" y="6.5" width="16" height="11" rx="1.5"/></svg>
  </button>
  <button data-tool="ellipse" title="Ellipse (O)" aria-label="Ellipse">
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><ellipse cx="12" cy="12" rx="8.5" ry="6.5"/></svg>
  </button>
  <button data-tool="line" title="Arrow (L)" aria-label="Arrow">
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19 18.5 5.5M18.5 5.5H12M18.5 5.5V12"/></svg>
  </button>
  <button data-tool="text" title="Text (T)" aria-label="Text">
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M6 6h12M12 6v13M9.5 19h5"/></svg>
  </button>
  <button data-tool="eraser" title="Eraser (E)" aria-label="Eraser">
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M15.5 4.5l4 4L9 19H5v-4z"/><path d="M11 9l4 4"/></svg>
  </button>
  <div class="sep"></div>
  <button id="undo-btn" title="Undo (Ctrl+Z)" aria-label="Undo">
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 4.5 3.5 9.5l5 5"/><path d="M4 9.5h9.5a6.5 6.5 0 0 1 6.5 6.5v1.5"/></svg>
  </button>
  <button id="redo-btn" title="Redo (Ctrl+Shift+Z)" aria-label="Redo">
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M15.5 4.5l5 5-5 5"/><path d="M20 9.5h-9.5A6.5 6.5 0 0 0 4 16v1.5"/></svg>
  </button>
</div>

<div id="props" class="panel">
  <div class="pgroup" id="stroke-colors"></div>
  <div class="psep"></div>
  <div class="pgroup" id="fill-colors"></div>
  <div class="psep"></div>
  <div class="pgroup" id="stroke-widths"></div>
</div>

<div id="zoombar" class="panel">
  <button id="zoom-out" class="icon" title="Zoom out" aria-label="Zoom out">
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 12h12"/></svg>
  </button>
  <button id="zoom-label" title="Reset to 100%">100%</button>
  <button id="zoom-in" class="icon" title="Zoom in" aria-label="Zoom in">
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 12h12M12 6v12"/></svg>
  </button>
  <div class="zsep"></div>
  <button id="zoom-fit" class="icon" title="Fit to content (0)" aria-label="Fit to content">
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 9V6a2 2 0 0 1 2-2h3M15 4h3a2 2 0 0 1 2 2v3M20 15v3a2 2 0 0 1-2 2h-3M9 20H6a2 2 0 0 1-2-2v-3"/></svg>
  </button>
</div>

<div id="hint"><kbd>Space</kbd>+drag pan &nbsp;·&nbsp; scroll zoom &nbsp;·&nbsp; <kbd>V</kbd><kbd>P</kbd><kbd>R</kbd><kbd>O</kbd><kbd>L</kbd><kbd>T</kbd><kbd>E</kbd> tools &nbsp;·&nbsp; <kbd>Del</kbd> delete</div>

<textarea id="text-editor" spellcheck="false"></textarea>
<div id="eraser-cursor"></div>

<script>
'use strict';
/* ============================================================
   1. CONSTANTS — tunable knobs
   ============================================================ */
const MIN_ZOOM = 0.25, MAX_ZOOM = 4;        // zoom limits (25% – 400%)
const HIT_TOL_PX = 5;                        // selection hit tolerance (screen px)
const ERASE_TOL_PX = 11;                     // eraser tolerance (screen px)
const HANDLE_SIZE = 8, HANDLE_HIT_PX = 8;    // resize handle size / hit radius (screen px)
const MIN_OBJ_SIZE = 3;                      // min size after resize (world units)
const GRID_SPACING = 24, GRID_MIN_PX = 14, GRID_DOT = 1.5;
const MAX_HISTORY = 60;
const STORAGE_KEY = 'slate.board.v1';
const TEXT_LINE_HEIGHT = 1.25;
const FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
const ACCENT = '#1971c2';
const CANVAS_BG = '#f7f6f3';
const GRID_COLOR = 'rgba(55,53,47,0.11)';
const PALETTE = {
  strokes: ['#37352f', '#e03131', '#f08c00', '#1971c2', '#2f9e44', '#6741d9'],
  fills:   ['transparent', '#ffe3e3', '#fff3bf', '#d3f9d8', '#d0ebff', '#e5dbff', '#e9ecef'],
  widths:  [2, 4, 8],
};
const DEFAULT_STYLE = { stroke: '#37352f', fill: 'transparent', strokeWidth: 2 };

/* ============================================================
   2. STATE
   ============================================================ */
let objects = [];                 // scene: single array of serializable shape objects
let cam = { x: 0, y: 0, zoom: 1 }; // viewport: screen = (world - cam) * zoom
let tool = 'select';
let selected = new Set();         // selected object ids
let defaults = { ...DEFAULT_STYLE };
let op = null;                    // active pointer operation
let spaceDown = false;
let editing = null;               // { obj, origText, isNew } while editing text
let needsRender = true;
const history = { stack: [], index: -1 };
let nudgeLast = 0;

/* ============================================================
   3. DOM & CANVAS SETUP
   ============================================================ */
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const measureCtx = document.createElement('canvas').getContext('2d');
const editor = document.getElementById('text-editor');
const eraserEl = document.getElementById('eraser-cursor');
const undoBtn = document.getElementById('undo-btn');
const redoBtn = document.getElementById('redo-btn');
const zoomLabel = document.getElementById('zoom-label');
let dpr = 1, viewW = 0, viewH = 0;

function resize(){
  dpr = window.devicePixelRatio || 1;
  viewW = window.innerWidth; viewH = window.innerHeight;
  canvas.width = Math.round(viewW * dpr);
  canvas.height = Math.round(viewH * dpr);
  canvas.style.width = viewW + 'px';
  canvas.style.height = viewH + 'px';
  if (editing) positionEditor();
  needsRender = true;
}

/* ---------- ids & serialization (caches use "_" prefix, stripped) ---------- */
let idCounter = 0;
function newId(){
  return 'o' + Date.now().toString(36) + (idCounter++).toString(36) + Math.floor(Math.random() * 1e4).toString(36);
}
const stripCaches = (k, v) => (k.startsWith('_') ? undefined : v);
const serialize = arr => JSON.stringify(arr, stripCaches);
const byId = id => objects.find(o => o.id === id);
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));

/* ============================================================
   4. PERSISTENCE & HISTORY
   ============================================================ */
let saveTimer = null;
function saveSoon(){ clearTimeout(saveTimer); saveTimer = setTimeout(save, 350); }
function save(){
  try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ v: 1, cam, objects: serialize(objects) })); }
  catch(e){ /* storage unavailable — ignore */ }
}
function loadBoard(){
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const d = JSON.parse(raw);
    if (d && Array.isArray(d.objects)) return d;
  } catch(e){}
  return null;
}

function pushHistory(){
  const s = serialize(objects);
  if (s === history.stack[history.index]) return;      // no-op guard
  history.stack.splice(history.index + 1);             // drop redo tail
  history.stack.push(s);
  if (history.stack.length > MAX_HISTORY) history.stack.shift();
  history.index = history.stack.length - 1;
  uiSync(); saveSoon();
}
function restoreState(i){
  history.index = i;
  objects = JSON.parse(history.stack[i]);
  pruneSelection(); uiSync(); saveSoon();
}
function undo(){ if (history.index > 0) restoreState(history.index - 1); }
function redo(){ if (history.index < history.stack.length - 1) restoreState(history.index + 1); }
// coalesce rapid nudges into a single undo step
function pushHistoryNudge(){
  const now = performance.now();
  if (now - nudgeLast < 600){
    const s = serialize(objects);
    if (s !== history.stack[history.index]){ history.stack[history.index] = s; saveSoon(); }
  } else {
    pushHistory();
  }
  nudgeLast = now;
}
function pruneSelection(){
  const ids = new Set(objects.map(o => o.id));
  for (const id of [...selected]) if (!ids.has(id)) selected.delete(id);
}
function uiSync(){ refreshPropsUI(); updateHistoryButtons(); needsRender = true; }

/* ============================================================
   5. GEOMETRY — bbox, translate, resize transforms
   ============================================================ */
function fontFor(size){ return size + 'px ' + FONT_STACK; }
function textMetrics(o){
  measureCtx.font = fontFor(o.fontSize);
  const lines = (o.text || '').split('\n');
  let w = 0;
  for (const l of lines) w = Math.max(w, measureCtx.measureText(l).width);
  return { w: Math.max(w, 10), h: lines.length * o.fontSize * TEXT_LINE_HEIGHT };
}
function getBBox(o){
  if (o._bbox) return o._bbox;
  let b;
  switch (o.type){
    case 'rect': case 'ellipse':
      b = { x: o.x, y: o.y, w: o.w, h: o.h }; break;
    case 'line':
      b = { x: Math.min(o.x1, o.x2), y: Math.min(o.y1, o.y2), w: Math.abs(o.x2 - o.x1), h: Math.abs(o.y2 - o.y1) }; break;
    case 'text': {
      const m = textMetrics(o);
      b = { x: o.x, y: o.y, w: m.w, h: m.h }; break;
    }
    case 'freedraw': {
      let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
      for (const p of o.points){
        if (p.x < x1) x1 = p.x; if (p.y < y1) y1 = p.y;
        if (p.x > x2) x2 = p.x; if (p.y > y2) y2 = p.y;
      }
      b = { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; break;
    }
  }
  o._bbox = b;
  return b;
}
function invalidate(o){ o._paths = null; o._bbox = null; }

function translateObject(o, dx, dy){
  switch (o.type){
    case 'rect': case 'ellipse': case 'text': o.x += dx; o.y += dy; break;
    case 'line': o.x1 += dx; o.y1 += dy; o.x2 += dx; o.y2 += dy; break;
    case 'freedraw': for (const p of o.points){ p.x += dx; p.y += dy; } break;
  }
  invalidate(o);
}
// restore a live object from its operation-start clone (deep for points)
function restoreFromClone(o, cl){
  Object.assign(o, cl);
  if (o.points) o.points = o.points.map(p => ({ x: p.x, y: p.y }));
  invalidate(o);
}
// map every geometry point: p -> anchor + (p - anchor) * (sx, sy)
function applyResize(o, anchor, sx, sy){
  const map = (x, y) => ({ x: anchor.x + (x - anchor.x) * sx, y: anchor.y + (y - anchor.y) * sy });
  if (o.type === 'rect' || o.type === 'ellipse'){
    const a = map(o.x, o.y), b = map(o.x + o.w, o.y + o.h);
    o.x = Math.min(a.x, b.x); o.y = Math.min(a.y, b.y);
    o.w = Math.abs(b.x - a.x); o.h = Math.abs(b.y - a.y);
  } else if (o.type === 'line'){
    const a = map(o.x1, o.y1), b = map(o.x2, o.y2);
    o.x1 = a.x; o.y1 = a.y; o.x2 = b.x; o.y2 = b.y;
  } else if (o.type === 'freedraw'){
    for (const p of o.points){ const m = map(p.x, p.y); p.x = m.x; p.y = m.y; }
  } else if (o.type === 'text'){
    const m = map(o.x, o.y); o.x = m.x; o.y = m.y;
    o.fontSize = clamp(o.fontSize * (sx + sy) / 2, 6, 400);
  }
  invalidate(o);
}
function makeObject(type, props){
  const o = {
    id: newId(), type,
    stroke: defaults.stroke, fill: defaults.fill, strokeWidth: defaults.strokeWidth,
    seed: (Math.random() * 1e9) | 0,
    ...props,
  };
  if (type !== 'rect' && type !== 'ellipse') o.fill = 'transparent';
  return o;
}

/* ============================================================
   6. HIT TESTING — world coordinates, screen-px tolerance
   ============================================================ */
function segDist(px, py, x1, y1, x2, y2){
  const dx = x2 - x1, dy = y2 - y1;
  const l2 = dx * dx + dy * dy;
  if (l2 === 0) return Math.hypot(px - x1, py - y1);
  let t = ((px - x1) * dx + (py - y1) * dy) / l2;
  t = clamp(t, 0, 1);
  return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
function hitObject(o, wx, wy, tol){
  switch (o.type){
    case 'rect': {
      // whole drawn region: interior plus the stroke band
      return wx >= o.x - tol && wx <= o.x + o.w + tol && wy >= o.y - tol && wy <= o.y + o.h + tol;
    }
    case 'ellipse': {
      const cx = o.x + o.w / 2, cy = o.y + o.h / 2;
      const rx = Math.max(0.001, o.w / 2 + tol), ry = Math.max(0.001, o.h / 2 + tol);
      return ((wx - cx) / rx) ** 2 + ((wy - cy) / ry) ** 2 <= 1;
    }
    case 'line':
      return segDist(wx, wy, o.x1, o.y1, o.x2, o.y2) <= tol + o.strokeWidth / 2;
    case 'freedraw': {
      const pts = o.points;
      const t = tol + o.strokeWidth / 2;
      for (let i = 0; i < pts.length - 1; i++)
        if (segDist(wx, wy, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y) <= t) return true;
      if (pts.length === 1) return Math.hypot(wx - pts[0].x, wy - pts[0].y) <= t;
      return false;
    }
    case 'text': {
      const b = getBBox(o);
      return wx >= b.x && wx <= b.x + b.w && wy >= b.y && wy <= b.y + b.h;
    }
  }
  return false;
}
function hitTest(wx, wy, tol){
  for (let i = objects.length - 1; i >= 0; i--){
    const o = objects[i];
    const b = getBBox(o);
    if (wx < b.x - tol || wx > b.x + b.w + tol || wy < b.y - tol || wy > b.y + b.h + tol) continue;
    if (hitObject(o, wx, wy, tol)) return o;
  }
  return null;
}
function bboxesIntersect(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;
}

/* ============================================================
   7. ROUGH PATHS — seeded hand-drawn rendering (cached Path2D)
   ============================================================ */
function mulberry32(a){
  return function(){
    a |= 0; a = a + 0x6D2B79F5 | 0;
    let t = Math.imul(a ^ a >>> 15, 1 | a);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}
function smoothPoints(p, pts, close){
  const n = pts.length;
  if (n === 0) return;
  p.moveTo(pts[0].x, pts[0].y);
  for (let i = 1; i < n - 1; i++){
    const mx = (pts[i].x + pts[i + 1].x) / 2, my = (pts[i].y + pts[i + 1].y) / 2;
    p.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
  }
  p.lineTo(pts[n - 1].x, pts[n - 1].y);
  if (close) p.closePath();
}
function roughRect(p, o, rnd, amp){
  if (o.w < 1 && o.h < 1){ p.moveTo(o.x, o.y); p.arc(o.x, o.y, 0.5, 0, Math.PI * 2); return; }
  const c = [[o.x, o.y], [o.x + o.w, o.y], [o.x + o.w, o.y + o.h], [o.x, o.y + o.h]];
  const j = () => (rnd() * 2 - 1) * amp * 0.4;
  p.moveTo(c[0][0] + j(), c[0][1] + j());
  for (let i = 0; i < 4; i++){
    const A = c[i], B = c[(i + 1) % 4];
    const dx = B[0] - A[0], dy = B[1] - A[1];
    const len = Math.hypot(dx, dy) || 1;
    const off = (rnd() * 2 - 1) * amp;
    p.quadraticCurveTo((A[0] + B[0]) / 2 - dy / len * off, (A[1] + B[1]) / 2 + dx / len * off, B[0] + j(), B[1] + j());
  }
  p.closePath();
}
function roughEllipse(p, o, rnd, amp){
  const cx = o.x + o.w / 2, cy = o.y + o.h / 2;
  const rx = o.w / 2, ry = o.h / 2;
  if (rx < 1.5 || ry < 1.5){ p.moveTo(cx - 0.5, cy); p.arc(cx, cy, 0.5, 0, Math.PI * 2); return; }
  const N = 26, start = rnd() * Math.PI * 2;
  const pts = [];
  for (let i = 0; i <= N; i++){
    const a = start + (i / N) * Math.PI * 2;
    const j = 1 + (rnd() * 2 - 1) * (amp / Math.max(rx, ry)) * 1.1;
    pts.push({ x: cx + Math.cos(a) * rx * j, y: cy + Math.sin(a) * ry * j });
  }
  smoothPoints(p, pts, true);
}
function roughLine(p, o, rnd, amp){
  const { x1, y1, x2, y2 } = o;
  const dx = x2 - x1, dy = y2 - y1;
  const len = Math.hypot(dx, dy);
  p.moveTo(x1, y1);
  if (len < 0.5) p.lineTo(x2, y2);
  else {
    const off = (rnd() * 2 - 1) * amp;
    p.quadraticCurveTo((x1 + x2) / 2 - dy / len * off, (y1 + y2) / 2 + dx / len * off, x2, y2);
  }
  const ang = Math.atan2(dy, dx);
  const ah = Math.max(9, o.strokeWidth * 3.5);
  for (const s of [-1, 1]){
    const a = ang + Math.PI + s * 0.48;
    p.moveTo(x2, y2);
    p.lineTo(x2 + Math.cos(a) * ah, y2 + Math.sin(a) * ah);
  }
}
function getPaths(o){
  if (o._paths) return o._paths;
  const rnd = mulberry32(o.seed || 1);
  const amp = Math.min(1.5, Math.max(0.7, o.strokeWidth * 0.3));
  const path = new Path2D();
  if (o.type === 'rect') roughRect(path, o, rnd, amp);
  else if (o.type === 'ellipse') roughEllipse(path, o, rnd, amp);
  else if (o.type === 'line') roughLine(path, o, rnd, amp);
  else if (o.type === 'freedraw') smoothPoints(path, o.points, false);
  o._paths = { path };
  return o._paths;
}

/* ============================================================
   8. RENDERING
   ============================================================ */
function toWorld(sx, sy){ return { x: cam.x + sx / cam.zoom, y: cam.y + sy / cam.zoom }; }
function toScreen(wx, wy){ return { x: (wx - cam.x) * cam.zoom, y: (wy - cam.y) * cam.zoom }; }

function drawGrid(){
  let s = GRID_SPACING;
  while (s * cam.zoom < GRID_MIN_PX) s *= 2;
  const step = s * cam.zoom;
  const ox = ((-cam.x * cam.zoom) % step + step) % step;
  const oy = ((-cam.y * cam.zoom) % step + step) % step;
  ctx.fillStyle = GRID_COLOR;
  for (let x = ox; x < viewW; x += step)
    for (let y = oy; y < viewH; y += step)
      ctx.fillRect(x - GRID_DOT, y - GRID_DOT, GRID_DOT * 2, GRID_DOT * 2);
}
function drawTextObject(o, alpha){
  ctx.globalAlpha = alpha;
  ctx.fillStyle = o.stroke || '#37352f';
  ctx.font = fontFor(o.fontSize);
  ctx.textBaseline = 'top';
  const lines = (o.text || '').split('\n');
  const lh = o.fontSize * TEXT_LINE_HEIGHT;
  for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], o.x, o.y + i * lh);
  ctx.globalAlpha = 1;
}
function drawObject(o, alpha = 1){
  if (o.type === 'text'){ drawTextObject(o, alpha); return; }
  if (o.type === 'freedraw' && o.points.length === 1){
    const p = o.points[0];
    ctx.globalAlpha = alpha;
    ctx.fillStyle = o.stroke;
    ctx.beginPath();
    ctx.arc(p.x, p.y, Math.max(o.strokeWidth / 2, 0.8), 0, Math.PI * 2);
    ctx.fill();
    ctx.globalAlpha = 1;
    return;
  }
  const { path } = getPaths(o);
  ctx.globalAlpha = alpha;
  ctx.lineJoin = 'round';
  ctx.lineCap = 'round';
  if (o.fill && o.fill !== 'transparent'){ ctx.fillStyle = o.fill; ctx.fill(path); }
  if (o.stroke && o.stroke !== 'transparent'){
    ctx.strokeStyle = o.stroke;
    ctx.lineWidth = o.strokeWidth;
    ctx.stroke(path);
  }
  ctx.globalAlpha = 1;
}
function shapeDraftCoords(o){
  let { x1, y1, x2, y2 } = o;
  if (o.shift){
    if (o.type === 'rect' || o.type === 'ellipse'){
      const s = Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1));
      x2 = x1 + (x2 >= x1 ? s : -s);
      y2 = y1 + (y2 >= y1 ? s : -s);
    } else if (o.type === 'line'){
      const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy);
      if (len > 1){
        const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
        x2 = x1 + Math.cos(a) * len; y2 = y1 + Math.sin(a) * len;
      }
    }
  }
  return { x1, y1, x2, y2 };
}
function drawShapeDraft(){
  const c = shapeDraftCoords(op);
  let o;
  if (op.type === 'line')
    o = { type: 'line', x1: c.x1, y1: c.y1, x2: c.x2, y2: c.y2, stroke: defaults.stroke, fill: 'transparent', strokeWidth: defaults.strokeWidth, seed: 1 };
  else {
    const x = Math.min(c.x1, c.x2), y = Math.min(c.y1, c.y2);
    o = { type: op.type, x, y, w: Math.abs(c.x2 - c.x1), h: Math.abs(c.y2 - c.y1), stroke: defaults.stroke, fill: 'transparent', strokeWidth: defaults.strokeWidth, seed: 1 };
  }
  drawObject(o, 0.7);
}
function selectionBBox(){
  let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
  for (const id of selected){
    const o = byId(id); if (!o) continue;
    const b = getBBox(o);
    x1 = Math.min(x1, b.x); y1 = Math.min(y1, b.y);
    x2 = Math.max(x2, b.x + b.w); y2 = Math.max(y2, b.y + b.h);
  }
  if (x1 === Infinity) return null;
  return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}
function drawSelectionUI(){
  const box = selectionBBox();
  if (!box) return;
  const s = toScreen(box.x, box.y);
  const w = box.w * cam.zoom, h = box.h * cam.zoom;
  ctx.strokeStyle = ACCENT;
  ctx.lineWidth = 1.5;
  ctx.strokeRect(s.x - 1, s.y - 1, w + 2, h + 2);
  const hs = HANDLE_SIZE / 2;
  for (const [hx, hy] of [[s.x, s.y], [s.x + w, s.y], [s.x, s.y + h], [s.x + w, s.y + h]]){
    ctx.fillStyle = '#fff';
    ctx.fillRect(hx - hs, hy - hs, HANDLE_SIZE, HANDLE_SIZE);
    ctx.strokeRect(hx - hs + 0.75, hy - hs + 0.75, HANDLE_SIZE - 1.5, HANDLE_SIZE - 1.5);
  }
}
function drawRubberband(){
  if (!op || op.kind !== 'rubberband') return;
  const x = Math.min(op.sx, op.sx2), y = Math.min(op.sy, op.sy2);
  const w = Math.abs(op.sx2 - op.sx), h = Math.abs(op.sy2 - op.sy);
  ctx.fillStyle = 'rgba(25,113,194,0.08)';
  ctx.fillRect(x, y, w, h);
  ctx.strokeStyle = ACCENT;
  ctx.lineWidth = 1;
  ctx.strokeRect(x + 0.5, y + 0.5, w, h);
}
function render(){
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.fillStyle = CANVAS_BG;
  ctx.fillRect(0, 0, viewW, viewH);
  drawGrid();
  // world space
  ctx.setTransform(dpr * cam.zoom, 0, 0, dpr * cam.zoom, -cam.x * cam.zoom * dpr, -cam.y * cam.zoom * dpr);
  for (const o of objects){
    if (editing && o.id === editing.obj.id) continue; // shown in the overlay editor
    drawObject(o);
  }
  if (op && op.kind === 'pen') drawObject(op.obj, 0.9);
  if (op && op.kind === 'shape') drawShapeDraft();
  // screen space
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  drawSelectionUI();
  drawRubberband();
}
function frame(){
  if (needsRender){
    needsRender = false;
    render();
    updateZoomUI();
  }
  requestAnimationFrame(frame);
}

/* ============================================================
   9. VIEWPORT — pan & zoom (world coords stay under cursor)
   ============================================================ */
function updateZoomUI(){
  zoomLabel.textContent = Math.round(cam.zoom * 100) + '%';
}
function zoomAt(sx, sy, factor){
  const w = toWorld(sx, sy);
  cam.zoom = clamp(cam.zoom * factor, MIN_ZOOM, MAX_ZOOM);
  cam.x = w.x - sx / cam.zoom;
  cam.y = w.y - sy / cam.zoom;
  if (editing) positionEditor();
  needsRender = true;
}
function fitView(){
  if (objects.length === 0){ resetView(); return; }
  let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
  for (const o of objects){
    const b = getBBox(o);
    x1 = Math.min(x1, b.x); y1 = Math.min(y1, b.y);
    x2 = Math.max(x2, b.x + b.w); y2 = Math.max(y2, b.y + b.h);
  }
  const pad = 60;
  cam.zoom = clamp(Math.min((viewW - pad * 2) / Math.max(1, x2 - x1), (viewH - pad * 2) / Math.max(1, y2 - y1)), MIN_ZOOM, MAX_ZOOM);
  cam.x = (x1 + x2) / 2 - viewW / 2 / cam.zoom;
  cam.y = (y1 + y2) / 2 - viewH / 2 / cam.zoom;
  needsRender = true;
}
function resetView(){
  cam = { x: 0, y: 0, zoom: 1 };
  needsRender = true;
}


/* ============================================================
   10. POINTER INTERACTIONS — every operation starts with a
       deep copy of the affected objects so undo is one step
   ============================================================ */
const cloneObj = o => JSON.parse(JSON.stringify(o, stripCaches));
function handleAt(wx, wy){
  if (selected.size === 0) return null;
  const box = selectionBBox();
  if (!box) return null;
  const tol = (HANDLE_HIT_PX + 3) / cam.zoom;
  const cs = [[box.x, box.y], [box.x + box.w, box.y], [box.x, box.y + box.h], [box.x + box.w, box.y + box.h]];
  for (let i = 0; i < 4; i++)
    if (Math.abs(wx - cs[i][0]) <= tol && Math.abs(wy - cs[i][1]) <= tol) return { index: i, ax: cs[i][0], ay: cs[i][1] };
  return null;
}
function startMove(sx, sy, shift){
  op = { kind: 'move', sx, sy, sx2: sx, sy2: sy, shift: !!shift,
    before: selected.size ? new Map([...selected].map(id => [id, cloneObj(byId(id))])) : null,
    moved: false };
}
function startResize(h){
  const box = selectionBBox();
  op = { kind: 'resize', h, ax: h.ax, ay: h.ay, bw: box.w, bh: box.h,
    before: new Map([...selected].map(id => [id, cloneObj(byId(id))])) };
}
function startPen(wx, wy){
  const o = makeObject('freedraw', { points: [{ x: wx, y: wy }] });
  objects.push(o);
  op = { kind: 'pen', obj: o, before: null, last: { x: wx, y: wy }, dist: 0 };
}
function startShape(sx, sy, wx, wy){
  op = { kind: 'shape', x1: wx, y1: wy, x2: wx, y2: wy, shift: false, type: tool };
}
function startErase(wx, wy){
  op = { kind: 'eraser', erased: [] };
  eraseAt(wx, wy);
}
function eraseAt(wx, wy){
  const tol = ERASE_TOL_PX / cam.zoom;
  for (let i = objects.length - 1; i >= 0; i--){
    const o = objects[i];
    if (hitObject(o, wx, wy, tol)){
      objects.splice(i, 1);
      selected.delete(o.id);
      op.erased.push(o.id);
    }
  }
  needsRender = true;
}
function setCursor(){
  let c;
  if (spaceDown) c = op && op.kind === 'pan' ? 'grabbing' : 'grab';
  else if (tool === 'select') c = 'default';
  else if (tool === 'text') c = 'text';
  else c = 'crosshair';
  canvas.style.cursor = c;
}
function eraseStroke(a, b){
  const tol = ERASE_TOL_PX / cam.zoom;
  for (let i = objects.length - 1; i >= 0; i--){
    const o = objects[i];
    let hit = false;
    if (o.type === 'freedraw'){
      const t = tol + o.strokeWidth / 2;
      for (let k = 0; k < o.points.length - 1 && !hit; k++)
        hit = segDist(a.x, a.y, o.points[k].x, o.points[k].y, o.points[k + 1].x, o.points[k + 1].y) <= t
          || segDist(b.x, b.y, o.points[k].x, o.points[k].y, o.points[k + 1].x, o.points[k + 1].y) <= t;
    } else hit = hitObject(o, a.x, a.y, tol) || hitObject(o, b.x, b.y, tol);
    if (hit){ objects.splice(i, 1); selected.delete(o.id); }
  }
}
function onPointerDown(e){
  if (e.target !== canvas) return;
  if (editing) commitText();
  if (e.button === 1 || (e.button === 0 && spaceDown)){
    e.preventDefault();
    try { canvas.setPointerCapture(e.pointerId); } catch(_){}
    op = { kind: 'pan', sx: e.clientX, sy: e.clientY, camX: cam.x, camY: cam.y };
    setCursor(); needsRender = true;
    return;
  }
  if (e.button !== 0) return;
  try { canvas.setPointerCapture(e.pointerId); } catch(_){}
  const { x: wx, y: wy } = toWorld(e.clientX, e.clientY);
  const sx = e.clientX, sy = e.clientY;
  if (tool === 'select'){
    const h = handleAt(wx, wy);
    if (h){ startResize(h); return; }
    const hit = hitTest(wx, wy, HIT_TOL_PX / cam.zoom);
    if (hit){
      if (e.shiftKey){
        if (selected.has(hit.id)) selected.delete(hit.id); else selected.add(hit.id);
        uiSync();
        return; // simple toggle click — no drag started
      }
      if (!selected.has(hit.id)){ selected.clear(); selected.add(hit.id); uiSync(); }
      startMove(sx, sy, false);
    } else {
      if (!e.shiftKey){ selected.clear(); uiSync(); }
      op = { kind: 'rubberband', sx, sy, sx2: sx, sy2: sy, shift: e.shiftKey };
    }
  }
  else if (tool === 'pen') startPen(wx, wy);
  else if (tool === 'rect' || tool === 'ellipse' || tool === 'line') startShape(sx, sy, wx, wy);
  else if (tool === 'text') startTextAt(wx, wy);
  else if (tool === 'eraser'){ eraserEl.style.display = 'block'; startErase(wx, wy); }
}
function onPointerMove(e){
  if (tool === 'eraser' && !op){
    eraserEl.style.left = e.clientX + 'px';
    eraserEl.style.top = e.clientY + 'px';
  }
  if (!op) return;
  const sx = e.clientX, sy = e.clientY;
  const { x: wx, y: wy } = toWorld(sx, sy);
  switch (op.kind){
    case 'pan':
      cam.x = op.camX - (sx - op.sx) / cam.zoom;
      cam.y = op.camY - (sy - op.sy) / cam.zoom;
      if (editing) positionEditor();
      needsRender = true;
      break;
    case 'rubberband':
      op.sx2 = sx; op.sy2 = sy;
      needsRender = true;
      break;
    case 'move': {
      if (!op.moved && Math.hypot(sx - op.sx, sy - op.sy) < 2) return;
      op.moved = true;
      if (!op.before) return;
      const dx = (sx - op.sx) / cam.zoom, dy = (sy - op.sy) / cam.zoom;
      for (const [id, cl] of op.before){
        const o = byId(id);
        if (o){ restoreFromClone(o, cl); translateObject(o, dx, dy); }
      }
      needsRender = true;
      break;
    }
    case 'resize': {
      const dx = wx - op.ax, dy = op.ay - wy; // anchor corner pulled toward cursor
      if (Math.abs(dx) < 0.001 && Math.abs(dy) < 0.001) return;
      let sx = 1, sy = 1;
      if (op.h.index === 0 || op.h.index === 3) sx = (op.bw + dx) / op.bw;
      if (op.h.index === 1 || op.h.index === 3) sy = (op.bh + dy) / op.bh;
      if (e.shiftKey) sy = sx; // preserve aspect
      sx = clamp(sx, MIN_OBJ_SIZE / (op.bw || 1), 100);
      sy = clamp(sy, MIN_OBJ_SIZE / (op.bh || 1), 100);
      for (const [id, cl] of op.before){
        const o = byId(id);
        if (!o) continue;
        restoreFromClone(o, cl);
        if (op.h.index === 0) applyResize(o, { x: o.x + o.w, y: o.y + o.h }, sx, sy);
        else if (op.h.index === 1) applyResize(o, { x: o.x, y: o.y + o.h }, sx, sy);
        else if (op.h.index === 2) applyResize(o, { x: o.x + o.w, y: o.y }, sx, sy);
        else applyResize(o, { x: o.x, y: o.y }, sx, sy);
      }
      needsRender = true;
      break;
    }
    case 'pen': {
      const d = Math.hypot(wx - op.last.x, wy - op.last.y);
      if (d >= 0.75 / cam.zoom){
        op.obj.points.push({ x: wx, y: wy });
        op.last = { x: wx, y: wy };
        needsRender = true;
      }
      break;
    }
    case 'shape':
      op.x2 = wx; op.y2 = wy; op.shift = e.shiftKey;
      needsRender = true;
      break;
    case 'eraser': {
      const prev = { x: op.lwx ?? wx, y: op.lwy ?? wy };
      eraseStroke(prev, { x: wx, y: wy });
      op.lwx = wx; op.lwy = wy;
      break;
    }
  }
}
function finishMove(){
  if (op.kind === 'move' && op.moved) pushHistory();
}
function onPointerUp(e){
  if (!op) return;
  const o = op;
  const { x: wx, y: wy } = toWorld(e.clientX, e.clientY);
  switch (o.kind){
    case 'pan':
      if (editing) positionEditor();
      break;
    case 'rubberband': {
      const a = toWorld(Math.min(o.sx, o.sx2), Math.min(o.sy, o.sy2));
      const b = toWorld(Math.max(o.sx, o.sx2), Math.max(o.sy, o.sy2));
      console.log('DBG RB-up', o.sx, o.sx2, o.sy, o.sy2, 'objs=', objects.length, 'sel=', selected.size);
      if (Math.abs(o.sx2 - o.sx) > 4 || Math.abs(o.sy2 - o.sy) > 4){
        const rect = { x: a.x, y: a.y, w: b.x - a.x, h: b.y - a.y };
        console.log('DBG RB-if rect=', JSON.stringify(rect), 'shift=', o.shift);
        if (o.shift) for (const obj of objects) if (bboxesIntersect(getBBox(obj), rect)) selected.add(obj.id);
        else { selected.clear(); for (const obj of objects) if (bboxesIntersect(getBBox(obj), rect)) selected.add(obj.id); }
        console.log('DBG RB-after sel=', selected.size);
        pushHistory(); // selection change is undoable
      } else {
        selected.clear();
      }
      uiSync();
      break;
    }
    case 'move':
      finishMove();
      break;
    case 'resize': {
      // enforce minimum size on the group bbox
      const box = selectionBBox();
      if (box && (box.w < MIN_OBJ_SIZE || box.h < MIN_OBJ_SIZE)){
        for (const [id, cl] of o.before) restoreFromClone(byId(id), cl);
      } else pushHistory();
      break;
    }
    case 'pen':
      if (o.obj.points.length === 1) o.obj.points.push({ x: o.obj.points[0].x + 0.01, y: o.obj.points[0].y });
      pushHistory();
      break;
    case 'shape': {
      const c = shapeDraftCoords(o);
      const size = Math.max(Math.abs(c.x2 - c.x1), Math.abs(c.y2 - c.y1));
      if (size > 2){
        let obj;
        if (o.type === 'line') obj = makeObject('line', { x1: c.x1, y1: c.y1, x2: c.x2, y2: c.y2 });
        else {
          const x = Math.min(c.x1, c.x2), y = Math.min(c.y1, c.y2);
          obj = makeObject(o.type, { x, y, w: Math.abs(c.x2 - c.x1), h: Math.abs(c.y2 - c.y1) });
        }
        objects.push(obj);
        selected.clear(); selected.add(obj.id);
        pushHistory();
        uiSync();
      }
      break;
    }
    case 'eraser':
      if (o.erased.length) pushHistory();
      break;
  }
  op = null;
  eraserEl.style.display = 'none';
  setCursor();
  needsRender = true;
}

/* ============================================================
   11. TEXT TOOL — inline editor overlay
   ============================================================ */
function startTextAt(wx, wy){
  const obj = makeObject('text', { x: wx, y: wy, text: '', fontSize: 20 });
  objects.push(obj);
  selected.clear(); selected.add(obj.id);
  uiSync();
  openEditor(obj, true);
}
function openEditor(obj, isNew){
  editing = { obj, origText: obj.text || '', isNew };
  editor.value = obj.text || '';
  editor.style.fontSize = (obj.fontSize * cam.zoom) + 'px';
  editor.style.fontFamily = FONT_STACK;
  positionEditor();
  editor.style.display = 'block';
  needsRender = true;
  requestAnimationFrame(() => { editor.focus(); editor.select(); });
}
function positionEditor(){
  if (!editing) return;
  const o = editing.obj;
  const s = toScreen(o.x, o.y);
  const m = textMetrics(o);
  editor.style.left = (s.x - 8) + 'px';
  editor.style.top = (s.y - 4) + 'px';
  editor.style.minWidth = Math.max(40, m.w * cam.zoom + 16) + 'px';
  editor.style.minHeight = Math.max(24, m.h * cam.zoom + 8) + 'px';
}
function commitText(){
  if (!editing) return;
  const { obj, origText, isNew } = editing;
  editing = null;
  editor.style.display = 'none';
  const text = editor.value.replace(/\n+$/, '');
  if (text.length === 0){
    if (isNew){
      objects.splice(objects.indexOf(obj), 1);
      selected.delete(obj.id);
    } else {
      obj.text = origText;
      invalidate(obj);
    }
  } else if (text !== origText){
    obj.text = text;
    invalidate(obj);
    pushHistory();
  }
  uiSync();
  needsRender = true;
}
function cancelText(){
  if (!editing) return;
  const { obj, isNew } = editing;
  editing = null;
  editor.style.display = 'none';
  if (isNew) objects.splice(objects.indexOf(obj), 1);
  selected.delete(obj.id);
  uiSync(); needsRender = true;
}

/* ============================================================
   12. KEYBOARD
   ============================================================ */
function nudgeSelection(dx, dy, big){
  if (selected.size === 0 || editing) return;
  const step = big ? 10 : 1;
  for (const id of selected){
    const o = byId(id); if (o) translateObject(o, dx * step, dy * step);
  }
  if (editing) positionEditor();
  pushHistoryNudge();
  needsRender = true;
}
function onKey(e){
  const meta = e.ctrlKey || e.metaKey;
  if (e.key === ' ' && !e.repeat){ spaceDown = true; setCursor(); e.preventDefault(); return; }
  if (editing){
    if (e.key === 'Escape'){ e.preventDefault(); cancelText(); }
    return; // let typing flow into the editor
  }
  if (meta && (e.key === 'z' || e.key === 'Z')){ e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
  if (meta && (e.key === 'y' || e.key === 'Y')){ e.preventDefault(); redo(); return; }
  if (meta && (e.key === 'a' || e.key === 'A')){
    e.preventDefault();
    selected = new Set(objects.map(o => o.id));
    uiSync(); return;
  }
  if (meta) return;
  switch (e.key){
    case 'v': case 'V': setTool('select'); break;
    case 'p': case 'P': setTool('pen'); break;
    case 'r': case 'R': setTool('rect'); break;
    case 'o': case 'O': setTool('ellipse'); break;
    case 'l': case 'L': setTool('line'); break;
    case 't': case 'T': setTool('text'); break;
    case 'e': case 'E': setTool('eraser'); break;
    case 'Delete': case 'Backspace':
      if (selected.size){
        for (const id of [...selected]){
          const i = objects.findIndex(o => o.id === id);
          if (i >= 0) objects.splice(i, 1);
        }
        selected.clear();
        pushHistory(); uiSync(); needsRender = true;
      }
      break;
    case 'ArrowLeft': e.preventDefault(); nudgeSelection(-1, 0, e.shiftKey); break;
    case 'ArrowRight': e.preventDefault(); nudgeSelection(1, 0, e.shiftKey); break;
    case 'ArrowUp': e.preventDefault(); nudgeSelection(0, -1, e.shiftKey); break;
    case 'ArrowDown': e.preventDefault(); nudgeSelection(0, 1, e.shiftKey); break;
    case 'Escape': selected.clear(); uiSync(); break;
    case '0': fitView(); break;
  }
}
function onKeyUp(e){
  if (e.key === ' '){ spaceDown = false; setCursor(); }
}
function onWheel(e){
  e.preventDefault();
  let dy = e.deltaY;
  if (e.deltaMode === 1) dy *= 16;
  zoomAt(e.clientX, e.clientY, Math.exp(-dy * 0.0015));
}
function onCanvasDblClick(e){
  const { x: wx, y: wy } = toWorld(e.clientX, e.clientY);
  const hit = hitTest(wx, wy, HIT_TOL_PX / cam.zoom);
  if (hit && hit.type === 'text'){
    selected.clear(); selected.add(hit.id); uiSync();
    openEditor(hit, false);
  } else if (!hit){
    startTextAt(wx, wy);
  }
}

/* ============================================================
   13. TOOLBAR / PROPERTIES / ZOOM UI
   ============================================================ */
function setTool(t){
  if (editing) commitText();
  tool = t;
  for (const b of document.querySelectorAll('#toolbar button[data-tool]'))
    b.classList.toggle('active', b.dataset.tool === t);
  if (t !== 'select') { selected.clear(); uiSync(); }
  setCursor();
  needsRender = true;
}
function applySelectionStyle(patch){
  if (selected.size === 0){ Object.assign(defaults, patch); uiSync(); return; }
  for (const id of selected){
    const o = byId(id); if (!o) continue;
    Object.assign(o, patch);
    invalidate(o);
  }
  pushHistory();
  uiSync();
  needsRender = true;
}
function currentStyle(){
  if (selected.size === 0) return defaults;
  const o = byId([...selected][0]);
  return o || defaults;
}
function refreshPropsUI(){
  const st = currentStyle();
  document.querySelectorAll('#stroke-colors .swatch').forEach(el =>
    el.classList.toggle('active', el.dataset.c === st.stroke));
  document.querySelectorAll('#fill-colors .swatch').forEach(el =>
    el.classList.toggle('active', el.dataset.c === st.fill));
  document.querySelectorAll('#stroke-widths .wbtn').forEach(el =>
    el.classList.toggle('active', +el.dataset.w === st.strokeWidth));
}
function updateHistoryButtons(){
  undoBtn.disabled = history.index <= 0;
  redoBtn.disabled = history.index >= history.stack.length - 1;
}
function buildUI(){
  const sc = document.getElementById('stroke-colors');
  for (const c of PALETTE.strokes){
    const b = document.createElement('button');
    b.className = 'swatch'; b.dataset.c = c;
    b.style.background = c; b.title = 'Stroke ' + c;
    b.onclick = () => applySelectionStyle({ stroke: c });
    sc.appendChild(b);
  }
  const fc = document.getElementById('fill-colors');
  for (const c of PALETTE.fills){
    const b = document.createElement('button');
    b.className = 'swatch' + (c === 'transparent' ? ' none' : '');
    b.dataset.c = c;
    if (c !== 'transparent') b.style.background = c;
    b.title = c === 'transparent' ? 'No fill' : 'Fill ' + c;
    b.onclick = () => applySelectionStyle({ fill: c });
    fc.appendChild(b);
  }
  const wc = document.getElementById('stroke-widths');
  for (const w of PALETTE.widths){
    const b = document.createElement('button');
    b.className = 'wbtn'; b.dataset.w = w; b.title = 'Stroke width ' + w;
    const d = Math.max(3, w);
    b.innerHTML = '<i style="width:' + d + 'px;height:' + d + 'px"></i>';
    b.onclick = () => applySelectionStyle({ strokeWidth: w });
    wc.appendChild(b);
  }
  for (const b of document.querySelectorAll('#toolbar button[data-tool]'))
    b.onclick = () => setTool(b.dataset.tool);
  undoBtn.onclick = undo;
  redoBtn.onclick = redo;
  document.getElementById('zoom-in').onclick = () => zoomAt(viewW / 2, viewH / 2, 1.25);
  document.getElementById('zoom-out').onclick = () => zoomAt(viewW / 2, viewH / 2, 0.8);
  zoomLabel.onclick = resetView;
  document.getElementById('zoom-fit').onclick = fitView;
}

/* ============================================================
   14. BOOT
   ============================================================ */
function seedBoard(){
  const mk = (type, props) => {
    const o = makeObject(type, props);
    o.seed = (Math.random() * 1e9) | 0;
    return o;
  };
  const r1 = mk('rect', { x: 140, y: 150, w: 220, h: 130, fill: '#d0ebff' });
  const e1 = mk('ellipse', { x: 430, y: 190, w: 150, h: 150, fill: '#fff3bf' });
  const a1 = mk('line', { x1: 365, y1: 215, x2: 425, y2: 250 });
  const t1 = mk('text', { x: 150, y: 90, text: 'Welcome to Slate 👋\nDraw, select, zoom — it all works.', fontSize: 22, stroke: '#37352f' });
  const pen = mk('freedraw', { points: [] });
  for (let i = 0; i <= 40; i++){
    const t = i / 40;
    pen.points.push({ x: 140 + t * 440, y: 360 + Math.sin(t * Math.PI * 3) * 22 });
  }
  objects.push(r1, e1, a1, t1, pen);
  cam = { x: 40, y: 20, zoom: 1 };
  history.stack = [serialize(objects)];
  history.index = 0;
  save();
}
function boot(){
  buildUI();
  resize();
  const saved = loadBoard();
  if (saved && saved.objects.length){
    objects = saved.objects;
    if (saved.cam && isFinite(saved.cam.x)) cam = saved.cam;
    history.stack = [serialize(objects)];
    history.index = 0;
  } else {
    seedBoard();
  }
  pruneSelection();
  setTool('select');
  refreshPropsUI();
  updateHistoryButtons();
  setCursor();
  window.addEventListener('resize', resize);
  canvas.addEventListener('pointerdown', onPointerDown);
  canvas.addEventListener('pointermove', onPointerMove);
  canvas.addEventListener('pointerup', onPointerUp);
  canvas.addEventListener('pointercancel', onPointerUp);
  canvas.addEventListener('wheel', onWheel, { passive: false });
  canvas.addEventListener('dblclick', onCanvasDblClick);
  canvas.addEventListener('contextmenu', e => e.preventDefault());
  window.addEventListener('keydown', onKey);
  window.addEventListener('keyup', onKeyUp);
  window.addEventListener('blur', () => { if (spaceDown){ spaceDown = false; setCursor(); } });
  editor.addEventListener('keydown', e => {
    e.stopPropagation();
    if (e.key === 'Escape'){ e.preventDefault(); cancelText(); }
    if (e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); commitText(); }
  });
  editor.addEventListener('input', positionEditor);
  editor.addEventListener('blur', () => { if (editing) commitText(); });
  document.addEventListener('visibilitychange', () => { if (document.hidden) save(); });
  window.addEventListener('beforeunload', save);
  requestAnimationFrame(frame);
}
boot();
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.8-27B-GGUF-XL","provider":"brigid","persona":"full","sessionId":"c7265193-c5ed-449a-a70d-c237717cc393","tokensIn":5273397,"tokensOut":160778,"tokensTotal":5434175,"cost":0,"turns":54,"toolCalls":61,"failedToolCalls":1,"timestamp":"2026-08-19T02:07:41.533Z"} -->