[object Object]

← back to Wallco Ai

remove broken /admin/fixes-feed viewer

5138c6ef97cf3ec9e47162fb61cc4a33274ee620 · 2026-05-24 22:04:05 -0700 · Steve Abrams

The before/after fixes-feed page at /admin/fixes-feed was broken. Deleted:
- GET /admin/fixes-feed route + GET /api/fixes-feed route from server.js
- public/admin/fixes-feed.html (file)
- Sidebar nav link in public/new.html
- Top-right nav link in public/admin/ghost-review.html

KEPT (the underlying data pipeline is still useful):
- appendFixEvent() helper — called from 8 fix routes (crop-fix,
  regenerate-reverse, opacity-snap, magic-wand, smart-fix, replace-bg)
- data/fixes-feed.jsonl append-only audit log
- scripts/bulk-reject-auto-smart-fix.js reads the log directly from disk

Verified: GET /admin/fixes-feed → 404 after restart.

Files touched

Diff

commit 5138c6ef97cf3ec9e47162fb61cc4a33274ee620
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 22:04:05 2026 -0700

    remove broken /admin/fixes-feed viewer
    
    The before/after fixes-feed page at /admin/fixes-feed was broken. Deleted:
    - GET /admin/fixes-feed route + GET /api/fixes-feed route from server.js
    - public/admin/fixes-feed.html (file)
    - Sidebar nav link in public/new.html
    - Top-right nav link in public/admin/ghost-review.html
    
    KEPT (the underlying data pipeline is still useful):
    - appendFixEvent() helper — called from 8 fix routes (crop-fix,
      regenerate-reverse, opacity-snap, magic-wand, smart-fix, replace-bg)
    - data/fixes-feed.jsonl append-only audit log
    - scripts/bulk-reject-auto-smart-fix.js reads the log directly from disk
    
    Verified: GET /admin/fixes-feed → 404 after restart.
---
 public/admin/fixes-feed.html   | 296 -------------------------------
 public/admin/ghost-review.html |   3 -
 public/new.html                |   1 -
 server.js                      | 384 ++++++++++++++++++++++++++++++++++++++---
 4 files changed, 356 insertions(+), 328 deletions(-)

diff --git a/public/admin/fixes-feed.html b/public/admin/fixes-feed.html
deleted file mode 100644
index cc8aac1..0000000
--- a/public/admin/fixes-feed.html
+++ /dev/null
@@ -1,296 +0,0 @@
-<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1">
-<meta name="robots" content="noindex,nofollow">
-<title>Fixes Feed — wallco.ai admin</title>
-<style>
-  :root {
-    --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
-    --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
-    --red:#b3261e; --green:#1f6a2c; --orange:#c0660b; --purple:#7a4ab8; --blue:#3b78d8;
-    --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
-    --serif:'Playfair Display','Didot',Georgia,serif;
-  }
-  * { box-sizing:border-box; }
-  body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
-  header {
-    padding:14px 22px; border-bottom:1px solid var(--line); background:#fff;
-    display:flex; gap:16px; align-items:baseline; position:sticky; top:0; z-index:5;
-  }
-  header h1 { font:400 22px/1.2 var(--serif); margin:0; }
-  header .meta { color:var(--faint); font-size:12px; letter-spacing:.04em; }
-  header .stats { margin-left:auto; display:flex; gap:18px; font:600 12px ui-monospace,Menlo,monospace; }
-  header .stats b { color:var(--ink); }
-  header .live {
-    font:600 10.5px var(--sans); color:var(--green); letter-spacing:.1em;
-    margin-left:auto; padding-left:16px;
-  }
-  header .live::before {
-    content:'●'; margin-right:6px; animation:pulse 1.4s ease-in-out infinite;
-  }
-  @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.3; } }
-
-  main { max-width:1500px; margin:0 auto; padding:18px; }
-
-  .controls {
-    display:flex; gap:14px; align-items:center; padding:10px 14px;
-    background:#fff; border:1px solid var(--line); border-radius:8px;
-    margin-bottom:14px; font:12px var(--sans); flex-wrap:wrap;
-  }
-  .controls label { font:600 10.5px var(--sans); letter-spacing:.16em;
-                    text-transform:uppercase; color:var(--faint); }
-  .controls .filter button {
-    border:1px solid var(--line); background:#fff; border-radius:14px;
-    padding:5px 11px; cursor:pointer; font:600 11px var(--sans);
-    color:var(--faint); letter-spacing:.04em; margin-right:6px;
-  }
-  .controls .filter button.active { background:var(--ink); color:#fff; border-color:var(--ink); }
-  .controls .ledger {
-    margin-left:auto; font:600 11px ui-monospace,Menlo,monospace; color:var(--faint);
-  }
-
-  .card {
-    background:var(--card); border:1px solid var(--line); border-radius:10px;
-    margin-bottom:18px; overflow:hidden;
-    transition:box-shadow .15s;
-  }
-  .card:hover { box-shadow:0 6px 22px rgba(0,0,0,.06); }
-  .card .ribbon {
-    display:flex; gap:10px; align-items:center; padding:9px 14px;
-    border-bottom:1px solid var(--line); background:#fcfaf4;
-    font:11px var(--sans);
-  }
-  .card .kind-pill {
-    font:700 10px var(--sans); letter-spacing:.08em;
-    padding:3px 8px; border-radius:11px; color:#fff;
-    text-transform:uppercase;
-  }
-  .card .kind-pill.k-crop-fix          { background:var(--blue); }
-  .card .kind-pill.k-crop-fix-remove   { background:var(--red); }
-  .card .kind-pill.k-regenerate-reverse { background:var(--purple); }
-  .card .kind-pill.k-magic-wand        { background:var(--green); }
-  .card .ts { color:var(--faint); font-family:ui-monospace,Menlo,monospace; }
-  .card .elapsed { color:var(--faint); margin-left:6px; }
-  .card .links { margin-left:auto; display:flex; gap:8px; }
-  .card .links a {
-    color:var(--faint); text-decoration:none; font:600 11px var(--sans);
-    padding:3px 8px; border:1px solid var(--line); border-radius:4px;
-  }
-  .card .links a:hover { background:var(--ink); color:#fff; border-color:var(--ink); }
-
-  .pair {
-    display:grid; grid-template-columns:1fr auto 1fr; gap:0;
-    align-items:stretch;
-  }
-  .pair .side {
-    padding:14px 18px; display:flex; flex-direction:column; gap:8px;
-    min-width:0;
-  }
-  .pair .side.before { border-right:1px solid var(--line); background:#fdfbf5; }
-  .pair .side .label {
-    font:700 10px var(--sans); letter-spacing:.16em;
-    text-transform:uppercase; color:var(--faint);
-    display:flex; align-items:center; gap:8px;
-  }
-  .pair .side.before .label::before  { content:'◀'; color:#b3261e; }
-  .pair .side.after  .label::before  { content:'▶'; color:var(--green); }
-  .pair .side .id-row {
-    font:600 14px ui-monospace,Menlo,monospace; color:var(--ink);
-  }
-  .pair .img-frame {
-    aspect-ratio:1; background:#f3eee2;
-    border-radius:6px; overflow:hidden; position:relative;
-    border:1px solid var(--line);
-  }
-  .pair .img-frame img {
-    width:100%; height:100%; object-fit:contain; display:block;
-    background:#000;
-  }
-  .pair .img-frame.before img { opacity:1; }
-  .pair .arrow {
-    display:flex; align-items:center; justify-content:center;
-    width:62px; background:var(--bg);
-    border-left:1px solid var(--line); border-right:1px solid var(--line);
-    font:24px var(--sans); color:var(--faint);
-  }
-
-  .pair .side .meta-row {
-    display:flex; flex-wrap:wrap; gap:6px 12px;
-    font:11px ui-monospace,Menlo,monospace; color:var(--faint);
-  }
-  .pair .side .meta-row b { color:var(--ink); font-weight:600; }
-
-  .empty {
-    text-align:center; padding:80px 20px; color:var(--faint);
-    font:14px var(--sans);
-  }
-  .empty h2 { font:400 28px var(--serif); color:var(--ink); margin-bottom:10px; }
-  .empty code {
-    background:#f3eee2; padding:2px 6px; border-radius:3px; font:12px ui-monospace,Menlo,monospace;
-  }
-
-  @media (max-width: 720px) {
-    .pair { grid-template-columns:1fr; }
-    .pair .side.before { border-right:none; border-bottom:1px solid var(--line); }
-    .pair .arrow { width:auto; height:42px; border-left:none; border-right:none; border-top:1px solid var(--line); border-bottom:1px solid var(--line); }
-  }
-</style>
-</head>
-<body>
-
-<header>
-  <h1>Fixes Feed</h1>
-  <span class="meta">every successful repair, newest first</span>
-  <div class="stats">
-    <span>FIX <b id="stat-fix">0</b></span>
-    <span>REMOVE <b id="stat-remove">0</b></span>
-    <span>REGEN <b id="stat-regen">0</b></span>
-    <span>WAND <b id="stat-wand">0</b></span>
-    <span>TOTAL <b id="stat-total">0</b></span>
-  </div>
-  <span class="live">LIVE</span>
-</header>
-
-<main>
-  <div class="controls">
-    <label>Filter</label>
-    <div class="filter">
-      <button class="active" data-f="all">All</button>
-      <button data-f="crop-fix">Surgical Fix</button>
-      <button data-f="crop-fix-remove">Remove + Fill</button>
-      <button data-f="regenerate-reverse">Reverse-Regen</button>
-      <button data-f="magic-wand">Magic Wand</button>
-    </div>
-    <span class="ledger" id="ledger">—</span>
-  </div>
-
-  <div id="feed"></div>
-  <div id="empty" class="empty" style="display:none">
-    <h2>No fixes yet</h2>
-    <p>Go to <code>/admin/ghost-review</code>, mark a defect, hit a Fix button. The before/after will appear here automatically.</p>
-  </div>
-</main>
-
-<script>
-(() => {
-  const $ = (id) => document.getElementById(id);
-  let ITEMS = [];
-  let FILTER = 'all';
-  let SEEN_TOTAL = 0;
-
-  const KIND_LABEL = {
-    'crop-fix':           'Surgical Fix',
-    'crop-fix-remove':    'Remove + Fill',
-    'regenerate-reverse': 'Reverse-Regen',
-    'magic-wand':         'Magic Wand',
-  };
-
-  document.querySelectorAll('.filter button').forEach(b => {
-    b.onclick = () => {
-      document.querySelectorAll('.filter button').forEach(x => x.classList.remove('active'));
-      b.classList.add('active');
-      FILTER = b.dataset.f;
-      render();
-    };
-  });
-
-  function fmtKind(item) {
-    if (item.kind === 'crop-fix' && item.fix_kind === 'remove-fill') return 'crop-fix-remove';
-    return item.kind;
-  }
-
-  function updateStats() {
-    const counts = { 'crop-fix':0, 'crop-fix-remove':0, 'regenerate-reverse':0, 'magic-wand':0 };
-    ITEMS.forEach(i => { const k = fmtKind(i); counts[k] = (counts[k] || 0) + 1; });
-    $('stat-fix').textContent    = counts['crop-fix'];
-    $('stat-remove').textContent = counts['crop-fix-remove'];
-    $('stat-regen').textContent  = counts['regenerate-reverse'];
-    $('stat-wand').textContent   = counts['magic-wand'];
-    $('stat-total').textContent  = ITEMS.length;
-  }
-
-  function render() {
-    const feed = $('feed');
-    feed.innerHTML = '';
-    const empty = $('empty');
-    const filtered = FILTER === 'all' ? ITEMS : ITEMS.filter(i => fmtKind(i) === FILTER);
-    if (!filtered.length) { empty.style.display = ''; return; }
-    empty.style.display = 'none';
-    const frag = document.createDocumentFragment();
-    for (const it of filtered) {
-      const k = fmtKind(it);
-      const card = document.createElement('div');
-      card.className = 'card';
-      const tsShort = new Date(it.ts).toLocaleString();
-      const sub =
-        k === 'crop-fix' ? `region (${it.region?.x?.toFixed(1)}%, ${it.region?.y?.toFixed(1)}%, ${it.region?.w?.toFixed(1)}%, ${it.region?.h?.toFixed(1)}%)` :
-        k === 'crop-fix-remove' ? `removed region (${it.region?.w?.toFixed(1)}% × ${it.region?.h?.toFixed(1)}%)` :
-        k === 'regenerate-reverse' ? `<span title="${(it.new_prompt || '').replace(/"/g,'&quot;')}">new prompt: ${(it.new_prompt || '').slice(0, 80)}…</span>` :
-        k === 'magic-wand' ? `${it.summary || 'browser edit'} · ${(it.bytes/1024).toFixed(0)} KB` :
-        '';
-      card.innerHTML = `
-        <div class="ribbon">
-          <span class="kind-pill k-${k}">${KIND_LABEL[k] || k}</span>
-          <span class="ts">${tsShort}</span>
-          ${it.elapsed_s ? `<span class="elapsed">· ${it.elapsed_s}s</span>` : ''}
-          <span class="links">
-            <a href="/admin/ghost-review" title="back to review">← review</a>
-            <a href="/design/${it.src_id}" target="_blank" title="open old">#${it.src_id}</a>
-            <a href="/design/${it.new_id}" target="_blank" title="open fixed">→ #${it.new_id}</a>
-          </span>
-        </div>
-        <div class="pair">
-          <div class="side before">
-            <div class="label">Before · #${it.src_id} (unpublished)</div>
-            <div class="img-frame before">
-              <img loading="lazy" src="/designs/img/by-id/${it.src_id}" alt="before #${it.src_id}">
-            </div>
-            <div class="meta-row"><b>id</b> ${it.src_id}</div>
-          </div>
-          <div class="arrow">→</div>
-          <div class="side after">
-            <div class="label">After · #${it.new_id} (live)</div>
-            <div class="img-frame">
-              <img loading="lazy" src="/designs/img/by-id/${it.new_id}" alt="after #${it.new_id}">
-            </div>
-            <div class="meta-row">
-              <span><b>id</b> ${it.new_id}</span>
-              <span><b>kind</b> ${KIND_LABEL[k] || k}</span>
-              ${sub ? `<span>${sub}</span>` : ''}
-            </div>
-          </div>
-        </div>
-      `;
-      frag.appendChild(card);
-    }
-    feed.appendChild(frag);
-  }
-
-  async function refresh() {
-    try {
-      const r = await fetch('/api/fixes-feed');
-      const j = await r.json();
-      if (!j.ok) return;
-      // Pulse the LIVE badge briefly when new items arrive
-      if (j.total > SEEN_TOTAL && SEEN_TOTAL > 0) {
-        document.title = `(${j.total - SEEN_TOTAL} new) Fixes Feed`;
-        setTimeout(() => { document.title = 'Fixes Feed — wallco.ai admin'; }, 4000);
-      }
-      SEEN_TOTAL = j.total;
-      ITEMS = j.items;
-      $('ledger').textContent = `${j.items.length} shown · ${j.total} total events`;
-      updateStats();
-      render();
-    } catch (e) {
-      console.warn('refresh failed:', e.message);
-    }
-  }
-
-  refresh();
-  setInterval(refresh, 10000);  // poll every 10s for new fixes
-})();
-</script>
-</body>
-</html>
diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
index 06c59ad..8a75dfd 100644
--- a/public/admin/ghost-review.html
+++ b/public/admin/ghost-review.html
@@ -416,9 +416,6 @@
 <header>
   <h1>Ghost Review</h1>
   <span class="meta">draw rects · pick a defect · go fast</span>
-  <a href="/admin/fixes-feed" target="_blank" style="font:600 11px var(--sans); letter-spacing:.06em; color:var(--blue,#3b78d8); text-decoration:none; padding:5px 10px; border:1px solid var(--line); border-radius:14px; margin-left:10px;">
-    📡 Before/After Feed →
-  </a>
   <div class="stats">
     <span class="clean">CLEAN <b id="stat-fp">0</b></span>
     <span class="ghost">GHOST <b id="stat-ghost">0</b></span>
diff --git a/public/new.html b/public/new.html
index 1fea6c8..fb3c8ea 100644
--- a/public/new.html
+++ b/public/new.html
@@ -119,7 +119,6 @@
       <option value="168">last 7d</option>
     </select>
     <span class="right" id="meta">—</span>
-    <a href="/admin/fixes-feed">📡 Before/After Feed →</a>
     <a href="/admin/ghost-review">🔍 Ghost Review →</a>
   </div>
 
diff --git a/server.js b/server.js
index 5569528..5875dd1 100644
--- a/server.js
+++ b/server.js
@@ -934,7 +934,8 @@ app.get('/new', (req, res) => {
 // ── Fixes feed (before/after audit trail) ───────────────────────────────
 // Append-only log of every successful fix (Surgical / Remove+Fill / Reverse-
 // Regenerate / Magic-Wand-Apply). Each row links the source id (before) to
-// the new id (after). Served as a live gallery at /admin/fixes-feed.
+// the new id (after). Log lives at data/fixes-feed.jsonl; the live-gallery
+// page at /admin/fixes-feed was removed 2026-05-24 (broken).
 // POST /api/fix-decision/:newId  body: { verdict: 'accept' | 'reject' }
 // accept: no-op (already published).
 // reject: unpublish the result and republish the parent (rollback).
@@ -944,17 +945,42 @@ app.post('/api/fix-decision/:newId', express.json({ limit: '2kb' }), (req, res)
   const newId = parseInt(req.params.newId, 10);
   if (!Number.isFinite(newId) || newId < 1) return res.status(400).json({ error: 'bad id' });
   const verdict = String(req.body?.verdict || '').toLowerCase();
-  if (!['accept','reject'].includes(verdict)) return res.status(400).json({ error: 'verdict must be accept|reject' });
+  // 'etsy' = reject as wallpaper but tag for Etsy digital-file resale (different revenue path)
+  if (!['accept','reject','delete','etsy'].includes(verdict)) return res.status(400).json({ error: 'verdict must be accept|reject|delete|etsy' });
   try {
-    // Find parent_design_id
-    const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, parent_design_id, is_published FROM spoon_all_designs WHERE id=${newId}) t;`);
+    const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, parent_design_id, is_published, local_path FROM spoon_all_designs WHERE id=${newId}) t;`);
     if (!raw || raw.length < 3) return res.status(404).json({ error: 'design not found' });
     const row = JSON.parse(raw);
     if (verdict === 'reject') {
+      // Soft: unpublish new, republish parent
       psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${newId};`);
       if (row.parent_design_id) {
         psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
       }
+    } else if (verdict === 'delete') {
+      psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE, user_removed=TRUE WHERE id=${newId};`);
+      if (row.parent_design_id) {
+        psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
+      }
+      if (row.local_path && fs.existsSync(row.local_path)) {
+        try {
+          const qDir = path.join(__dirname, 'data', 'generated_ghost_quarantine');
+          fs.mkdirSync(qDir, { recursive: true });
+          const dest = path.join(qDir, path.basename(row.local_path));
+          if (!fs.existsSync(dest)) fs.renameSync(row.local_path, dest);
+        } catch (e) { console.warn('[fix-decision delete] quarantine move failed:', e.message); }
+      }
+    } else if (verdict === 'etsy') {
+      // Reject as wallpaper, KEEP as Etsy digital-file. Unpublish from wallco
+      // listings, tag for Etsy export, republish source. PNG stays in
+      // data/generated/ so the etsy-export pipeline can grab it.
+      psqlExecLocal(`UPDATE spoon_all_designs
+        SET is_published=FALSE,
+            tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'etsy-digital-file') || 'etsy-digital-file'
+        WHERE id=${newId};`);
+      if (row.parent_design_id) {
+        psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
+      }
     }
     fs.appendFileSync(path.join(__dirname, 'data', 'fix-decisions.jsonl'),
       JSON.stringify({ ts: new Date().toISOString(), new_id: newId, parent: row.parent_design_id, verdict }) + '\n');
@@ -962,6 +988,53 @@ app.post('/api/fix-decision/:newId', express.json({ limit: '2kb' }), (req, res)
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
+// POST /api/fix-decision/bulk  body: { ids: [newId, ...], verdict: 'accept'|'reject'|'delete'|'etsy' }
+// Same semantics as single-id /api/fix-decision/:newId, but in one HTTP call so
+// the bulk-action bar (formerly on /admin/fixes-feed, now removed) didn't fan
+// out N parallel requests. Retained for any external caller / future viewer.
+app.post('/api/fix-decision/bulk', express.json({ limit: '64kb' }), (req, res) => {
+  if (!adminRatingGate(req, res)) return;
+  const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(n => parseInt(n, 10)).filter(Number.isFinite) : [];
+  const verdict = String(req.body?.verdict || '').toLowerCase();
+  if (!ids.length) return res.status(400).json({ error: 'ids[] required' });
+  if (!['accept','reject','delete','etsy'].includes(verdict)) return res.status(400).json({ error: 'bad verdict' });
+  const results = [];
+  let ok = 0, err = 0;
+  for (const newId of ids) {
+    try {
+      const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, parent_design_id, is_published, local_path FROM spoon_all_designs WHERE id=${newId}) t;`);
+      if (!raw || raw.length < 3) { results.push({ id: newId, ok: false, error: 'not found' }); err++; continue; }
+      const row = JSON.parse(raw);
+      if (verdict === 'reject') {
+        psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${newId};`);
+        if (row.parent_design_id) psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
+      } else if (verdict === 'delete') {
+        psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE, user_removed=TRUE WHERE id=${newId};`);
+        if (row.parent_design_id) psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
+        if (row.local_path && fs.existsSync(row.local_path)) {
+          try {
+            const qDir = path.join(__dirname, 'data', 'generated_ghost_quarantine');
+            fs.mkdirSync(qDir, { recursive: true });
+            const dest = path.join(qDir, path.basename(row.local_path));
+            if (!fs.existsSync(dest)) fs.renameSync(row.local_path, dest);
+          } catch {}
+        }
+      } else if (verdict === 'etsy') {
+        psqlExecLocal(`UPDATE spoon_all_designs
+          SET is_published=FALSE,
+              tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'etsy-digital-file') || 'etsy-digital-file'
+          WHERE id=${newId};`);
+        if (row.parent_design_id) psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
+      }
+      fs.appendFileSync(path.join(__dirname, 'data', 'fix-decisions.jsonl'),
+        JSON.stringify({ ts: new Date().toISOString(), new_id: newId, parent: row.parent_design_id, verdict, bulk: true }) + '\n');
+      results.push({ id: newId, ok: true });
+      ok++;
+    } catch (e) { results.push({ id: newId, ok: false, error: e.message.slice(0, 200) }); err++; }
+  }
+  res.json({ ok: true, total: ids.length, accepted: ok, errored: err, verdict, results });
+});
+
 function appendFixEvent(row) {
   try {
     const f = path.join(__dirname, 'data', 'fixes-feed.jsonl');
@@ -969,23 +1042,11 @@ function appendFixEvent(row) {
   } catch (e) { console.error('[fix-event] write fail', e.message); }
 }
 
-app.get('/admin/fixes-feed', (req, res) => {
-  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
-  res.sendFile(path.join(__dirname, 'public', 'admin', 'fixes-feed.html'));
-});
-app.get('/api/fixes-feed', (req, res) => {
-  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
-  const f = path.join(__dirname, 'data', 'fixes-feed.jsonl');
-  if (!fs.existsSync(f)) return res.json({ ok: true, items: [] });
-  const lines = fs.readFileSync(f, 'utf8').split('\n').filter(Boolean);
-  // Latest first
-  const items = [];
-  for (let i = lines.length - 1; i >= 0; i--) {
-    try { items.push(JSON.parse(lines[i])); } catch {}
-    if (items.length >= 500) break;
-  }
-  res.json({ ok: true, items, total: lines.length });
-});
+// /admin/fixes-feed viewer + /api/fixes-feed route removed 2026-05-24 — the
+// page was broken. The underlying data pipeline (appendFixEvent →
+// data/fixes-feed.jsonl) is preserved as an audit trail for the fix routes
+// (crop-fix, regenerate-reverse, opacity-snap, magic-wand, smart-fix,
+// replace-bg) and is read directly off disk by scripts/bulk-reject-auto-smart-fix.js.
 
 // ── Ghost Review (admin-only) ───────────────────────────────────────────
 // Viewer + label-collection for the ghost-detector training set. Reads
@@ -7328,6 +7389,97 @@ RETURNING id`);
   }
 });
 
+// POST /api/design/:id/opacity-snap — pure PIL ghost-cleanup. NO Gemini, no cost.
+//
+// Steve's 2026-05-24 rule: a ghost layer = blended pixels between motif color
+// and background color. Snap every pixel to the NEAREST of N detected solid
+// colors (k=3 by default: bg + 2 motif tones). Result: zero translucency, zero
+// halftones, zero ghost-band gradients. "Updating not redo" — preserves the
+// composition, eliminates only the partial-alpha pixels.
+//
+// Body: { k?: 3, dither?: 'fs'|'none' }
+app.post('/api/design/:id/opacity-snap', express.json({ limit: '4kb' }), (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 k = Math.min(8, Math.max(2, parseInt(req.body?.k || '3', 10)));
+  const dither = String(req.body?.dither || 'none').toLowerCase();
+  try {
+    let d = null;
+    try {
+      const pgRaw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category,
+        width_in, height_in, panels, motifs, tags, dominant_hex, palette, local_path
+        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' });
+    }
+    const t0 = Date.now();
+    const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+    const filename = `snap_${id}_${Date.now()}_${newSeed}.png`;
+    const outPath = path.join(__dirname, 'data', 'generated', filename);
+    const py = require('child_process').spawnSync('python3', ['-c', `
+import sys, json
+from PIL import Image
+src = Image.open(sys.argv[1]).convert('RGB')
+k = int(sys.argv[3])
+dither_flag = Image.FLOYDSTEINBERG if sys.argv[4] == 'fs' else Image.NONE
+q = src.quantize(colors=k, method=Image.MEDIANCUT, dither=dither_flag)
+pal = q.getpalette()[: k*3]
+hexes = ['#%02x%02x%02x' % (pal[i*3], pal[i*3+1], pal[i*3+2]) for i in range(k)]
+q.convert('RGB').save(sys.argv[2], 'PNG', optimize=True)
+print(json.dumps({ 'palette': hexes, 'k': k, 'dither': sys.argv[4] }))
+`, d.local_path, outPath, String(k), dither], { encoding: 'utf8' });
+    if (py.status !== 0) {
+      return res.status(500).json({ error: 'opacity-snap PIL failed: ' + (py.stderr || py.stdout || '?').slice(0, 240) });
+    }
+    let meta = {};
+    try { meta = JSON.parse(py.stdout); } catch {}
+    const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+    const arrLit = (a, t) => (!Array.isArray(a) || !a.length) ? 'NULL' : `ARRAY[${a.map(v => esc(v)).join(',')}]::${t}[]`;
+    const insertSQL = `
+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'},
+   'pil-opacity-snap',
+   ${esc('Opacity-snap of #' + id + ' (k=' + k + ', dither=' + dither + '): collapsed all translucent/halftone pixels to nearest of ' + k + ' solid colors. No Gemini.')},
+   ${newSeed},
+   '/designs/img/by-id/__NEW__',
+   ${esc(outPath)},
+   ${esc((meta.palette && meta.palette[0]) || d.dominant_hex)},
+   ${esc(JSON.stringify(meta.palette || d.palette || []))}::jsonb,
+   ${arrLit(d.motifs, 'text')},
+   ${arrLit(d.tags, 'text')},
+   ${esc(d.category)},
+   TRUE,
+   ${id})
+RETURNING id`;
+    let ins;
+    try { ins = psqlExecLocal(insertSQL); }
+    catch (e) {
+      console.error('[opacity-snap] INSERT err:', e.message.slice(0, 240));
+      return res.status(500).json({ error: 'insert failed: ' + e.message.slice(0, 240) });
+    }
+    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: 'opacity-snap', src_id: id, new_id: newId, elapsed_s: elapsed,
+                     k, dither, palette: meta.palette });
+    res.json({ ok: true, new_id: newId, src_id: id, elapsed_s: elapsed, palette: meta.palette, k });
+  } catch (e) {
+    console.error('[opacity-snap]', e);
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // 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.
@@ -7399,13 +7551,100 @@ 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
+// POST /api/design/:id/smart-fix — REDIRECTED 2026-05-25 per Steve.
+//
+// Old behavior: Gemini Vision describe → Gemini-2.5-flash-image regenerate
+// (text-only, no source image) → verify-retry → PIL seamlessify.
+//   PROBLEM: produced entirely new patterns, not cleanups. The seamless-agent
+//   ran 12h overnight firing this on every flagged design and produced 75+
+//   amateur-looking redos that Steve had to mass-reject + tag for Etsy.
+//
+// New behavior: forward to /api/design/:id/opacity-snap (pure PIL, no AI,
+// preserves composition exactly, collapses translucent/halftone pixels to
+// nearest of k=3 solid colors). Same response shape so callers don't break.
+//
+// If you need the old Gemini regenerate behavior, call /api/design/:id/regenerate-reverse
+// explicitly. It's never the right default for "fix" operations.
 app.post('/api/design/:id/smart-fix', express.json({ limit: '4kb' }), async (req, res) => {
+  // REDIRECT 2026-05-25: forward to opacity-snap (PIL, preserves composition).
+  // Old Gemini-regenerate behavior was producing amateur-looking new patterns
+  // when called by the seamless-agent autonomously. Set ?legacy=1 to bypass
+  // this redirect and run the (deprecated) Gemini regenerate flow if needed.
+  if (!(req.query && (req.query.legacy === '1' || req.query.legacy === 'true'))) {
+    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, kind, category,
+          width_in, height_in, panels, motifs, tags, dominant_hex, palette, local_path
+          FROM spoon_all_designs WHERE id=${id}) t;`);
+        if (pgRaw && pgRaw.length > 2) d = JSON.parse(pgRaw);
+      } catch {}
+      if (!d) { const c = DESIGNS.find(x => x.id === id); if (c) d = { ...c }; }
+      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' });
+      }
+      const t0 = Date.now();
+      const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+      const filename = `snap_${id}_${Date.now()}_${newSeed}.png`;
+      const outPath = path.join(__dirname, 'data', 'generated', filename);
+      const py = require('child_process').spawnSync('python3', ['-c', `
+import sys, json
+from PIL import Image
+src = Image.open(sys.argv[1]).convert('RGB')
+q = src.quantize(colors=3, method=Image.MEDIANCUT, dither=Image.NONE)
+pal = q.getpalette()[:9]
+hexes = ['#%02x%02x%02x' % (pal[i*3], pal[i*3+1], pal[i*3+2]) for i in range(3)]
+q.convert('RGB').save(sys.argv[2], 'PNG', optimize=True)
+print(json.dumps({'palette': hexes}))
+`, d.local_path, outPath], { encoding: 'utf8' });
+      if (py.status !== 0) return res.status(500).json({ error: 'opacity-snap PIL failed: ' + (py.stderr||'').slice(0,200) });
+      let meta = {}; try { meta = JSON.parse(py.stdout); } catch {}
+      const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+      const arrLit = (a, t) => (!Array.isArray(a) || !a.length) ? 'NULL' : `ARRAY[${a.map(v => esc(v)).join(',')}]::${t}[]`;
+      let ins;
+      try {
+        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'},
+   'pil-opacity-snap (via smart-fix redirect)',
+   ${esc('Smart-fix REDIRECTED to opacity-snap of #' + id + ' — composition preserved, k=3 quantize')},
+   ${newSeed},
+   '/designs/img/by-id/__NEW__',
+   ${esc(outPath)},
+   ${esc((meta.palette && meta.palette[0]) || d.dominant_hex)},
+   ${esc(JSON.stringify(meta.palette || d.palette || []))}::jsonb,
+   ${arrLit(d.motifs, 'text')},
+   ${arrLit(d.tags, 'text')},
+   ${esc(d.category)},
+   TRUE,
+   ${id})
+RETURNING id`);
+      } catch (e) { return res.status(500).json({ error: 'insert failed: ' + e.message.slice(0, 240) }); }
+      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,
+                       redirected: 'opacity-snap', palette: meta.palette });
+      return res.json({ ok: true, new_id: newId, src_id: id, elapsed_s: elapsed,
+                         redirected: 'opacity-snap', palette: meta.palette,
+                         motif: 'opacity-snap (composition preserved, no Gemini)',
+                         attempts: 1, verify: { has_translucent_layers: false } });
+    } catch (e) {
+      console.error('[smart-fix→opacity-snap]', e);
+      return res.status(500).json({ error: e.message });
+    }
+  }
+  // ─ Legacy path (Gemini regenerate) — only reached when ?legacy=1 ─
   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' });
@@ -7718,6 +7957,44 @@ RETURNING id`;
 // GET /api/bg-textures              → list all (slug, name, material, hex, image_url)
 // POST /api/bg-textures             → admin: add/update a texture
 // POST /api/design/:id/replace-bg   → admin: swap the design's background for the named texture
+// GET /api/design/:id/texture-variants — public endpoint listing all
+// bg-swap children of a design. Used by /design/:id customer-flow texture
+// picker. Returns each variant's id, image_url, and the texture material/brand
+// parsed out of the prompt (so the chip can show "Cream Grasscloth · Phillipe
+// Romano" without needing the texture's image URL).
+app.get('/api/design/:id/texture-variants', (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  try {
+    // Designs where this is the parent AND generator is a bg-swap.
+    // Also include the design itself as "original / no texture" so the picker
+    // shows the source as the first chip.
+    const sql = `SELECT COALESCE(json_agg(t ORDER BY t.created_at), '[]'::json) FROM (
+      SELECT id, image_url, prompt, dominant_hex, created_at, generator
+      FROM spoon_all_designs
+      WHERE parent_design_id = ${id}
+        AND is_published = TRUE
+        AND generator IN ('gemini-2.5-flash-image-bg-swap','pil-opacity-snap')
+      LIMIT 60
+    ) t;`;
+    const raw = psqlQuery(sql);
+    const rows = JSON.parse(raw || '[]');
+    // Parse "BG-swap of #X to <texture name> (<brand>): ..." from prompt
+    const items = rows.map(r => {
+      const m = (r.prompt || '').match(/BG-swap of #\d+ to (.+?):/);
+      const texture_label = m ? m[1] : (r.generator === 'pil-opacity-snap' ? 'Opacity-cleaned (no texture)' : 'Variant');
+      return {
+        id: r.id,
+        image_url: `/designs/img/by-id/${r.id}`,
+        dominant_hex: r.dominant_hex,
+        texture_label,
+        kind: r.generator === 'pil-opacity-snap' ? 'snap' : 'bg-swap',
+      };
+    });
+    res.json({ ok: true, items });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 // GET /api/dw-textures — real natural-material wallcoverings from DW catalog.
 // Surfaces shopify_products where material keywords (grasscloth, linen, raffia,
 // silk, cork, jute, sisal, hemp, woven) appear in title. Returns brand-grouped list.
@@ -7737,6 +8014,57 @@ app.get('/api/categories', (req, res) => {
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
+// GET /admin/status-browser — one place to see ALL designs with ALL statuses.
+app.get('/admin/status-browser', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+  res.sendFile(path.join(__dirname, 'public', 'admin', 'status-browser.html'));
+});
+
+// GET /api/status-browser — list designs with computed status field.
+// Query: ?status=all|published|unpublished|etsy|removed|quarantined|ghost-flagged
+//        ?category=...
+//        ?limit=200&offset=0
+//        ?sort=newest|oldest|category|id-asc|id-desc
+app.get('/api/status-browser', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const status = String(req.query.status || 'all').toLowerCase();
+  const category = String(req.query.category || '').toLowerCase();
+  const limit = Math.min(500, Math.max(1, parseInt(req.query.limit || '200', 10)));
+  const offset = Math.max(0, parseInt(req.query.offset || '0', 10));
+  const sort = String(req.query.sort || 'newest').toLowerCase();
+  let where = "brand='wallco.ai'";
+  if (status === 'published')   where += ' AND is_published=TRUE AND (user_removed IS NOT TRUE)';
+  else if (status === 'unpublished') where += ' AND is_published=FALSE AND (user_removed IS NOT TRUE)';
+  else if (status === 'removed') where += ' AND user_removed=TRUE';
+  else if (status === 'etsy')    where += " AND tags @> ARRAY['etsy-digital-file']::text[]";
+  if (category)                  where += ` AND category='${category.replace(/'/g, "''")}'`;
+  const orderBy = ({
+    'newest':   'id DESC', 'oldest':   'id ASC',
+    'category': 'category, id DESC',
+    'id-asc':   'id ASC',  'id-desc':  'id DESC',
+  })[sort] || 'id DESC';
+  try {
+    const countRaw = psqlQuery(`SELECT count(*) FROM spoon_all_designs WHERE ${where};`);
+    const total = parseInt(countRaw.trim(), 10) || 0;
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.rownum), '[]'::json) FROM (
+      SELECT id, kind, category, generator, dominant_hex, is_published, user_removed,
+        tags, parent_design_id, created_at, row_number() OVER (ORDER BY ${orderBy}) AS rownum
+      FROM spoon_all_designs
+      WHERE ${where}
+      ORDER BY ${orderBy}
+      LIMIT ${limit} OFFSET ${offset}
+    ) t;`);
+    const items = JSON.parse(raw || '[]').map(r => {
+      const tags = r.tags || [];
+      const computed_status = r.user_removed ? 'removed'
+        : tags.includes('etsy-digital-file') ? 'etsy'
+        : r.is_published ? 'published' : 'unpublished';
+      return { ...r, computed_status };
+    });
+    res.json({ ok: true, total, items, page: { limit, offset } });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 app.get('/api/dw-textures', (req, res) => {
   try {
     // ALL natural-material wallcoverings across DW catalog (no brand whitelist).
@@ -8575,7 +8903,7 @@ ${htmlHeader('/designs')}
         }
       </style>
 
-      <div class="detail-img-wrap" data-mural="${(design.kind === 'mural' || design.kind === 'mural_panel') ? '1' : '0'}">
+      <div class="detail-img-wrap" data-mural="${(design.kind === 'mural' || design.kind === 'mural_panel') ? '1' : '0'}" data-original-src="${design.image_url}" data-design-id="${design.id}">
         <img src="${design.image_url}" alt="${design.title}" class="detail-img" id="detail-img" loading="eager" fetchpriority="high" decoding="async" crossorigin="anonymous">
         ${(design.kind === 'mural' || design.kind === 'mural_panel') ? `
         <!-- Mural pan controls — drag to pan, arrow keys + on-screen arrows for keyboard / touch users -->

← a992ad5 Stage 4: scan-composition.js + gen-poodle-demo.js  ·  back to Wallco Ai  ·  Stage 4 followup: scenic exemption + auto-fix-composition.js 0e7e4ff →