[object Object]

← back to Wallco Ai

ghost-review: magic wand — click motif to isolate, then recolor/erase/copy/paste/apply

7031abfccf7d717986bf13fd7857599ab36330c0 · 2026-05-23 23:49:11 -0700 · Steve Abrams

VIEWER — toggle wand mode with the green Wand button on stage (or W key):
  - Click any pixel to flood-fill all connected pixels of similar color
  - Tolerance slider re-runs the fill (0=strict, 120=loose)
  - Selection rendered as a 50% green overlay on a separate canvas
  - Per-image working canvas holds non-destructive edits

Actions on the selection:
  - Recolor: color picker → replaces all selected pixels
  - Erase: fills selected pixels with avg color of NON-selected pixels (bg)
  - Copy: captures bounding box as a masked clipboard buffer
  - Paste: click anywhere to drop the clipboard at that point (Esc to cancel)
  - Clear: drops selection without applying any edit
  - Apply: composites all edits into a new PNG, posts to /save-edited

SERVER — new POST /api/design/:id/save-edited:
  - Accepts data:image/png;base64,... from the browser canvas
  - Inserts a new spoon_all_designs row with generator='magic-wand-browser-edit'
  - parent_design_id = source · unpublishes the original
  - Returns { new_id, src_id, bytes }

UX:
  - Wand mode disables the rect-drag mousedown so clicks isolate, not draw
  - Working canvas persists across recolor/erase/paste operations until
    Apply (or next image)
  - Auto-saves a tp_other label on Apply so the action is recorded
  - Auto-advances to next unlabeled after Apply

Files touched

Diff

commit 7031abfccf7d717986bf13fd7857599ab36330c0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 23 23:49:11 2026 -0700

    ghost-review: magic wand — click motif to isolate, then recolor/erase/copy/paste/apply
    
    VIEWER — toggle wand mode with the green Wand button on stage (or W key):
      - Click any pixel to flood-fill all connected pixels of similar color
      - Tolerance slider re-runs the fill (0=strict, 120=loose)
      - Selection rendered as a 50% green overlay on a separate canvas
      - Per-image working canvas holds non-destructive edits
    
    Actions on the selection:
      - Recolor: color picker → replaces all selected pixels
      - Erase: fills selected pixels with avg color of NON-selected pixels (bg)
      - Copy: captures bounding box as a masked clipboard buffer
      - Paste: click anywhere to drop the clipboard at that point (Esc to cancel)
      - Clear: drops selection without applying any edit
      - Apply: composites all edits into a new PNG, posts to /save-edited
    
    SERVER — new POST /api/design/:id/save-edited:
      - Accepts data:image/png;base64,... from the browser canvas
      - Inserts a new spoon_all_designs row with generator='magic-wand-browser-edit'
      - parent_design_id = source · unpublishes the original
      - Returns { new_id, src_id, bytes }
    
    UX:
      - Wand mode disables the rect-drag mousedown so clicks isolate, not draw
      - Working canvas persists across recolor/erase/paste operations until
        Apply (or next image)
      - Auto-saves a tp_other label on Apply so the action is recorded
      - Auto-advances to next unlabeled after Apply
---
 public/admin/ghost-review.html | 333 ++++++++++++++++++++++++++++++++++++++++-
 server.js                      |  70 +++++++++
 2 files changed, 398 insertions(+), 5 deletions(-)

diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
index 03914ba..8d77cdf 100644
--- a/public/admin/ghost-review.html
+++ b/public/admin/ghost-review.html
@@ -158,16 +158,26 @@
     background:rgba(0,0,0,.7); color:#fff; font:600 11px ui-monospace,Menlo,monospace;
     padding:5px 10px; border-radius:4px; pointer-events:none; z-index:3;
   }
-  .stage .seam-test-btn {
-    position:absolute; bottom:14px; right:14px; z-index:4;
+  .stage .seam-test-btn,
+  .stage .wand-btn {
+    position:absolute; bottom:14px; z-index:4;
     background:rgba(0,0,0,.75); color:#fff; border:1px solid #555;
     padding:9px 14px; border-radius:6px; cursor:pointer;
     font:600 12px var(--sans); letter-spacing:.06em;
     display:flex; align-items:center; gap:8px;
   }
-  .stage .seam-test-btn:hover { background:#000; }
+  .stage .seam-test-btn { right:14px; }
+  .stage .wand-btn      { right:185px; }
+  .stage .seam-test-btn:hover, .stage .wand-btn:hover { background:#000; }
   .stage .seam-test-btn.active { background:var(--orange); border-color:var(--orange); color:#fff; }
-  .stage .seam-test-btn .icon { font-size:14px; }
+  .stage .wand-btn.active      { background:#7ad17a; border-color:#5fa85f; color:#0a2a0a; }
+  .stage .seam-test-btn .icon, .stage .wand-btn .icon { font-size:14px; }
+  .stage.wand-mode { cursor:crosshair; }
+  .stage canvas.wand-overlay {
+    position:absolute; pointer-events:none;
+    user-select:none; z-index:2;
+    image-rendering:pixelated;
+  }
   .stage canvas.seam-canvas {
     max-width:100%; max-height:96vh; display:block;
     user-select:none; pointer-events:none;
@@ -451,6 +461,9 @@
       <button class="seam-test-btn" id="seam-test-btn" title="quadrant-swap to bring outer edges to the center (X)">
         <span class="icon">⊞</span> Seam Test <kbd style="background:#222;color:#fff;border:1px solid #555;padding:0 5px;border-radius:3px;font:600 10px ui-monospace,Menlo,monospace;margin-left:4px">X</kbd>
       </button>
+      <button class="wand-btn" id="wand-btn" title="click any motif to isolate it via flood-fill (W)">
+        <span class="icon">🪄</span> Wand <kbd style="background:#222;color:#fff;border:1px solid #555;padding:0 5px;border-radius:3px;font:600 10px ui-monospace,Menlo,monospace;margin-left:4px">W</kbd>
+      </button>
     </div>
     <div class="panel">
       <button class="closex" id="close-btn" title="close (esc)">×</button>
@@ -509,6 +522,36 @@
       </div>
       <div id="fix-status" style="font:11.5px var(--sans); color:var(--faint); display:none; padding:8px 10px; background:#eef4ff; border-left:3px solid #3b78d8; border-radius:0 4px 4px 0;"></div>
 
+      <h3 style="color:var(--ink); font-size:12px;">🪄 Magic Wand <span style="margin-left:6px;font:600 10px var(--sans); color:var(--faint);">(<kbd>W</kbd> to enter wand mode · click any motif)</span></h3>
+      <div id="wand-panel" style="display:none; padding:10px 12px; border:1px solid #5fa85f; border-radius:6px; background:#f0f9f0; display:flex; flex-direction:column; gap:8px;">
+        <div style="display:flex; gap:8px; align-items:center; font:11px ui-monospace,Menlo,monospace;">
+          <span id="wand-swatch" style="display:inline-block; width:18px; height:18px; border-radius:3px; border:1px solid #888; vertical-align:middle"></span>
+          <span id="wand-stats">click a pixel</span>
+        </div>
+        <div style="display:flex; gap:6px; align-items:center;">
+          <span style="font:10px var(--sans); color:var(--faint); width:50px">tolerance</span>
+          <input type="range" id="wand-tol" min="0" max="120" step="2" value="28" style="flex:1">
+          <span id="wand-tol-val" style="font:600 11px ui-monospace,Menlo,monospace; color:var(--faint); width:30px; text-align:right">28</span>
+        </div>
+        <div style="display:flex; gap:6px;">
+          <label style="font:11px var(--sans); align-self:center;">recolor →</label>
+          <input type="color" id="wand-color" value="#222222" style="flex:1; height:30px; border:1px solid var(--line); border-radius:4px;">
+          <button id="wand-recolor" style="padding:6px 10px; border:1px solid var(--line); border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);">Apply</button>
+        </div>
+        <div style="display:flex; gap:6px; flex-wrap:wrap;">
+          <button id="wand-erase"  style="flex:1; padding:7px 8px; border:1px solid var(--red); color:var(--red); border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);">🗑 Erase</button>
+          <button id="wand-copy"   style="flex:1; padding:7px 8px; border:1px solid #3b78d8; color:#3b78d8; border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);">⎘ Copy</button>
+          <button id="wand-paste"  style="flex:1; padding:7px 8px; border:1px solid #3b78d8; color:#3b78d8; border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);" disabled>📋 Paste</button>
+          <button id="wand-clear"  style="flex:1; padding:7px 8px; border:1px solid var(--line); color:var(--faint); border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);">Clear</button>
+        </div>
+        <button id="wand-apply" style="padding:9px 10px; border:1px solid #5fa85f; color:#fff; background:#5fa85f; border-radius:4px; cursor:pointer; font:700 12px var(--sans);">
+          💾 Apply All Edits → save as new design
+        </button>
+        <div style="font:10.5px var(--sans); color:var(--faint); line-height:1.4">
+          edits are non-destructive until <b>Apply</b>. you can recolor, erase, copy/paste multiple regions before committing.
+        </div>
+      </div>
+
       <h3>Verdict (saves to training labels)</h3>
       <div class="actions">
         <button class="b-fp"    id="btn-fp">✓ Clean (false-positive) <kbd>F</kbd></button>
@@ -795,6 +838,7 @@
       updateRectCount();
       if (SEAM_VIEW) buildSeamCanvas();
       runAutoSuggest();
+      wandResetForNewImage();
     };
     mainImg.src = item.image_url;
     if (mainImg.complete) {
@@ -802,6 +846,7 @@
       updateRectCount();
       if (SEAM_VIEW) buildSeamCanvas();
       runAutoSuggest();
+      wandResetForNewImage();
     }
     $('notes').value = (lbl && lbl.notes) || '';
     $('notes-row').style.display = DRAW_MODE === 'other' ? '' : 'none';
@@ -837,6 +882,7 @@
   // ── Drawing ────────────────────────────────────────────────────────────
   let dragging = false, dragStart = null, dragGhost = null;
   stage.addEventListener('mousedown', (e) => {
+    if (WAND_ON) return;  // wand uses click handler, not drag-rect
     if (e.target.tagName === 'BUTTON') return;
     if (e.target.classList.contains('x')) return;
     if (CURRENT === null) return;
@@ -1181,6 +1227,279 @@
   // Re-render suggestions on window resize so they stay anchored
   window.addEventListener('resize', () => { drawSuggestions(); });
 
+  // ── Magic Wand (select / recolor / erase / copy / paste / apply) ──────
+  let WAND_ON = false;
+  let WAND_TOL = 28;
+  let SELECTION_MASK = null;     // Uint8Array of working canvas size
+  let WORKING_CANVAS = null;     // off-screen edited copy of the source
+  let CLIPBOARD = null;          // {data: ImageData, w, h}
+  let LAST_CLICK = null;
+  let PASTE_ARMED = false;
+
+  function wandResetForNewImage() {
+    WORKING_CANVAS = null; SELECTION_MASK = null; LAST_CLICK = null;
+    CLIPBOARD = null; PASTE_ARMED = false;
+    $('wand-paste').disabled = true;
+    $('wand-stats').textContent = 'click a pixel';
+    $('wand-swatch').style.background = '';
+    imgWrap.querySelectorAll('.wand-overlay').forEach(el => el.remove());
+    if (WAND_ON) ensureWorkingCanvas();
+  }
+  function ensureWorkingCanvas() {
+    if (WORKING_CANVAS) return WORKING_CANVAS;
+    const w = mainImg.naturalWidth, h = mainImg.naturalHeight;
+    if (!w) return null;
+    WORKING_CANVAS = document.createElement('canvas');
+    WORKING_CANVAS.width = w; WORKING_CANVAS.height = h;
+    WORKING_CANVAS.getContext('2d').drawImage(mainImg, 0, 0, w, h);
+    return WORKING_CANVAS;
+  }
+  function ensureWandOverlay() {
+    let ov = imgWrap.querySelector('.wand-overlay');
+    if (!ov) {
+      ov = document.createElement('canvas');
+      ov.className = 'wand-overlay';
+      imgWrap.appendChild(ov);
+    }
+    positionWandOverlay(ov);
+    return ov;
+  }
+  function positionWandOverlay(ov) {
+    if (!WORKING_CANVAS) return;
+    const r = mainImg.getBoundingClientRect();
+    const wr = imgWrap.getBoundingClientRect();
+    ov.width = WORKING_CANVAS.width;
+    ov.height = WORKING_CANVAS.height;
+    ov.style.width = r.width + 'px';
+    ov.style.height = r.height + 'px';
+    ov.style.left = (r.left - wr.left) + 'px';
+    ov.style.top  = (r.top  - wr.top)  + 'px';
+  }
+  function toggleWand() {
+    if (CURRENT === null) return;
+    WAND_ON = !WAND_ON;
+    $('wand-btn').classList.toggle('active', WAND_ON);
+    stage.classList.toggle('wand-mode', WAND_ON);
+    $('wand-panel').style.display = WAND_ON ? 'flex' : 'none';
+    if (WAND_ON) {
+      ensureWorkingCanvas();
+      ensureWandOverlay();
+      toast('🪄 wand mode — click any motif color to select it');
+    } else {
+      imgWrap.querySelectorAll('.wand-overlay').forEach(el => el.remove());
+    }
+  }
+  $('wand-btn').onclick = toggleWand;
+
+  function runFloodFill() {
+    if (!LAST_CLICK || !WORKING_CANVAS) return;
+    const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
+    const ctx = WORKING_CANVAS.getContext('2d');
+    const img = ctx.getImageData(0, 0, w, h);
+    const data = img.data;
+    const i0 = (LAST_CLICK.py * w + LAST_CLICK.px) * 4;
+    const tR = data[i0], tG = data[i0+1], tB = data[i0+2];
+    $('wand-swatch').style.background = `rgb(${tR},${tG},${tB})`;
+    $('wand-color').value = '#' + [tR,tG,tB].map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2,'0')).join('');
+    SELECTION_MASK = new Uint8Array(w * h);
+    const stack = [[LAST_CLICK.px, LAST_CLICK.py]];
+    const tol2 = WAND_TOL * WAND_TOL * 3;
+    while (stack.length) {
+      const [x, y] = stack.pop();
+      if (x < 0 || y < 0 || x >= w || y >= h) continue;
+      const idx = y * w + x;
+      if (SELECTION_MASK[idx]) continue;
+      const di = idx * 4;
+      const dr = data[di] - tR, dg = data[di+1] - tG, db = data[di+2] - tB;
+      if (dr*dr + dg*dg + db*db > tol2) continue;
+      SELECTION_MASK[idx] = 1;
+      stack.push([x+1, y]); stack.push([x-1, y]);
+      stack.push([x, y+1]); stack.push([x, y-1]);
+    }
+    drawSelectionOverlay();
+    const count = SELECTION_MASK.reduce((s,v) => s + v, 0);
+    $('wand-stats').textContent = `${count.toLocaleString()} px (${((count / (w*h)) * 100).toFixed(1)}% of image) · rgb(${tR},${tG},${tB})`;
+  }
+  function drawSelectionOverlay() {
+    const ov = ensureWandOverlay();
+    if (!ov || !WORKING_CANVAS) return;
+    positionWandOverlay(ov);
+    const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
+    const ctx = ov.getContext('2d');
+    ctx.clearRect(0, 0, w, h);
+    if (!SELECTION_MASK) return;
+    const out = ctx.createImageData(w, h);
+    for (let i = 0; i < SELECTION_MASK.length; i++) {
+      if (SELECTION_MASK[i]) {
+        const o = i * 4;
+        out.data[o] = 90; out.data[o+1] = 220; out.data[o+2] = 90; out.data[o+3] = 130;
+      }
+    }
+    ctx.putImageData(out, 0, 0);
+  }
+  function onWandClick(e) {
+    const r = mainImg.getBoundingClientRect();
+    if (e.clientX < r.left || e.clientX > r.right ||
+        e.clientY < r.top  || e.clientY > r.bottom) return;
+    if (!WORKING_CANVAS) ensureWorkingCanvas();
+    const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
+    const px = Math.floor((e.clientX - r.left) / r.width  * w);
+    const py = Math.floor((e.clientY - r.top)  / r.height * h);
+    LAST_CLICK = { px, py };
+    runFloodFill();
+  }
+
+  function wandRecolor() {
+    if (!SELECTION_MASK || !WORKING_CANVAS) { toast('select first'); return; }
+    const hex = $('wand-color').value;
+    const R = parseInt(hex.slice(1,3),16), G = parseInt(hex.slice(3,5),16), B = parseInt(hex.slice(5,7),16);
+    const ctx = WORKING_CANVAS.getContext('2d');
+    const img = ctx.getImageData(0, 0, WORKING_CANVAS.width, WORKING_CANVAS.height);
+    let n = 0;
+    for (let i = 0; i < SELECTION_MASK.length; i++) {
+      if (SELECTION_MASK[i]) {
+        img.data[i*4] = R; img.data[i*4+1] = G; img.data[i*4+2] = B;
+        n++;
+      }
+    }
+    ctx.putImageData(img, 0, 0);
+    mainImg.src = WORKING_CANVAS.toDataURL('image/png');
+    toast(`✓ recolored ${n.toLocaleString()} px`);
+  }
+  function wandErase() {
+    if (!SELECTION_MASK || !WORKING_CANVAS) { toast('select first'); return; }
+    const ctx = WORKING_CANVAS.getContext('2d');
+    const img = ctx.getImageData(0, 0, WORKING_CANVAS.width, WORKING_CANVAS.height);
+    // Avg color of NON-selected pixels = bg
+    let aR = 0, aG = 0, aB = 0, nb = 0;
+    for (let i = 0; i < SELECTION_MASK.length; i++) {
+      if (!SELECTION_MASK[i]) { aR += img.data[i*4]; aG += img.data[i*4+1]; aB += img.data[i*4+2]; nb++; }
+    }
+    const R = Math.round(aR/nb), G = Math.round(aG/nb), B = Math.round(aB/nb);
+    let n = 0;
+    for (let i = 0; i < SELECTION_MASK.length; i++) {
+      if (SELECTION_MASK[i]) { img.data[i*4] = R; img.data[i*4+1] = G; img.data[i*4+2] = B; n++; }
+    }
+    ctx.putImageData(img, 0, 0);
+    mainImg.src = WORKING_CANVAS.toDataURL('image/png');
+    toast(`✓ erased ${n.toLocaleString()} px to bg rgb(${R},${G},${B})`);
+  }
+  function wandCopy() {
+    if (!SELECTION_MASK || !WORKING_CANVAS) { toast('select first'); return; }
+    const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
+    let minX = w, minY = h, maxX = 0, maxY = 0;
+    for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
+      if (SELECTION_MASK[y*w + x]) {
+        if (x<minX) minX=x; if (x>maxX) maxX=x;
+        if (y<minY) minY=y; if (y>maxY) maxY=y;
+      }
+    }
+    const rw = maxX - minX + 1, rh = maxY - minY + 1;
+    if (rw < 1 || rh < 1) { toast('empty selection'); return; }
+    const ctx = WORKING_CANVAS.getContext('2d');
+    const region = ctx.getImageData(minX, minY, rw, rh);
+    // Apply mask as alpha
+    for (let y = 0; y < rh; y++) for (let x = 0; x < rw; x++) {
+      if (!SELECTION_MASK[(y+minY) * w + (x+minX)]) region.data[(y*rw + x)*4 + 3] = 0;
+    }
+    CLIPBOARD = { data: region, w: rw, h: rh };
+    $('wand-paste').disabled = false;
+    toast(`⎘ copied ${rw}×${rh} region`);
+  }
+  function wandPasteArm() {
+    if (!CLIPBOARD) { toast('nothing copied'); return; }
+    PASTE_ARMED = true;
+    toast('click to paste · Esc to cancel');
+  }
+  function doPaste(clickX, clickY) {
+    if (!CLIPBOARD || !WORKING_CANVAS) return;
+    const r = mainImg.getBoundingClientRect();
+    const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
+    const cx = Math.floor((clickX - r.left) / r.width * w);
+    const cy = Math.floor((clickY - r.top)  / r.height * h);
+    const ctx = WORKING_CANVAS.getContext('2d');
+    const tmp = document.createElement('canvas');
+    tmp.width = CLIPBOARD.w; tmp.height = CLIPBOARD.h;
+    tmp.getContext('2d').putImageData(CLIPBOARD.data, 0, 0);
+    ctx.drawImage(tmp, cx - CLIPBOARD.w/2, cy - CLIPBOARD.h/2);
+    PASTE_ARMED = false;
+    mainImg.src = WORKING_CANVAS.toDataURL('image/png');
+    toast(`📋 pasted at (${cx},${cy})`);
+  }
+  function wandClear() {
+    SELECTION_MASK = null; LAST_CLICK = null;
+    drawSelectionOverlay();
+    $('wand-stats').textContent = 'click a pixel';
+    $('wand-swatch').style.background = '';
+  }
+  async function wandApply() {
+    if (CURRENT === null || !WORKING_CANVAS) { toast('no edits to save'); return; }
+    if (!confirm('Save all magic-wand edits as a new design? Original will be unpublished.')) return;
+    const item = VIEW[CURRENT];
+    const stat = $('fix-status');
+    stat.style.display = ''; stat.style.borderLeftColor = '#5fa85f'; stat.style.background = '#f0f9f0';
+    stat.textContent = 'Uploading edited PNG…';
+    try {
+      const dataUrl = WORKING_CANVAS.toDataURL('image/png');
+      const r = await fetch(`/api/design/${item.id}/save-edited`, {
+        method:'POST', headers:{'Content-Type':'application/json'},
+        body: JSON.stringify({ data_url: dataUrl, edits_summary: 'magic-wand: recolor/erase/paste' }),
+      });
+      const j = await r.json();
+      if (!r.ok) throw new Error(j.error || ('http ' + r.status));
+      stat.innerHTML = `✓ Saved as <a href="/design/${j.new_id}" target="_blank" style="color:var(--green); font-weight:600">#${j.new_id}</a> · original #${item.id} unpublished`;
+      toast(`✓ saved → #${j.new_id}`);
+      // Save a tp_other label
+      try {
+        await fetch('/api/ghost-review/label', {
+          method:'POST', headers:{'Content-Type':'application/json'},
+          body: JSON.stringify({ id: item.id, verdict: 'tp_other', crops: [{x:0,y:0,w:1,h:1,tag:'other'}], notes: `magic-wand edit → #${j.new_id}` }),
+        });
+      } catch {}
+      STAT.other = (STAT.other || 0) + 1;
+      LABELS.set(item.id, { verdict: 'tp_other', crops: [], wand_to: j.new_id });
+      updateStats();
+      WORKING_CANVAS = null;
+      wandClear();
+      setTimeout(() => {
+        recomputeView(); render();
+        if (FILTER === 'unlabeled') {
+          if (CURRENT >= VIEW.length) CURRENT = VIEW.length - 1;
+          if (VIEW.length === 0) { close(); return; }
+          open(CURRENT);
+        } else {
+          if (CURRENT < VIEW.length - 1) navigate(1);
+        }
+      }, 2200);
+    } catch (e) {
+      stat.style.borderLeftColor = 'var(--red)'; stat.style.background = '#fce8e6';
+      stat.innerHTML = '✗ Save failed: ' + e.message;
+    }
+  }
+
+  $('wand-tol').oninput = (e) => {
+    WAND_TOL = parseInt(e.target.value, 10);
+    $('wand-tol-val').textContent = WAND_TOL;
+    if (LAST_CLICK) runFloodFill();
+  };
+  $('wand-recolor').onclick = wandRecolor;
+  $('wand-erase').onclick  = wandErase;
+  $('wand-copy').onclick   = wandCopy;
+  $('wand-paste').onclick  = wandPasteArm;
+  $('wand-clear').onclick  = wandClear;
+  $('wand-apply').onclick  = wandApply;
+
+  // Stage click handler for wand (separate from rect-drag mousedown)
+  stage.addEventListener('click', (e) => {
+    if (!WAND_ON) return;
+    if (e.target.tagName === 'BUTTON' || e.target.classList.contains('x')) return;
+    if (e.target.classList.contains('rect') || e.target.classList.contains('sgst')) return;
+    if (PASTE_ARMED) doPaste(e.clientX, e.clientY);
+    else onWandClick(e);
+  });
+  // Reposition overlay on resize
+  window.addEventListener('resize', () => { if (WAND_ON) drawSelectionOverlay(); });
+
   // ── Save verdict ───────────────────────────────────────────────────────
   function toast(msg) {
     const t = $('toast');
@@ -1419,7 +1738,10 @@
     if (!modal.classList.contains('open')) return;
     if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
     const k = e.key.toLowerCase();
-    if (k === 'escape') close();
+    if (k === 'escape') {
+      if (PASTE_ARMED) { PASTE_ARMED = false; toast('paste cancelled'); }
+      else close();
+    }
     else if (k === 'arrowleft')  navigate(-1);
     else if (k === 'arrowright') navigate(1);
     else if (k === 'f') save('fp');
@@ -1435,6 +1757,7 @@
     else if (k === 'r') { if (!$('btn-fix').classList.contains('gated')) doFix(); }
     else if (k === 'd') { if (!$('btn-remove').classList.contains('gated')) doFix('remove-fill'); }
     else if (k === 'e') { doRegen(); }
+    else if (k === 'w') { toggleWand(); }
   });
 
   // ── Boot ───────────────────────────────────────────────────────────────
diff --git a/server.js b/server.js
index 5846766..4fdf25f 100644
--- a/server.js
+++ b/server.js
@@ -7017,6 +7017,76 @@ RETURNING id`);
   }
 });
 
+// POST /api/design/:id/save-edited — accept a base64-PNG of locally-edited
+// pixels (magic-wand recolor/erase/paste) and save it as a NEW design.
+// No Gemini call — just stores the bytes the browser produced.
+// Body: { data_url: 'data:image/png;base64,...', edits_summary?: '...' }
+app.post('/api/design/:id/save-edited', express.json({ limit: '32mb' }), async (req, res) => {
+  if (!adminRatingGate(req, res)) return;
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  const dataUrl = String(req.body?.data_url || '');
+  const summary = String(req.body?.edits_summary || 'browser-edited').slice(0, 300);
+  if (!dataUrl.startsWith('data:image/png;base64,')) {
+    return res.status(400).json({ error: 'data_url must be data:image/png;base64,...' });
+  }
+  const b64 = dataUrl.slice('data:image/png;base64,'.length);
+  const buf = Buffer.from(b64, 'base64');
+  if (buf.length < 1024) return res.status(400).json({ error: 'tiny png ' + buf.length + 'b' });
+
+  try {
+    let d = null;
+    try {
+      const pgRaw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, prompt, seed, category,
+        width_in, height_in, panels, motifs, tags, generator, dominant_hex, palette, local_path,
+        product_line, parent_design_id, dig_number
+        FROM spoon_all_designs WHERE id=${id}) t;`);
+      if (pgRaw && pgRaw.length > 2) d = JSON.parse(pgRaw);
+    } catch { /* non-fatal */ }
+    if (!d) {
+      const cached = DESIGNS.find(x => x.id === id);
+      if (cached) d = { ...cached };
+    }
+    if (!d) return res.status(404).json({ error: 'design not found' });
+
+    const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+    const filename = `wandedit_${id}_${Date.now()}_${newSeed}.png`;
+    const outPath = path.join(__dirname, 'data', 'generated', filename);
+    fs.writeFileSync(outPath, buf);
+
+    const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+    const ins = psqlExecLocal(`
+INSERT INTO spoon_all_designs
+  (kind, brand, width_in, height_in, panels, generator, prompt, seed,
+   image_url, local_path, dominant_hex, palette, motifs, tags, category, is_published,
+   parent_design_id)
+VALUES
+  (${esc(d.kind || 'tile_repeat')}, 'wallco.ai',
+   ${d.width_in || 'NULL'}, ${d.height_in || 'NULL'}, ${d.panels || 'NULL'},
+   'magic-wand-browser-edit',
+   ${esc(`Magic-wand edit of #${id}: ${summary}`)},
+   ${newSeed},
+   '/designs/img/by-id/__NEW__',
+   ${esc(outPath)},
+   ${esc(d.dominant_hex)},
+   ${esc(JSON.stringify(d.palette || []))}::jsonb,
+   ${d.motifs ? `ARRAY[${(d.motifs || []).map(m => esc(m)).join(',')}]::text[]` : 'NULL'},
+   ${d.tags ? `ARRAY[${(d.tags || []).map(t => esc(t)).join(',')}]::text[]` : 'NULL'},
+   ${esc(d.category)},
+   TRUE,
+   ${id})
+RETURNING id`);
+    const newId = parseInt(ins, 10);
+    if (!newId) return res.status(500).json({ error: 'failed to insert new row' });
+    psqlExecLocal(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}', is_published=TRUE WHERE id=${newId}`);
+    psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${id}`);
+    return res.json({ ok: true, new_id: newId, src_id: id, bytes: buf.length });
+  } catch (e) {
+    console.error('[save-edited]', e);
+    return res.status(500).json({ error: e.message });
+  }
+});
+
 app.post('/api/design/:id/ghosting', express.json({ limit: '16kb' }), (req, res) => {
   if (!adminRatingGate(req, res)) return;
   const id = parseInt(req.params.id, 10);

← 9666be2 scripts: oneshot trellis-upgrade regen for drunk-zoo-36 #266  ·  back to Wallco Ai  ·  add ghost-rescore-flagged.js — quantify detector-change resc a3c2b00 →