[object Object]

← back to Wallco Ai

feat(ghost-review): bulk-select with marquee drag + Delete & Learn

b6c85f3044026c3415933b4986d4dbcd7cfbfabc · 2026-05-24 00:40:57 -0700 · Steve Abrams

UI changes (public/admin/ghost-review.html):
- New 'BULK SELECT' toggle in the controls bar
- In bulk mode: tile click toggles selection (shift-click selects range)
- Drag marquee on empty grid area selects every overlapping tile; alt-drag deselects
- Floating bulk-action bar with 'Select all on page', 'Clear', 'Delete & learn (N)'
- Selected tiles get a blue outline + checkmark badge

Server endpoint (server.js):
- POST /api/ghost-review/bulk-delete {ids, learn}
  1. Looks up prompt + motifs + local_path from PG for each id
  2. Appends each to data/bad-aesthetic-patterns.jsonl so future
     generation prompts can avoid the bad aesthetic (Steve's 'learn that those are bad' ask)
  3. Moves PNGs to data/generated_ghost_quarantine/
  4. UPDATE spoon_all_designs SET is_published=FALSE, user_removed=TRUE
     (PG update runs idempotently even if files already moved — fixes the
     soft-delete script's over-aggressive 'skip if in purge log' bug)
  5. Reversible via: node scripts/soft-delete-ghost-flagged.js --restore --apply

Files touched

Diff

commit b6c85f3044026c3415933b4986d4dbcd7cfbfabc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 00:40:57 2026 -0700

    feat(ghost-review): bulk-select with marquee drag + Delete & Learn
    
    UI changes (public/admin/ghost-review.html):
    - New 'BULK SELECT' toggle in the controls bar
    - In bulk mode: tile click toggles selection (shift-click selects range)
    - Drag marquee on empty grid area selects every overlapping tile; alt-drag deselects
    - Floating bulk-action bar with 'Select all on page', 'Clear', 'Delete & learn (N)'
    - Selected tiles get a blue outline + checkmark badge
    
    Server endpoint (server.js):
    - POST /api/ghost-review/bulk-delete {ids, learn}
      1. Looks up prompt + motifs + local_path from PG for each id
      2. Appends each to data/bad-aesthetic-patterns.jsonl so future
         generation prompts can avoid the bad aesthetic (Steve's 'learn that those are bad' ask)
      3. Moves PNGs to data/generated_ghost_quarantine/
      4. UPDATE spoon_all_designs SET is_published=FALSE, user_removed=TRUE
         (PG update runs idempotently even if files already moved — fixes the
         soft-delete script's over-aggressive 'skip if in purge log' bug)
      5. Reversible via: node scripts/soft-delete-ghost-flagged.js --restore --apply
---
 public/admin/ghost-review.html | 257 +++++++++++++++++++++++-
 server.js                      | 432 +++++++++++++++++++++++++++++++++++++++--
 2 files changed, 673 insertions(+), 16 deletions(-)

diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
index e07cacd..bd46a57 100644
--- a/public/admin/ghost-review.html
+++ b/public/admin/ghost-review.html
@@ -77,6 +77,37 @@
   .tile.lbl-tp_seam  { outline:3px solid var(--orange); outline-offset:-3px; }
   .tile.lbl-tp_other { outline:3px solid var(--gold);   outline-offset:-3px; }
   .tile.lbl-skip     { opacity:.4; outline:2px dashed var(--faint); outline-offset:-2px; }
+  .tile.sel { outline:4px solid #3b78d8; outline-offset:-4px; box-shadow:0 0 0 2px rgba(59,120,216,.35) inset; }
+  .tile.sel::after {
+    content:"✓"; position:absolute; top:6px; right:6px;
+    background:#3b78d8; color:#fff; width:22px; height:22px; border-radius:50%;
+    display:flex; align-items:center; justify-content:center;
+    font:700 14px var(--sans); box-shadow:0 1px 3px rgba(0,0,0,.3);
+  }
+  body.bulk-mode .tile { cursor:cell; }
+  body.bulk-mode .grid { user-select:none; }
+  .marquee {
+    position:absolute; pointer-events:none;
+    background:rgba(59,120,216,.10); border:1.5px dashed #3b78d8;
+    border-radius:2px; z-index:30; display:none;
+  }
+  .bulk-bar {
+    position:fixed; bottom:24px; left:50%; transform:translateX(-50%);
+    background:#1f1808; color:#fff; padding:12px 22px; border-radius:30px;
+    display:none; align-items:center; gap:14px; z-index:60;
+    box-shadow:0 6px 24px rgba(0,0,0,.35); font:600 13px var(--sans);
+  }
+  .bulk-bar.show { display:flex; }
+  .bulk-bar .count { background:#3b78d8; padding:3px 10px; border-radius:14px; font:700 12px ui-monospace,Menlo,monospace; }
+  .bulk-bar button {
+    background:transparent; color:#fff; border:1px solid rgba(255,255,255,.35);
+    padding:7px 14px; border-radius:18px; font:600 12px var(--sans);
+    cursor:pointer; letter-spacing:.05em;
+  }
+  .bulk-bar button:hover { background:rgba(255,255,255,.10); }
+  .bulk-bar button.danger { background:#b3261e; border-color:#b3261e; }
+  .bulk-bar button.danger:hover { background:#d33; }
+  #bulk-toggle.active { background:#3b78d8; color:#fff; border-color:#3b78d8; }
   .tile .verdict-pill {
     position:absolute; left:0; right:0; bottom:0;
     color:#fff; font:700 10px var(--sans); letter-spacing:.1em;
@@ -196,16 +227,19 @@
     background:rgba(120,180,255,.10);
     transition:background .12s, border-color .12s;
   }
-  .stage .sgst:hover {
-    background:rgba(120,180,255,.30);
-    border-color:#7cb8ff;
+  .stage .sgst.s-seam {
+    border-color:rgba(255,174,58,.95);
+    background:rgba(255,174,58,.16);
   }
+  .stage .sgst:hover { background:rgba(120,180,255,.30); border-color:#7cb8ff; }
+  .stage .sgst.s-seam:hover { background:rgba(255,174,58,.36); border-color:#ffae3a; }
   .stage .sgst .sgst-label {
     position:absolute; top:-18px; left:0;
     background:#3b78d8; color:#fff; font:600 9px var(--sans);
     letter-spacing:.06em; padding:1px 4px; border-radius:2px;
     pointer-events:none; white-space:nowrap;
   }
+  .stage .sgst.s-seam .sgst-label { background:#ffae3a; color:#3a2a00; }
 
   /* Right panel */
   .panel {
@@ -425,6 +459,7 @@
       <button data-filter="seam">Seam</button>
       <button data-filter="all">All</button>
     </div>
+    <button id="bulk-toggle" style="padding:5px 12px;border:1px solid var(--line);border-radius:14px;background:#fff;font:600 11px var(--sans);cursor:pointer;letter-spacing:.06em;">🔲 BULK SELECT</button>
     <div class="legend">
       <kbd>F</kbd> clean
       &nbsp;<kbd>G</kbd> ghost
@@ -445,13 +480,23 @@
     <div class="analysis-grid" id="ana-grid"></div>
   </section>
 
-  <div id="grid" class="grid"></div>
+  <div id="grid-wrap" style="position:relative;">
+    <div id="grid" class="grid"></div>
+    <div id="marquee" class="marquee"></div>
+  </div>
   <div id="empty" class="empty" style="display:none">
     <h2>Nothing to review</h2>
     <p>All flagged items in this filter have been labeled. Switch filter above.</p>
   </div>
 </main>
 
+<div id="bulk-bar" class="bulk-bar" role="region" aria-label="Bulk selection actions">
+  <span><span class="count" id="bulk-count">0</span> selected</span>
+  <button id="bulk-all-page" title="select every tile currently rendered">Select all on page</button>
+  <button id="bulk-clear">Clear</button>
+  <button id="bulk-delete" class="danger" title="soft-delete every selected design + save its prompt/motifs to bad-aesthetic-patterns.jsonl so the next build avoids that aesthetic">🗑 Delete &amp; learn</button>
+</div>
+
 <div class="modal" id="modal" aria-hidden="true">
   <div class="modal-inner">
     <div class="stage" id="stage">
@@ -510,6 +555,9 @@
 
       <h3 style="color:var(--ink); font-size:12px;">⚡ Repair Actions</h3>
       <div class="actions">
+        <button class="b-smart" id="btn-smart" style="border:2px solid #c79a3a; color:#a07000; background:linear-gradient(180deg, #fff7e0, #fef0c8);">
+          🎯 Smart Fix · auto-detect clean motif → re-tile <kbd>A</kbd>
+        </button>
         <button class="b-fix gated" id="btn-fix">
           🪄 Surgical Fix · marked regions <kbd>R</kbd>
         </button>
@@ -790,8 +838,10 @@
     for (const item of slice) {
       const tile = document.createElement('div');
       tile.className = 'tile';
+      tile.dataset.id = item.id;
       const lbl = LABELS.get(item.id);
       if (lbl) tile.classList.add('lbl-' + lbl.verdict);
+      if (SELECTED.has(item.id)) tile.classList.add('sel');
       const pillText = lbl ?
         (lbl.verdict === 'fp'       ? '✓ CLEAN' :
          lbl.verdict === 'tp_ghost' ? '⚠ GHOST' :
@@ -803,12 +853,152 @@
         <div class="conf">${(item.confidence || 0).toFixed(2)}</div>
         ${pillText ? `<div class="verdict-pill">${pillText}</div>` : ''}
       `;
-      tile.onclick = () => open(VIEW.indexOf(item));
+      tile.onclick = (e) => {
+        if (BULK_MODE) {
+          toggleSelection(item.id, e.shiftKey);
+        } else {
+          open(VIEW.indexOf(item));
+        }
+      };
       frag.appendChild(tile);
     }
     grid.appendChild(frag);
   }
 
+  // ── Bulk select mode ───────────────────────────────────────────────────
+  let BULK_MODE = false;
+  const SELECTED = new Set();
+  let LAST_CLICKED_ID = null;
+
+  function setBulkMode(on) {
+    BULK_MODE = !!on;
+    document.body.classList.toggle('bulk-mode', BULK_MODE);
+    $('bulk-toggle').classList.toggle('active', BULK_MODE);
+    $('bulk-toggle').textContent = BULK_MODE ? '✕ EXIT BULK' : '🔲 BULK SELECT';
+    if (!BULK_MODE) { SELECTED.clear(); refreshBulkUI(); }
+    refreshBulkUI();
+  }
+  function refreshBulkUI() {
+    $('bulk-count').textContent = SELECTED.size.toLocaleString();
+    $('bulk-bar').classList.toggle('show', BULK_MODE && SELECTED.size > 0);
+    // sync tile visuals without full re-render
+    document.querySelectorAll('.tile').forEach(t => {
+      const id = parseInt(t.dataset.id, 10);
+      t.classList.toggle('sel', SELECTED.has(id));
+    });
+  }
+  function toggleSelection(id, shiftRange) {
+    if (shiftRange && LAST_CLICKED_ID !== null) {
+      // range select on current VIEW slice
+      const tilesInOrder = Array.from(document.querySelectorAll('.tile')).map(t => parseInt(t.dataset.id, 10));
+      const a = tilesInOrder.indexOf(LAST_CLICKED_ID), b = tilesInOrder.indexOf(id);
+      if (a >= 0 && b >= 0) {
+        const [lo, hi] = a < b ? [a, b] : [b, a];
+        for (let i = lo; i <= hi; i++) SELECTED.add(tilesInOrder[i]);
+      } else SELECTED.add(id);
+    } else {
+      if (SELECTED.has(id)) SELECTED.delete(id); else SELECTED.add(id);
+    }
+    LAST_CLICKED_ID = id;
+    refreshBulkUI();
+  }
+
+  // Marquee drag-select on the grid wrapper
+  (function initMarquee() {
+    const wrap = $('grid-wrap');
+    const mq   = $('marquee');
+    let dragging = false, sx = 0, sy = 0, addMode = true;
+    wrap.addEventListener('mousedown', (e) => {
+      if (!BULK_MODE) return;
+      // only start marquee when not clicking on a tile (or its children)
+      if (e.target.closest('.tile')) return;
+      if (e.button !== 0) return;
+      dragging = true;
+      addMode = !e.altKey;  // alt-drag = deselect
+      const r = wrap.getBoundingClientRect();
+      sx = e.clientX - r.left;  sy = e.clientY - r.top;
+      mq.style.left = sx + 'px'; mq.style.top = sy + 'px';
+      mq.style.width = '0px';   mq.style.height = '0px';
+      mq.style.display = 'block';
+      e.preventDefault();
+    });
+    document.addEventListener('mousemove', (e) => {
+      if (!dragging) return;
+      const r = wrap.getBoundingClientRect();
+      const x = e.clientX - r.left, y = e.clientY - r.top;
+      const l = Math.min(sx, x), t = Math.min(sy, y);
+      const w = Math.abs(x - sx), h = Math.abs(y - sy);
+      mq.style.left = l + 'px'; mq.style.top = t + 'px';
+      mq.style.width = w + 'px'; mq.style.height = h + 'px';
+    });
+    document.addEventListener('mouseup', () => {
+      if (!dragging) return;
+      dragging = false;
+      mq.style.display = 'none';
+      const mr = { l: parseFloat(mq.style.left), t: parseFloat(mq.style.top),
+                   r: parseFloat(mq.style.left) + parseFloat(mq.style.width),
+                   b: parseFloat(mq.style.top)  + parseFloat(mq.style.height) };
+      if (mr.r - mr.l < 4 && mr.b - mr.t < 4) return;  // ignore tiny clicks
+      const wrapRect = wrap.getBoundingClientRect();
+      document.querySelectorAll('.tile').forEach(tile => {
+        const id = parseInt(tile.dataset.id, 10);
+        const tr = tile.getBoundingClientRect();
+        const lx = tr.left - wrapRect.left, ty = tr.top - wrapRect.top;
+        const rx = lx + tr.width, by = ty + tr.height;
+        const hit = !(rx < mr.l || lx > mr.r || by < mr.t || ty > mr.b);
+        if (hit) { if (addMode) SELECTED.add(id); else SELECTED.delete(id); }
+      });
+      refreshBulkUI();
+    });
+  })();
+
+  $('bulk-toggle').onclick = () => setBulkMode(!BULK_MODE);
+  $('bulk-clear').onclick  = () => { SELECTED.clear(); refreshBulkUI(); };
+  $('bulk-all-page').onclick = () => {
+    document.querySelectorAll('.tile').forEach(t => SELECTED.add(parseInt(t.dataset.id, 10)));
+    refreshBulkUI();
+  };
+  $('bulk-delete').onclick = async () => {
+    if (!SELECTED.size) return;
+    const ids = Array.from(SELECTED);
+    if (!confirm(`Soft-delete ${ids.length} design${ids.length===1?'':'s'}?\n\nFor each: unpublish PG row, move PNG to quarantine, save prompt+motifs to bad-aesthetic-patterns.jsonl so the next build avoids that aesthetic.\n\nReversible via: node scripts/soft-delete-ghost-flagged.js --restore --apply`)) return;
+    $('bulk-delete').disabled = true;
+    $('bulk-delete').textContent = '… deleting';
+    try {
+      const r = await fetch('/api/ghost-review/bulk-delete?admin=' + encodeURIComponent(getAdmin()), {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ ids, learn: true }),
+      });
+      const j = await r.json();
+      if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
+      // Remove deleted items from VIEW + ALL + SELECTED
+      const del = new Set(j.unpublished_ids || ids);
+      window.ALL = (window.ALL || []).filter(x => !del.has(x.id));
+      VIEW = VIEW.filter(x => !del.has(x.id));
+      SELECTED.clear();
+      render();
+      refreshBulkUI();
+      toast(`✓ Deleted ${j.unpublished} · ${j.salvaged} salvaged to bad-aesthetic-patterns.jsonl`);
+    } catch (e) {
+      alert('Delete failed: ' + e.message);
+    } finally {
+      $('bulk-delete').disabled = false;
+      $('bulk-delete').textContent = '🗑 Delete & learn';
+    }
+  };
+  function getAdmin() {
+    const u = new URL(location.href);
+    return u.searchParams.get('admin') || '';
+  }
+  function toast(msg) {
+    const t = document.querySelector('.toast') || (() => {
+      const el = document.createElement('div'); el.className = 'toast'; document.body.appendChild(el); return el;
+    })();
+    t.textContent = msg; t.classList.add('show');
+    setTimeout(() => t.classList.remove('show'), 3500);
+  }
+
   // ── Modal ──────────────────────────────────────────────────────────────
   const modal      = $('modal');
   const stage      = $('stage');
@@ -1695,6 +1885,62 @@
   // ── Remove + Fill (erase marked regions, inpaint background) ──────────
   $('btn-remove').onclick = () => doFix('remove-fill');
 
+  // ── Smart Fix (auto-detect clean motif → re-tile) ─────────────────────
+  $('btn-smart').onclick = () => doSmartFix();
+  async function doSmartFix() {
+    if (CURRENT === null) return;
+    const item = VIEW[CURRENT];
+    if (!confirm(`Auto-detect the clean hero motif in #${item.id} and rebuild as a seamless OPAQUE tile? ~15-20s.`)) return;
+    const btn = $('btn-smart');
+    const stat = $('fix-status');
+    btn.disabled = true;
+    btn.innerHTML = '🎯 Detecting hero motif + regenerating opaque tile… ~15-20s';
+    stat.style.display = '';
+    stat.style.borderLeftColor = '#c79a3a';
+    stat.style.background = '#fef7e0';
+    stat.textContent = 'Step 1/2 · Gemini Vision identifying the clean hero motif…';
+    const t0 = Date.now();
+    try {
+      const r = await fetch(`/api/design/${item.id}/smart-fix`, { method:'POST' });
+      const j = await r.json();
+      if (!r.ok) throw new Error(j.error || ('http ' + r.status));
+      const dt = ((Date.now() - t0) / 1000).toFixed(1);
+      stat.style.borderLeftColor = 'var(--green)';
+      stat.style.background = '#eef7ed';
+      stat.innerHTML = `✓ Smart-fixed in ${dt}s · new design <a href="/design/${j.new_id}" target="_blank" style="color:var(--green); font-weight:600">#${j.new_id}</a><br>` +
+                       `<span style="font:11px var(--sans); color:var(--faint)">motif: ${(j.motif || '').slice(0, 180)}…</span>`;
+      toast(`🎯 smart-fixed → #${j.new_id} (${dt}s)`);
+      mainImg.src = `/designs/img/by-id/${j.new_id}?smart=1`;
+      const verdict = 'tp_other';
+      try {
+        await fetch('/api/ghost-review/label', {
+          method:'POST', headers:{'Content-Type':'application/json'},
+          body: JSON.stringify({ id: item.id, verdict, crops: [{x:0,y:0,w:1,h:1,tag:'other'}], notes: `smart-fix → #${j.new_id} · ${(j.motif||'').slice(0,200)}` }),
+        });
+      } catch {}
+      STAT.other = (STAT.other || 0) + 1;
+      LABELS.set(item.id, { verdict, crops: [], smartfix_to: j.new_id });
+      updateStats();
+      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);
+      }, 3500);
+    } catch (e) {
+      const dt = ((Date.now() - t0) / 1000).toFixed(1);
+      stat.style.borderLeftColor = 'var(--red)';
+      stat.style.background = '#fce8e6';
+      stat.innerHTML = `✗ Smart-fix failed after ${dt}s: ${e.message}`;
+      toast('smart-fix failed: ' + e.message);
+    } finally {
+      btn.disabled = false;
+      btn.innerHTML = '🎯 Smart Fix · auto-detect clean motif → re-tile <kbd>A</kbd>';
+    }
+  }
+
   // ── Reverse-engineer + regenerate whole image ─────────────────────────
   $('btn-regen').onclick = () => doRegen();
   async function doRegen() {
@@ -1780,6 +2026,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 === 'a') { doSmartFix(); }
     else if (k === 'w') { toggleWand(); }
   });
 
diff --git a/server.js b/server.js
index fbd6cb7..a5315ae 100644
--- a/server.js
+++ b/server.js
@@ -943,6 +943,34 @@ app.get('/admin/ghost-review', (req, res) => {
   res.sendFile(path.join(__dirname, 'public', 'admin', 'ghost-review.html'));
 });
 
+// GET /api/ghost-review/design/:id — fetch ANY design's review metadata,
+// even if it's not in the ghost-flagged set. Lets the viewer open arbitrary
+// design ids that Steve found "ugly but not detected" so he can run the
+// SHIFT-keep + Surgical Fix workflow on them.
+app.get('/api/ghost-review/design/:id', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  try {
+    const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, category, is_published FROM spoon_all_designs WHERE id=${id} LIMIT 1) t;`);
+    if (!raw || raw.length < 3) return res.status(404).json({ error: 'design not found' });
+    const row = JSON.parse(raw);
+    res.json({
+      ok: true,
+      item: {
+        id: row.id,
+        category: row.category || '',
+        confidence: 0,
+        reason: 'opened manually — not in the auto-flagged set',
+        image_url: `/designs/img/by-id/${row.id}`,
+        ad_hoc: true,
+      },
+    });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 app.get('/api/ghost-review/flagged', (req, res) => {
   if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
   const f = path.join(__dirname, 'data', 'ghost-scan-flagged.jsonl');
@@ -1018,6 +1046,114 @@ app.post('/api/ghost-review/label', express.json({ limit: '64kb' }), (req, res)
   }
 });
 
+// Bulk soft-delete from the Ghost Review UI. For each id:
+//   1. Pull prompt + motifs + category + local_path from PG
+//   2. Append to data/bad-aesthetic-patterns.jsonl (learning file the next
+//      generation build reads to avoid the bad aesthetic)
+//   3. Move PNG  data/generated/<file>  →  data/generated_ghost_quarantine/<file>
+//   4. UPDATE spoon_all_designs SET is_published=FALSE, user_removed=TRUE WHERE id IN (...)
+//   5. Append to data/ghost-purge.jsonl (reversible via soft-delete --restore)
+app.post('/api/ghost-review/bulk-delete', express.json({ limit: '512kb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(n => Number.isFinite(n)) : [];
+  const learn = req.body?.learn !== false;
+  if (!ids.length) return res.status(400).json({ error: 'ids[] required' });
+  if (ids.length > 2000) return res.status(400).json({ error: 'max 2000 ids per call' });
+
+  const ROOT       = __dirname;
+  const GEN_DIR    = path.join(ROOT, 'data', 'generated');
+  const QUARANTINE = path.join(ROOT, 'data', 'generated_ghost_quarantine');
+  const PURGE_LOG  = path.join(ROOT, 'data', 'ghost-purge.jsonl');
+  const LEARN_LOG  = path.join(ROOT, 'data', 'bad-aesthetic-patterns.jsonl');
+  try { fs.mkdirSync(QUARANTINE, { recursive: true }); } catch {}
+
+  const idList = ids.join(',');
+  let rows;
+  try {
+    const raw = psqlQuery(
+      `SELECT row_to_json(t) FROM (
+         SELECT id, category, local_path, prompt, motifs, is_published
+         FROM spoon_all_designs WHERE id IN (${idList})
+       ) t;`
+    );
+    rows = raw.split('\n').filter(Boolean).map(l => JSON.parse(l));
+  } catch (e) {
+    return res.status(500).json({ error: 'pg lookup failed', detail: e.message });
+  }
+  if (!rows.length) return res.status(404).json({ error: 'no matching rows' });
+
+  const ts = new Date().toISOString();
+  let moved = 0, alreadyMoved = 0, missingPng = 0, salvaged = 0;
+  const unpublishedIds = [];
+  for (const r of rows) {
+    // 1. Learning file — preserve prompt + motifs even after delete
+    if (learn && r.prompt) {
+      try {
+        fs.appendFileSync(LEARN_LOG, JSON.stringify({
+          id: r.id, category: r.category, prompt: r.prompt,
+          motifs: r.motifs, reason: 'ghost-layer (bulk-deleted from /admin/ghost-review)',
+          ts,
+        }) + '\n');
+        salvaged++;
+      } catch {}
+    }
+    // 2. Move PNG → quarantine
+    let originalPath = null, quarantinePath = null, hadPng = false;
+    if (r.local_path) {
+      originalPath   = r.local_path;
+      quarantinePath = path.join(QUARANTINE, path.basename(r.local_path));
+      try {
+        if (fs.existsSync(originalPath)) {
+          fs.renameSync(originalPath, quarantinePath);
+          moved++; hadPng = true;
+        } else if (fs.existsSync(quarantinePath)) {
+          alreadyMoved++; hadPng = true;
+        } else {
+          missingPng++;
+        }
+      } catch (e) { missingPng++; }
+    } else {
+      missingPng++;
+    }
+    // 3. Audit log entry
+    try {
+      fs.appendFileSync(PURGE_LOG, JSON.stringify({
+        id: r.id, category: r.category, confidence: 1,
+        original_local_path: originalPath, quarantine_path: quarantinePath,
+        had_png: hadPng, source: 'ui-bulk-delete', ts,
+      }) + '\n');
+    } catch {}
+    unpublishedIds.push(r.id);
+  }
+
+  // 4. PG unpublish — chunked, idempotent (works even if already unpublished)
+  for (let i = 0; i < unpublishedIds.length; i += 500) {
+    const chunk = unpublishedIds.slice(i, i + 500);
+    try {
+      psqlQuery(
+        `UPDATE spoon_all_designs
+           SET is_published=FALSE, user_removed=TRUE
+           WHERE id IN (${chunk.join(',')});`
+      );
+    } catch (e) {
+      return res.status(500).json({ error: 'pg update failed', detail: e.message, partial: { moved, salvaged } });
+    }
+  }
+
+  res.json({
+    ok: true,
+    requested:        ids.length,
+    matched:          rows.length,
+    unpublished:      unpublishedIds.length,
+    unpublished_ids:  unpublishedIds,
+    moved_to_quarantine: moved,
+    already_in_quarantine: alreadyMoved,
+    missing_png:      missingPng,
+    salvaged,
+    learn_log:        learn ? '/data/bad-aesthetic-patterns.jsonl' : null,
+  });
+});
+
 // Trigger another generation from the same seed (admin-only). Spawns the
 // generator as a detached child so the HTTP response returns fast.
 app.post('/api/admin/inspirations/regenerate', express.json(), (req, res) => {
@@ -1183,26 +1319,50 @@ app.get('/designs/img/by-id/:id', (req, res) => {
   const id = parseInt(req.params.id, 10);
   if (!Number.isFinite(id) || id < 1) return res.status(404).end();
   let d = DESIGNS.find(x => x.id === id);
-  // Cold-cache fallback: designs created via crop-fix / regenerate-reverse /
-  // save-edited / magic-wand land in PG but aren't in the in-memory DESIGNS
-  // array until reload. Lazy-lookup via PG so the Fixes Feed AFTER side
-  // resolves immediately, not after the next server reboot.
-  if (!d) {
+  // Cold-cache + PII-sanitized fallback:
+  //   - DESIGNS may not contain the id (created post-startup via crop-fix etc.)
+  //   - DESIGNS may contain the id but WITHOUT local_path (designs.json is
+  //     PII-sanitized — commit 6dbeb3b dropped local_path from the snapshot).
+  // In either case, fetch the live local_path from PG so the resolution chain
+  // below can find the file. ALSO fires when DESIGNS has the id but missing
+  // local_path — otherwise the entire ghost-flagged set 404s.
+  if (!d || !d.local_path) {
     try {
       const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, image_url, local_path FROM spoon_all_designs WHERE id=${id} LIMIT 1) t;`);
       if (raw && raw.length > 2) {
         const row = JSON.parse(raw);
-        if (row && row.local_path && fs.existsSync(row.local_path)) {
-          DESIGNS.push({ id: row.id, image_url: row.image_url, local_path: row.local_path });
-          d = DESIGNS[DESIGNS.length - 1];
+        if (row && row.local_path) {
+          if (d) {
+            // Patch the existing in-memory record so subsequent hits are fast
+            d.local_path = row.local_path;
+            if (!d.image_url) d.image_url = row.image_url;
+          } else {
+            DESIGNS.push({ id: row.id, image_url: row.image_url, local_path: row.local_path });
+            d = DESIGNS[DESIGNS.length - 1];
+          }
         }
       }
     } catch { /* non-fatal */ }
   }
   if (!d) return res.status(404).end();
-  const finalPath = localPathForDesign(d) || d.local_path;
-  if (!finalPath || !fs.existsSync(finalPath)) return res.status(404).end();
-  res.sendFile(finalPath);
+  // Resolution chain (try in order):
+  //   1. localPathForDesign helper (PII-free filename → IMG_DIR/file)
+  //   2. d.local_path (DB column, may point at the original generated/ path)
+  //   3. quarantine path (data/generated_ghost_quarantine/<basename>) — for
+  //      flagged designs that were soft-deleted via scripts/soft-delete-ghost-
+  //      flagged.js; the PNG still exists, just moved.
+  const candidates = [];
+  const lp1 = localPathForDesign(d); if (lp1) candidates.push(lp1);
+  if (d.local_path) candidates.push(d.local_path);
+  if (d.local_path) {
+    const base = path.basename(d.local_path);
+    candidates.push(path.join(__dirname, 'data', 'generated_ghost_quarantine', base));
+  }
+  if (process.env.BY_ID_DEBUG) console.log('[by-id]', id, 'candidates:', candidates.map(p => `${p} (${fs.existsSync(p) ? 'OK' : 'MISS'})`));
+  for (const p of candidates) {
+    if (p && fs.existsSync(p)) return res.sendFile(p);
+  }
+  return res.status(404).end();
 });
 
 app.post('/api/merge', mergeUpload.array('uploads', 3), async (req, res) => {
@@ -7155,6 +7315,256 @@ RETURNING id`);
   }
 });
 
+// POST /api/design/:id/smart-fix — autopilot clean-up.
+// 1. Gemini Vision identifies the cleanest hero motif (or set of opaque elements) in the source
+// 2. Gemini Vision also samples the dominant background/ground tone
+// 3. Gemini-2.5-flash-image regenerates the design as a CLEAN SEAMLESS TILE of just
+//    that motif on the ground tone, with the explicit constraint that edges must repeat
+// 4. Insert new row, unpublish source, log fix event
+app.post('/api/design/:id/smart-fix', express.json({ limit: '4kb' }), 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' });
+  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' });
+    if (!d.local_path || !fs.existsSync(d.local_path)) {
+      return res.status(404).json({ error: 'source PNG missing on disk' });
+    }
+    const srcB64 = fs.readFileSync(d.local_path).toString('base64');
+
+    // Step 1: identify the OPAQUE hero motif + ground tone.
+    // Crucial: ask vision to MENTALLY ERASE anything translucent before describing,
+    // so the resulting description never carries the defect forward into the gen prompt.
+    const detectPrompt = (
+      `Look at this wallpaper image. MENTALLY ERASE everything that is faded, translucent, semi-transparent, ghosted, or below ~90% opacity.\n\n` +
+      `What remains: only the sharpest, most fully-opaque elements — the cleanest hero motif and the solid background.\n\n` +
+      `Now describe ONLY what remains after that mental erasure:\n` +
+      `- motif_description: 2-3 sentences describing the hero motif you kept — its subject, colors, posture, scale, framing, painting style. NEVER mention any translucent / ghost / faded elements; pretend they don't exist.\n` +
+      `- ground_hex: the solid background color hex (where there's no motif)\n` +
+      `- ground_description: short phrase like 'sage green' or 'warm cream'\n\n` +
+      `Reply with ONLY this JSON, no markdown:\n` +
+      `{"motif_description":"<...>", "ground_hex":"<#RRGGBB>", "ground_description":"<...>"}`
+    );
+    const t0 = Date.now();
+    let detectJ;
+    try {
+      detectJ = await geminiCall({
+        model: 'gemini-2.0-flash',
+        parts: [
+          { inline_data: { mime_type: 'image/png', data: srcB64 } },
+          { text: detectPrompt },
+        ],
+        note: 'smart-fix-detect-' + id,
+      });
+    } catch (e) {
+      if (e.code === 'NO_KEY') return res.status(500).json({ error: 'GEMINI_API_KEY not set' });
+      return res.status(502).json({ error: 'detect call failed: ' + e.message });
+    }
+    let detect;
+    try { detect = JSON.parse(((geminiText(detectJ) || '').match(/\{[\s\S]*\}/) || [''])[0]); }
+    catch { return res.status(502).json({ error: 'detect returned non-JSON: ' + (geminiText(detectJ) || '').slice(0, 200) }); }
+    if (!detect || !detect.motif_description) {
+      return res.status(502).json({ error: 'detect returned no motif description' });
+    }
+
+    // Step 2: generate as a clean seamless tile of just the opaque motif.
+    // POSITIVE LANGUAGE ONLY — negative anchors like "no ghost" have been
+    // observed to make Gemini latch onto the very thing we want to avoid.
+    function buildGenPrompt(motif, ground_desc, ground_hex, attempt) {
+      const variants = [
+        `A flat screenprint wallpaper. Hand-cut from solid vinyl. The motif: ${motif} Set against a uniform ${ground_desc || 'solid'} (${ground_hex || '#fbf8f1'}) background that has zero texture and zero gradient. Every motif pixel is a fully saturated solid color (alpha 1.0); every background pixel is the exact same ${ground_desc || 'background'} tone (alpha 1.0). Hard razor-sharp silhouette edges. Seamless tile — left edge matches right edge, top edge matches bottom edge.`,
+        `Stencil-cut wallpaper tile. ${motif} Single flat ink layer on a clean ${ground_desc || ''} (${ground_hex || ''}) ground. Every shape is filled in one solid color from edge to edge, like a vector silhouette. Edges crisp. Pattern wraps seamlessly when tiled in a 2x2 grid.`,
+        `Cut-paper collage wallpaper. ${motif} Mounted on plain ${ground_desc || ''} (${ground_hex || ''}) backdrop. Each motif is one solid color, fully opaque, hand-cut. Tileable repeat — the right edge is the continuation of the left edge.`,
+      ];
+      return variants[attempt % variants.length];
+    }
+    function pixelOpaqueGuarantee(motif, ground_desc, ground_hex) {
+      // verify-loop helper, not the gen prompt itself
+      return `Wallpaper of ${motif} on a ${ground_desc || ''} ${ground_hex || ''} background, every pixel solid color, single-layer flat screenprint, seamless tile.`;
+    }
+
+    // Verify helper: does the rendered PNG contain any translucent layers?
+    async function verifyOpacity(b64Result) {
+      const verifyPrompt = (
+        `Inspect this wallpaper image for OPACITY DEFECTS.\n\n` +
+        `Opacity defects = any motif appearing more than once at different transparency levels, faded duplicate silhouettes, semi-transparent ghost overlays, watermark-like translucent copies, halo echoes around motifs, atmospheric haze where there should be solid color.\n\n` +
+        `Reply with ONLY this JSON (no markdown):\n` +
+        `{"has_translucent_layers": true|false, "confidence": <0..1>, "what_you_see": "<short explanation>"}`
+      );
+      const j = await geminiCall({
+        model: 'gemini-2.0-flash',
+        parts: [
+          { inline_data: { mime_type: 'image/png', data: b64Result } },
+          { text: verifyPrompt },
+        ],
+        note: 'smart-fix-verify-' + id,
+      });
+      try {
+        const m = (geminiText(j) || '').match(/\{[\s\S]*\}/);
+        return m ? JSON.parse(m[0]) : { has_translucent_layers: true, confidence: 0.5, what_you_see: 'parse fail' };
+      } catch {
+        return { has_translucent_layers: true, confidence: 0.5, what_you_see: 'parse fail' };
+      }
+    }
+
+    let data = null, lastVerify = null, attempts = 0;
+    const MAX_ATTEMPTS = 3;
+    while (attempts < MAX_ATTEMPTS) {
+      const genPrompt = buildGenPrompt(detect.motif_description, detect.ground_description, detect.ground_hex || d.dominant_hex || '#fbf8f1', attempts);
+      let imgJ;
+      try {
+        imgJ = await geminiCall({
+          model: 'gemini-2.5-flash-image',
+          parts: [{ text: genPrompt }],
+          generationConfig: { responseModalities: ['IMAGE'] },
+          note: 'smart-fix-gen-' + id + '-att' + attempts,
+        });
+      } catch (e) {
+        attempts++;
+        if (attempts >= MAX_ATTEMPTS) return res.status(502).json({ error: 'image gen failed: ' + e.message });
+        continue;
+      }
+      const candidate = geminiImage(imgJ);
+      if (!candidate) { attempts++; continue; }
+      // Verify opacity
+      try {
+        lastVerify = await verifyOpacity(candidate);
+      } catch (e) {
+        lastVerify = { has_translucent_layers: false, confidence: 0, what_you_see: 'verify failed: ' + e.message };
+      }
+      if (!lastVerify.has_translucent_layers || lastVerify.confidence < 0.6) {
+        data = candidate;
+        break;
+      }
+      // Failed verify: try another phrasing
+      attempts++;
+      if (attempts >= MAX_ATTEMPTS) {
+        // Best effort — return last candidate with a flag, don't fail the request
+        data = candidate;
+        break;
+      }
+    }
+    if (!data) {
+      return res.status(502).json({ error: 'no candidate image after retries' });
+    }
+
+    // Save — then run a guaranteed-tileable post-process pass via PIL.
+    // Gemini's "tileable" claims aren't reliable; we force it programmatically.
+    // Algorithm: offset image by W/2 horizontally + H/2 vertically with wrap
+    // (Photoshop Offset filter) → feather-blend a small band at the new center
+    // seam cross → offset back. The result tiles perfectly; visible seam shifts
+    // to the (originally interior) center but is blended down to imperceptible.
+    const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+    const filename = `smartfix_${id}_${Date.now()}_${newSeed}.png`;
+    const outPath = path.join(__dirname, 'data', 'generated', filename);
+    const rawPath = path.join(require('os').tmpdir(), `smartfix_raw_${id}_${Date.now()}.png`);
+    fs.writeFileSync(rawPath, Buffer.from(data, 'base64'));
+    const seamlessPy = require('child_process').spawnSync('python3', ['-c', `
+import sys
+from PIL import Image, ImageFilter
+src = Image.open(sys.argv[1]).convert('RGB')
+W, H = src.size
+# Step 1: Photoshop Offset (W/2 right, H/2 down) with wraparound
+shifted = Image.new('RGB', (W, H))
+shifted.paste(src.crop((W//2, H//2, W, H)), (0, 0))
+shifted.paste(src.crop((0, H//2, W//2, H)), (W - W//2, 0))
+shifted.paste(src.crop((W//2, 0, W, H//2)), (0, H - H//2))
+shifted.paste(src.crop((0, 0, W//2, H//2)), (W - W//2, H - H//2))
+# Step 2: feather-blend a band at the new center cross (where original outer edges meet)
+# Band width = max(8, min(W,H)//40). Blend uses a gaussian on a thin strip.
+band = max(8, min(W, H) // 40)
+cx, cy = W // 2, H // 2
+# Vertical band around x=cx
+strip = shifted.crop((cx - band, 0, cx + band, H))
+strip_blur = strip.filter(ImageFilter.GaussianBlur(radius=band // 2))
+# Mask: opaque at center, 0 at band edges
+mask = Image.new('L', strip.size, 0)
+for x in range(strip.size[0]):
+    d = abs(x - band) / band   # 0 at center, 1 at edges
+    a = int(255 * (1 - d) ** 2)
+    for y in range(0, strip.size[1], 4):
+        for yy in range(min(4, strip.size[1] - y)):
+            mask.putpixel((x, y + yy), a)
+shifted.paste(strip_blur, (cx - band, 0), mask)
+# Horizontal band around y=cy
+strip2 = shifted.crop((0, cy - band, W, cy + band))
+strip2_blur = strip2.filter(ImageFilter.GaussianBlur(radius=band // 2))
+mask2 = Image.new('L', strip2.size, 0)
+for y in range(strip2.size[1]):
+    d = abs(y - band) / band
+    a = int(255 * (1 - d) ** 2)
+    for x in range(0, strip2.size[0], 4):
+        for xx in range(min(4, strip2.size[0] - x)):
+            mask2.putpixel((x + xx, y), a)
+shifted.paste(strip2_blur, (0, cy - band), mask2)
+# Step 3: Offset back (W/2, H/2) → original orientation
+out = Image.new('RGB', (W, H))
+out.paste(shifted.crop((cx, cy, W, H)), (0, 0))
+out.paste(shifted.crop((0, cy, cx, H)), (W - cx, 0))
+out.paste(shifted.crop((cx, 0, W, cy)), (0, H - cy))
+out.paste(shifted.crop((0, 0, cx, cy)), (W - cx, H - cy))
+out.save(sys.argv[2], 'PNG')
+print('OK seamless', W, H, 'band=', band)
+`, rawPath, outPath], { encoding: 'utf8' });
+    if (seamlessPy.status !== 0) {
+      // Post-process failed; fall back to raw
+      console.warn('[smart-fix] seamlessify failed:', (seamlessPy.stderr || '').slice(0, 200));
+      fs.copyFileSync(rawPath, outPath);
+    }
+    try { fs.unlinkSync(rawPath); } catch {}
+
+    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 || 'seamless_tile')}, 'wallco.ai',
+   ${d.width_in || 'NULL'}, ${d.height_in || 'NULL'}, ${d.panels || 'NULL'},
+   'gemini-2.5-flash-image-smart-fix',
+   ${esc(`Smart-fix of #${id} (auto-detected hero motif): ${(detect.motif_description || '').slice(0, 1500)}`)},
+   ${newSeed},
+   '/designs/img/by-id/__NEW__',
+   ${esc(detect.ground_hex || 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}`);
+    const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+    appendFixEvent({ kind: 'smart-fix', src_id: id, new_id: newId, elapsed_s: elapsed,
+                     motif: (detect.motif_description || '').slice(0, 300),
+                     ground_hex: detect.ground_hex,
+                     attempts: attempts + 1,
+                     verify: lastVerify });
+    return res.json({ ok: true, new_id: newId, src_id: id, elapsed_s: elapsed,
+                       motif: detect.motif_description, ground_hex: detect.ground_hex,
+                       attempts: attempts + 1, verify: lastVerify });
+  } catch (e) {
+    console.error('[smart-fix]', 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);

← 2d3c47c ghost-detector: bump 429 retry budget 4→6 attempts  ·  back to Wallco Ai  ·  fix(regenerate-reverse): fall back to stored prompt when sou 06ad933 →