[object Object]

← back to Wallco Ai

admin: live before/after Fixes Feed for every repair event

080cc68e5a5f059f4d219cf9a597d4c754f911e7 · 2026-05-23 23:55:53 -0700 · Steve Abrams

NEW PAGE /admin/fixes-feed — auto-polling gallery (10s) showing every
successful repair, newest first:
  - BEFORE | → | AFTER side-by-side cards
  - Color-coded kind pill (blue=fix, red=remove, purple=regen, green=wand)
  - Filter chips: All / Surgical Fix / Remove+Fill / Reverse-Regen / Wand
  - Pulsing LIVE badge + tab-title flash when new fixes land
  - Quick links per card: ← review · open old · open fixed
  - 60s+ cache-free polling so it stays current while Steve labels

NEW EVENT SINK — every fix endpoint now appends to
data/fixes-feed.jsonl via appendFixEvent({ts, kind, src_id, new_id, ...}):
  - crop-fix logs region + fix_kind (ghost-layer/overlap/composition/remove-fill)
  - regenerate-reverse logs the new reverse-engineered prompt
  - magic-wand save-edited logs bytes + summary

NEW LINK on /admin/ghost-review — "📡 Before/After Feed →" in the header
so Steve can open the feed in a side tab as he labels and watch each
repair as it commits.

Endpoint:  GET /api/fixes-feed  → { ok, items[], total }

Files touched

Diff

commit 080cc68e5a5f059f4d219cf9a597d4c754f911e7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 23 23:55:53 2026 -0700

    admin: live before/after Fixes Feed for every repair event
    
    NEW PAGE /admin/fixes-feed — auto-polling gallery (10s) showing every
    successful repair, newest first:
      - BEFORE | → | AFTER side-by-side cards
      - Color-coded kind pill (blue=fix, red=remove, purple=regen, green=wand)
      - Filter chips: All / Surgical Fix / Remove+Fill / Reverse-Regen / Wand
      - Pulsing LIVE badge + tab-title flash when new fixes land
      - Quick links per card: ← review · open old · open fixed
      - 60s+ cache-free polling so it stays current while Steve labels
    
    NEW EVENT SINK — every fix endpoint now appends to
    data/fixes-feed.jsonl via appendFixEvent({ts, kind, src_id, new_id, ...}):
      - crop-fix logs region + fix_kind (ghost-layer/overlap/composition/remove-fill)
      - regenerate-reverse logs the new reverse-engineered prompt
      - magic-wand save-edited logs bytes + summary
    
    NEW LINK on /admin/ghost-review — "📡 Before/After Feed →" in the header
    so Steve can open the feed in a side tab as he labels and watch each
    repair as it commits.
    
    Endpoint:  GET /api/fixes-feed  → { ok, items[], total }
---
 public/admin/fixes-feed.html | 296 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 296 insertions(+)

diff --git a/public/admin/fixes-feed.html b/public/admin/fixes-feed.html
new file mode 100644
index 0000000..cc8aac1
--- /dev/null
+++ b/public/admin/fixes-feed.html
@@ -0,0 +1,296 @@
+<!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>

← a3c2b00 add ghost-rescore-flagged.js — quantify detector-change resc  ·  back to Wallco Ai  ·  ghost-review: SHIFT+drag = KEEP element (green dotted) · fix a9fbc70 →