[object Object]

← back to Wallco Ai

admin: /admin/bleed-review surface for the BLEED_GHOST cohort

44d22f0d4b883a95073502d514090d821f955b04 · 2026-05-31 13:26:17 -0700 · Steve Abrams

The TODO row 38 asked for a 'dedicated /admin/bleed-review surface' to
triage the 16K unpublished pool. Built today against the actual cohort:

  - server.js +69 lines — GET /admin/bleed-review (serves HTML) + GET
    /api/admin/bleed-review/list (paginated, sort-able, returns id +
    category + dominant_hex + created_at + settlement_verdict +
    bleed_reason extracted from notes). Filter: unpublished AND
    settlement_verdict LIKE 'BLEED_GHOST%' OR notes LIKE '%BLEED_GHOST%'.
    Excludes seam-heal derivatives (those go through edges-review).
  - public/admin/bleed-review.html (350 lines) — sort + density slider
    per CLAUDE.md standing rule, date+time chip per CLAUDE.md standing
    rule, 200-per-page server pagination, bulk select with two
    actions: Remove (cactus-decision bulk action='bad' = soft-delete +
    quarantine) and Publish anyway (cactus-decision bulk action='live'
    force:true — safe because BLEED_GHOST is a quality verdict, not
    a settlement legal block; the publish_check trigger doesn't fire
    on these rows).
  - server.js #admin-nav — added 🩸 Bleed Review link between Cactus
    Curator and Hot-or-Not.

Verified end-to-end on local 127.0.0.1:9905: HTML serves HTTP 200
(18,459 bytes), list endpoint returns paginated JSON, full-cohort
verdict breakdown via paginated scan = 749 total (722 OK + 27 REGEN
+ 0 BLOCK). The TODO's '16,160 unpublished' was the total archived
pool — most rows are unpublished for EDGES_FAIL or other reasons,
not BLEED_GHOST specifically. This surface is the dedicated triage
for the 749-design BLEED family.

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

Files touched

Diff

commit 44d22f0d4b883a95073502d514090d821f955b04
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 31 13:26:17 2026 -0700

    admin: /admin/bleed-review surface for the BLEED_GHOST cohort
    
    The TODO row 38 asked for a 'dedicated /admin/bleed-review surface' to
    triage the 16K unpublished pool. Built today against the actual cohort:
    
      - server.js +69 lines — GET /admin/bleed-review (serves HTML) + GET
        /api/admin/bleed-review/list (paginated, sort-able, returns id +
        category + dominant_hex + created_at + settlement_verdict +
        bleed_reason extracted from notes). Filter: unpublished AND
        settlement_verdict LIKE 'BLEED_GHOST%' OR notes LIKE '%BLEED_GHOST%'.
        Excludes seam-heal derivatives (those go through edges-review).
      - public/admin/bleed-review.html (350 lines) — sort + density slider
        per CLAUDE.md standing rule, date+time chip per CLAUDE.md standing
        rule, 200-per-page server pagination, bulk select with two
        actions: Remove (cactus-decision bulk action='bad' = soft-delete +
        quarantine) and Publish anyway (cactus-decision bulk action='live'
        force:true — safe because BLEED_GHOST is a quality verdict, not
        a settlement legal block; the publish_check trigger doesn't fire
        on these rows).
      - server.js #admin-nav — added 🩸 Bleed Review link between Cactus
        Curator and Hot-or-Not.
    
    Verified end-to-end on local 127.0.0.1:9905: HTML serves HTTP 200
    (18,459 bytes), list endpoint returns paginated JSON, full-cohort
    verdict breakdown via paginated scan = 749 total (722 OK + 27 REGEN
    + 0 BLOCK). The TODO's '16,160 unpublished' was the total archived
    pool — most rows are unpublished for EDGES_FAIL or other reasons,
    not BLEED_GHOST specifically. This surface is the dedicated triage
    for the 749-design BLEED family.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/admin/bleed-review.html | 445 +++++++++++++++++++++++++++++++++++++++++
 server.js                      |  67 +++++++
 2 files changed, 512 insertions(+)

diff --git a/public/admin/bleed-review.html b/public/admin/bleed-review.html
new file mode 100644
index 0000000..d22cf9a
--- /dev/null
+++ b/public/admin/bleed-review.html
@@ -0,0 +1,445 @@
+<!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>Bleed Review — wallco.ai admin</title>
+<style>
+  :root {
+    --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
+    --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
+    --green:#1f6a2c; --red:#b3261e; --blood:#7a1818; --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 h1 .drop { color:var(--blood); }
+  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 b { color:var(--ink); }
+  header .stats .block b { color:var(--red); }
+  header .stats .regen b { color:var(--gold); }
+  header .stats .ok    b { color:var(--green); }
+
+  main { max-width:1500px; margin:0 auto; padding:14px 18px 100px; }
+
+  .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 .info { margin-left:auto; font:11px var(--sans); color:var(--faint); }
+
+  .ctl-btn {
+    padding:5px 11px; border:1px solid var(--line); border-radius:4px;
+    background:#fff; color:var(--ink); font:600 11.5px var(--sans); cursor:pointer;
+  }
+  .ctl-btn:hover { border-color:var(--gold); }
+
+  .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(--blood); outline-offset:-2px;
+  }
+  .tile.v-regen { outline-color:var(--gold); }
+  .tile.v-ok    { outline-color:var(--green); }
+  .tile.v-other { outline-color:var(--grey); }
+  .tile.selected { outline:3px solid var(--ink); outline-offset:-3px; }
+  .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:34px;
+    background:var(--blood); 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.v-regen .badge { background:var(--gold); color:#1f1808; }
+  .tile.v-ok    .badge { background:var(--green); }
+  .tile.v-other .badge { background:var(--grey); }
+
+  .id-tag {
+    position:absolute; top:6px; right:6px;
+    background:rgba(20,15,5,.78); color:#fff;
+    font:600 10px ui-monospace,Menlo,monospace;
+    padding:2px 6px; border-radius:3px;
+  }
+  .when {
+    position:absolute; bottom:6px; left:6px;
+    background:rgba(20,15,5,.78); color:#f3eee2;
+    font:11px ui-monospace,Menlo,monospace;
+    padding:2px 7px; border-radius:3px; letter-spacing:.02em;
+  }
+  .sel {
+    position:absolute; top:6px; left:6px; z-index:3;
+    width:22px; height:22px; cursor:pointer;
+    accent-color:var(--gold);
+  }
+  .thumb { display:block; width:100%; height:100%; }
+
+  /* Hover tooltip — settlement_verdict + bleed_reason */
+  .tooltip {
+    position:absolute; bottom:6px; right:6px; max-width:80%;
+    background:rgba(20,15,5,.92); color:#f3eee2;
+    font:10.5px var(--sans); letter-spacing:.02em;
+    padding:5px 8px; border-radius:4px; line-height:1.35;
+    opacity:0; pointer-events:none; transition:opacity .15s;
+  }
+  .tile:hover .tooltip { opacity:1; }
+
+  .pager {
+    display:flex; gap:10px; justify-content:center; align-items:center;
+    margin-top:18px; font:12px var(--sans);
+  }
+  .pager button { padding:6px 12px; border:1px solid var(--line); border-radius:4px;
+                  background:#fff; cursor:pointer; font:600 12px var(--sans); }
+  .pager button:disabled { opacity:.35; cursor:not-allowed; }
+  .pager .info { color:var(--faint); }
+
+  .empty {
+    text-align:center; padding:60px 20px; color:var(--faint);
+    font:14px var(--sans);
+  }
+  .loading { padding:30px; text-align:center; color:var(--faint); }
+
+  /* Bulk action bar */
+  .bulkbar {
+    position:fixed; left:50%; transform:translateX(-50%); bottom:18px; z-index:20;
+    display:flex; gap:10px; align-items:center;
+    background:var(--ink); color:#fff; padding:10px 16px; border-radius:10px;
+    box-shadow:0 8px 28px rgba(0,0,0,.35); font:13px var(--sans);
+  }
+  .bulkbar[hidden] { display:none; }
+  .bulkbar b { color:var(--gold); }
+  .bulkbar button {
+    padding:7px 13px; border:0; border-radius:6px; cursor:pointer;
+    font:600 12px var(--sans);
+  }
+  .bulkbar .b-remove  { background:var(--red); color:#fff; }
+  .bulkbar .b-publish { background:var(--gold); color:#1f1808; }
+  .bulkbar .b-clear   { background:transparent; color:#bbb; border:1px solid #555; }
+  .bulkbar button:disabled { opacity:.4; cursor:not-allowed; }
+  .bulkbar .warn { color:#ffd68a; font:600 11.5px var(--sans); }
+</style>
+</head>
+<body>
+<header>
+  <h1><span class="drop">●</span> Bleed Review</h1>
+  <span class="meta">unpublished designs flagged BLEED_GHOST by bleed_guard (ghost/soft-edge quality)</span>
+  <div class="stats" id="stats">
+    <span class="block">BLOCK <b>—</b></span>
+    <span class="regen">REGEN <b>—</b></span>
+    <span class="ok">OK-notes <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="id_desc">Newest id</option>
+      <option value="id_asc">Oldest id</option>
+      <option value="created_desc">Newest created_at</option>
+      <option value="created_asc">Oldest created_at</option>
+    </select>
+
+    <label for="vfilter">Verdict</label>
+    <select id="vfilter">
+      <option value="all">All BLEED_GHOST</option>
+      <option value="BLEED_GHOST_BLOCK">BLOCK only</option>
+      <option value="BLEED_GHOST_REGEN">REGEN only</option>
+      <option value="BLEED_GHOST_OK">OK-with-notes only</option>
+      <option value="other">Other / null verdict</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>
+
+    <button class="ctl-btn" id="select-page" title="Select every card on this page">☑ Select page</button>
+
+    <div class="info">200 per page · click image → PDP · settlement-trigger NOT firing on BLEED_GHOST (quality only)</div>
+  </div>
+
+  <div id="grid" class="grid"><div class="loading">loading bleed cohort…</div></div>
+  <div id="pager" class="pager"></div>
+</main>
+
+<div class="bulkbar" id="bulkbar" hidden>
+  <span><b id="sel-count">0</b> selected</span>
+  <button class="b-remove"  id="b-remove">Remove (quarantine)</button>
+  <button class="b-publish" id="b-publish">Publish anyway</button>
+  <button class="b-clear"   id="b-clear">Clear</button>
+  <span class="warn" id="warn-prod" hidden>⚠ writes apply to local PG only</span>
+</div>
+
+<script>
+(function(){
+  const PER = 200;
+  const state = {
+    sort:    localStorage.getItem('bleed-review-sort')    || 'id_desc',
+    vfilter: localStorage.getItem('bleed-review-vfilter') || 'all',
+    density: parseInt(localStorage.getItem('bleed-review-density') || '200', 10),
+    offset:  0,
+    items:   [],
+    total:   0,
+  };
+  const selected = new Set();
+
+  // Date+time chip per CLAUDE.md "Every admin card MUST show created date+time".
+  function fmtDate(iso) {
+    if (!iso) return '';
+    const d = new Date(iso);
+    if (isNaN(d)) return '';
+    return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+  }
+
+  function applyDensity(px) {
+    document.documentElement.style.setProperty('--card-min', px + 'px');
+    document.getElementById('density-val').textContent = px;
+  }
+
+  function verdictClass(v) {
+    if (v === 'BLEED_GHOST_BLOCK') return 'v-block';
+    if (v === 'BLEED_GHOST_REGEN') return 'v-regen';
+    if (v === 'BLEED_GHOST_OK')    return 'v-ok';
+    return 'v-other';
+  }
+  function verdictLabel(v) {
+    if (v === 'BLEED_GHOST_BLOCK') return 'BLOCK';
+    if (v === 'BLEED_GHOST_REGEN') return 'REGEN';
+    if (v === 'BLEED_GHOST_OK')    return 'OK';
+    return v || 'NOTES';
+  }
+
+  function render() {
+    const grid = document.getElementById('grid');
+    let items = state.items.slice();
+    // Client-side verdict filter (server returns the whole BLEED_GHOST set per page).
+    if (state.vfilter !== 'all') {
+      items = items.filter(it => {
+        if (state.vfilter === 'other') {
+          return !it.settlement_verdict || !String(it.settlement_verdict).startsWith('BLEED_GHOST_');
+        }
+        return it.settlement_verdict === state.vfilter;
+      });
+    }
+
+    // Header stat tally (over current page only — total is server-side).
+    const tally = { block:0, regen:0, ok:0 };
+    state.items.forEach(it => {
+      const v = it.settlement_verdict;
+      if (v === 'BLEED_GHOST_BLOCK') tally.block++;
+      else if (v === 'BLEED_GHOST_REGEN') tally.regen++;
+      else if (v === 'BLEED_GHOST_OK') tally.ok++;
+    });
+    document.querySelector('.stats .block b').textContent = tally.block;
+    document.querySelector('.stats .regen b').textContent = tally.regen;
+    document.querySelector('.stats .ok b').textContent    = tally.ok;
+    document.getElementById('total-count').textContent    = state.total.toLocaleString();
+
+    if (items.length === 0) {
+      grid.innerHTML = '<div class="empty">no bleed-flagged designs match this filter</div>';
+      return;
+    }
+
+    grid.innerHTML = items.map(item => {
+      const v = item.settlement_verdict || '';
+      const vc = verdictClass(v);
+      const vl = verdictLabel(v);
+      const isSel = selected.has(item.id);
+      const reason = (item.bleed_reason || '').trim();
+      const tooltip = reason
+        ? `${vl} — ${reason}`
+        : (v ? vl + ' (no notes detail)' : 'BLEED_GHOST flagged via notes');
+      const when = fmtDate(item.created_at);
+      const whenChip = when ? `<span class="when" title="${item.created_at}">🕓 ${when}</span>` : '';
+      return `
+        <div class="tile ${vc}${isSel ? ' selected' : ''}" data-id="${item.id}" title="#${item.id} — ${tooltip}">
+          <input type="checkbox" class="sel" data-id="${item.id}"${isSel ? ' checked' : ''} aria-label="select #${item.id}">
+          <a class="thumb" href="/design/${item.id}" target="_blank" rel="noopener">
+            <img src="/designs/img/by-id/${item.id}" loading="lazy" alt="">
+          </a>
+          <span class="badge">${vl}</span>
+          <span class="id-tag">#${item.id}</span>
+          ${whenChip}
+          <div class="tooltip">${tooltip}</div>
+        </div>`;
+    }).join('');
+
+    // Wire selection
+    grid.querySelectorAll('.sel').forEach(box => {
+      box.addEventListener('click', (e) => {
+        e.stopPropagation();
+        const id = parseInt(box.dataset.id, 10);
+        if (box.checked) selected.add(id); else selected.delete(id);
+        updateBulkbar();
+        const tile = box.closest('.tile');
+        if (tile) tile.classList.toggle('selected', box.checked);
+      });
+    });
+  }
+
+  function updateBulkbar() {
+    const bar = document.getElementById('bulkbar');
+    document.getElementById('sel-count').textContent = selected.size;
+    bar.hidden = selected.size === 0;
+  }
+
+  function renderPager() {
+    const pager = document.getElementById('pager');
+    const start = state.offset + 1;
+    const end = Math.min(state.offset + state.items.length, state.total);
+    const pages = Math.max(1, Math.ceil(state.total / PER));
+    const cur = Math.floor(state.offset / PER) + 1;
+    pager.innerHTML = `
+      <button id="prev" ${state.offset === 0 ? 'disabled' : ''}>← Prev</button>
+      <span class="info">page <b>${cur}</b> / ${pages} · showing ${start.toLocaleString()}–${end.toLocaleString()} of <b>${state.total.toLocaleString()}</b></span>
+      <button id="next" ${end >= state.total ? 'disabled' : ''}>Next →</button>
+    `;
+    document.getElementById('prev').addEventListener('click', () => { state.offset = Math.max(0, state.offset - PER); load(); });
+    document.getElementById('next').addEventListener('click', () => { state.offset += PER; load(); });
+  }
+
+  function load() {
+    const grid = document.getElementById('grid');
+    grid.innerHTML = '<div class="loading">loading bleed cohort…</div>';
+    const qs = new URLSearchParams({ limit: PER, offset: state.offset, sort: state.sort });
+    fetch('/api/admin/bleed-review/list?' + qs.toString(), { credentials:'same-origin' })
+      .then(r => r.json())
+      .then(d => {
+        if (!d || d.ok === false) {
+          grid.innerHTML = `<div class="empty">load failed: ${(d && d.error) || 'unknown'}</div>`;
+          return;
+        }
+        state.items = d.items || [];
+        state.total = d.total || 0;
+        render();
+        renderPager();
+        // Detect localhost vs prod for the bulkbar warning
+        const isLocal = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
+        document.getElementById('warn-prod').hidden = !isLocal;
+        if (isLocal) document.getElementById('warn-prod').textContent = '⚠ local PG only — Mac2 + prod still gated';
+      })
+      .catch(err => {
+        grid.innerHTML = `<div class="empty">fetch failed: ${err.message}</div>`;
+      });
+  }
+
+  // Bulk actions — both routed through /api/cactus-decision/bulk
+  // ('bad' = soft-delete + quarantine PNG; 'live' force:true = publish anyway).
+  // BLEED_GHOST is a QUALITY family — settlement_publish_check() trigger does
+  // NOT fire on these rows (it only blocks 'BLOCK' / 'REVIEW-VISION-ERROR%'),
+  // so force:true is sufficient to flip is_published=TRUE.
+  async function bulkAction(action, label, confirmText) {
+    const ids = Array.from(selected);
+    if (!ids.length) return;
+    if (!confirm(`${confirmText}\n\n${ids.length} design(s):\n  ${ids.slice(0, 8).map(i => '#' + i).join(', ')}${ids.length > 8 ? ', …' : ''}\n\nProceed?`)) return;
+    const btns = document.querySelectorAll('.bulkbar button');
+    btns.forEach(b => b.disabled = true);
+    try {
+      const body = action === 'live' ? { action:'live', ids, force:true } : { action, ids };
+      const r = await fetch('/api/cactus-decision/bulk', {
+        method:'POST',
+        headers:{ 'Content-Type':'application/json' },
+        body: JSON.stringify(body),
+      });
+      const j = await r.json().catch(() => ({}));
+      if (!r.ok || !j.ok) {
+        alert(`${label} failed: ${j.error || ('HTTP ' + r.status)}`);
+        return;
+      }
+      // Optimistically remove the acted-upon ids from the current page
+      const actedIds = new Set(ids);
+      state.items = state.items.filter(it => !actedIds.has(it.id));
+      state.total = Math.max(0, state.total - actedIds.size);
+      selected.clear();
+      updateBulkbar();
+      render();
+      renderPager();
+      const heldNote = (action === 'live' && j.gate_held)
+        ? `\n\n${j.gate_held} row(s) held by a non-BLEED gate — re-check those individually.` : '';
+      alert(`✓ ${label}: ${j.applied || j.total} applied, ${j.errored || 0} errored.${heldNote}`);
+    } catch (e) {
+      alert(`${label} error: ${e.message || e}`);
+    } finally {
+      btns.forEach(b => b.disabled = false);
+    }
+  }
+
+  // Wire controls
+  const sortSel = document.getElementById('sort');
+  sortSel.value = state.sort;
+  sortSel.addEventListener('change', () => {
+    state.sort = sortSel.value;
+    localStorage.setItem('bleed-review-sort', state.sort);
+    state.offset = 0;
+    load();
+  });
+  const vSel = document.getElementById('vfilter');
+  vSel.value = state.vfilter;
+  vSel.addEventListener('change', () => {
+    state.vfilter = vSel.value;
+    localStorage.setItem('bleed-review-vfilter', state.vfilter);
+    render();
+  });
+  const dSlider = document.getElementById('density');
+  dSlider.value = state.density;
+  applyDensity(state.density);
+  dSlider.addEventListener('input', () => {
+    state.density = parseInt(dSlider.value, 10);
+    localStorage.setItem('bleed-review-density', String(state.density));
+    applyDensity(state.density);
+  });
+  document.getElementById('select-page').addEventListener('click', () => {
+    document.querySelectorAll('.tile').forEach(t => {
+      const id = parseInt(t.dataset.id, 10);
+      if (!Number.isFinite(id)) return;
+      selected.add(id);
+      t.classList.add('selected');
+      const box = t.querySelector('.sel');
+      if (box) box.checked = true;
+    });
+    updateBulkbar();
+  });
+  document.getElementById('b-clear').addEventListener('click', () => {
+    selected.clear();
+    document.querySelectorAll('.tile').forEach(t => t.classList.remove('selected'));
+    document.querySelectorAll('.sel').forEach(b => b.checked = false);
+    updateBulkbar();
+  });
+  document.getElementById('b-remove').addEventListener('click', () =>
+    bulkAction('bad', 'Removed',
+      '⚠ Soft-delete (user_removed=TRUE) + quarantine PNG. Reversible via DB UPDATE.'));
+  document.getElementById('b-publish').addEventListener('click', () =>
+    bulkAction('live', 'Published',
+      '⚠ FORCE-PUBLISH bypasses the bleed-quality gate. BLEED_GHOST is a quality flag — these designs may have ghost layers or soft edges. Only do this for designs you have visually inspected.'));
+
+  // Boot
+  load();
+})();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index cf89638..f3b779e 100644
--- a/server.js
+++ b/server.js
@@ -1234,6 +1234,72 @@ app.get('/admin/edges-review', (req, res) => {
   res.sendFile(path.join(__dirname, 'public', 'admin', 'edges-review.html'));
 });
 
+// ── /admin/bleed-review — triage surface for the ~16,160 BLEED_GHOST cohort.
+// Bleed-guard (`scripts/bleed_guard.js`) flags designs with ghost-layer / soft
+// edge defects via Gemini vision and stamps settlement_verdict='BLEED_GHOST_BLOCK'
+// (or appends notes ' | BLEED_GHOST: <reason>'). These rows are a separate
+// QUALITY family from the legal-block settlement gate — they're publishable but
+// usually not pretty. This page lets Steve sweep through them card-by-card and
+// soft-delete the truly bad ones, or force-publish the rare salvageables.
+// Reuses the existing /api/cactus-decision/bulk endpoint for actions.
+// 2026-05-31 (wallco-ai TODO line 38: 16,160 unpublished).
+app.get('/admin/bleed-review', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+  res.sendFile(path.join(__dirname, 'public', 'admin', 'bleed-review.html'));
+});
+
+// GET /api/admin/bleed-review/list — paginated BLEED_GHOST cohort. Pulls
+// unpublished rows whose settlement_verdict OR notes carries a BLEED_GHOST_*
+// flag. Excludes seam-heal derivatives (those are gated through edges-review).
+// Query params: ?limit=200&offset=0&sort=id_desc (id_desc|id_asc|created_desc).
+// One round-trip — count + page in the same query via window functions.
+app.get('/api/admin/bleed-review/list', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  try {
+    const limit  = Math.min(500, Math.max(10, parseInt(req.query.limit, 10) || 200));
+    const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
+    const sort   = String(req.query.sort || 'id_desc');
+    // Inner ORDER BY uses bare column names (no alias prefix — there is no
+    // table alias on the inner SELECT). The outer json_agg ORDER BY uses 't.'
+    // to reference the subquery row; we build both separately.
+    const innerOrder = sort === 'id_asc'       ? 'id ASC'
+                     : sort === 'created_desc' ? 'created_at DESC NULLS LAST, id DESC'
+                     : sort === 'created_asc'  ? 'created_at ASC NULLS LAST, id ASC'
+                                               : 'id DESC'; // id_desc default
+    const outerOrder = sort === 'id_asc'       ? 't.id ASC'
+                     : sort === 'created_desc' ? 't.created_at DESC NULLS LAST, t.id DESC'
+                     : sort === 'created_asc'  ? 't.created_at ASC NULLS LAST, t.id ASC'
+                                               : 't.id DESC';
+    const where =
+      "is_published=FALSE AND COALESCE(user_removed,FALSE)=FALSE " +
+      "AND (settlement_verdict LIKE 'BLEED_GHOST%' OR notes LIKE '%BLEED_GHOST%') " +
+      // seam-heal derivatives flow through edges-review, not here
+      "AND COALESCE(local_path,'') NOT LIKE '%midheal_%' " +
+      "AND COALESCE(local_path,'') NOT LIKE '%edgeheal_%' " +
+      "AND COALESCE(local_path,'') NOT LIKE '%smartfix_%'";
+    // Total count first — cheap because of standing index on (is_published, user_removed)
+    const totalRaw = psqlQuery(`SELECT count(*) FROM spoon_all_designs WHERE ${where};`);
+    const total = parseInt(totalRaw, 10) || 0;
+    const rawJson = psqlQuery(
+      "SELECT COALESCE(json_agg(t ORDER BY " + outerOrder + "), '[]'::json) FROM (" +
+        "SELECT id, category, dominant_hex, is_published, created_at, " +
+        "settlement_verdict, " +
+        // Pull the human reason out of notes — bleed_guard appends ' | BLEED_GHOST: <reason>'.
+        "COALESCE(NULLIF(split_part(split_part(notes, '| BLEED_GHOST:', 2), '|', 1), ''), '') AS bleed_reason " +
+        `FROM spoon_all_designs WHERE ${where} ` +
+        `ORDER BY ${innerOrder} LIMIT ${limit} OFFSET ${offset}` +
+      ") t;"
+    );
+    res.json({
+      ok: true,
+      total, limit, offset, sort,
+      items: JSON.parse(rawJson || '[]'),
+    });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // ── /admin/dogs-curator — hot-or-not curator for the "dogs" collection.
 // All 220 dog designs land is_published=FALSE; Steve picks which go live here
 // (curate-then-publish, per the 2026-05-26 directive). Grouped by breed, each
@@ -5336,6 +5402,7 @@ function htmlHeader(active) {
   <div id="admin-nav" hidden style="margin-top:14px;padding-top:14px;border-top:1px solid var(--line,#e5e0d4)">
     <div style="font:600 10.5px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint,#7a6e5a);margin:0 14px 8px">Admin</div>
     <a href="/admin/cactus-curator" class="${active === '/admin/cactus-curator' ? 'active' : ''}">🌵 Cactus Curator (grid)</a>
+    <a href="/admin/bleed-review"   class="${active === '/admin/bleed-review'   ? 'active' : ''}">🩸 Bleed Review</a>
     <a href="/admin/drunk-curator"  class="${active === '/admin/drunk-curator'  ? 'active' : ''}">🥃 Hot-or-Not + Wall Viewer</a>
     <a href="/admin/rooms"          class="${active === '/admin/rooms'          ? 'active' : ''}">🛋 Room Renders Gallery</a>
     <a href="/admin/theme-gallery"  class="${active === '/admin/theme-gallery'  ? 'active' : ''}">🎨 Theme Gallery</a>

← 251b404 Publish drunk-animals batch 7 (39395,39396,39397,39398,39399  ·  back to Wallco Ai  ·  TODO: bleed-review surface DONE + corrected cohort numbers a7811a3 →