[object Object]

← back to Wallco Ai

edges-agent: unpublish 11,274 seam-defect designs + admin red-outline review

57869a4c1611ee383942dff4049959013010e08c · 2026-05-26 07:49:58 -0700 · Steve Abrams

Ran the 6-lens seamless-tile scanner (~/.claude/skills/edges-agent) across
all 27,267 published designs. 41% failed — primarily SDXL internal-grid
leaks (7,333), with 2,883 catastrophic both-axis fails and 1,058 edge-wrap
regressions. 38 scan errors (missing/corrupt local PNG).

PG writes (via apply-unpublish.py):
  - is_published=false on 11,312 flagged ids
  - notes appended with ` | EDGES_FAIL: lens=... max=... grid=... ts=...`
    (or EDGES_SCAN_ERROR for missing files), preserving prior provenance

Server changes:
  - loadDesigns() now drops is_published=false rows from the in-memory
    DESIGNS array. The stale line-215 sitemap comment ("renders them all")
    is fixed — gating happens at the loader.
  - /admin/edges-review serves the new admin grid page (red-outlined cards
    colored by defect class: orange=mids-only, red=edges-only, dark=both,
    grey=scan-err). Per-card hover tooltip shows the 6 lens ΔE scores.
  - /api/admin/edges-review/flagged returns parsed lens scores from the
    structured EDGES_FAIL: tag in notes — 11,312 items in one shot.
  - refresh_designs_snapshot.py now also emits `unpublish_reason` (the
    extracted EDGES_FAIL tag) so future admin views don't need a PG hit.

Direct /design/:id access still works for the now-unpublished rows via the
existing lazy PG lookup at line ~9128 — only list views drop them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 57869a4c1611ee383942dff4049959013010e08c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 26 07:49:58 2026 -0700

    edges-agent: unpublish 11,274 seam-defect designs + admin red-outline review
    
    Ran the 6-lens seamless-tile scanner (~/.claude/skills/edges-agent) across
    all 27,267 published designs. 41% failed — primarily SDXL internal-grid
    leaks (7,333), with 2,883 catastrophic both-axis fails and 1,058 edge-wrap
    regressions. 38 scan errors (missing/corrupt local PNG).
    
    PG writes (via apply-unpublish.py):
      - is_published=false on 11,312 flagged ids
      - notes appended with ` | EDGES_FAIL: lens=... max=... grid=... ts=...`
        (or EDGES_SCAN_ERROR for missing files), preserving prior provenance
    
    Server changes:
      - loadDesigns() now drops is_published=false rows from the in-memory
        DESIGNS array. The stale line-215 sitemap comment ("renders them all")
        is fixed — gating happens at the loader.
      - /admin/edges-review serves the new admin grid page (red-outlined cards
        colored by defect class: orange=mids-only, red=edges-only, dark=both,
        grey=scan-err). Per-card hover tooltip shows the 6 lens ΔE scores.
      - /api/admin/edges-review/flagged returns parsed lens scores from the
        structured EDGES_FAIL: tag in notes — 11,312 items in one shot.
      - refresh_designs_snapshot.py now also emits `unpublish_reason` (the
        extracted EDGES_FAIL tag) so future admin views don't need a PG hit.
    
    Direct /design/:id access still works for the now-unpublished rows via the
    existing lazy PG lookup at line ~9128 — only list views drop them.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/admin/edges-review.html      | 330 ++++++++++++++++++++++++++++++++++++
 scripts/refresh_designs_snapshot.py |  22 ++-
 server.js                           | 107 +++++++++++-
 3 files changed, 450 insertions(+), 9 deletions(-)

diff --git a/public/admin/edges-review.html b/public/admin/edges-review.html
new file mode 100644
index 0000000..7f99ce4
--- /dev/null
+++ b/public/admin/edges-review.html
@@ -0,0 +1,330 @@
+<!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>Edges Review — wallco.ai admin</title>
+<style>
+  :root {
+    --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
+    --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
+    --green:#1f6a2c; --red:#b3261e; --orange:#c0660b; --grey:#888;
+    --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:12px 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:16px; font:600 12px ui-monospace,Menlo,monospace; }
+  header .stats span b { color:var(--ink); }
+  header .stats .mids b { color:var(--orange); }
+  header .stats .edges b { color:var(--red); }
+  header .stats .both b { color:#7a1818; }
+  header .stats .err b { color:var(--grey); }
+
+  main { max-width:1500px; margin:0 auto; padding:14px 18px 80px; }
+
+  .controls {
+    display:flex; gap:14px; align-items:center; padding:9px 14px;
+    background:#fff; border:1px solid var(--line); border-radius:8px;
+    margin-bottom:12px; flex-wrap:wrap; font-size:12px;
+  }
+  .controls label { font:600 10.5px var(--sans); letter-spacing:.16em;
+                    text-transform:uppercase; color:var(--faint); }
+  .controls select { padding:5px 9px; border:1px solid var(--line);
+                     border-radius:4px; background:#fff; font:12px var(--sans); }
+  .controls input[type=range] { vertical-align:middle; width:160px; }
+  .controls .density-val { font:600 11px ui-monospace,Menlo,monospace;
+                           color:var(--faint); min-width:32px; display:inline-block; }
+  .controls .legend { margin-left:auto; font:11px var(--sans); color:var(--faint); }
+  .controls .legend .sw {
+    display:inline-block; width:10px; height:10px; vertical-align:-1px;
+    margin-right:4px; border-radius:2px;
+  }
+  .controls .legend .sw.mids   { background:var(--orange); }
+  .controls .legend .sw.edges  { background:var(--red); }
+  .controls .legend .sw.both   { background:#7a1818; }
+  .controls .legend .sw.err    { background:var(--grey); }
+
+  .grid {
+    display:grid;
+    grid-template-columns:repeat(auto-fill,minmax(var(--card-min,200px),1fr));
+    gap:10px;
+  }
+  .tile {
+    position:relative; aspect-ratio:1; background:#f3eee2;
+    border-radius:6px; overflow:hidden;
+    cursor:pointer; transition:transform .12s, box-shadow .12s;
+    outline:2px solid var(--red); outline-offset:-2px;
+  }
+  .tile.grid-mids   { outline-color:var(--orange); }
+  .tile.grid-edges  { outline-color:var(--red); }
+  .tile.grid-both   { outline-color:#7a1818; }
+  .tile.grid-err    { outline-color:var(--grey); }
+  .tile img { width:100%; height:100%; object-fit:cover; display:block; }
+  .tile:hover { transform:translateY(-2px); box-shadow:0 6px 16px rgba(0,0,0,.18); }
+
+  .badge {
+    position:absolute; top:6px; left:6px;
+    background:var(--red); color:#fff;
+    font:700 9px var(--sans); letter-spacing:.16em; text-transform:uppercase;
+    padding:3px 7px; border-radius:3px;
+    box-shadow:0 1px 3px rgba(0,0,0,.25);
+  }
+  .tile.grid-mids  .badge { background:var(--orange); }
+  .tile.grid-both  .badge { background:#7a1818; }
+  .tile.grid-err   .badge { background:var(--grey); }
+
+  .id-tag {
+    position:absolute; bottom:6px; right:6px;
+    background:rgba(0,0,0,.65); color:#fff;
+    font:600 10px ui-monospace,Menlo,monospace;
+    padding:2px 6px; border-radius:3px;
+  }
+
+  /* Hover tooltip — shows lens scores */
+  .tooltip {
+    position:absolute; bottom:30px; left:6px; right:6px;
+    background:rgba(20,12,4,.92); color:#fff;
+    font:11px ui-monospace,Menlo,monospace;
+    padding:6px 8px; border-radius:4px;
+    opacity:0; pointer-events:none; transition:opacity .14s;
+    z-index:2; line-height:1.4;
+  }
+  .tooltip .row { display:flex; justify-content:space-between; }
+  .tooltip .row .k { color:#c9a14b; }
+  .tooltip .row .v { color:#fff; }
+  .tooltip .row .v.fail { color:#ff8a7a; font-weight:700; }
+  .tooltip .row .v.warn { color:#ffd68a; }
+  .tile:hover .tooltip { opacity:1; }
+
+  .empty {
+    text-align:center; padding:60px 20px; color:var(--faint);
+    font:14px var(--sans);
+  }
+  .loading { text-align:center; padding:40px; color:var(--faint); }
+
+  /* Pagination */
+  .pager { margin-top:18px; display:flex; gap:6px; justify-content:center;
+           flex-wrap:wrap; font:12px var(--sans); }
+  .pager a, .pager span {
+    padding:5px 10px; border:1px solid var(--line); border-radius:4px;
+    color:var(--ink); text-decoration:none; background:#fff;
+  }
+  .pager a:hover { background:#f3eee2; }
+  .pager .active { background:var(--ink); color:#fff; border-color:var(--ink); }
+</style>
+</head>
+<body>
+<header>
+  <h1>Edges Review</h1>
+  <span class="meta">designs unpublished because the 6-lens seamless scan failed</span>
+  <div class="stats" id="stats">
+    <span class="mids">mids-only <b>—</b></span>
+    <span class="edges">edges-only <b>—</b></span>
+    <span class="both">both <b>—</b></span>
+    <span class="err">scan-err <b>—</b></span>
+    <span>total <b id="total-count">—</b></span>
+  </div>
+</header>
+
+<main>
+  <div class="controls">
+    <label for="sort">Sort</label>
+    <select id="sort">
+      <option value="max_desc">Worst ΔE first</option>
+      <option value="max_asc">Best ΔE first</option>
+      <option value="id_desc">Newest id</option>
+      <option value="id_asc">Oldest id</option>
+      <option value="grid">By defect type</option>
+    </select>
+
+    <label for="filter">Filter</label>
+    <select id="filter">
+      <option value="all">All defects</option>
+      <option value="mids_only">SDXL grid-leak (mids only)</option>
+      <option value="edges_only">Edge-wrap defect</option>
+      <option value="both">Catastrophic (both)</option>
+      <option value="err">Scan error / missing file</option>
+    </select>
+
+    <label for="density">Density</label>
+    <input id="density" type="range" min="120" max="380" step="20" value="200">
+    <span class="density-val" id="density-val">200</span>
+
+    <div class="legend">
+      <span><span class="sw mids"></span>mids-only</span>
+      &nbsp;
+      <span><span class="sw edges"></span>edges-only</span>
+      &nbsp;
+      <span><span class="sw both"></span>both</span>
+      &nbsp;
+      <span><span class="sw err"></span>scan-err</span>
+    </div>
+  </div>
+
+  <div id="grid" class="grid"><div class="loading">loading flagged designs…</div></div>
+  <div id="pager" class="pager"></div>
+</main>
+
+<script>
+(function(){
+  const PER = 200;
+  const state = {
+    sort: localStorage.getItem('edges-review-sort') || 'max_desc',
+    filter: localStorage.getItem('edges-review-filter') || 'all',
+    density: parseInt(localStorage.getItem('edges-review-density') || '200', 10),
+    page: 1,
+    items: [],
+  };
+
+  function classifyGrid(item) {
+    if (!item.ok) return 'err';
+    const e = item.edges_max || 0, m = item.mids_max || 0;
+    if (e > 12 && m > 12) return 'both';
+    if (m > 12) return 'mids';
+    if (e > 12) return 'edges';
+    return 'err';
+  }
+
+  function applyDensity(px) {
+    document.documentElement.style.setProperty('--card-min', px + 'px');
+    document.getElementById('density-val').textContent = px;
+  }
+
+  function render() {
+    const grid = document.getElementById('grid');
+    const items = state.items.slice();
+    // Filter
+    const filtered = items.filter(it => {
+      if (state.filter === 'all') return true;
+      const g = classifyGrid(it);
+      return (state.filter === 'mids_only' && g === 'mids')
+          || (state.filter === 'edges_only' && g === 'edges')
+          || (state.filter === 'both' && g === 'both')
+          || (state.filter === 'err' && g === 'err');
+    });
+    // Sort
+    filtered.sort((a, b) => {
+      if (state.sort === 'max_desc') return (b.max_de || 0) - (a.max_de || 0);
+      if (state.sort === 'max_asc')  return (a.max_de || 0) - (b.max_de || 0);
+      if (state.sort === 'id_desc')  return b.id - a.id;
+      if (state.sort === 'id_asc')   return a.id - b.id;
+      if (state.sort === 'grid') {
+        const order = { both:0, edges:1, mids:2, err:3 };
+        return (order[classifyGrid(a)] || 9) - (order[classifyGrid(b)] || 9);
+      }
+      return 0;
+    });
+
+    // Update stats
+    const stats = { mids:0, edges:0, both:0, err:0 };
+    items.forEach(it => { stats[classifyGrid(it)]++; });
+    document.querySelector('.stats .mids b').textContent  = stats.mids;
+    document.querySelector('.stats .edges b').textContent = stats.edges;
+    document.querySelector('.stats .both b').textContent  = stats.both;
+    document.querySelector('.stats .err b').textContent   = stats.err;
+    document.getElementById('total-count').textContent = items.length;
+
+    const pages = Math.max(1, Math.ceil(filtered.length / PER));
+    if (state.page > pages) state.page = 1;
+    const slice = filtered.slice((state.page - 1) * PER, state.page * PER);
+
+    if (slice.length === 0) {
+      grid.innerHTML = '<div class="empty">no flagged designs match this filter</div>';
+    } else {
+      grid.innerHTML = slice.map(item => {
+        const g = classifyGrid(item);
+        const badge = g === 'err' ? 'SCAN ERR' : 'NOT ACTIVE';
+        const lenses = item.lenses || {};
+        const lensRows = ['top','bottom','left','right','h_mid','v_mid'].map(k => {
+          const v = lenses[k];
+          if (!v) return '';
+          const cls = v.verdict === 'FAIL' ? 'fail' : (v.verdict === 'WARN' ? 'warn' : '');
+          return `<div class="row"><span class="k">${k}</span><span class="v ${cls}">ΔE ${v.score}</span></div>`;
+        }).join('');
+        const reason = item.reason || (item.ok === false ? (item.error || 'scan error') : 'edges_fail');
+        return `
+          <a class="tile grid-${g}" href="/design/${item.id}" target="_blank" rel="noopener" title="${reason}">
+            <img src="/designs/img/by-id/${item.id}" loading="lazy" alt="">
+            <span class="badge">${badge}</span>
+            <span class="id-tag">#${item.id}</span>
+            <div class="tooltip">
+              ${lensRows || `<div class="row"><span class="k">error</span><span class="v fail">${item.error || 'unknown'}</span></div>`}
+            </div>
+          </a>`;
+      }).join('');
+    }
+
+    const pager = document.getElementById('pager');
+    if (pages > 1) {
+      const links = [];
+      for (let p = 1; p <= pages; p++) {
+        links.push(p === state.page
+          ? `<span class="active">${p}</span>`
+          : `<a href="#" data-page="${p}">${p}</a>`);
+      }
+      pager.innerHTML = links.join('');
+      pager.querySelectorAll('a').forEach(a => {
+        a.addEventListener('click', e => {
+          e.preventDefault();
+          state.page = parseInt(a.dataset.page, 10);
+          render();
+          window.scrollTo(0, 0);
+        });
+      });
+    } else {
+      pager.innerHTML = '';
+    }
+  }
+
+  // Wire controls
+  document.getElementById('sort').value = state.sort;
+  document.getElementById('filter').value = state.filter;
+  document.getElementById('density').value = state.density;
+  applyDensity(state.density);
+
+  document.getElementById('sort').addEventListener('change', e => {
+    state.sort = e.target.value;
+    localStorage.setItem('edges-review-sort', state.sort);
+    state.page = 1;
+    render();
+  });
+  document.getElementById('filter').addEventListener('change', e => {
+    state.filter = e.target.value;
+    localStorage.setItem('edges-review-filter', state.filter);
+    state.page = 1;
+    render();
+  });
+  document.getElementById('density').addEventListener('input', e => {
+    state.density = parseInt(e.target.value, 10);
+    localStorage.setItem('edges-review-density', String(state.density));
+    applyDensity(state.density);
+  });
+
+  // Fetch
+  fetch('/api/admin/edges-review/flagged')
+    .then(r => r.json())
+    .then(data => {
+      if (!data.ok) {
+        document.getElementById('grid').innerHTML =
+          `<div class="empty">error: ${data.error || 'unknown'}</div>`;
+        return;
+      }
+      state.items = data.items || [];
+      render();
+    })
+    .catch(err => {
+      document.getElementById('grid').innerHTML =
+        `<div class="empty">fetch failed: ${err.message}</div>`;
+    });
+})();
+</script>
+</body>
+</html>
diff --git a/scripts/refresh_designs_snapshot.py b/scripts/refresh_designs_snapshot.py
index 4e04d59..6ff40b3 100644
--- a/scripts/refresh_designs_snapshot.py
+++ b/scripts/refresh_designs_snapshot.py
@@ -73,14 +73,26 @@ def title_for(cat, hex_, id_):
 rows = psql_json(
     "SELECT COALESCE(json_agg(t),'[]'::json) FROM ("
     "SELECT id, kind, category, dominant_hex, prompt, generator, seed, local_path, "
-    "is_published, created_at, motifs, room_mockups, "
+    "is_published, created_at, motifs, room_mockups, notes, "
     "user_removed, user_color_good, user_style_good, user_scale_good, user_stars "
     # 2026-05-22 — added 'comfy' so Mac1 ComfyUI-generated designs (drunk-monkeys-v2 etc)
     # land in the snapshot. Previously these were silently excluded, making the live
     # viewer show 0 results for any cat= filter pointing at a comfy-generated category.
+    # 2026-05-26 — added 'notes' so the edges-agent EDGES_FAIL: tag rides along into
+    # designs.json. Admin "edges-fail review" surface reads it without an extra PG hit.
     "FROM spoon_all_designs WHERE generator IN ('replicate','gemini-2.5-flash-image-edit','comfy') ORDER BY id) t;"
 )
 
+import re as _re
+_EDGES_FAIL_RE = _re.compile(r'\| (EDGES_FAIL|EDGES_SCAN_ERROR): ([^|]+)')
+def extract_edges_tag(notes):
+    if not notes:
+        return None
+    m = _EDGES_FAIL_RE.search(notes)
+    if not m:
+        return None
+    return f"{m.group(1)}: {m.group(2).strip()}"
+
 out = []
 for r in rows:
     h, l, s = hex_to_hls(r['dominant_hex'] or '')
@@ -114,7 +126,13 @@ for r in rows:
         "user_stars":      r.get('user_stars'),
         # Render-ready URL list — server-side disk paths flipped to /designs/room/:id/:room.
         # Snapshot only stores room types; server resolves the file from disk at request time.
-        "room_mockups": sorted(list((r.get('room_mockups') or {}).keys()))
+        "room_mockups": sorted(list((r.get('room_mockups') or {}).keys())),
+        # Extracted edges-agent flag (NULL when clean). Drives the admin red-outline
+        # treatment + the per-design "why this is not active" tooltip. Format:
+        #   "EDGES_FAIL: lens=top:78.6,bottom:78.6 max=78.6 grid=mids_only ts=..."
+        # Customer-safe — no Mac2 paths, no vendor names. Public /api/designs filter
+        # is_published before serializing anyway, so this field never reaches public.
+        "unpublish_reason": extract_edges_tag(r.get('notes'))
     })
 
 OUT.write_text(json.dumps(out, indent=2))
diff --git a/server.js b/server.js
index 55922b2..f708b18 100644
--- a/server.js
+++ b/server.js
@@ -212,10 +212,10 @@ app.get('/sitemap.xml', (req, res) => {
   for (const p of staticPages) {
     urls.push(`  <url><loc>https://${SITE}${p.loc}</loc><changefreq>${p.freq}</changefreq><priority>${p.priority}</priority><lastmod>${today}</lastmod></url>`);
   }
-  // Note: is_published is currently false for every row in designs.json (admin
-  // hasn't run the publish step yet) but /designs renders them all — so the
-  // sitemap mirrors the live catalog. If/when a "draft" flag is introduced,
-  // gate here.
+  // 2026-05-26: DESIGNS is now filtered to is_published=true at loadDesigns,
+  // so the sitemap automatically mirrors only the live catalog. No extra
+  // gate needed here. Unpublished rows (edges-fail, settlement, bleed-ghost,
+  // user_removed) are excluded by construction.
   for (const d of DESIGNS) {
     const sat = Math.max(0, Math.min(1, Number(d.saturation) || 0));
     const hasRoom = (d.room_mockups || []).length > 0 ? 0.1 : 0;
@@ -289,9 +289,14 @@ function passesAestheticGate(d) {
 function loadDesigns() {
   try {
     const raw = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
-    // Hide soft-removed designs, then drop anything that fails the aesthetic
-    // gate (neon / >3 hues / rainbow tags).
-    const beforeGate = raw.filter(d => !d.user_removed);
+    // Hide soft-removed designs, drop unpublished (is_published=false — covers
+    // edges-fail/settlement/bleed-ghost/admin-unpublished), then drop anything
+    // that fails the aesthetic gate (neon / >3 hues / rainbow tags).
+    // 2026-05-26: added is_published gate — the comment at the sitemap loop
+    // saying "/designs renders them all" is now stale. Unpublished rows are
+    // off-grid; admin views fetch from PG via /admin/edges-fail-review and
+    // direct /design/:id access still works via lazy PG lookup at line ~9128.
+    const beforeGate = raw.filter(d => !d.user_removed && d.is_published !== false);
     DESIGNS = beforeGate.filter(passesAestheticGate);
     const dropped = beforeGate.length - DESIGNS.length;
     if (dropped > 0) console.log(`[aesthetic-gate] dropped ${dropped} designs at load (neon/multi-hue/rainbow)`);
@@ -1058,6 +1063,94 @@ app.get('/admin/ghost-review', (req, res) => {
   res.sendFile(path.join(__dirname, 'public', 'admin', 'ghost-review.html'));
 });
 
+// ── /admin/edges-review — 6-lens seamless-tile defect review.
+// Shows every design that was unpublished by the edges-agent sweep
+// (notes ~ '| EDGES_FAIL:' or '| EDGES_SCAN_ERROR:'). Red-outlined chips
+// with hover tooltip showing the per-lens ΔE. Click → /design/:id.
+app.get('/admin/edges-review', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+  res.sendFile(path.join(__dirname, 'public', 'admin', 'edges-review.html'));
+});
+
+app.get('/api/admin/edges-review/flagged', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  try {
+    // Pull every row that the edges-agent flagged. notes column carries the
+    // structured tag — re-parse it here so the client sees clean lens scores.
+    const rawJson = psqlQuery(
+      "SELECT COALESCE(json_agg(t), '[]'::json) FROM (" +
+        "SELECT id, notes FROM spoon_all_designs " +
+        "WHERE notes LIKE '%EDGES_FAIL:%' OR notes LIKE '%EDGES_SCAN_ERROR:%' " +
+        "ORDER BY id DESC" +
+      ") t;"
+    );
+    const rows = JSON.parse(rawJson || '[]');
+
+    // Parse "EDGES_FAIL: lens=top:78.6,bottom:78.6 max=78.6 grid=mids_only ts=..."
+    const FAIL_RE = /\| EDGES_FAIL:\s*lens=([^ ]+)\s+max=([\d.]+)\s+grid=(\w+)\s+ts=([^|]+?)(?:\s*\|.*)?$/;
+    const ERR_RE  = /\| EDGES_SCAN_ERROR:\s*([^ ]+)\s+ts=([^|]+?)(?:\s*\|.*)?$/;
+
+    const items = rows.map(r => {
+      const m = r.notes && r.notes.match(FAIL_RE);
+      if (m) {
+        const lensStr = m[1], maxDe = parseFloat(m[2]), grid = m[3], ts = m[4].trim();
+        const lenses = {};
+        let edgesMax = 0, midsMax = 0;
+        lensStr.split(',').forEach(kv => {
+          const [k, v] = kv.split(':');
+          const score = parseFloat(v);
+          let verdict = 'PASS';
+          if (score > 12.0) verdict = 'FAIL';
+          else if (score > 5.0) verdict = 'WARN';
+          lenses[k] = { score, verdict };
+          if (k === 'top' || k === 'bottom' || k === 'left' || k === 'right') {
+            if (score > edgesMax) edgesMax = score;
+          } else if (k === 'h_mid' || k === 'v_mid') {
+            if (score > midsMax) midsMax = score;
+          }
+        });
+        return {
+          id: r.id,
+          ok: true,
+          image_url: `/designs/img/by-id/${r.id}`,
+          verdict: 'FAIL',
+          lenses,
+          edges_max: edgesMax,
+          mids_max: midsMax,
+          max_de: maxDe,
+          grid,
+          ts,
+          reason: `${grid} @ max ΔE ${maxDe}`,
+        };
+      }
+      const e = r.notes && r.notes.match(ERR_RE);
+      if (e) {
+        return {
+          id: r.id,
+          ok: false,
+          image_url: `/designs/img/by-id/${r.id}`,
+          error: e[1],
+          ts: e[2].trim(),
+          reason: `scan error: ${e[1]}`,
+        };
+      }
+      // Notes matched the LIKE filter but regex didn't — surface so we don't
+      // silently drop edge cases.
+      return {
+        id: r.id,
+        ok: false,
+        image_url: `/designs/img/by-id/${r.id}`,
+        error: 'unparseable_tag',
+        reason: 'edges_fail tag present but unparseable',
+      };
+    });
+
+    res.json({ ok: true, items });
+  } catch (err) {
+    res.status(500).json({ ok: false, error: err.message });
+  }
+});
+
 // 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

← 1d8b4c6 add mkdir-based mutex wrapper for cron ticks (macOS has no f  ·  back to Wallco Ai  ·  fliepaper-bugs: scrub category-leak in generator + YOLO loop 6e609a5 →