← Whiteboard results

Whiteboard

Tiel Coder 35B A3B GGUF MTP · 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 44,019 bytes · SHA-256 a8ef6b9e55a3
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Whiteboard</title>
<style>
  /* ============================================================
     APP CHROME / LAYOUT
     ============================================================ */
  * { box-sizing: border-box; -webkit-user-select: none; user-select: none; }
  html, body {
    margin: 0; padding: 0; height: 100%; overflow: hidden;
    font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    background: #e5e7eb; /* slightly darker app chrome around the canvas */
  }
  #board {
    position: fixed; inset: 0; display: block;
    background: #ffffff; /* light canvas */
    touch-action: none;
    cursor: default;
  }

  /* ---------- Floating panels ---------- */
  .panel {
    position: fixed; z-index: 10;
    background: #ffffff;
    border: 1px solid #e6e8eb;
    border-radius: 14px;
    box-shadow: 0 6px 24px rgba(15, 23, 42, 0.12), 0 1px 2px rgba(15,23,42,.06);
    display: flex; gap: 4px; align-items: center;
    padding: 6px;
  }

  /* ---------- Toolbar (top center) ---------- */
  #toolbar { top: 14px; left: 50%; transform: translateX(-50%); }
  .tool-btn {
    width: 40px; height: 40px; border: none; background: transparent;
    border-radius: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center;
    color: #334155; transition: background .12s, color .12s;
  }
  .tool-btn:hover { background: #f1f5f9; }
  .tool-btn.active { background: #2563eb; color: #fff; }
  .tool-btn svg { width: 20px; height: 20px; }
  .tool-btn .kbd {
    position: absolute; font-size: 9px; color: #94a3b8; bottom: 2px; right: 4px;
  }
  .tool-btn { position: relative; }
  .sep { width: 1px; height: 24px; background: #e6e8eb; margin: 0 4px; }

  .icon-btn {
    width: 36px; height: 36px; border: none; background: transparent;
    border-radius: 9px; cursor: pointer; color: #475569;
    display: flex; align-items: center; justify-content: center;
  }
  .icon-btn:hover { background: #f1f5f9; }
  .icon-btn:disabled { opacity: .35; cursor: default; }
  .icon-btn:disabled:hover { background: transparent; }
  .icon-btn svg { width: 18px; height: 18px; }

  /* ---------- Properties bar (bottom center) ---------- */
  #props { bottom: 16px; left: 50%; transform: translateX(-50%); gap: 10px; }
  .props-group { display: flex; align-items: center; gap: 5px; padding: 0 6px; border-right: 1px solid #eef0f3; }
  .props-group:last-child { border-right: none; padding-right: 2px; }
  .props-label { font-size: 11px; color: #94a3b8; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }
  .swatch {
    width: 22px; height: 22px; border-radius: 6px; border: 2px solid transparent;
    cursor: pointer; padding: 0; transition: transform .08s;
  }
  .swatch:hover { transform: scale(1.08); }
  .swatch.active { border-color: #2563eb; }
  .swatch.transparent {
    background:
      linear-gradient(45deg, #cbd5e1 25%, transparent 25%) 0 0/6px 6px,
      linear-gradient(-45deg, #cbd5e1 25%, transparent 25%) 0 0/6px 6px,
      linear-gradient(45deg, transparent 75%, #cbd5e1 75%) 0 0/6px 6px,
      linear-gradient(-45deg, transparent 75%, #cbd5e1 75%) 0 0/6px 6px;
    background-color: #fff;
  }
  .width-btn {
    width: 34px; height: 34px; border: none; background: transparent; border-radius: 8px;
    cursor: pointer; display: flex; align-items: center; justify-content: center;
  }
  .width-btn:hover { background: #f1f5f9; }
  .width-btn.active { background: #eff6ff; }
  .dot { border-radius: 50%; background: #334155; }

  /* ---------- Zoom control (bottom right) ---------- */
  #zoom { bottom: 16px; right: 16px; gap: 2px; padding: 4px; }
  .zoom-val { min-width: 56px; text-align: center; font-size: 13px; font-variant-numeric: tabular-nums; color: #334155; font-weight: 600; }
  .zoom-btn {
    min-width: 32px; height: 32px; border: none; background: transparent; color: #334155;
    border-radius: 8px; cursor: pointer; font-size: 16px; line-height: 1;
  }
  .zoom-btn:hover { background: #f1f5f9; }

  /* ---------- Help hint ---------- */
  #hint {
    position: fixed; top: 14px; left: 16px; z-index: 9;
    color: #94a3b8; font-size: 11.5px; line-height: 1.6;
    background: rgba(255,255,255,.85); backdrop-filter: blur(4px);
    padding: 10px 14px; border-radius: 10px; border: 1px solid #eef0f3;
    pointer-events: none; max-width: 240px;
  }
  #hint b { color: #64748b; font-weight: 600; }
  #hint kbd {
    font-family: inherit; font-size: 10px; background: #f1f5f9; border: 1px solid #e2e8f0;
    border-radius: 4px; padding: 0 4px; color: #475569;
  }

  /* ---------- Inline text editor ---------- */
  #text-editor {
    position: fixed; z-index: 20; margin: 0; padding: 2px 3px;
    background: transparent; border: 1px solid #3b82f6; border-radius: 3px;
    outline: none; overflow: hidden; white-space: pre;
    color: #1e293b; caret-color: #2563eb;
    -webkit-user-select: text; user-select: text;
    box-shadow: 0 2px 12px rgba(37,99,235,.22);
  }
</style>
</head>
<body>
  <canvas id="board"></canvas>
  <textarea id="text-editor" style="display:none;"></textarea>

  <div id="hint">
    <b>Whiteboard</b><br>
    <kbd>Space</kbd>+drag / middle-drag — pan<br>
    Wheel — zoom to cursor<br>
    <kbd>V</kbd> select · <kbd>P</kbd> pen · <kbd>R</kbd> rect<br>
    <kbd>O</kbd> ellipse · <kbd>L</kbd> line · <kbd>T</kbd> text · <kbd>E</kbd> eraser<br>
    <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo · <kbd>Del</kbd> remove
  </div>

  <div id="toolbar" class="panel">
    <button class="tool-btn" data-tool="select" title="Select (V)">
      <svg viewBox="0 0 24 24"><path d="M6 3l11 8.5-4.6 1.3-1.9 4.7L6 3z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round"/></svg>
      <span class="kbd">V</span>
    </button>
    <button class="tool-btn" data-tool="pen" title="Pen (P)">
      <svg viewBox="0 0 24 24"><path d="M4 20l1.5-6L16 5.5 18.5 8 12 14.5 6 20z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M14.5 7L17 4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
      <span class="kbd">P</span>
    </button>
    <button class="tool-btn" data-tool="rect" title="Rectangle (R)">
      <svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="14" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>
      <span class="kbd">R</span>
    </button>
    <button class="tool-btn" data-tool="ellipse" title="Ellipse (O)">
      <svg viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="8" ry="6" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>
      <span class="kbd">O</span>
    </button>
    <button class="tool-btn" data-tool="line" title="Line / Arrow (L)">
      <svg viewBox="0 0 24 24"><line x1="5" y1="19" x2="17.5" y2="6.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M17.5 6.5l-2.6 .9 .9 2.6 1.7-3.5z" fill="currentColor"/></svg>
      <span class="kbd">L</span>
    </button>
    <button class="tool-btn" data-tool="text" title="Text (T)">
      <svg viewBox="0 0 24 24"><path d="M7 5v14M17 5v14M9 8h6M9 16h6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
      <span class="kbd">T</span>
    </button>
    <button class="tool-btn" data-tool="eraser" title="Eraser (E)">
      <svg viewBox="0 0 24 24"><path d="M6 20h13.5L21 16.5V6a1 1 0 0 0-1-1H6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><line x1="9" y1="13" x2="15" y2="13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
      <span class="kbd">E</span>
    </button>

    <div class="sep"></div>
    <button class="icon-btn" id="undo-btn" title="Undo (Ctrl+Z)">
      <svg viewBox="0 0 24 24"><path d="M10 9H6a3 3 0 0 0-3 3v1a2 2 0 0 0 2 2h2M10 9l-3-3M10 9l-3 3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
    </button>
    <button class="icon-btn" id="redo-btn" title="Redo (Ctrl+Shift+Z)">
      <svg viewBox="0 0 24 24"><path d="M14 9h4a3 3 0 0 1 3 3v1a2 2 0 0 1-2 2h-2M14 9l3-3M14 9l3 3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
    </button>
    <div class="sep"></div>
    <button class="icon-btn" id="reset-btn" title="Reset view">
      <svg viewBox="0 0 24 24"><path d="M4 10a8 8 0 0 1 13.7-5.3L20 6M20 14a8 8 0 0 1-13.7 5.3L4 18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M20 6v4h-4M4 18v-4h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
    </button>
  </div>

  <div id="props" class="panel">
    <div class="props-group" id="stroke-group">
      <span class="props-label">Stroke</span>
    </div>
    <div class="props-group" id="fill-group">
      <span class="props-label">Fill</span>
    </div>
    <div class="props-group" id="width-group">
      <span class="props-label">Size</span>
    </div>
  </div>

  <div id="zoom" class="panel">
    <button class="zoom-btn" id="zoom-out" title="Zoom out">−</button>
    <span class="zoom-val" id="zoom-val">100%</span>
    <button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
  </div>

<script>
/* ============================================================
   TUNABLE CONSTANTS
   ============================================================ */
const CONFIG = {
  FONT: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
  zoom: { min: 0.25, max: 4, step: 0.12 },
  hitTolerance: 6,        // screen px added around shapes for easier clicking
  handleSize: 9,          // selection handle size (screen px)
  nudge: 1,               // px per arrow press
  nudgeShift: 10,         // px per arrow press with shift
  dragThreshold: 3,       // screen px before a press counts as a drag
  grid: { screenSpacing: 26, dotRadius: 1.1, color: '#d8dde5' },
  maxHistory: 200,
  palette: [
    '#1e293b', // ink
    '#2563eb', // blue
    '#dc2626', // red
    '#16a34a', // green
    '#ea580c', // orange
    '#7c3aed', // purple
    '#0891b2', // cyan
    '#db2777', // pink
    '#64748b', // slate
    '#f59e0b', // amber
  ],
  defaultStroke: '#1e293b',
  defaultFill: 'transparent',
  defaultWidth: 2,
  textFontSize: 18,
};

/* ============================================================
   STATE
   ============================================================ */
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const measureCtx = document.createElement('canvas').getContext('2d');
measureCtx.font = `18px ${CONFIG.FONT}`;

let scene = [];                       // array of serializable shape objects
let selectedIds = new Set();          // Set of object ids
let activeTool = 'select';
let viewport = { x: 0, y: 0, zoom: 1 };

let history = [];                     // array of scene snapshots
let historyIndex = -1;

let spaceDown = false;
let active = null;                    // current pointer interaction
let preview = null;                   // shape being created
let textState = null;                 // inline text editor state

let editorZ = 1; // remember zoom used to size editor (kept in sync)

/* ============================================================
   UTILITY
   ============================================================ */
let uidCounter = Date.now();
function uid() { return 'o' + (++uidCounter); }
function clone(o) { return JSON.parse(JSON.stringify(o)); }
function findObj(id) { return scene.find(o => o.id === id); }
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }

// Coordinate transforms (pan/zoom are in CSS px, world in world units)
function screenToWorld(sx, sy) { return { x: (sx - viewport.x) / viewport.zoom, y: (sy - viewport.y) / viewport.zoom }; }
function worldToScreen(wx, wy) { return { x: wx * viewport.zoom + viewport.x, y: wy * viewport.zoom + viewport.y }; }

/* ============================================================
   BOUNDS
   ============================================================ */
function getBounds(o) {
  switch (o.type) {
    case 'rect':
    case 'ellipse':
    case 'text':
      return { x: o.x, y: o.y, width: o.width, height: o.height };
    case 'line':
    case 'arrow': {
      const x = Math.min(o.x1, o.x2), y = Math.min(o.y1, o.y2);
      return { x, y, width: Math.abs(o.x2 - o.x1), height: Math.abs(o.y2 - o.y1) };
    }
    case 'freehand': {
      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
      for (const p of o.points) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; }
      if (minX === Infinity) return { x: 0, y: 0, width: 0, height: 0 };
      return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
    }
  }
  return { x: 0, y: 0, width: 0, height: 0 };
}
function getGroupBounds() {
  if (selectedIds.size === 0) return null;
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  for (const id of selectedIds) {
    const o = findObj(id); if (!o) continue;
    const b = getBounds(o);
    minX = Math.min(minX, b.x); minY = Math.min(minY, b.y);
    maxX = Math.max(maxX, b.x + b.width); maxY = Math.max(maxY, b.y + b.height);
  }
  return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
function getFirstSelected() {
  for (const id of selectedIds) { const o = findObj(id); if (o) return o; }
  return null;
}

/* ============================================================
   HIT-TESTING (world coordinates)
   ============================================================ */
function segDist(px, py, ax, ay, bx, by) {
  const dx = bx - ax, dy = by - ay;
  const len2 = dx * dx + dy * dy;
  let t = len2 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
  t = clamp(t, 0, 1);
  const cx = ax + t * dx, cy = ay + t * dy;
  return Math.hypot(px - cx, py - cy);
}

function hitTest(world, obj) {
  const tol = CONFIG.hitTolerance / viewport.zoom; // world-space tolerance
  const b = getBounds(obj);
  switch (obj.type) {
    case 'rect':
    case 'text':
      return world.x >= b.x - tol && world.x <= b.x + b.width + tol &&
             world.y >= b.y - tol && world.y <= b.y + b.height + tol;
    case 'ellipse': {
      const cx = b.x + b.width / 2, cy = b.y + b.height / 2;
      const rx = b.width / 2, ry = b.height / 2;
      if (rx < 0.001 || ry < 0.001) return false;
      const m = (tol / rx) + (tol / ry);
      const nx = (world.x - cx) / rx, ny = (world.y - cy) / ry;
      return nx * nx + ny * ny <= 1 + m;
    }
    case 'line':
    case 'arrow':
      return segDist(world.x, world.y, obj.x1, obj.y1, obj.x2, obj.y2) <= tol + (obj.strokeWidth || 2) / 2;
    case 'freehand': {
      const pts = obj.points;
      if (pts.length === 0) return false;
      if (pts.length === 1) return Math.hypot(world.x - pts[0].x, world.y - pts[0].y) <= tol + (pts[0].w || obj.strokeWidth) / 2;
      const half = (obj.strokeWidth || 2) / 2;
      for (let i = 0; i < pts.length - 1; i++) {
        if (segDist(world.x, world.y, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y) <= tol + half) return true;
      }
      return false;
    }
  }
  return false;
}
// Topmost object under a world point (last in scene draws on top)
function hitTestWorld(world) {
  for (let i = scene.length - 1; i >= 0; i--) {
    if (hitTest(world, scene[i])) return scene[i];
  }
  return null;
}

/* ============================================================
   SELECTION HANDLES
   ============================================================ */
function handleAt(world) {
  if (selectedIds.size === 0) return null;
  const b = getGroupBounds(); if (!b) return null;
  const hs = CONFIG.handleSize / viewport.zoom;
  const corners = {
    tl: { x: b.x, y: b.y },
    tr: { x: b.x + b.width, y: b.y },
    bl: { x: b.x, y: b.y + b.height },
    br: { x: b.x + b.width, y: b.y + b.height },
  };
  // Higher-priority (front-most drawn) first
  for (const k of ['br', 'bl', 'tr', 'tl']) {
    const c = corners[k];
    if (Math.hypot(world.x - c.x, world.y - c.y) <= hs) return k;
  }
  return null;
}

/* ============================================================
   RENDERING
   ============================================================ */
function resizeCanvas() {
  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.floor(window.innerWidth * dpr);
  canvas.height = Math.floor(window.innerHeight * dpr);
  canvas.style.width = window.innerWidth + 'px';
  canvas.style.height = window.innerHeight + 'px';
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  render();
}

function drawGrid() {
  const w0 = screenToWorld(0, 0);
  const w1 = screenToWorld(canvas.width / (window.devicePixelRatio || 1), canvas.height / (window.devicePixelRatio || 1));
  // world spacing that keeps dots ~screenSpacing apart, clamped so we never overdraw
  let spacing = CONFIG.grid.screenSpacing / viewport.zoom;
  // snap to a nice power-of-ten-ish step
  const mag = Math.pow(10, Math.floor(Math.log10(spacing)));
  const norm = spacing / mag;
  if (norm < 2) spacing = 2 * mag; else if (norm < 5) spacing = 5 * mag; else spacing = 10 * mag;
  if (spacing < 4) spacing = 4;

  const r = CONFIG.grid.dotRadius / viewport.zoom;
  const startX = Math.floor(w0.x / spacing) * spacing;
  const startY = Math.floor(w0.y / spacing) * spacing;
  ctx.fillStyle = CONFIG.grid.color;
  ctx.beginPath();
  for (let x = startX; x <= w1.x; x += spacing) {
    for (let y = startY; y <= w1.y; y += spacing) {
      ctx.moveTo(x + r, y);
      ctx.arc(x, y, r, 0, Math.PI * 2);
    }
  }
  ctx.fill();
}

function strokeStyle(o) {
  if (o.type === 'freehand') return o.fillColor === 'transparent' ? (o.color || CONFIG.defaultStroke) : (o.fillColor);
  return o.color === 'transparent' ? (o.fillColor || CONFIG.defaultFill) : (o.color);
}

function drawObject(o) {
  const sw = Math.max(0.5, (o.strokeWidth || 2) / viewport.zoom);
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';

  const showFill = o.fillColor && o.fillColor !== 'transparent';
  const showStroke = o.color && o.color !== 'transparent';

  switch (o.type) {
    case 'rect': {
      if (showFill) { ctx.fillStyle = o.fillColor; ctx.fillRect(o.x, o.y, o.width, o.height); }
      if (showStroke) { ctx.strokeStyle = o.color; ctx.lineWidth = sw; ctx.strokeRect(o.x, o.y, o.width, o.height); }
      break;
    }
    case 'ellipse': {
      ctx.beginPath();
      ctx.ellipse(o.x + o.width / 2, o.y + o.height / 2, Math.abs(o.width / 2), Math.abs(o.height / 2), 0, 0, Math.PI * 2);
      if (showFill) { ctx.fillStyle = o.fillColor; ctx.fill(); }
      if (showStroke) { ctx.strokeStyle = o.color; ctx.lineWidth = sw; ctx.stroke(); }
      break;
    }
    case 'line':
    case 'arrow': {
      ctx.strokeStyle = o.color; ctx.lineWidth = sw;
      ctx.beginPath();
      ctx.moveTo(o.x1, o.y1); ctx.lineTo(o.x2, o.y2); ctx.stroke();
      // arrowhead
      const ang = Math.atan2(o.y2 - o.y1, o.x2 - o.x1);
      const len = Math.max(8, sw * 5);
      ctx.fillStyle = o.color;
      ctx.beginPath();
      ctx.moveTo(o.x2, o.y2);
      ctx.lineTo(o.x2 - len * Math.cos(ang - Math.PI / 7), o.y2 - len * Math.sin(ang - Math.PI / 7));
      ctx.lineTo(o.x2 - len * Math.cos(ang + Math.PI / 7), o.y2 - len * Math.sin(ang + Math.PI / 7));
      ctx.closePath(); ctx.fill();
      break;
    }
    case 'freehand':
      drawFreehand(o);
      break;
    case 'text':
      drawText(o);
      break;
  }
}

function drawFreehand(o) {
  const pts = o.points;
  if (pts.length === 0) return;
  const fillColor = (o.fillColor && o.fillColor !== 'transparent') ? o.fillColor : (o.color || CONFIG.defaultStroke);
  if (pts.length === 1) {
    ctx.fillStyle = fillColor;
    ctx.beginPath();
    ctx.arc(pts[0].x, pts[0].y, (pts[0].w || o.strokeWidth || 2) / 2 / 2, 0, Math.PI * 2);
    ctx.fill();
    return;
  }
  // Build a filled ribbon from per-point widths (pressure-smooth-ish).
  // Widths are divided by zoom so the stroke keeps a constant on-screen thickness.
  const invZoom = 1 / viewport.zoom;
  const left = [], right = [];
  for (let i = 0; i < pts.length; i++) {
    const p = pts[i];
    let dx, dy;
    if (i === 0) { dx = pts[1].x - p.x; dy = pts[1].y - p.y; }
    else if (i === pts.length - 1) { dx = p.x - pts[i - 1].x; dy = p.y - pts[i - 1].y; }
    else { dx = pts[i + 1].x - pts[i - 1].x; dy = pts[i + 1].y - pts[i - 1].y; }
    const len = Math.hypot(dx, dy) || 1; dx /= len; dy /= len;
    const nx = -dy, ny = dx;
    const hw = ((p.w || o.strokeWidth || 2) / 2) * invZoom;
    left.push({ x: p.x + nx * hw, y: p.y + ny * hw });
    right.push({ x: p.x - nx * hw, y: p.y - ny * hw });
  }
  ctx.fillStyle = fillColor;
  ctx.beginPath();
  ctx.moveTo(left[0].x, left[0].y);
  for (let i = 1; i < left.length; i++) ctx.lineTo(left[i].x, left[i].y);
  for (let i = right.length - 1; i >= 0; i--) ctx.lineTo(right[i].x, right[i].y);
  ctx.closePath();
  ctx.fill();
}

function drawText(o) {
  const fontPx = Math.max(6, (o.fontSize || CONFIG.textFontSize));
  measureCtx.font = `${fontPx}px ${CONFIG.FONT}`;
  const lines = (o.text || '').split('\n');
  let w = 0;
  for (const ln of lines) w = Math.max(w, measureCtx.measureText(ln).width);
  const lineH = fontPx * 1.35;
  const padX = 3, padY = 2;
  o._measuredW = w + padX * 2;
  o._measuredH = lines.length * lineH + padY * 2;
  o.width = Math.max(o.width || w, w + padX * 2);
  o.height = Math.max(o.height || lineH, lines.length * lineH + padY * 2);

  ctx.fillStyle = o.color || CONFIG.defaultStroke;
  ctx.textBaseline = 'top';
  ctx.font = `${fontPx}px ${CONFIG.FONT}`;
  lines.forEach((ln, i) => {
    ctx.fillText(ln, o.x + padX, o.y + padY + i * lineH);
  });
}

function drawSelectionOverlay() {
  if (selectedIds.size === 0) return;
  const b = getGroupBounds(); if (!b) return;
  const tl = worldToScreen(b.x, b.y);
  const br = worldToScreen(b.x + b.width, b.y + b.height);
  const hs = CONFIG.handleSize;
  ctx.save();
  ctx.strokeStyle = '#2563eb';
  ctx.lineWidth = 1;
  ctx.setLineDash([5, 4]);
  ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y);
  ctx.setLineDash([]);
  const corners = [
    [tl.x, tl.y], [br.x, tl.y], [tl.x, br.y], [br.x, br.y],
  ];
  for (const [cx, cy] of corners) {
    ctx.fillStyle = '#ffffff';
    ctx.strokeStyle = '#2563eb';
    ctx.lineWidth = 1.2;
    ctx.beginPath();
    ctx.fillRect(cx - hs / 2, cy - hs / 2, hs, hs);
    ctx.strokeRect(cx - hs / 2, cy - hs / 2, hs, hs);
  }
  ctx.restore();
}

function render() {
  const dpr = window.devicePixelRatio || 1;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.save();
  ctx.translate(viewport.x, viewport.y);
  ctx.scale(viewport.zoom, viewport.zoom);
  drawGrid();
  for (const o of scene) drawObject(o);
  if (preview) drawObject(preview);
  ctx.restore();

  drawSelectionOverlay();
  updateZoomUI();
}

/* ============================================================
   HISTORY (undo / redo)
   ============================================================ */
function pushHistory() {
  const snap = JSON.parse(JSON.stringify(scene));
  if (historyIndex >= 0 && JSON.stringify(history[historyIndex]) === JSON.stringify(snap)) return;
  history = history.slice(0, historyIndex + 1);
  history.push(snap);
  if (history.length > CONFIG.maxHistory) { history.shift(); historyIndex--; }
  historyIndex = history.length - 1;
  scheduleSave();
}
function undo() {
  if (historyIndex > 0) {
    historyIndex--;
    scene = JSON.parse(JSON.stringify(history[historyIndex]));
    selectedIds.clear();
    render();
    scheduleSave();
  }
}
function redo() {
  if (historyIndex < history.length - 1) {
    historyIndex++;
    scene = JSON.parse(JSON.stringify(history[historyIndex]));
    selectedIds.clear();
    render();
    scheduleSave();
  }
}

/* ============================================================
   PERSISTENCE (localStorage) — board survives reload
   ============================================================ */
let saveTimer = null;
function scheduleSave() {
  if (saveTimer) clearTimeout(saveTimer);
  saveTimer = setTimeout(() => {
    try { localStorage.setItem('whiteboard_scene', JSON.stringify(scene)); } catch (e) {}
  }, 180);
}
function loadScene() {
  try {
    const raw = localStorage.getItem('whiteboard_scene');
    if (raw) { const s = JSON.parse(raw); if (Array.isArray(s)) scene = s; }
  } catch (e) {}
}

/* ============================================================
   TEXT EDITOR (inline)
   ============================================================ */
function startTextEditor(world) {
  const obj = {
    id: uid(), type: 'text', x: world.x, y: world.y,
    width: 160, height: CONFIG.textFontSize * 1.35 + 4,
    text: '', color: CONFIG.defaultStroke, fillColor: 'transparent',
    strokeWidth: 1, fontSize: CONFIG.textFontSize,
  };
  scene.push(obj);
  selectedIds = new Set([obj.id]);

  const s = worldToScreen(world.x, world.y);
  editorZ = viewport.zoom;
  const ta = document.getElementById('text-editor');
  if (!ta) {
    const el = document.createElement('textarea');
    el.id = 'text-editor';
    el.rows = 1;
    document.body.appendChild(el);
  }
  const el = document.getElementById('text-editor');
  const fontPx = obj.fontSize * viewport.zoom;
  el.style.font = `${fontPx}px ${CONFIG.FONT}`;
  el.style.left = s.x + 'px';
  el.style.top = s.y + 'px';
  el.style.width = Math.max(80, obj.width * viewport.zoom) + 'px';
  el.style.height = Math.max(28, obj.height * viewport.zoom) + 'px';
  el.value = '';
  el.style.display = 'block';
  el.focus();

  textState = { obj, el };
  el.oninput = () => onTextInput();
  el.onkeydown = (e) => onTextKey(e);
  el.onblur = () => commitText();
}
function onTextInput() {
  if (!textState) return;
  const { obj, el } = textState;
  obj.text = el.value;
  // recompute size from measured text
  const lines = (el.value || '').split('\n');
  const fontPx = obj.fontSize;
  measureCtx.font = `${fontPx}px ${CONFIG.FONT}`;
  let w = 0;
  for (const ln of lines) w = Math.max(w, measureCtx.measureText(ln).width);
  const lineH = fontPx * 1.35;
  const padX = 6, padY = 4;
  obj.width = Math.max(60, w + padX);
  obj.height = Math.max(lineH, lines.length * lineH + padY);
  // keep editor visually in sync
  el.style.width = (obj.width * viewport.zoom) + 'px';
  el.style.height = (obj.height * viewport.zoom) + 'px';
  render();
}
function onTextKey(e) {
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitText(); }
  else if (e.key === 'Escape') { e.preventDefault(); commitText(); }
}
function commitText() {
  if (!textState) return;
  const { obj, el } = textState;
  obj.text = el.value;
  el.style.display = 'none';
  textState = null;
  pushHistory();
  render();
}

/* ============================================================
   INTERACTIONS — pointer events
   ============================================================ */
function pointerWorld(e) { return screenToWorld(e.clientX, e.clientY); }

canvas.addEventListener('pointerdown', (e) => {
  if (textState) return; // textarea owns input while active
  if (e.button === 1 || (spaceDown && e.button === 0)) { startPan(e); return; }
  if (e.button !== 0) return;

  const w = pointerWorld(e);
  switch (activeTool) {
    case 'select': onSelectDown(e, w); break;
    case 'pen':
      preview = { id: uid(), type: 'freehand', points: [{ x: w.x, y: w.y, w: pressureWidth(e) }],
        color: CONFIG.defaultStroke, fillColor: 'transparent', strokeWidth: CONFIG.defaultWidth };
      active = { type: 'draw' };
      render(); break;
    case 'rect':
    case 'ellipse':
      preview = { id: uid(), type: activeTool, x: w.x, y: w.y, width: 0, height: 0,
        color: CONFIG.defaultStroke, fillColor: CONFIG.defaultFill, strokeWidth: CONFIG.defaultWidth };
      active = { type: 'draw', startX: w.x, startY: w.y }; break;
    case 'line':
    case 'arrow':
      preview = { id: uid(), type: activeTool, x1: w.x, y1: w.y, x2: w.x, y2: w.y,
        color: CONFIG.defaultStroke, fillColor: 'transparent', strokeWidth: CONFIG.defaultWidth };
      active = { type: 'draw' }; break;
    case 'text':
      startTextEditor(w); break;
    case 'eraser':
      active = { type: 'erase', deleted: new Set() };
      eraseAt(w); break;
  }
});

function pressureWidth(e) {
  const base = CONFIG.defaultWidth;
  const p = e.pressure;
  if (p && p > 0) return clamp(base * (0.5 + p * 1.4), base * 0.4, base * 3);
  return base;
}

function startPan(e) {
  active = { type: 'pan', origX: viewport.x, origY: viewport.y, sx: e.clientX, sy: e.clientY };
  canvas.style.cursor = 'grabbing';
}

function eraseAt(w) {
  const hit = hitTestWorld(w);
  if (hit && !active.deleted.has(hit.id)) {
    active.deleted.add(hit.id);
    scene = scene.filter(o => o.id !== hit.id);
    selectedIds.delete(hit.id);
    render();
  }
}

canvas.addEventListener('pointermove', (e) => {
  if (!active) return;
  switch (active.type) {
    case 'pan': {
      viewport.x = active.origX + (e.clientX - active.sx);
      viewport.y = active.origY + (e.clientY - active.sy);
      render();
      break;
    }
    case 'draw': drawMove(e); break;
    case 'move': moveMove(e); break;
    case 'resize': resizeMove(e); break;
    case 'rubber': rubberMove(e); break;
    case 'erase': eraseAt(pointerWorld(e)); break;
  }
});

function drawMove(e) {
  const w = pointerWorld(e);
  if (preview.type === 'freehand') {
    const last = preview.points[preview.points.length - 1];
    if (Math.hypot(w.x - last.x, w.y - last.y) > 1.5 / viewport.zoom) {
      preview.points.push({ x: w.x, y: w.y, w: pressureWidth(e) });
    }
  } else if (preview.type === 'line' || preview.type === 'arrow') {
    preview.x2 = w.x; preview.y2 = w.y;
  } else {
    // rect/ellipse — startX/startY captured on pointerdown
    preview.x = Math.min(active.startX, w.x);
    preview.y = Math.min(active.startY, w.y);
    preview.width = Math.abs(w.x - active.startX);
    preview.height = Math.abs(w.y - active.startY);
  }
  render();
}

function moveMove(e) {
  const w = pointerWorld(e);
  const dx = w.x - active.startWorld.x;
  const dy = w.y - active.startWorld.y;
  for (const id of selectedIds) {
    const o = findObj(id); if (o) translateObject(o, dx, dy);
  }
  active.moved = active.moved || (Math.hypot(dx, dy) > 0.01);
  render();
}

function translateObject(o, dx, dy) {
  switch (o.type) {
    case 'rect': case 'ellipse': case 'text':
      o.x += dx; o.y += dy; break;
    case 'line': case 'arrow':
      o.x1 += dx; o.y1 += dy; o.x2 += dx; o.y2 += dy; break;
    case 'freehand':
      for (const p of o.points) { p.x += dx; p.y += dy; }
      break;
  }
}

function resizeMove(e) {
  const w = pointerWorld(e);
  const oldB = active.oldBounds;
  const pivot = active.pivot;
  // signed scale factors relative to the fixed corner
  const sx = (w.x - pivot.x) / oldB.width;
  const sy = (w.y - pivot.y) / oldB.height;
  const fs = e.shiftKey ? Math.max(Math.abs(sx), Math.abs(sy)) : sx;
  for (const id of selectedIds) {
    const o = findObj(id); if (o) transformAbout(o, pivot, fs, e.shiftKey ? fs : sy);
  }
  active.moved = true;
  render();
}

// pivot (fixed corner) + drag corner for a given resize handle, relative to oldB
function dragCorner(handle, oldB) {
  switch (handle) {
    case 'br': return { pivot: { x: oldB.x, y: oldB.y }, corner: { x: oldB.x + oldB.width, y: oldB.y + oldB.height } };
    case 'tl': return { pivot: { x: oldB.x + oldB.width, y: oldB.y + oldB.height }, corner: { x: oldB.x, y: oldB.y } };
    case 'tr': return { pivot: { x: oldB.x, y: oldB.y + oldB.height }, corner: { x: oldB.x + oldB.width, y: oldB.y } };
    case 'bl': return { pivot: { x: oldB.x + oldB.width, y: oldB.y }, corner: { x: oldB.x, y: oldB.y + oldB.height } };
  }
}

function transformAbout(o, pivot, sx, sy) {
  const tx = (v) => ({ x: pivot.x + (v.x - pivot.x) * sx, y: pivot.y + (v.y - pivot.y) * sy });
  switch (o.type) {
    case 'rect':
    case 'ellipse': {
      const c = [[o.x, o.y], [o.x + o.width, o.y], [o.x + o.width, o.y + o.height], [o.x, o.y + o.height]].map(p => tx({ x: p[0], y: p[1] }));
      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
      for (let i = 0; i < c.length; i++) { minX = Math.min(minX, c[i].x); minY = Math.min(minY, c[i].y); maxX = Math.max(maxX, c[i].x); maxY = Math.max(maxY, c[i].y); }
      o.x = minX; o.y = minY; o.width = maxX - minX; o.height = maxY - minY;
      break;
    }
    case 'text': {
      const c = [[o.x, o.y], [o.x + o.width, o.y], [o.x + o.width, o.y + o.height], [o.x, o.y + o.height]].map(p => tx({ x: p[0], y: p[1] }));
      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
      for (let i = 0; i < c.length; i++) { minX = Math.min(minX, c[i].x); minY = Math.min(minY, c[i].y); maxX = Math.max(maxX, c[i].x); maxY = Math.max(maxY, c[i].y); }
      o.x = minX; o.y = minY; o.width = maxX - minX; o.height = maxY - minY;
      o.fontSize = Math.max(4, (o.fontSize || CONFIG.textFontSize) * Math.max(Math.abs(sx), Math.abs(sy)));
      break;
    }
    case 'line':
    case 'arrow': {
      const a = tx({ x: o.x1, y: o.y1 }), b = tx({ x: o.x2, y: o.y2 });
      o.x1 = a.x; o.y1 = a.y; o.x2 = b.x; o.y2 = b.y;
      break;
    }
    case 'freehand':
      for (const p of o.points) { const t = tx(p); p.x = t.x; p.y = t.y; }
      break;
  }
}

function rubberMove(e) {
  active.currentWorld = pointerWorld(e);
  render();
}

function selectInBand(b0, b1) {
  const x = Math.min(b0.x, b1.x), y = Math.min(b0.y, b1.y);
  const w = Math.abs(b1.x - b0.x), h = Math.abs(b1.y - b0.y);
  const band = { x, y, width: w, height: h };
  const newSel = new Set();
  for (const o of scene) {
    const b = getBounds(o);
    if (b.x <= band.x + band.width && b.x + b.width >= band.x &&
        b.y <= band.y + band.height && b.y + b.height >= band.y) {
      newSel.add(o.id);
    }
  }
  selectedIds = newSel;
}

canvas.addEventListener('pointerup', (e) => {
  if (!active) return;
  const t = active.type;
  if (t === 'pan') { canvas.style.cursor = toolCursor(); active = null; return; }
  if (t === 'erase') {
    if (active.deleted.size > 0) pushHistory();
    active = null;
    render();
    return;
  }
  if (t === 'draw') {
    finalizeDraw();
    active = null;
    return;
  }
  if (t === 'rubber') {
    selectInBand(active.startWorld, active.currentWorld || active.startWorld);
    render();
    active = null;
    return;
  }
  // move / resize -> one undo step
  if (active.moved) pushHistory();
  active = null;
  render();
});

function finalizeDraw() {
  if (!preview) return;
  // discard zero-size shapes (accidental clicks)
  const b = getBounds(preview);
  const tiny = (b.width < 2 && b.height < 2) && (preview.type !== 'freehand');
  if (preview.type === 'freehand' && preview.points.length < 2) { preview = null; render(); return; }
  if (tiny) { preview = null; render(); return; }
  scene.push(preview);
  selectedIds = new Set([preview.id]);
  preview = null;
  pushHistory();
}

/* ============================================================
   INTERACTIONS — select tool detail
   ============================================================ */
function onSelectDown(e, w) {
  // handle first
  const h = handleAt(w);
  if (h) {
    const oldB = getGroupBounds();
    const { pivot } = dragCorner(h, oldB);
    active = { type: 'resize', handle: h, pivot, oldBounds: oldB, moved: false };
    return;
  }
  const hit = hitTestWorld(w);
  if (hit) {
    if (e.shiftKey) {
      if (selectedIds.has(hit.id)) selectedIds.delete(hit.id); else selectedIds.add(hit.id);
      render();
      return;
    }
    if (!selectedIds.has(hit.id)) { selectedIds = new Set([hit.id]); render(); }
    // begin move (start scene captured implicitly by history at pointerup)
    active = { type: 'move', startWorld: w, moved: false };
  } else {
    if (!e.shiftKey) { selectedIds = new Set(); render(); }
    active = { type: 'rubber', startWorld: w, currentWorld: w };
  }
}

/* ============================================================
   INTERACTIONS — keyboard
   ============================================================ */
function toolCursor() {
  if (spaceDown) return 'grab';
  switch (activeTool) {
    case 'select': return 'default';
    case 'pen': case 'rect': case 'ellipse': case 'line': case 'arrow': case 'eraser': return 'crosshair';
    case 'text': return 'text';
  }
  return 'default';
}
function setTool(t) {
  activeTool = t;
  document.querySelectorAll('.tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === t));
  canvas.style.cursor = toolCursor();
}

window.addEventListener('keydown', (e) => {
  if (textState) return;
  const mod = e.ctrlKey || e.metaKey;
  if (mod && (e.key.toLowerCase() === 'z' || e.key.toLowerCase() === 'y')) {
    e.preventDefault();
    if (mod && e.shiftKey) redo();
    else undo();
    return;
  }
  if (e.key === 'Delete' || e.key === 'Backspace') {
    if (selectedIds.size > 0) {
      e.preventDefault();
      scene = scene.filter(o => !selectedIds.has(o.id));
      selectedIds.clear();
      pushHistory();
    }
    return;
  }
  if (e.key === ' ') { e.preventDefault(); spaceDown = true; canvas.style.cursor = 'grab'; return; }

  const k = e.key.toLowerCase();
  if (k === 'v') setTool('select');
  else if (k === 'p') setTool('pen');
  else if (k === 'r') setTool('rect');
  else if (k === 'o') setTool('ellipse');
  else if (k === 'l') setTool('line');
  else if (k === 't') setTool('text');
  else if (k === 'e') setTool('eraser');
  else if (k === 'escape') { selectedIds = new Set(); render(); }
  else if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
    e.preventDefault();
    const step = e.shiftKey ? CONFIG.nudgeShift : CONFIG.nudge;
    let dx = 0, dy = 0;
    if (e.key === 'ArrowLeft') dx = -step;
    else if (e.key === 'ArrowRight') dx = step;
    else if (e.key === 'ArrowUp') dy = -step;
    else if (e.key === 'ArrowDown') dy = step;
    for (const id of selectedIds) { const o = findObj(id); if (o) translateObject(o, dx, dy); }
    render();
    active = { type: 'nudge-commit' };
  }
});
window.addEventListener('keyup', (e) => {
  if (e.key === ' ') { spaceDown = false; canvas.style.cursor = toolCursor(); }
  else if (active && active.type === 'nudge-commit') { pushHistory(); active = null; }
});

/* ============================================================
   ZOOM (toward cursor) + UI
   ============================================================ */
function updateZoomUI() {
  document.getElementById('zoom-val').textContent = Math.round(viewport.zoom * 100) + '%';
}
function setZoom(newZoom, focusX, focusY) {
  newZoom = clamp(newZoom, CONFIG.zoom.min, CONFIG.zoom.max);
  if (newZoom === viewport.zoom) return;
  // keep the world point under (focusX, focusY) fixed
  const wx = (focusX - viewport.x) / viewport.zoom;
  const wy = (focusY - viewport.y) / viewport.zoom;
  viewport.zoom = newZoom;
  viewport.x = focusX - wx * newZoom;
  viewport.y = focusY - wy * newZoom;
  render();
}
canvas.addEventListener('wheel', (e) => {
  e.preventDefault();
  const factor = e.deltaY < 0 ? 1 + CONFIG.zoom.step : 1 - CONFIG.zoom.step;
  setZoom(viewport.zoom * factor, e.clientX, e.clientY);
}, { passive: false });

document.getElementById('zoom-in').addEventListener('click', () => setZoom(viewport.zoom * (1 + CONFIG.zoom.step), window.innerWidth / 2, window.innerHeight / 2));
document.getElementById('zoom-out').addEventListener('click', () => setZoom(viewport.zoom / (1 + CONFIG.zoom.step), window.innerWidth / 2, window.innerHeight / 2));
document.getElementById('reset-btn').addEventListener('click', resetView);
document.getElementById('undo-btn').addEventListener('click', undo);
document.getElementById('redo-btn').addEventListener('click', redo);

function resetView() {
  if (scene.length === 0) { viewport = { x: 0, y: 0, zoom: 1 }; render(); return; }
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  for (const o of scene) { const b = getBounds(o); minX = Math.min(minX, b.x); minY = Math.min(minY, b.y); maxX = Math.max(maxX, b.x + b.width); maxY = Math.max(maxY, b.y + b.height); }
  const pad = 60;
  const bw = Math.max(1, maxX - minX), bh = Math.max(1, maxY - minY);
  const z = clamp(Math.min((window.innerWidth - pad * 2) / bw, (window.innerHeight - pad * 2) / bh, 3), 0.1, 4);
  viewport.zoom = z;
  viewport.x = window.innerWidth / 2 - (minX + bw / 2) * z;
  viewport.y = window.innerHeight / 2 - (minY + bh / 2) * z;
  render();
}

/* ============================================================
   TOOLBAR + PROPERTIES WIRING
   ============================================================ */
document.querySelectorAll('.tool-btn').forEach(b => {
  b.addEventListener('click', () => setTool(b.dataset.tool));
});

function buildSwatches(containerId, colors, prop, isFill) {
  const c = document.getElementById(containerId);
  const all = [...colors, 'transparent'];
  all.forEach(col => {
    const b = document.createElement('button');
    b.className = 'swatch';
    if (col === 'transparent') b.classList.add('transparent');
    else b.style.background = col;
    b.dataset.color = col;
    b.title = col === 'transparent' ? 'None' : col;
    b.addEventListener('click', () => applyStyle(prop, col, c));
    c.appendChild(b);
  });
}
function applyStyle(prop, value, groupEl) {
  // set default for new objects
  if (prop === 'color') CONFIG.defaultStroke = value;
  if (prop === 'fillColor') CONFIG.defaultFill = value;
  // apply to selection
  if (selectedIds.size > 0) {
    for (const id of selectedIds) { const o = findObj(id); if (o) o[prop] = value; }
    pushHistory();
  }
  render();
  // active highlight
  groupEl.querySelectorAll('.swatch').forEach(s => s.classList.toggle('active', s.dataset.color === value));
}
function applyWidth(w, groupEl) {
  CONFIG.defaultWidth = w;
  if (selectedIds.size > 0) {
    for (const id of selectedIds) { const o = findObj(id); if (o) o.strokeWidth = w; }
    pushHistory();
  }
  render();
  groupEl.querySelectorAll('.width-btn').forEach(b => b.classList.toggle('active', +b.dataset.w === w));
}

buildSwatches('stroke-group', CONFIG.palette, 'color');
buildSwatches('fill-group', CONFIG.palette, 'fillColor');
{
  const g = document.getElementById('width-group');
  [1, 2, 4, 8, 14].forEach(w => {
    const b = document.createElement('button');
    b.className = 'width-btn';
    b.dataset.w = w;
    const d = document.createElement('span');
    d.className = 'dot';
    d.style.width = w + 'px'; d.style.height = w + 'px';
    b.appendChild(d);
    b.addEventListener('click', () => applyWidth(w, g));
    g.appendChild(b);
  });
  // default active
  g.querySelector('.width-btn[data-w="2"]').classList.add('active');
}

/* ============================================================
   INIT
   ============================================================ */
function init() {
  loadScene();
  resizeCanvas();
  // seed history with current board so undo starts from a known state
  history = [JSON.parse(JSON.stringify(scene))];
  historyIndex = 0;
  setTool('select');
  // click on empty canvas (no active tool interaction) deselects
  window.addEventListener('resize', resizeCanvas);
  render();
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"peculiar-ragdoll/Tiel-Coder-35B-A3B-GGUF-MTP","provider":"brigid","persona":"full","sessionId":"5e9d079f-01bd-4be4-b71d-8eb313a05349","tokensIn":9029281,"tokensOut":78172,"tokensTotal":9107453,"cost":0,"turns":116,"toolCalls":115,"failedToolCalls":0,"timestamp":"2026-09-04T22:24:37.306Z"} -->