← 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
A public/admin/bleed-review.htmlM server.js
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 →