← back to Wallco Ai
public/admin/design-curator.html
1867 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Design Curator — wallco.ai admin</title>
<style>
:root { --cols: 5; --bg:#0e0f10; --panel:#17191b; --line:#272b2e; --ink:#e9ecef; --mut:#9aa3ab; --accent:#7bc96f; }
* { box-sizing: border-box; }
body { margin:0; font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background:var(--bg); color:var(--ink); }
header { position:sticky; top:0; z-index:20; background:rgba(14,15,16,.96); backdrop-filter:blur(8px);
border-bottom:1px solid var(--line); padding:10px 16px; }
.row { display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
h1 { font-size:16px; margin:0; font-weight:650; letter-spacing:.2px; }
h1 .leaf { color:var(--accent); }
.sub { color:var(--mut); font-size:12px; }
label.ctl { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--mut); }
select, input[type=range] { accent-color:var(--accent); }
select { background:var(--panel); color:var(--ink); border:1px solid var(--line); border-radius:7px; padding:5px 8px; font-size:13px; }
input[type=range] { width:130px; }
.pill { background:var(--panel); border:1px solid var(--line); border-radius:999px; padding:3px 10px; font-size:12px; color:var(--mut); }
.pill b { color:var(--ink); }
.grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; padding:14px 16px 120px; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px; overflow:hidden;
display:flex; flex-direction:column; position:relative; transition:opacity .2s, transform .1s; }
.card.decided { opacity:.5; }
.card.sel { outline:2px solid #4aa3ff; outline-offset:-2px; }
.thumb { position:relative; aspect-ratio:1/1; background:#000; }
.thumb img { width:100%; height:100%; object-fit:cover; display:block; -webkit-user-drag:none; user-select:none; }
/* On-hover room mockup overlay — Steve 2026-05-28. Fades in over the flat
tile so the curator can see how the pattern reads on a wall in context. */
.thumb .card-room { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; opacity:0; transition:opacity .18s ease-out; pointer-events:none; z-index:5; }
.card:hover .thumb .card-room { opacity:1; }
/* Tiny corner badge so Steve sees which chips have a room mockup at all */
.thumb .b.room { background:#0ea5e9; color:#fff; }
.sel-box { position:absolute; top:6px; left:6px; width:20px; height:20px; border-radius:5px; border:2px solid #fff;
background:rgba(0,0,0,.45); cursor:pointer; z-index:3; }
.card.sel .sel-box { background:#4aa3ff; border-color:#4aa3ff; }
.badges { position:absolute; top:6px; right:6px; display:flex; flex-direction:column; gap:4px; align-items:flex-end; z-index:3; }
.b { font-size:10.5px; font-weight:700; padding:2px 6px; border-radius:6px; background:rgba(0,0,0,.62); color:#fff; line-height:1.3; }
.b.rank { background:rgba(123,201,111,.92); color:#0e0f10; }
.b.pub { background:#2e7d32; } .b.unpub { background:#5a5f63; }
.b.fix { background:#c98a00; } .b.dig { background:#7a5bd6; } .b.web { background:#1565c0; }
.b.tif { background:#2a6b35; color:#dff5e0; } .b.notif { background:#5a4030; color:#ffd0a8; opacity:.75; }
.b.pass { background:#2e7d32; } .b.warn { background:#b07a00; } .b.fail { background:#a13030; } .b.none{background:#555;}
.b.reason { background:#e11d2a; color:#fff; font-weight:800; letter-spacing:.02em; box-shadow:0 1px 4px rgba(0,0,0,.4); }
/* Drunk-curator wins ported here — extra ID cues at the top of the queue */
.b.fresh { background:#0b6d3a; } /* ✨ created in last 24h */
.b.toprank { background:#b5891a; color:#1a1a1a; font-weight:800; } /* ★ top-N by composite rank */
.state { position:absolute; bottom:0; left:0; right:0; padding:18px 6px 5px; font-size:11px; font-weight:700; text-align:center;
color:#fff; background:linear-gradient(transparent,rgba(0,0,0,.8)); z-index:2; display:none; }
/* Seam-mismatch callout — the common repeat-break error */
.seam-mismatch { position:absolute; top:6px; left:6px; right:auto; z-index:4; max-width:78%;
background:#b3261e; color:#fff; font-size:10.5px; font-weight:700; line-height:1.2;
padding:4px 7px; border-radius:4px; box-shadow:0 1px 4px rgba(0,0,0,.4); }
.meta { padding:7px 9px 4px; }
.cw { font-size:12px; color:var(--ink); font-weight:600; text-transform:capitalize; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.scores { display:flex; align-items:center; gap:8px; margin-top:3px; color:var(--mut); font-size:11px; }
.dot { width:11px; height:11px; border-radius:50%; border:1px solid rgba(255,255,255,.25); display:inline-block; vertical-align:-1px; }
.when { margin-top:3px; color:var(--mut); font-size:10.5px; font-variant-numeric:tabular-nums; }
/* SHIFT-drag "bad area" annotation boxes — id BAD elements for fixing */
.bad-box { position:absolute; border:2px dashed #ff3b30; background:rgba(255,59,48,.14); z-index:5; box-sizing:border-box; border-radius:2px; }
.bad-box.drawing { pointer-events:none; }
.bad-box .x { position:absolute; top:-9px; right:-9px; width:17px; height:17px; line-height:15px; text-align:center;
background:#ff3b30; color:#fff; border-radius:50%; font:800 12px sans-serif; cursor:pointer; z-index:6; }
.bad-box .lbl { position:absolute; bottom:-1px; left:-1px; background:#ff3b30; color:#fff; font:800 9px sans-serif; padding:1px 5px; border-radius:0 3px 0 0; }
body.shifting .thumb { cursor:crosshair; }
.ctl-btn { padding:6px 12px; border:1px solid #6a5a3a; border-radius:6px; background:#221e16; color:#ffd68a;
font:700 12px sans-serif; cursor:pointer; }
.ctl-btn:hover { background:#ffb300; color:#1a1a1a; }
.ctl-btn:disabled { opacity:.6; cursor:wait; }
/* candidate (auto-detected, unconfirmed) boxes — amber dashed with confirm/delete */
.bad-box.cand { border-color:#ffb300; background:rgba(255,179,0,.13); }
.bad-box.cand .lbl { background:#ffb300; color:#1a1a1a; }
.bad-box .ok { position:absolute; top:-9px; right:11px; width:17px; height:17px; line-height:15px; text-align:center;
background:#2e7d32; color:#fff; border-radius:50%; font:800 11px sans-serif; cursor:pointer; z-index:6; }
/* defect-type picker after drawing a box */
.label-picker { position:fixed; z-index:60; background:#17150f; border:1px solid #4a4636; border-radius:9px; padding:8px;
display:flex; flex-direction:column; gap:5px; box-shadow:0 10px 30px rgba(0,0,0,.55); min-width:152px; }
.label-picker .lp-h { font:800 10px var(--sans,sans-serif); color:#8a7a5e; text-transform:uppercase; letter-spacing:.08em; margin:0 2px 3px; }
.label-picker button { padding:7px 10px; border:0; border-radius:5px; background:#2a2620; color:#ece4d4; font:700 12.5px sans-serif; cursor:pointer; text-align:left; }
.label-picker button:hover { background:#e11d2a; color:#fff; }
.label-picker .lp-cancel { background:transparent; color:#8a7a5e; font-size:11px; font-weight:600; }
.auto-id-btn { position:absolute; bottom:6px; left:6px; z-index:4; background:rgba(0,0,0,.6); color:#ffd68a;
border:1px solid #6a5a3a; border-radius:5px; font:700 10px sans-serif; padding:3px 8px; cursor:pointer; }
.auto-id-btn:hover { background:#ffb300; color:#1a1a1a; }
.auto-id-btn.busy { opacity:.5; cursor:wait; }
.dbg-btn { position:absolute; bottom:6px; right:6px; z-index:4; background:rgba(0,0,0,.7); color:#ff9a9a;
border:1px solid #6a3a3a; border-radius:5px; font:700 10px sans-serif; padding:3px 8px;
text-decoration:none; cursor:pointer; }
.dbg-btn:hover { background:#ff4d4d; color:#fff; border-color:#ff4d4d; }
/* marquee rubber-band selection */
#marquee { position:fixed; z-index:40; border:1.5px solid #4aa3ff; background:rgba(74,163,255,.16); pointer-events:none; display:none; border-radius:3px; }
body.dragging { user-select:none; cursor:crosshair; }
body.dragging .card { cursor:crosshair; }
/* Action buttons collapse by default → compact cards; reveal on hover, or pin via the header toggle. */
.acts { display:none; grid-template-columns:1fr 1fr; gap:4px; padding:6px 8px 9px; }
.card:hover .acts, body.show-acts .acts { display:grid; }
.acts button { font:600 11px/1.2 inherit; padding:7px 4px; border-radius:7px; border:1px solid var(--line);
background:#1d2023; color:var(--ink); cursor:pointer; transition:filter .1s; }
.acts button:hover { filter:brightness(1.25); }
.acts .bad { border-color:#5a2222; color:#ff9a9a; }
.acts .dig { border-color:#3d2f63; color:#cbb8ff; }
.acts .fix { border-color:#5a4410; color:#ffd98a; }
.acts .live { border-color:#1f4d2a; color:#9ff0a6; }
.acts .etsy { border-color:#5a3f17; color:#ffb86b; }
.acts .unpub { border-color:#3a3f44; color:#a8b3bc; }
.acts .bakebtn { border-color:#1f4a5a; color:#7fe3ff; background:#13262d; }
.acts .bakebtn:disabled { opacity:.6; }
.acts .roombtn { border-color:#1f5a3a; color:#9af0c2; background:#102a1d; }
.acts .roombtn:disabled { opacity:.6; }
.roomgen { display:none; }
.roomgen.show { display:block; padding:6px 8px 2px; }
.roomgen-img { width:100%; border-radius:8px; display:block; }
.roomgen-cap { font:600 11px/1.4 inherit; color:#9af0c2; padding:3px 0 2px; }
.roomgen-status { font:600 12px/1.4 inherit; color:#9aa; text-align:center; padding:8px; }
.roomgen-status.err { color:#ff9a9a; }
/* Haiku-vision verdict chip — appears on cards where the haiku-weekend pass flagged a defect */
.b.haiku { background:#4a1f5a; color:#f2c8ff; font-weight:700; }
.b.haiku.ok { background:#1f4d2a; color:#9ff0a6; }
.b.haiku.ghost { background:#5a2a2a; color:#ff9a9a; }
.b.haiku.fuzzy_seam { background:#5a4410; color:#ffd98a; }
.b.haiku.tone_break { background:#5a4410; color:#ffd98a; }
.b.haiku.mural_misclass{ background:#5a3f17; color:#ffb86b; }
.b.haiku.vendor_leak { background:#5a2a2a; color:#ff9a9a; }
.b.haiku.bleed { background:#2a3f5a; color:#a8d8ff; }
.b.haiku.other { background:#2a3f5a; color:#a8d8ff; }
/* In-card "chat" panel that appears after Fix runs — explains what the heal pipeline did + the haiku reason */
.fix-panel { margin-top:6px; padding:8px 10px; border:1px solid var(--line); border-radius:6px;
background:#14171a; font-size:12px; }
.fix-row { display:flex; gap:8px; padding:3px 0; align-items:flex-start; }
.fix-row .ico { flex:0 0 18px; font-size:14px; line-height:1.3; }
.fix-row .msg { flex:1; color:var(--ink); line-height:1.4; word-wrap:break-word; }
.fix-row .msg b { color:#9ff0a6; font-weight:700; }
.fix-row.haiku .msg b { color:#f2c8ff; }
.fix-row.err .msg, .fix-row.err .msg b { color:#ff9a9a; }
.restore-btn { margin-top:6px; padding:5px 11px; font:600 11px inherit;
background:#1d2023; color:#a8b3bc; border:1px solid #3a3f44; border-radius:5px; cursor:pointer; }
.restore-btn:hover { filter:brightness(1.25); }
.card.moved-to-etsy { opacity:0; transform:scale(.92); transition:opacity .3s ease, transform .3s ease; }
.acts button kbd { font:inherit; opacity:.55; }
/* sticky bulk bar */
#bulk { position:fixed; bottom:0; left:0; right:0; z-index:30; transform:translateY(110%); transition:transform .2s;
background:#16181a; border-top:1px solid var(--line); padding:12px 16px; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
#bulk.show { transform:none; }
#bulk b { color:var(--accent); }
#bulk button { font:600 13px inherit; padding:8px 14px; border-radius:8px; border:1px solid var(--line); background:#1d2023; color:var(--ink); cursor:pointer; }
#bulk .bad{color:#ff9a9a;} #bulk .dig{color:#cbb8ff;} #bulk .fix{color:#ffd98a;} #bulk .live{color:#9ff0a6;} #bulk .etsy{color:#ffb86b;} #bulk .unpub{color:#a8b3bc;}
#bulk .clear{margin-left:auto;color:var(--mut);}
#empty,#err { padding:60px 16px; text-align:center; color:var(--mut); }
#err { color:#ff9a9a; }
/* detail modal */
#modal { position:fixed; inset:0; z-index:60; background:rgba(0,0,0,.82); display:none; align-items:center; justify-content:center; padding:24px; }
#modal.show { display:flex; }
.modal-box { position:relative; background:var(--panel); border:1px solid var(--line); border-radius:12px; max-width:1120px; width:100%; max-height:90vh; display:flex; overflow:hidden; }
.modal-img { flex:0 0 52%; background:#000; display:flex; align-items:center; justify-content:center; }
.modal-img img { max-width:100%; max-height:90vh; object-fit:contain; display:block; }
.modal-side { flex:1; padding:18px 22px; overflow:auto; }
.modal-side h2 { margin:0 0 2px; font-size:18px; }
.modal-close { position:absolute; top:8px; right:12px; font-size:28px; line-height:1; color:#fff; cursor:pointer; background:none; border:none; z-index:2; }
.kv { display:grid; grid-template-columns:128px 1fr; gap:3px 12px; font-size:12.5px; margin-top:12px; }
.kv dt { color:var(--mut); } .kv dd { margin:0; color:var(--ink); word-break:break-word; }
.pal { display:flex; gap:4px; flex-wrap:wrap; margin-top:8px; }
.pal i { width:22px; height:22px; border-radius:4px; border:1px solid rgba(255,255,255,.2); display:block; }
.modal-acts { display:flex; gap:6px; margin-top:16px; flex-wrap:wrap; }
.modal-acts button { font:600 12px inherit; padding:8px 13px; border-radius:7px; border:1px solid var(--line); background:#1d2023; color:var(--ink); cursor:pointer; }
.modal-acts .bad{color:#ff9a9a;border-color:#5a2222;} .modal-acts .dig{color:#cbb8ff;border-color:#3d2f63;}
.modal-acts .fix{color:#ffd98a;border-color:#5a4410;} .modal-acts .live{color:#9ff0a6;border-color:#1f4d2a;}
.modal-acts .etsy{color:#ffb86b;border-color:#5a3f17;} .modal-acts .unpub{color:#a8b3bc;border-color:#3a3f44;}
.modal-bake { display:flex; gap:9px; align-items:center; flex-wrap:wrap; margin-top:11px; padding-top:11px; border-top:1px solid var(--line); }
.modal-bake .bake { font:600 12px inherit; padding:8px 13px; border-radius:7px; border:1px solid #1f4a5a; background:#13262d; color:#7fe3ff; cursor:pointer; }
.modal-bake .bake:disabled { opacity:.6; cursor:default; }
.legend { font-size:11px; color:var(--mut); }
.legend code { background:var(--panel); padding:1px 5px; border-radius:4px; border:1px solid var(--line); color:var(--ink); }
/* HSB + contrast live adjustment panel (non-destructive CSS preview, Steve 2026-05-31) */
#adjPanel { display:none; flex-wrap:wrap; gap:16px; align-items:center; padding:9px 14px; border-bottom:1px solid var(--line); background:#16191c; }
#adjPanel.on { display:flex; }
#adjPanel .adj { display:flex; align-items:center; gap:7px; font:600 12px inherit; color:var(--mut); }
#adjPanel .adj input[type=range] { width:120px; }
#adjPanel .adjval { display:inline-block; min-width:62px; text-align:right; color:var(--ink); font-variant-numeric:tabular-nums; }
#adjPanel .adjnote { font-size:11px; color:var(--mut); }
#adjPanel .adj-presets { display:flex; gap:5px; align-items:center; }
#adjPanel .adj-preset { font:600 11px inherit; padding:4px 9px; border-radius:6px; border:1px solid var(--line); background:#1d2023; color:var(--ink); cursor:pointer; }
#adjPanel .adj-preset:hover { border-color:var(--accent); }
#adjPanel .adj-preset.on { background:#1f3a4d; color:#7fe3ff; border-color:#1f4a5a; }
#adjPanel .adj-ba { display:none; gap:8px; align-items:center; }
#adjPanel .adj-ba.on { display:flex; }
#adjPanel .ba-wrap { display:flex; flex-direction:column; align-items:center; gap:2px; }
#adjPanel .ba-wrap em { font-style:normal; font-size:9px; color:var(--mut); text-transform:uppercase; letter-spacing:.04em; }
#adjPanel .ba-img { width:38px; height:38px; object-fit:cover; border-radius:5px; border:1px solid var(--line); background:#0c0e10; }
#adjPanel .ba-after { filter: var(--img-adjust, none); }
#adjToggle.on { background:#1f4d2a; color:#9ff0a6; border-color:#1f4d2a; }
#grid img, #modal img, #bbstack img { filter: var(--img-adjust, none); }
</style>
</head>
<body>
<header>
<div class="row">
<h1><span class="leaf">❖</span> Design Curator</h1>
<span class="pill" id="stat">loading…</span>
<label class="ctl">Collection
<select id="collection" title="switch the curated line — navigates to ?category=<line>"></select>
</label>
<label class="ctl">Sort
<select id="sort">
<option value="rank">Composite rank ▼ (catalog-like)</option>
<option value="vision">Vision quality ▼</option>
<option value="seam">Seam quality ▼</option>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="colorway">Color (hue) ▦</option>
</select>
</label>
<label class="ctl">Show
<select id="filter">
<option value="undecided">Undecided</option>
<option value="all">All (live)</option>
<option value="published">Published</option>
<option value="unpublished">Unpublished</option>
<option value="web">Web-viewer</option>
<option value="fix">Needs fixing</option>
<option value="digital">Digital file</option>
<option value="removed">Removed</option>
</select>
</label>
<label class="ctl">Density
<input type="range" id="density" min="2" max="9" step="1" value="5">
</label>
<button id="adjToggle" class="ctl-btn" title="HSB + contrast live preview on all images — non-destructive, persists in localStorage">🎨 Adjust</button>
<button id="autoall" class="ctl-btn" title="auto-detect bad areas on every visible card">🔍 Auto-ID all visible</button>
<button id="selectall" class="ctl-btn" title="Toggle select all visible cards — click again to clear">☑ Select all visible</button>
<button id="toggleacts" class="ctl-btn" title="show/hide the Bad/Digital/Fix/Publish buttons on every card (they always show on hover)">▸ Actions</button>
<span class="legend"><b>drag</b> across images to select · <b>shift+drag on an image</b> = ▢ box a bad area (red) · <b>shift+click</b> a card to add one · keys on hover: <code>1</code>bad <code>2</code>digital <code>3</code>fix <code>4</code>publish <code>x</code>select</span>
</div>
</header>
<div id="adjPanel">
<span class="adj" style="color:var(--ink)">🎨 Image adjustments</span>
<label class="adj">Hue <input type="range" id="adjHue" min="0" max="255" step="1"><span class="adjval" id="adjHueV"></span></label>
<label class="adj">Sat <input type="range" id="adjSat" min="0" max="255" step="1"><span class="adjval" id="adjSatV"></span></label>
<label class="adj">Bri <input type="range" id="adjBri" min="0" max="255" step="1"><span class="adjval" id="adjBriV"></span></label>
<label class="adj">Con <input type="range" id="adjCon" min="0" max="200" step="1"><span class="adjval" id="adjConV"></span></label>
<span class="adj-presets" id="adjPresets" title="quick-apply curated HSB+contrast combos">
<button class="adj-preset" data-preset="neutral">Neutral</button>
<button class="adj-preset" data-preset="vivid">Vivid</button>
<button class="adj-preset" data-preset="muted">Muted</button>
<button class="adj-preset" data-preset="warm">Warm</button>
<button class="adj-preset" data-preset="cool">Cool</button>
<button class="adj-preset" data-preset="bw">B&W</button>
</span>
<button id="adjReset" class="ctl-btn" title="back to neutral (Hue 127 · Sat 127 · Bri 127 · Con 100)">Reset</button>
<button id="adjCopyCss" class="ctl-btn" title="copy the current CSS filter string to the clipboard">⧉ Copy CSS</button>
<span class="adj-ba" id="adjBA" title="before / after — live preview of the current adjustment on a sample card">
<span class="ba-wrap"><img class="ba-img ba-before" id="baBefore" alt=""><em>before</em></span>
<span class="ba-wrap"><img class="ba-img ba-after" id="baAfter" alt=""><em>after</em></span>
</span>
<span class="adjnote">live preview only — non-destructive, doesn't touch the stored image · keys: <b>0</b> reset, <b>[</b>/<b>]</b> nudge focused slider</span>
</div>
<div id="err" style="display:none"></div>
<div id="empty" style="display:none">No designs match this filter.</div>
<div id="hint" style="display:none;margin:8px 14px 0;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:#16191c;color:var(--mut);font-size:13px;">
<span id="hintmsg"></span>
<button id="hintall" style="margin-left:8px;font:600 12px inherit;padding:4px 10px;border-radius:6px;border:1px solid var(--line);background:#1d2023;color:var(--ink);cursor:pointer;">Show all →</button>
</div>
<div class="grid" id="grid"></div>
<div id="marquee"></div>
<div id="modal"><div class="modal-box" id="modalBox"></div></div>
<div id="bulk">
<span><b id="bulkn">0</b> selected</span>
<button class="bad" data-a="bad">1 · Bad — remove all</button>
<button class="dig" data-a="digital">2 · Digital file</button>
<button class="fix" data-a="fix">3 · Needs fixing</button>
<button class="live" data-a="live">4 · Publish</button>
<button class="etsy" data-a="etsy">🛒 5 · Etsy bucket</button>
<button class="unpub" data-a="unpublish">📴 6 · Unpublish</button>
<button class="clear" id="bulkclear">clear selection</button>
</div>
<script>
const ADMIN = new URLSearchParams(location.search).get('admin');
// Collection is a URL param — this curator now serves cactus, drunk-animals, etc.
const CATEGORY = (new URLSearchParams(location.search).get('category') || 'cactus');
// q(path) — build a fetchable URL. Anchors '/'-prefixed paths to location.origin
// so URLs are credentials-free; the Fetch spec rejects Request construction
// when the resolved URL contains userinfo (happens when the page was loaded
// via http://admin:pw@host/...). Appends ?admin=<token> when present.
const q = s => {
const path = s.startsWith('/') ? location.origin + s : s;
return ADMIN ? path + (path.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(ADMIN) : path;
};
const $ = id => document.getElementById(id);
const grid = $('grid');
const LS = {
get d(){ return +(localStorage.getItem('cc_density')||5); }, set d(v){ localStorage.setItem('cc_density',v); },
get s(){ return localStorage.getItem('cc_sort')||'rank'; }, set s(v){ localStorage.setItem('cc_sort',v); },
get f(){
// One-time migration (Steve 2026-05-29): stale localStorage from earlier sessions
// may pin filter='all', which makes Bad-removed cards keep showing up with REMOVED
// pills and feel like the Bad click is broken. Reset 'all' → 'undecided' once.
if (!localStorage.getItem('cc_filter_migrated_undecided_2026_05_29')
&& localStorage.getItem('cc_filter') === 'all') {
localStorage.setItem('cc_filter', 'undecided');
}
localStorage.setItem('cc_filter_migrated_undecided_2026_05_29','1');
return localStorage.getItem('cc_filter')||'undecided';
}, set f(v){ localStorage.setItem('cc_filter',v); },
};
let ALL = []; // every design from the API
const decided = new Map(); // id -> last action this session (optimistic)
const selected = new Set();
function applyDensity(v){ document.documentElement.style.setProperty('--cols', v); }
$('density').value = LS.d; applyDensity(LS.d);
$('sort').value = LS.s;
$('filter').value = LS.f;
$('density').addEventListener('input', e => { LS.d = e.target.value; applyDensity(e.target.value); });
// Select-all toggle (Steve 2026-05-29 "allow user to select 1 to all in check marks").
// First click selects every visible card; second click clears the selection. Animated
// cards (already 'moved-to-etsy' transitioning out) skipped so a recent Bad/Publish
// doesn't quietly come back into the selection set.
$('selectall').addEventListener('click', () => {
const cards = [...grid.querySelectorAll('.card[data-id]:not(.moved-to-etsy)')];
if (!cards.length) return;
const allSelected = cards.every(c => c.classList.contains('sel'));
if (allSelected) {
cards.forEach(c => { selected.delete(parseInt(c.dataset.id,10)); c.classList.remove('sel'); });
} else {
cards.forEach(c => { const id = parseInt(c.dataset.id,10); if (!isNaN(id)) { selected.add(id); c.classList.add('sel'); } });
}
$('bulkn').textContent = selected.size;
$('bulk').classList.toggle('show', selected.size > 0);
$('selectall').textContent = (selected.size && selected.size === cards.length) ? '✕ Clear selection' : '☑ Select all visible';
});
// Auto-ID every visible card (capped, limited concurrency so we don't hammer Gemini).
$('autoall').addEventListener('click', async () => {
const cards = [...grid.querySelectorAll('.card')];
if (!cards.length) return;
const CAP = 60, todo = cards.slice(0, CAP);
if (!confirm(`Auto-ID ${todo.length} visible card(s) via Gemini vision (~$${(todo.length*0.003).toFixed(2)})`
+ (cards.length>CAP ? ` — capped at ${CAP} of ${cards.length}; narrow the filter for the rest.` : '?'))) return;
const btn = $('autoall'), orig = btn.textContent; btn.disabled = true;
let done = 0, idx = 0;
const worker = async () => {
while (idx < todo.length) {
const el = todo[idx++], id = parseInt(el.dataset.id, 10), d = ALL.find(x => x.id === id);
if (d) { try { await autoId(d, el, el.querySelector('.auto-id-btn')); } catch {} }
btn.textContent = `… ${++done}/${todo.length}`;
}
};
await Promise.all(Array.from({ length: 4 }, worker));
btn.textContent = orig; btn.disabled = false;
});
// Collapse/pin the per-card action buttons (collapsed by default; always reveal on hover).
(function(){
const tb = $('toggleacts');
const set = on => { document.body.classList.toggle('show-acts', on); tb.textContent = on ? '▾ Actions' : '▸ Actions'; };
set(localStorage.getItem('cc_showacts') === '1');
tb.addEventListener('click', () => {
const on = !document.body.classList.contains('show-acts');
localStorage.setItem('cc_showacts', on ? '1' : '0'); set(on);
});
})();
$('sort').addEventListener('change', e => { LS.s = e.target.value; render(); });
$('filter').addEventListener('change', e => { LS.f = e.target.value; render(); });
// "Show all" button in the hidden-by-filter hint — one-click flip from
// undecided/etc to 'all' so the curator sees every item without hunting the
// dropdown.
$('hintall')?.addEventListener('click', () => { $('filter').value='all'; LS.f='all'; render(); });
function num(v){ return v==null ? null : Number(v); }
// dominant_hex → sort key: group by hue around the colour wheel; push near-greys
// (low saturation) to the end ordered dark→light, so colour sort reads cleanly.
function hueKey(hex){
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex||'').trim());
if(!m) return 1000;
const n = parseInt(m[1],16), r=(n>>16&255)/255, g=(n>>8&255)/255, b=(n&255)/255;
const mx=Math.max(r,g,b), mn=Math.min(r,g,b), d=mx-mn, l=(mx+mn)/2;
const sat = d===0 ? 0 : d/(1-Math.abs(2*l-1));
let h=0; if(d){ if(mx===r)h=((g-b)/d+6)%6; else if(mx===g)h=(b-r)/d+2; else h=(r-g)/d+4; h*=60; }
return sat<0.12 ? 720 + (1-l)*100 : h; // greys after all hues, dark→light
}
function sortFns(mode){
const r = d => num(d.rank_score) ?? -1, v = d => num(d.vision_score) ?? -1, s = d => num(d.seam_score) ?? -1;
switch(mode){
case 'vision': return (a,b)=> v(b)-v(a) || a.id-b.id;
case 'seam': return (a,b)=> s(b)-s(a) || a.id-b.id;
case 'newest': return (a,b)=> new Date(b.created_at)-new Date(a.created_at) || b.id-a.id;
case 'oldest': return (a,b)=> new Date(a.created_at)-new Date(b.created_at) || a.id-b.id;
case 'colorway': return (a,b)=> hueKey(a.dominant_hex)-hueKey(b.dominant_hex) || a.id-b.id; // sort by COLOR (hue wheel)
default: return (a,b)=> r(b)-r(a) || a.id-b.id; // composite rank
}
}
// Mural-style categories (monterey-mural, cactus-mural, tree-mural, cactus-pine-scenic,
// any "*-mural" / "*-scenic") get web_viewer=true at creation time as part of the
// panel pipeline — so excluding web_viewer items from "undecided" wipes the whole
// category from view. For those categories, treat web_viewer as orthogonal to
// undecided-ness so the curator actually has cards to triage.
const IS_MURAL_CAT = /(?:^|\W)(mural|scenic)(?:$|\W|-)/i.test(CATEGORY);
function passesFilter(d, f){
const act = decided.get(d.id);
switch(f){
case 'all': return true;
case 'published': return d.is_published && act!=='bad' && act!=='digital';
case 'unpublished': return !d.is_published && !(d.user_removed || act==='bad');
case 'web': return d.web_viewer || act==='live';
case 'fix': return !!d.needs_fixing_at || act==='fix';
case 'digital': return !!d.digital_file_at || act==='digital';
case 'removed': return d.user_removed || act==='bad';
case 'undecided': // not yet touched in DB and not acted on this session
default: return !act && !d.user_removed && !d.needs_fixing_at && !d.digital_file_at
&& (IS_MURAL_CAT || !d.web_viewer);
}
}
function colorway(cat){
if(!cat) return 'cactus';
return cat.includes('·') ? cat.split('·').slice(1).join('·').trim() : cat;
}
function verdictClass(v){ return v==='PASS'?'pass':v==='WARN'?'warn':v==='FAIL'?'fail':'none'; }
// Short red defect label shown on the card front when a design FAILs — says WHY.
// Map a bad_examples defect_tag → short human label for the red chip.
const TAG_LABEL = { seams:'Seams', repeat_break_lr:'Seams', repeat_break_tb:'Seams',
edges_fail:'Seams', poor_edges:'Poor edges', cartoonish:'Cartoonish', blurry:'Blurry',
ghost:'Ghost', bleed:'Bleed', heal_derivative:'Healed', manual_bad_region:'Flagged',
no_bottle:'No bottle', not_animals:'Not animals' };
function failReason(d){
const reasons = [];
const hmid = num(d.seam_hmid_raw), vmid = num(d.seam_vmid_raw);
if (d.seam_verdict === 'FAIL' || (hmid != null && hmid > 12) || (vmid != null && vmid > 12)) reasons.push('Seams');
const vis = num(d.vision_score);
if (vis != null && vis < 40) reasons.push('Blurry'); // low vision-quality ≈ soft/blurry/artifacted
// Defect tags recorded in bad_examples (manual flags, audits) — surface each.
(d.defect_tags || []).forEach(t => { if (TAG_LABEL[t]) reasons.push(TAG_LABEL[t]); });
return [...new Set(reasons)];
}
// Top-N rank chip threshold + fresh-window — at-a-glance "this is the strongest
// few" and "this just came out of the gen pipeline" cues, ported from drunk-curator.
const TOP_RANK_N = 10;
const FRESH_MS = 24 * 3600 * 1000;
function isFreshIso(iso){ if(!iso) return false; try{ return (Date.now() - new Date(iso).getTime()) < FRESH_MS; }catch{return false;} }
function pickRoomKey(rm){
if (!rm) return null;
// Shape varies: array ["living_room"] OR object {"living_room":"/path"}.
const keys = Array.isArray(rm) ? rm : (typeof rm === 'object' ? Object.keys(rm) : []);
if (!keys.length) return null;
return keys.includes('living_room') ? 'living_room'
: keys.includes('bedroom') ? 'bedroom'
: keys.includes('office') ? 'office'
: keys[0];
}
// 🛋 On-demand room-setting: POST /api/room → inject the rendered room image
// below the design image in this chip. Reuses the auth-gated $/call renderer
// (admin token via q()); shows a spinner, then the image + caption.
async function renderRoomSetting(id, btn){
const box = document.getElementById('roomgen-' + id);
const orig = btn.textContent;
btn.disabled = true; btn.textContent = '🛋 Rendering…';
if (box){ box.classList.add('show'); box.innerHTML = '<div class="roomgen-status">🛋 Rendering room… (~15s)</div>'; }
try {
const r = await fetch(q('/api/room'), {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ design_id: id, roomType: 'bedroom' })
});
const j = await r.json().catch(()=>({}));
if (!r.ok || !j.ok) throw new Error(j.message || j.error || ('HTTP ' + r.status));
if (box) box.innerHTML =
`<img class="roomgen-img" src="${j.image_url}" alt="room setting" loading="lazy">` +
`<div class="roomgen-cap">🛋 ${ (j.roomType||'room').replace(/_/g,' ') } mockup</div>`;
btn.textContent = '🛋 Room ✓';
} catch (e){
if (box) box.innerHTML = `<div class="roomgen-status err">⚠ ${e.message}</div>`;
btn.disabled = false; btn.textContent = orig;
}
}
function roomImgHTML(d){
const k = pickRoomKey(d.room_mockups);
if (!k) return '';
return `<img class="card-room" loading="lazy" src="/designs/room/design_${d.id}_${k}.png" alt="" aria-hidden="true">`;
}
function roomBadgeHTML(d){
const k = pickRoomKey(d.room_mockups);
if (!k) return '';
return `<span class="b room" title="hover for ${k.replace(/_/g,' ')} mockup">🛋</span>`;
}
function card(d, idx){
const el = document.createElement('div');
el.className = 'card';
el.dataset.id = d.id;
const act = decided.get(d.id);
if(act) el.classList.add('decided');
if(selected.has(d.id)) el.classList.add('sel');
const rank = num(d.rank_score), vis = num(d.vision_score), seam = num(d.seam_score);
// Seam-mismatch callout — the common repeat-break error. h-mid raw > tolerance
// = the pattern doesn't connect across the horizontal midline (top/bottom halves
// don't tile); v-mid = vertical line. Tolerance 12 (WALLCO_SEAMLESS_TOLERANCE).
const SEAM_TOL = 12;
const hmid = num(d.seam_hmid_raw), vmid = num(d.seam_vmid_raw);
const seamLines = [];
if (hmid != null && hmid > SEAM_TOL) seamLines.push('horizontal line');
if (vmid != null && vmid > SEAM_TOL) seamLines.push('vertical line');
const seamCallout = seamLines.length
? `<div class="seam-mismatch" title="h-mid ΔE ${hmid??'—'} · v-mid ΔE ${vmid??'—'} (tolerance ${SEAM_TOL})">⚠ Seam Mismatch — ${seamLines.join(' & ')}</div>`
: '';
const stateLabel = act ? ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED'})[act]
: d.web_viewer ? '✓ PUBLISHED' : d.needs_fixing_at ? '⚠ NEEDS FIXING'
: d.digital_file_at ? '⬇ DIGITAL FILE' : d.user_removed ? '✕ REMOVED' : '';
el.innerHTML = `
<div class="thumb">
<div class="sel-box" title="select (x)"></div>
<div class="badges">
${(idx != null && idx < TOP_RANK_N) ? `<span class="b toprank" title="top ${TOP_RANK_N} by current sort">★ top ${idx+1}</span>` : ''}
${isFreshIso(d.created_at) ? `<span class="b fresh" title="created in the last 24h">✨ fresh</span>` : ''}
<span class="b rank">${rank!=null? '#'+Math.round(rank) : '—'}</span>
<span class="b ${d.is_published?'pub':'unpub'}">${d.is_published?'PUB':'unpub'}</span>
${d.web_viewer?'<span class="b web">WEB</span>':''}
${d.tif_path
? `<span class="b tif" title="TIF archive: ${d.tif_w_px||'?'}×${d.tif_h_px||'?'}px · ${d.tif_bytes?(d.tif_bytes/1024/1024).toFixed(2)+' MB':'?'} · ${(d.tif_path||'').replace(/^.*\//,'')}">📦 TIF ${d.tif_w_px||''}</span>`
: `<span class="b notif" title="no TIF archive — generate via /admin/etsy-bucket or scripts/archive-svg-5400.js">📦 NO TIF</span>`}
${roomBadgeHTML(d)}
<span class="b ${verdictClass(d.seam_verdict)}">${d.seam_verdict||'no scan'}</span>
${failReason(d).map(r=>`<span class="b reason" title="failed: ${r}">${r}</span>`).join('')}
${(window.HAIKU_VERDICTS && window.HAIKU_VERDICTS[d.id]) ?
`<span class="b haiku ${(window.HAIKU_VERDICTS[d.id].verdict||'').toLowerCase()}" title="Haiku 4.5 vision verdict: ${(window.HAIKU_VERDICTS[d.id].reason||'').replace(/"/g,'"')}">🤖 ${window.HAIKU_VERDICTS[d.id].verdict}</span>`
: ''}
</div>
<img loading="lazy" src="/designs/img/by-id/${d.id}" alt="cactus ${d.id}">
${roomImgHTML(d)}
${seamCallout}
<button class="auto-id-btn" title="auto-detect bad areas (you confirm/delete)">🔍 Auto-ID</button>
<a class="dbg-btn" href="${q('/admin/seam-debug/'+d.id)}" target="_blank" rel="noopener noreferrer" title="open seam-defect debugger — see where the mid-line breaks">🔬 Debug seams</a>
<div class="state" style="${stateLabel?'display:block':''}">${stateLabel}</div>
</div>
<div class="roomgen" id="roomgen-${d.id}"></div>
<div class="meta">
<div class="cw" title="${d.category||''}">${colorway(d.category)} <span class="sub">#${d.id}</span></div>
<div class="scores">
<span class="dot" style="background:${d.dominant_hex||'#333'}"></span>
<span title="vision quality">V ${vis!=null?Math.round(vis):'·'}</span>
<span title="seam quality">S ${seam!=null?Math.round(seam):'·'}</span>
</div>
<div class="when" title="created ${d.created_at||''}">🕓 ${fmtDate(d.created_at)}</div>
</div>
<div class="acts">
<button class="bad" data-a="bad" title="Bad pattern — remove from all (1)">✕ Bad <kbd>1</kbd></button>
<button class="dig" data-a="digital" title="Unpublish, sell as digital file (2)">⬇ Digital <kbd>2</kbd></button>
<button class="fix" data-a="fix" title="Keep but needs fixing (3)">⚠ Fix <kbd>3</kbd></button>
<button class="live" data-a="live" title="Publish — go live in the web viewer (4)">✓ Publish <kbd>4</kbd></button>
<button class="etsy" data-a="etsy" title="Send to Etsy bucket (5) — removes from new-design pool, queues for digital download listing">🛒 Etsy <kbd>5</kbd></button>
<button class="unpub" data-a="unpublish" title="Take down from live catalog (6) — sets is_published=FALSE, reversible via Publish">📴 Unpublish <kbd>6</kbd></button>
<button class="roombtn" data-room="${d.id}" style="grid-column:1/-1" title="Generate a room-setting mockup for this design and show it below the image (~15s, ~$0.10).">🛋 Room setting</button>
<button class="bakebtn" data-bake="${d.id}" style="grid-column:1/-1" title="Bake the current 🎨 Adjust settings onto this image as a NEW unpublished design (original untouched). Set the Adjust sliders off neutral first.">⤓ Bake adjustment</button>
</div>`;
el.querySelector('.sel-box').addEventListener('click', e => { e.stopPropagation(); toggleSel(d.id, el); });
// single click → admin detail modal · shift+click → add/remove from selection
// double-click → open the customer-facing PRODUCT PAGE (PDP /design/:id) in a
// new tab — "open it up as a new product" (Steve 2026-05-29). Single click is
// debounced ~230ms so a double-click doesn't also flash the modal open first.
let clickTimer = null;
el.querySelector('.thumb').addEventListener('click', e => {
if (e.target.classList.contains('sel-box')) return;
if (e.target.classList.contains('auto-id-btn')) return;
if (suppressClick) return; // a marquee drag just ended
if (e.shiftKey) { e.preventDefault(); toggleSel(d.id, el); return; }
if (clickTimer) return; // 2nd click of a dblclick — let dblclick win
clickTimer = setTimeout(() => { clickTimer = null; openDetail(d.id); }, 230);
});
el.querySelector('.thumb').addEventListener('dblclick', e => {
if (e.target.classList.contains('sel-box') || e.target.classList.contains('auto-id-btn')) return;
if (e.shiftKey) return;
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
e.preventDefault();
window.open('/design/' + d.id, '_blank', 'noopener'); // PDP — customer product page, no admin token
});
el.querySelectorAll('.acts button[data-a]').forEach(b =>
b.addEventListener('click', () => decide(d.id, b.dataset.a, el)));
// A5 — per-card bake (no modal): reuse the same canvas→/api/design/:id/bake path
el.querySelector('.acts [data-bake]')?.addEventListener('click', e => {
e.stopPropagation();
bakeAdjustment(d.id, e.currentTarget);
});
// 🛋 Room setting — generate a room mockup on demand, show it below the design image
el.querySelector('.acts [data-room]')?.addEventListener('click', e => {
e.stopPropagation();
renderRoomSetting(d.id, e.currentTarget);
});
// SHIFT+drag on the image → draw a dotted "bad area" box (id BAD elements for fixing)
const thumb = el.querySelector('.thumb');
thumb.addEventListener('mousedown', e => {
if (!e.shiftKey || e.target.classList.contains('x') || e.target.classList.contains('sel-box')) return;
e.preventDefault(); e.stopPropagation(); // block marquee-select; this is a draw
const rect = thumb.getBoundingClientRect();
const sx = (e.clientX-rect.left)/rect.width, sy = (e.clientY-rect.top)/rect.height;
const box = document.createElement('div'); box.className = 'bad-box drawing'; thumb.appendChild(box);
let cur = null;
const mv = ev => {
const cx = Math.max(0,Math.min(1,(ev.clientX-rect.left)/rect.width));
const cy = Math.max(0,Math.min(1,(ev.clientY-rect.top)/rect.height));
cur = { x:Math.min(sx,cx), y:Math.min(sy,cy), w:Math.abs(cx-sx), h:Math.abs(cy-sy) };
box.style.cssText = `left:${cur.x*100}%;top:${cur.y*100}%;width:${cur.w*100}%;height:${cur.h*100}%`;
};
const up = async (ev) => {
window.removeEventListener('mousemove', mv); window.removeEventListener('mouseup', up);
box.remove();
if (cur && cur.w > 0.02 && cur.h > 0.02) {
suppressClick = true; setTimeout(()=>{ suppressClick=false; }, 150);
const label = await pickLabel(ev.clientX, ev.clientY); // choose the defect type
if (label) addAnnotation(d, { ...cur, label }, el);
}
};
window.addEventListener('mousemove', mv); window.addEventListener('mouseup', up);
});
el.querySelector('.auto-id-btn').addEventListener('click', e => { e.stopPropagation(); autoId(d, el, e.currentTarget); });
renderBoxes(d, el);
el.addEventListener('mouseenter', () => hovered = d.id);
el.addEventListener('mouseleave', () => { if(hovered===d.id) hovered=null; });
return el;
}
// Render bad-area boxes on a card's thumb (idempotent). Two kinds:
// • confirmed (d.annotations) — red, saved, with × to remove
// • candidates (d.candidates) — amber dashed, auto-detected, with ✓ confirm / × reject
function makeBox(b, isCand, onX, onOk){
const div = document.createElement('div');
div.className = 'bad-box' + (isCand ? ' cand' : '');
div.style.cssText = `left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%`;
div.innerHTML = (isCand ? `<span class="ok" title="confirm">✓</span>` : '')
+ `<span class="x" title="${isCand?'reject':'remove'}">×</span>`
+ (b.label ? `<span class="lbl">${b.label}</span>` : '');
div.querySelector('.x').addEventListener('click', e => { e.stopPropagation(); onX(); });
if (isCand) div.querySelector('.ok').addEventListener('click', e => { e.stopPropagation(); onOk(); });
return div;
}
function renderBoxes(d, el){
const thumb = el.querySelector('.thumb');
thumb.querySelectorAll('.bad-box').forEach(n => n.remove());
(d.annotations || []).forEach((b, i) => {
if (b.auto) {
// auto-detected (amber): ✓ promote to confirmed, × reject
thumb.appendChild(makeBox(b, true,
() => { d.annotations.splice(i, 1); renderBoxes(d, el); saveAnnotations(d); },
() => { b.auto = false; renderBoxes(d, el); saveAnnotations(d); }));
} else {
// human-confirmed (red): × remove
thumb.appendChild(makeBox(b, false, () => removeAnnotation(d, i, el)));
}
});
}
// Auto-detect + PERSIST error boxes (auto-flagged/amber) so the squares survive
// reloads and accumulate as the learning corpus. ✓ promotes to confirmed (red).
function autoId(d, el, btn){
if (btn) { btn.classList.add('busy'); btn.textContent = '… scanning'; }
return fetch(q(`/api/admin/cactus/${d.id}/auto-id`))
.then(r => r.json())
.then(j => {
if (!j.ok) { alert('Auto-ID failed: ' + (j.error||'unknown')); return; }
d.annotations = j.boxes || []; // persisted merged boxes (auto + confirmed)
renderBoxes(d, el);
if (btn) btn.textContent = d.annotations.some(b => b.auto) ? '🔍 Auto-ID' : '✓ clean';
})
.catch(err => alert('Auto-ID error: ' + err.message))
.finally(() => { if (btn) btn.classList.remove('busy'); });
}
// Defect-type picker shown after drawing a bad-area box (collection-aware).
const BOX_LABELS = /drunk/i.test(CATEGORY)
? ['No bottle','Not animals','Poor edges','Cartoonish','Seams','Blurry']
: ['Seams','Poor edges','Cartoonish','Blurry','Ghost','Bleed'];
let _picker = null;
function closePicker(){ if(_picker){ document.removeEventListener('mousedown', _picker._out, true); _picker.remove(); _picker=null; } }
function pickLabel(clientX, clientY){
return new Promise(resolve => {
closePicker();
const p = document.createElement('div'); p.className='label-picker'; _picker = p;
p.innerHTML = `<div class="lp-h">Bad element:</div>` +
BOX_LABELS.map(l=>`<button data-l="${l}">${l}</button>`).join('') +
`<button class="lp-cancel" data-l="">cancel</button>`;
document.body.appendChild(p);
p.style.left = Math.min(clientX, window.innerWidth - 190) + 'px';
p.style.top = Math.min(clientY, window.innerHeight - 230) + 'px';
const done = l => { closePicker(); resolve(l || null); };
p.addEventListener('mousedown', e => e.stopPropagation()); // don't start a marquee
p.querySelectorAll('button').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); done(b.dataset.l); }));
p._out = ev => { if(_picker && !_picker.contains(ev.target)) done(null); };
setTimeout(()=> document.addEventListener('mousedown', p._out, true), 0);
});
}
function addAnnotation(d, box, el){ d.annotations = d.annotations || []; d.annotations.push(box); renderBoxes(d, el); saveAnnotations(d); }
function removeAnnotation(d, i, el){ (d.annotations||[]).splice(i,1); renderBoxes(d, el); saveAnnotations(d); }
function saveAnnotations(d){
fetch(q(`/api/admin/cactus/${d.id}/annotations`), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ boxes: d.annotations||[] }) })
.catch(()=>{});
}
let hovered = null;
let suppressClick = false; // true briefly after a marquee drag so the click doesn't open detail
function render(){
const f = $('filter').value;
const list = ALL.filter(d => passesFilter(d, f)).sort(sortFns($('sort').value));
grid.innerHTML = '';
$('empty').style.display = list.length ? 'none' : 'block';
// Surface the hidden-by-filter count so Steve never wonders "why so few cards"
// (monterey-mural is the canonical case — 549 items, 546 web_viewer, default
// 'undecided' filter would hide all but 3 if IS_MURAL_CAT weren't lifting that cut).
const hidden = ALL.length - list.length;
const hint = $('hint');
if (hidden > 0 && f !== 'all' && hint) {
$('hintmsg').textContent = `${hidden} of ${ALL.length} hidden by '${f}' filter.`;
hint.style.display = 'block';
} else if (hint) {
hint.style.display = 'none';
}
const frag = document.createDocumentFragment();
// Pass position-in-sorted-list so card() can render the ★ top-N rank pill on the front of the queue.
list.forEach((d, i) => frag.appendChild(card(d, i)));
grid.appendChild(frag);
updateStat();
}
function updateStat(){
const total = ALL.length;
const done = ALL.filter(d => decided.get(d.id) || d.user_removed || d.needs_fixing_at || d.digital_file_at || d.web_viewer).length;
$('stat').innerHTML = `<b>${total}</b> ${CATEGORY} · <b>${done}</b> decided · <b>${total-done}</b> left`;
}
function toggleSel(id, el){
// Resolve the card element across BOTH the main grid AND the by-base stack
// (#bbstack). When toggled from a delegated handler `el` is passed; on
// keyboard 'x' from any hovered card we look it up here.
el = el || document.querySelector(`#grid .card[data-id="${id}"], #bbstack .card[data-id="${id}"]`);
if(selected.has(id)){ selected.delete(id); el?.classList.remove('sel'); }
else { selected.add(id); el?.classList.add('sel'); }
$('bulkn').textContent = selected.size;
$('bulk').classList.toggle('show', selected.size>0);
}
async function decide(id, action, el){
el?.classList.add('decided');
const st = el?.querySelector('.state');
if(st){ st.textContent = ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET',unpublish:'📴 UNPUBLISHED'})[action]; st.style.display='block'; }
decided.set(id, action);
// patch local model so filters/sorts stay consistent without a full reload
const d = ALL.find(x=>x.id===id);
if(d){
if(action==='bad'){ d.is_published=false; d.user_removed=true; d.web_viewer=false; }
else if(action==='digital'){ d.is_published=false; d.web_viewer=false; d.digital_file_at=today(); }
else if(action==='fix'){ d.needs_fixing_at=today(); }
else if(action==='live'){ d.is_published=true; d.web_viewer=true; d.user_removed=false; d.needs_fixing_at=null; d.digital_file_at=null; }
else if(action==='etsy'){ d.in_etsy_bucket=true; }
else if(action==='unpublish'){ d.is_published=false; d.web_viewer=false; }
}
updateStat();
// Etsy uses its own endpoint (separate table); short-circuit here.
if(action==='etsy'){
try{
const r=await fetch(q('/api/etsy-bucket/add'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:[id]})});
const j=await r.json().catch(()=>({}));
if(!r.ok||!j.ok){ throw new Error(j.error||('HTTP '+r.status)); }
const result=(j.results||[])[0]||{};
if(!result.ok){ el?.classList.remove('decided'); if(st) st.style.display='none'; decided.delete(id); flash(`#${id} not added: ${result.reason||'unknown'}`); return; }
// hide the card — it's out of the new-design pool now
el?.classList.add('moved-to-etsy');
setTimeout(()=>el?.remove(), 350);
}catch(e){ el?.classList.remove('decided'); if(st) st.style.display='none'; decided.delete(id); flash(`#${id} Etsy add failed: ${e.message}`); }
return;
}
try {
const r = await fetch(q(`/api/cactus-decision/${id}`), {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action })
});
const j = await r.json().catch(()=>({}));
if(!r.ok || j.ok===false){ throw new Error(j.error||('HTTP '+r.status)); }
// Live + gate-fail → the verdict IS saved (parked w/ needs_fixing) — show
// amber "Publish anyway" toast that retries with force:true. Same pattern as
// drunk-curator's HOT-gate-held flow ([[feedback_wallco_hot_vote_save_even_when_gate_blocks]]).
if(j.gate_held){
// DTD verdict 2026-05-30 (panel B, park-by-default): a Publish click on a
// gate-failed design PARKS it (the verdict is saved server-side with
// needs_fixing + curator-loved tags) and surfaces the reasons + an explicit
// "Publish anyway" link. It does NOT auto-force past the gate. Only a
// deliberate human click on that link publishes a triple-gate-failed design.
// This reverts the 2026-05-29 auto-force behavior that silently shipped
// #41813 (seam ΔE 25.85 · 12-color blowup · vision off-concept) live.
// Mirrors the sibling drunk-curator forcePublish() pattern. Honors the
// standing [[feedback_only_clean_designs_welcome]] default.
decided.set(id,'held');
if(st){ st.textContent='🔧 HELD — review'; st.style.display='block'; }
el?.classList.remove('decided');
const reasons=(j.reasons||[]).join(' · ')||'failed publish gate';
const t=document.createElement('div');
t.id='cactus-gatetoast';
t.style.cssText='position:fixed;top:60px;left:50%;transform:translateX(-50%);background:#8a6d1f;color:#fff;padding:8px 16px;border-radius:8px;z-index:99;font-size:13px;max-width:90vw;box-shadow:0 4px 18px rgba(0,0,0,.5)';
t.innerHTML=`🔧 #${id} held — not auto-published: ${escapeHtml(reasons)}. <a href="#" id="cactus-forcepub" style="color:#ffe9a8;font-weight:600;text-decoration:underline">Publish anyway</a>`;
document.getElementById('cactus-gatetoast')?.remove();
document.body.appendChild(t);
setTimeout(()=>document.getElementById('cactus-gatetoast')?.remove(), 9000);
// The override is a deliberate human action — only a click fires force:true.
document.getElementById('cactus-forcepub')?.addEventListener('click', async ev=>{
ev.preventDefault();
try {
const r2=await fetch(q(`/api/cactus-decision/${id}`),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'live',force:true})});
const j2=await r2.json().catch(()=>({}));
if(r2.ok && j2.ok){
decided.set(id,'live');
if(st){st.textContent='✓ PUBLISHED';}
const dd=ALL.find(x=>x.id===id);
if(dd){dd.is_published=true; dd.web_viewer=true; dd.user_removed=false; dd.needs_fixing_at=null; dd.digital_file_at=null;}
document.getElementById('cactus-gatetoast')?.remove();
if(el){ el.classList.add('moved-to-etsy'); setTimeout(()=>el.remove(),300); }
} else {
flash(`#${id} force-publish failed: ${j2.error||r2.status}`);
}
} catch(e){ flash(`#${id} force-publish failed: ${e.message}`); }
});
} else if (action === 'fix' && el) {
// Steve 2026-05-29: the Fix button now ALSO runs the seam-heal pipeline
// in-place (not just stamp needs_fixing_at). Result chat panel + Restore
// button land inside the card. Original PNG is backed up alongside as
// __preseam.png — restore-preseam reverses the swap.
runFixAndDisplay(id, el);
} else if (['bad','digital','unpublish','live'].includes(action) && el) {
// Steve 2026-05-29: per-card Bad/Digital/Unpublish/Publish now animate
// the card out of the grid on success (mirrors bulk's 300ms fade+remove).
// Without this the card sits there with its new state-pill while the SQL
// update already landed, making the click feel broken. Live was added
// 2026-05-29 because Steve was hitting 'auto-force-publishing…' but
// seeing no visible action — the card was sitting there published but
// still in the Undecided view. Etsy keeps its own self-hide path.
el.classList.add('moved-to-etsy');
setTimeout(() => el.remove(), 300);
}
} catch(e){
el?.classList.remove('decided');
if(st) st.style.display='none';
decided.delete(id);
flash('Save failed for #'+id+': '+e.message);
}
}
// Run /api/design/:id/fix → shift-and-blend seam heal. On promotion (an actual
// fix landed), refresh the card thumb with a cache-bust and append a chat-style
// panel showing what the pipeline did + the Haiku verdict reason + a Restore
// button that swaps the __preseam.png backup back into canonical.
async function runFixAndDisplay(id, el){
if (!el) return;
let panel = el.querySelector('.fix-panel');
if (panel) panel.remove();
panel = document.createElement('div');
panel.className = 'fix-panel';
panel.innerHTML = `<div class="fix-row"><span class="ico">🔧</span><div class="msg">Running fix-seam… <span class="spin">⏳</span></div></div>`;
el.appendChild(panel);
try {
const r = await fetch(q(`/api/design/${id}/fix`), {
method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'
});
const j = await r.json().catch(()=>({}));
if(!r.ok || j.ok===false) throw new Error(j.error || ('HTTP '+r.status));
if (j.promoted) {
const img = el.querySelector('img');
if (img) {
const u = new URL(img.src, location.origin);
u.searchParams.set('t', String(Date.now()));
img.src = u.toString();
}
}
const haiku = (window.HAIKU_VERDICTS||{})[id];
let html = '';
if (j.promoted) {
html += `<div class="fix-row"><span class="ico">🔧</span><div class="msg"><b>Seam-healed in place.</b> fixed=${j.fixed} · ${j.duration_ms}ms<br>${escapeHtml(j.note||'')}</div></div>`;
} else if (j.already_seamless > 0) {
html += `<div class="fix-row"><span class="ico">✓</span><div class="msg"><b>No seam defect detected.</b> already-seamless n=${j.already_seamless}.</div></div>`;
} else {
html += `<div class="fix-row"><span class="ico">·</span><div class="msg"><b>No change applied.</b> ${escapeHtml(j.note||'fix-seam reported no actionable seam.')}</div></div>`;
}
if (haiku) {
html += `<div class="fix-row haiku"><span class="ico">🤖</span><div class="msg"><b>Haiku ${haiku.verdict}:</b> ${escapeHtml(haiku.reason||'')}</div></div>`;
}
if (j.promoted) {
html += `<button class="restore-btn">↶ Restore original</button>`;
}
panel.innerHTML = html;
const rb = panel.querySelector('.restore-btn');
if (rb) rb.addEventListener('click', e => { e.stopPropagation(); runRestore(id, el); });
} catch(e){
panel.innerHTML = `<div class="fix-row err"><span class="ico">⚠</span><div class="msg"><b>Fix failed:</b> ${escapeHtml(e.message)}</div></div>`;
}
}
async function runRestore(id, el){
const panel = el.querySelector('.fix-panel');
try {
const r = await fetch(q(`/api/design/${id}/restore-preseam`), {
method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'
});
const j = await r.json().catch(()=>({}));
if(!r.ok || j.ok===false) throw new Error(j.error || ('HTTP '+r.status));
const img = el.querySelector('img');
if (img) {
const u = new URL(img.src, location.origin);
u.searchParams.set('t', String(Date.now()));
img.src = u.toString();
}
if (panel) panel.innerHTML = `<div class="fix-row"><span class="ico">↶</span><div class="msg"><b>Restored original.</b> ${escapeHtml(j.note||'')}</div></div>`;
} catch(e){
if (panel) panel.innerHTML = `<div class="fix-row err"><span class="ico">⚠</span><div class="msg"><b>Restore failed:</b> ${escapeHtml(e.message)}</div></div>`;
}
}
function escapeHtml(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
async function bulk(action){
const ids = [...selected];
if(!ids.length) return;
if(action==='bad' && !confirm(`Remove ${ids.length} designs from all surfaces? (quarantines the PNGs + drops them from the live site + HARD-DELETES the TIF masters — irreversible)`)) return;
if(action==='unpublish' && !confirm(`Unpublish ${ids.length} designs from the live catalog? (sets is_published=FALSE; fully reversible via Publish — no quarantine, no tagging)`)) return;
// ONE batched request — not N un-awaited decide() calls. The old loop fired a
// fetch per id; each spawned a server-side psql process, so a few-hundred-card
// bulk-remove became a psql fork storm that timed out and half-applied. The
// /api/cactus-decision/bulk endpoint now collapses bad/digital/fix to a single
// SQL UPDATE and drops the removed designs from the live index server-side.
const labels = {bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET',unpublish:'📴 UNPUBLISHED'};
ids.forEach(id=>{ const el=grid.querySelector(`.card[data-id="${id}"]`); el?.classList.add('decided');
const st=el?.querySelector('.state'); if(st){ st.textContent=labels[action]; st.style.display='block'; } });
try{
let j;
if(action==='etsy'){
const r=await fetch(q('/api/etsy-bucket/add'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids})});
j=await r.json().catch(()=>({})); if(!r.ok||!j.ok) throw new Error(j.error||('HTTP '+r.status));
} else {
// DTD verdict 2026-05-30 (panel B, park-by-default): bulk Publish does NOT
// auto-force past the publish gate. Gate-failed designs come back parked
// (server reports them in j.results[].gate_held + j.gate_held count); the
// post-response handler re-flags them NEEDS FIXING and a toast then offers
// an explicit "Publish N held anyway" override. Reverts the 2026-05-29
// unconditional force that silently shipped gate-failed designs live.
const body = {ids,action};
const r=await fetch(q('/api/cactus-decision/bulk'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
j=await r.json().catch(()=>({})); if(!r.ok||j.ok===false) throw new Error(j.error||('HTTP '+r.status));
}
ids.forEach(id=>{
const el=grid.querySelector(`.card[data-id="${id}"]`);
const d=ALL.find(x=>x.id===id);
decided.set(id, action);
if(action==='bad'){ if(d){d.is_published=false;d.user_removed=true;d.web_viewer=false;} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
else if(action==='digital'){ if(d){d.is_published=false;d.web_viewer=false;d.digital_file_at=today();} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
else if(action==='unpublish'){ if(d){d.is_published=false;d.web_viewer=false;} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
else if(action==='etsy'){ if(d){d.in_etsy_bucket=true;} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
else if(action==='fix'){ if(d){d.needs_fixing_at=today();} }
else if(action==='live'){ if(d){d.is_published=true;d.web_viewer=true;d.user_removed=false;d.needs_fixing_at=null;d.digital_file_at=null;}
// Animate published cards out of the grid so the bulk-Publish gives the
// SAME visible feedback as per-card Publish + the other bulk actions —
// without this the cards published fine server-side but sat there
// unchanged, so a bulk-Publish "did nothing" from Steve's POV (2026-05-30).
// Skip gate-held cards (they stay, re-flagged as needs-fixing below).
const held=(j.results||[]).some(rr=>rr&&rr.id===id&&rr.gate_held);
if(!held){ el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); } }
});
// 'live' gate-held items come back parked as needs-fixing — reflect that.
if(action==='live' && Array.isArray(j.results)){
j.results.filter(rr=>rr&&rr.gate_held).forEach(rr=>{ decided.set(rr.id,'fix');
const st=grid.querySelector(`.card[data-id="${rr.id}"] .state`); if(st){ st.textContent='⚠ NEEDS FIXING'; } });
}
let msg = action==='live' ? `${j.applied||0} published${j.gate_held?`, ${j.gate_held} parked (gate)`:''}`
: action==='etsy' ? `${(j.results||[]).filter(x=>x&&x.ok).length||ids.length} → Etsy bucket`
: `${j.applied??ids.length} ${({bad:'removed',digital:'→ digital file',fix:'flagged needs-fixing'})[action]}`;
if(j.dropped_from_index!=null) msg += ` · ${j.dropped_from_index} dropped from live index`;
if(j.errored) msg += ` · ${j.errored} errored`;
flash(msg);
// DTD park-by-default override: if any were parked at the gate, offer an
// explicit one-click "Publish N held anyway" — the only path to publishing
// gate-failed designs in bulk. Re-POSTs only the held ids with force:true.
if(action==='live' && j.gate_held>0){
const heldIds=(j.results||[]).filter(rr=>rr&&rr.gate_held).map(rr=>rr.id);
document.getElementById('cactus-gatetoast')?.remove();
const t=document.createElement('div'); t.id='cactus-gatetoast';
t.style.cssText='position:fixed;top:60px;left:50%;transform:translateX(-50%);background:#8a6d1f;color:#fff;padding:8px 16px;border-radius:8px;z-index:99;font-size:13px;max-width:90vw;box-shadow:0 4px 18px rgba(0,0,0,.5)';
t.innerHTML=`🔧 ${heldIds.length} held at the publish gate. <a href="#" id="cactus-bulkforce" style="color:#ffe9a8;font-weight:600;text-decoration:underline">Publish ${heldIds.length} anyway</a>`;
document.body.appendChild(t);
setTimeout(()=>document.getElementById('cactus-gatetoast')?.remove(), 12000);
document.getElementById('cactus-bulkforce')?.addEventListener('click', async ev=>{
ev.preventDefault();
try{
const r=await fetch(q('/api/cactus-decision/bulk'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:heldIds,action:'live',force:true})});
const jf=await r.json().catch(()=>({})); if(!r.ok||jf.ok===false) throw new Error(jf.error||('HTTP '+r.status));
heldIds.forEach(id=>{ const el=grid.querySelector(`.card[data-id="${id}"]`); const d=ALL.find(x=>x.id===id);
decided.set(id,'live'); if(d){d.is_published=true;d.web_viewer=true;d.user_removed=false;d.needs_fixing_at=null;d.digital_file_at=null;}
el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); });
document.getElementById('cactus-gatetoast')?.remove();
flash(`${jf.applied||heldIds.length} force-published`);
updateStat();
}catch(e){ flash(`Force-publish failed: ${e.message}`); }
});
}
}catch(e){
ids.forEach(id=>{ const el=grid.querySelector(`.card[data-id="${id}"]`); el?.classList.remove('decided');
const st=el?.querySelector('.state'); if(st) st.style.display='none'; decided.delete(id); });
flash(`Bulk ${action} failed: ${e.message}`);
}
updateStat();
selected.forEach(id=>grid.querySelector(`.card[data-id="${id}"]`)?.classList.remove('sel'));
selected.clear(); $('bulkn').textContent=0; $('bulk').classList.remove('show');
}
$('bulk').querySelectorAll('button[data-a]').forEach(b => b.addEventListener('click', ()=>bulk(b.dataset.a)));
$('bulkclear').addEventListener('click', ()=>{ selected.forEach(id=>grid.querySelector(`.card[data-id="${id}"]`)?.classList.remove('sel')); selected.clear(); $('bulk').classList.remove('show'); });
document.addEventListener('keydown', e => { if(e.key==='Shift') document.body.classList.add('shifting'); });
document.addEventListener('keyup', e => { if(e.key==='Shift') document.body.classList.remove('shifting'); });
window.addEventListener('blur', () => document.body.classList.remove('shifting'));
document.addEventListener('keydown', e => {
if(e.key==='Escape'){ closeDetail(); return; }
if(e.target.tagName==='SELECT'||e.target.tagName==='INPUT') return;
if($('modal').classList.contains('show')) return; // don't fire grid keys behind an open modal
const map = {'1':'bad','2':'digital','3':'fix','4':'live','5':'etsy','6':'unpublish'};
if(hovered && map[e.key]){ const el = grid.querySelector(`.card[data-id="${hovered}"]`); decide(hovered, map[e.key], el); }
else if(hovered && (e.key==='x'||e.key==='X')){ toggleSel(hovered, grid.querySelector(`.card[data-id="${hovered}"]`)); }
});
function today(){ return new Date().toISOString().slice(0,10); }
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 flash(msg){ const e=$('err'); e.textContent=msg; e.style.display='block'; setTimeout(()=>e.style.display='none', 4000); }
// ── detail modal: click a card to see every field ─────────────────────────
function paletteSwatches(pal){
let arr = Array.isArray(pal) ? pal : (pal && Array.isArray(pal.colors) ? pal.colors : []);
const hexes = arr.map(c => typeof c==='string' ? c : (c && (c.hex||c.color||c.value))).filter(Boolean);
return hexes.length ? '<div class="pal">'+hexes.map(h=>`<i style="background:${h}" title="${h}"></i>`).join('')+'</div>' : '';
}
function esc(s){ return String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
function detailHTML(d){
const row = (k,v) => (v==null||v==='') ? '' : `<dt>${k}</dt><dd>${v}</dd>`;
const list = v => Array.isArray(v) ? v.map(esc).join(', ') : (v==null?'':esc(v));
const seam = d.seam_score!=null
? `${Math.round(d.seam_score)} (${d.seam_verdict||'—'}) · edge ${d.seam_edge_raw??'—'} / mid ${d.seam_mid_raw??'—'} · h-mid ${d.seam_hmid_raw??'—'} v-mid ${d.seam_vmid_raw??'—'}`
: '—';
const status = d.user_removed ? '✕ removed' : d.web_viewer ? '✓ published (web viewer)'
: d.is_published ? 'published' : d.needs_fixing_at ? '⚠ needs fixing' : d.digital_file_at ? '⬇ digital file' : 'unpublished';
return `
<button class="modal-close" onclick="closeDetail()" title="close (Esc)">×</button>
<div class="modal-img"><img src="/designs/img/by-id/${d.id}" alt="cactus ${d.id}"></div>
<div class="modal-side">
<h2>${esc(d.ai_title || ('Cactus #'+d.id))}</h2>
<div class="sub">${esc(d.category||'')} · #${d.id} · ${status}</div>
${paletteSwatches(d.palette)}
<dl class="kv">
${row('Composite rank', d.rank_score!=null?Math.round(d.rank_score):'—')}
${row('Vision quality', d.vision_score!=null ? Math.round(d.vision_score)+(d.vision_model?` · ${esc(d.vision_model)}`:'') : 'not scored')}
${row('Seam quality', seam)}
${row('Created', d.created_at)}
${row('Status', status)}
${row('Published', d.is_published?'yes':'no')}
${row('Digital file', d.digital_file_at)}
${row('Needs fixing', d.needs_fixing_at)}
${row('Kind', d.kind)}
${row('Dominant hex', d.dominant_hex ? `<span class="dot" style="background:${d.dominant_hex}"></span> ${d.dominant_hex}` : '')}
${row('Pattern', d.ai_pattern)}
${row('Color name', d.ai_color_name)}
${row('Dig #', d.dig_number)}
${row('Product line', d.product_line)}
${row('Generator', d.generator)}
${row('Seed', d.seed)}
${row('Size', (d.width_in&&d.height_in) ? `${d.width_in} × ${d.height_in} in${d.panels?` · ${d.panels} panels`:''}` : '')}
${row('Settlement', d.settlement_verdict)}
${row('Motifs', list(d.motifs))}
${row('Tags', list(d.tags))}
${row('Notes', list(d.notes))}
${row('Prompt', list(d.prompt))}
</dl>
<div class="modal-acts">
<button class="bad" data-a="bad">✕ Bad</button>
<button class="dig" data-a="digital">⬇ Digital</button>
<button class="fix" data-a="fix">⚠ Fix</button>
<button class="live" data-a="live">✓ Publish</button>
</div>
<div class="modal-bake">
<button class="bake" data-bake="${d.id}" title="Render the current 🎨 Adjust settings onto this image and save it as a NEW unpublished design. The original is untouched.">⤓ Bake adjustment → new design</button>
<span class="adjnote" id="bakeMsg"></span>
</div>
</div>`;
}
async function openDetail(id){
const m=$('modal'), box=$('modalBox');
box.innerHTML='<div class="modal-side">Loading…</div>';
m.classList.add('show');
try{
const r=await fetch(q(`/api/admin/cactus/${id}`));
const j=await r.json();
if(!j.ok) throw new Error(j.error||('HTTP '+r.status));
box.innerHTML=detailHTML(j.design);
box.querySelectorAll('.modal-acts button').forEach(b=>b.addEventListener('click',()=>{
decide(id, b.dataset.a, grid.querySelector(`.card[data-id="${id}"]`));
closeDetail();
}));
const bakeBtn = box.querySelector('[data-bake]');
if (bakeBtn) bakeBtn.addEventListener('click', ()=>bakeAdjustment(id, bakeBtn));
}catch(e){ box.innerHTML='<div class="modal-side">Error loading #'+id+': '+esc(e.message)+' <button class="modal-close" onclick="closeDetail()">×</button></div>'; }
}
// Render the current 🎨 Adjust filter onto a full-res canvas (pixel-parity with
// the preview) and POST to the existing /api/design/:id/bake endpoint, which
// saves it as a NEW unpublished design — original untouched, settlement-gated.
async function bakeAdjustment(id, btn){
const msg = document.getElementById('bakeMsg');
const filter = (window.ccAdjust && window.ccAdjust.filterString());
if (!filter){
if(msg){ msg.style.color='var(--mut)'; msg.textContent='Move the 🎨 Adjust sliders off neutral first — nothing to bake.'; }
else { const o=btn.textContent; btn.textContent='set 🎨 Adjust first'; setTimeout(()=>{btn.textContent=o;},1600); }
return;
}
// A7 — extreme-adjustment guard: require a second click to confirm a wild bake.
// (low saturation / B&W is intentional, so only HIGH sat + extreme bri/con flag.)
const av = window.ccAdjust.values();
const extreme = (av.sat/127 > 1.9) || (av.bri/127 < 0.5) || (av.bri/127 > 1.7) || (av.con/100 > 1.6) || (av.con/100 < 0.45);
if (extreme && !btn._bakeArmed){
btn._bakeArmed = true;
const o = btn.textContent;
btn.textContent = '⚠ extreme — click again';
if (msg){ msg.style.color='#ffd98a'; msg.textContent='That adjustment is strong — click Bake again within 4s to confirm.'; }
btn._disarm = setTimeout(function(){ btn._bakeArmed=false; btn.textContent=o; if(msg && /strong/.test(msg.textContent||'')) msg.textContent=''; }, 4000);
return;
}
btn._bakeArmed = false; if (btn._disarm) clearTimeout(btn._disarm);
const orig = btn.textContent; btn.disabled = true; btn.textContent = 'Baking…';
if (msg){ msg.style.color='var(--mut)'; msg.textContent=''; }
try{
const img = new Image();
await new Promise((ok,err)=>{ img.onload=ok; img.onerror=()=>err(new Error('source image failed to load')); img.src='/designs/img/by-id/'+id; });
const w = img.naturalWidth||img.width, h = img.naturalHeight||img.height;
if (!w || !h) throw new Error('image has no dimensions');
const cv = document.createElement('canvas'); cv.width=w; cv.height=h;
const ctx = cv.getContext('2d'); ctx.filter = filter; ctx.drawImage(img,0,0,w,h);
const b64 = cv.toDataURL('image/png').split(',')[1];
const r = await fetch('/api/design/'+id+'/bake', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ image_b64: b64 }) });
const j = await r.json();
if (!j.ok) throw new Error(j.error || ('HTTP '+r.status));
const v = window.ccAdjust.values();
if (msg){ msg.style.color='#7fe3ff'; msg.innerHTML='✓ Baked '+w+'×'+h+' (H'+v.hue+' S'+v.sat+' B'+v.bri+' C'+v.con+') → new unpublished design <b>#'+j.design_id+'</b> · find it under <i>Show ▸ Unpublished</i>'; }
btn.textContent = '✓ Baked #'+j.design_id;
}catch(e){
if (msg){ msg.style.color='#ff9a9a'; msg.textContent='Bake failed: '+e.message; }
btn.textContent = orig; btn.disabled = false;
}
}
function closeDetail(){ $('modal').classList.remove('show'); }
$('modal').addEventListener('click', e => { if(e.target.id==='modal') closeDetail(); });
// reconcile the live selection Set + .sel classes + bulk bar to a target set
function applySelection(next){
// Find the card in EITHER the flat grid (#grid) or the by-base stack
// (#bbstack) so marquee-select works in both layouts.
const findCard = id => document.querySelector(`#grid .card[data-id="${id}"], #bbstack .card[data-id="${id}"]`);
for(const id of [...selected]) if(!next.has(id)){ selected.delete(id); findCard(id)?.classList.remove('sel'); }
for(const id of next) if(!selected.has(id)){ selected.add(id); findCard(id)?.classList.add('sel'); }
$('bulkn').textContent = selected.size;
$('bulk').classList.toggle('show', selected.size>0);
}
// ── marquee rubber-band select: drag = select, shift+drag = add ────────────
// Works across BOTH the flat grid (#grid) and the by-base family stack
// (#bbstack). Mousedown can start on either; mousemove enumerates every
// .card in either container and adds any that intersect the marquee rect.
(function(){
const mq = $('marquee'), THRESH = 5;
let dragging=false, moved=false, sx=0, sy=0, baseSel=null;
function startDrag(e){
if(e.button!==0) return;
// Don't start a drag on action buttons, the sel-box, or the colorway
// thumb strip (.bbstrip — those <a>s are navigation, not selection).
if(e.target.closest('.acts') || e.target.classList.contains('sel-box') || e.target.closest('.bbstrip')) return;
dragging=true; moved=false; sx=e.clientX; sy=e.clientY;
baseSel = e.shiftKey ? new Set(selected) : new Set();
}
grid.addEventListener('mousedown', startDrag);
// Attach to the stack TOO — it's created later by ensureStack(), so we use
// a MutationObserver to bind once it appears (and only once per session).
function bindStack(){
const stack = document.getElementById('bbstack');
if (!stack || stack.__mqBound) return;
stack.addEventListener('mousedown', startDrag);
stack.__mqBound = true;
}
bindStack();
new MutationObserver(bindStack).observe(document.body, { childList:true, subtree:true });
window.addEventListener('mousemove', e => {
if(!dragging) return;
if(!moved && Math.abs(e.clientX-sx)<THRESH && Math.abs(e.clientY-sy)<THRESH) return;
if(!moved){
moved=true; document.body.classList.add('dragging'); mq.style.display='block';
if(baseSel.size===0) applySelection(new Set()); // fresh (non-shift) drag clears first
}
e.preventDefault();
const l=Math.min(sx,e.clientX), t=Math.min(sy,e.clientY), r=Math.max(sx,e.clientX), b=Math.max(sy,e.clientY);
mq.style.left=l+'px'; mq.style.top=t+'px'; mq.style.width=(r-l)+'px'; mq.style.height=(b-t)+'px';
const next = new Set(baseSel);
// Enumerate cards in both containers — whichever is visible contributes.
const cards = [...grid.querySelectorAll('.card[data-id]'),
...document.querySelectorAll('#bbstack .card[data-id]')];
for(const cardEl of cards){
const cr = cardEl.getBoundingClientRect();
if(!(cr.right<l || cr.left>r || cr.bottom<t || cr.top>b)) next.add(+cardEl.dataset.id);
}
applySelection(next);
});
window.addEventListener('mouseup', () => {
if(!dragging) return;
if(moved){ suppressClick=true; setTimeout(()=>{ suppressClick=false; }, 60); } // swallow the post-drag click
dragging=false; moved=false; baseSel=null;
document.body.classList.remove('dragging'); mq.style.display='none';
});
})();
async function load(){
try {
// Fan-out: pull the catalog list AND the Haiku-vision verdicts in parallel.
// Haiku verdicts populate window.HAIKU_VERDICTS so renderCard can show a
// "🤖 <verdict>" chip on each card the weekend scan flagged. Verdicts failure
// is non-fatal — chips just won't render.
const [listR, haikuR] = await Promise.all([
fetch(q('/api/admin/cactus/list?include=removed&category=' + encodeURIComponent(CATEGORY))),
fetch(q('/api/admin/cactus/haiku-verdicts')).catch(() => null),
]);
if(!listR.ok) throw new Error('list '+listR.status+(listR.status===404?' (admin gate — append ?admin=TOKEN)':''));
const j = await listR.json();
ALL = j.items || [];
if (haikuR && haikuR.ok) {
try { const hj = await haikuR.json(); if (hj.ok) window.HAIKU_VERDICTS = hj.verdicts || {}; } catch {}
}
// Reflect the loaded collection in the title.
const h = document.querySelector('h1'); if(h && CATEGORY!=='cactus') h.textContent = '❖ ' + CATEGORY + ' Curator';
render();
} catch(e){ $('err').style.display='block'; $('err').textContent = 'Failed to load: '+e.message; }
}
// Populate the Collection picker from the distinct-lines endpoint. URL is the
// only state — on change we navigate to ?category=<line> (preserving ?admin=).
async function loadCollections(){
const sel = $('collection');
try {
const r = await fetch(q('/api/admin/curator/collections'));
if(!r.ok) throw new Error('collections '+r.status);
const j = await r.json();
const lines = (j.collections || []).slice();
// Always reflect the active CATEGORY even if it falls under the HAVING>=5 cut.
if(!lines.some(c => c.line === CATEGORY)) lines.unshift({ line: CATEGORY, total: null });
sel.innerHTML = lines.map(c => {
const counts = c.total == null ? '' : ` (${c.total} · ${c.published}✓ · ${c.removed}✕)`;
return `<option value="${esc(c.line)}"${c.line===CATEGORY?' selected':''}>${esc(c.line)}${counts}</option>`;
}).join('');
} catch(e){
// Non-fatal — keep the picker usable with at least the current line.
sel.innerHTML = `<option value="${esc(CATEGORY)}" selected>${esc(CATEGORY)}</option>`;
}
}
$('collection').addEventListener('change', e => {
const line = e.target.value;
if(!line || line === CATEGORY) return;
const params = new URLSearchParams();
params.set('category', line);
if(ADMIN) params.set('admin', ADMIN);
location.search = '?' + params.toString(); // navigates; URL carries all state
});
loadCollections();
load();
// ===== "By-base layout" — index page mode =================================
// Swaps the flat grid for a vertical stack of FAMILY CARDS — each row is one
// base pattern shown large, with a horizontal strip of all its colorway
// children below. Works on ANY ?category= that has parent_design_id linkage
// (face-skull-damask, cactus, chinoiserie, designer-zoo-calm, designer-scenic,
// drunk-zoo-36, muybridge-plate, stripe, throttled-plaster-roses). Toggle
// state persists in localStorage so it stays on across reloads.
(() => {
const URL_CAT = new URLSearchParams(location.search).get('category') || '';
const LS_KEY = 'cactus_curator_bblayout';
const LS_PUB = 'cactus_curator_bblayout_pubonly';
let families = [], stackEl = null, loaded = false, btn = null, pubBtn = null;
let pubOnly = localStorage.getItem(LS_PUB) !== '0'; // default ON: only show families with published content
function setBtnState(on) {
if (!btn) return;
btn.textContent = (on ? '◆ ' : '🎨 ') + 'By-base layout';
btn.style.background = on ? 'var(--ink, #eef1f3)' : '';
btn.style.color = on ? '#111' : '';
btn.title = on ? 'showing 1 pattern + colorways per row — click to return to flat grid'
: 'group designs by base pattern with their colorway thumbs underneath (works on any collection)';
}
function ensureStack() {
if (stackEl) return stackEl;
stackEl = document.createElement('div');
stackEl.id = 'bbstack';
// Reuse the same .grid class as the index page — same density (--cols),
// same gap, same padding — so it reads as the existing tight grid.
stackEl.className = 'grid';
stackEl.style.display = 'none';
const grid = document.getElementById('grid');
if (grid && grid.parentNode) grid.parentNode.insertBefore(stackEl, grid.nextSibling);
else document.body.appendChild(stackEl);
return stackEl;
}
function familyCardHTML(fam) {
const allRaw = [
{ id: fam.id, category: fam.category, dominant_hex: fam.dominant_hex, is_published: fam.is_published },
...(fam.children || []),
];
// When "Published only" is on, hide unpublished thumbs from the strip too —
// the family card shows only what's actually live on the storefront.
const all = pubOnly ? allRaw.filter(c => c.is_published) : allRaw;
// Re-use the index page's own .card / .thumb / .meta classes so this reads
// visually identical to a regular catalog tile — base in the thumb slot,
// colorway pills inline in the meta row + a thumb strip where .acts goes.
const cwLabel = (fam.category || 'base');
const dot = fam.dominant_hex || '#333';
// Reflect any in-session decision on the base id so a re-render after a
// bulk action shows the right state.
const act = (typeof decided !== 'undefined' && decided.get) ? decided.get(fam.id) : null;
const selClass = (typeof selected !== 'undefined' && selected.has && selected.has(fam.id)) ? ' sel' : '';
const stateLabel = act ? ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET'})[act] : '';
return `<div class="card bbfam${act?' decided':''}${selClass}" data-base="${fam.id}" data-id="${fam.id}">
<div class="thumb">
<div class="sel-box" title="select (x)"></div>
<div class="badges">
<span class="b ${fam.is_published?'pub':'unpub'}">${fam.is_published?'PUB':'unpub'}</span>
<span class="b reason" title="${all.length} colorways in family">${all.length}cw</span>
</div>
<a class="bb-link" href="/admin/seam-debug/${fam.id}" target="_blank" rel="noopener noreferrer" style="display:block;width:100%;height:100%">
<img loading="lazy" src="/designs/img/by-id/${fam.id}" alt="base ${fam.id}">
${roomImgHTML(fam)}
</a>
<div class="state" style="${stateLabel?'display:block':''}">${stateLabel}</div>
</div>
<div class="meta">
<div class="cw" title="${fam.category||''}">base <span class="sub">#${fam.id}</span></div>
<div class="scores">
<span class="dot" style="background:${dot}"></span>
<span>${all.length} cw</span>
</div>
<div class="when" title="created ${fam.created_at||''}">🕓 ${(typeof fmtDate==='function' ? fmtDate(fam.created_at) : (fam.created_at||'').slice(0,16).replace('T',' '))}</div>
</div>
<div class="bbstrip" style="display:flex;gap:3px;overflow-x:auto;padding:0 8px 9px;scrollbar-width:thin">
${all.map(c => {
const isBase = c.id === fam.id;
const border = isBase ? '#ffd68a' : (c.is_published ? '#3a6a3a' : '#2a2a2a');
const cw = (c.category || '').split(' · ')[1] || 'base';
return `<a href="/admin/seam-debug/${c.id}" target="_blank" rel="noopener noreferrer" title="#${c.id} · ${cw}${c.is_published?' · ✓ published':''}" style="flex:0 0 auto;border:1.5px solid ${border};border-radius:4px;overflow:hidden;background:#000;text-decoration:none;line-height:0">
<img loading="lazy" src="/designs/img/by-id/${c.id}" style="display:block;width:38px;height:38px;object-fit:cover">
</a>`;
}).join('')}
</div>
<div class="acts">
<button class="bad" data-a="bad" title="Bad pattern — remove from all (1)">✕ Bad <kbd>1</kbd></button>
<button class="dig" data-a="digital" title="Unpublish, sell as digital file (2)">⬇ Digital <kbd>2</kbd></button>
<button class="fix" data-a="fix" title="Keep but needs fixing (3)">⚠ Fix <kbd>3</kbd></button>
<button class="live" data-a="live" title="Publish — go live in the web viewer (4)">✓ Publish <kbd>4</kbd></button>
<button class="etsy" data-a="etsy" title="Send to Etsy bucket (5)">🛒 Etsy <kbd>5</kbd></button>
<button class="unpub" data-a="unpublish" title="Take down from live catalog (6)">📴 Unpublish <kbd>6</kbd></button>
</div>
</div>`;
}
// Delegated handlers on the stack — survives every renderStack() innerHTML
// rebuild. sel-box → toggleSel; .acts button → decide; shift-click thumb →
// toggleSel (mirrors the index page's shift-click-to-select).
function bindStackHandlers() {
if (stackEl.__handlersBound) return;
stackEl.addEventListener('click', e => {
const card = e.target.closest('.card.bbfam');
if (!card) return;
const id = +card.dataset.id;
// sel-box → selection toggle
if (e.target.classList.contains('sel-box')) {
e.preventDefault(); e.stopPropagation();
if (typeof toggleSel === 'function') toggleSel(id, card);
return;
}
// action button → decide()
const actBtn = e.target.closest('.acts button[data-a]');
if (actBtn) {
e.preventDefault(); e.stopPropagation();
if (typeof decide === 'function') decide(id, actBtn.dataset.a, card);
return;
}
// shift+click on the main thumb img → toggle select (NOT open the link)
if (e.shiftKey && e.target.closest('.bb-link')) {
e.preventDefault(); e.stopPropagation();
if (typeof toggleSel === 'function') toggleSel(id, card);
return;
}
}, true); // capture so we beat the <a>'s default navigation when needed
stackEl.__handlersBound = true;
}
function renderStack() {
if (!stackEl) ensureStack();
// When "Published only" is on, drop families that have zero published rows
// (no base published AND no published colorway) — they'd render as empty cards.
const visible = pubOnly
? families.filter(f => f.is_published || (f.children || []).some(c => c.is_published))
: families;
if (!visible.length) {
stackEl.innerHTML = `<div style="grid-column:1 / -1;text-align:center;padding:60px;color:var(--mut,#9aa3ab)">
${pubOnly ? 'no published families in this category — toggle "Published only" off to see all' : 'no bases with colorway children in this category'}
</div>`;
return;
}
stackEl.innerHTML = visible.map(familyCardHTML).join('');
bindStackHandlers();
}
function showStack() {
const grid = document.getElementById('grid'); if (grid) grid.style.display = 'none';
// 'grid' (NOT block) — otherwise it overrides .grid class display:grid and
// the cards stack 1-up instead of using the --cols density.
ensureStack().style.display = 'grid';
if (!loaded) {
stackEl.innerHTML = '<div style="text-align:center;padding:60px;color:var(--mut,#9aa3ab)">loading families…</div>';
fetch('/api/admin/cactus/families?category=' + encodeURIComponent(URL_CAT))
.then(r => r.json())
.then(j => {
families = (j.families || []).filter(f => (f.children || []).length > 0);
loaded = true;
renderStack();
})
.catch(e => { stackEl.innerHTML = '<div style="color:#f88;padding:20px;text-align:center">load failed: ' + (e.message || e) + '</div>'; });
}
}
function hideStack() {
const grid = document.getElementById('grid'); if (grid) grid.style.display = '';
if (stackEl) stackEl.style.display = 'none';
}
function setMode(on) {
if (on) { localStorage.setItem(LS_KEY, '1'); showStack(); }
else { localStorage.removeItem(LS_KEY); hideStack(); }
setBtnState(on);
}
function setPubBtnState() {
if (!pubBtn) return;
pubBtn.textContent = pubOnly ? '✓ Published only' : '◯ Published only';
pubBtn.style.background = pubOnly ? '#1d3b1d' : '';
pubBtn.style.color = pubOnly ? '#bdf1bd' : '';
pubBtn.title = pubOnly
? 'showing only families & thumbs that are live on the storefront — click to show all'
: 'showing all families (published + unpublished + held) — click to filter to published only';
}
function togglePubOnly() {
pubOnly = !pubOnly;
if (pubOnly) localStorage.removeItem(LS_PUB); else localStorage.setItem(LS_PUB, '0');
setPubBtnState();
const inStack = loaded && stackEl && stackEl.style.display !== 'none';
if (inStack) { renderStack(); return; }
// Main-grid mode: previously this toggle did nothing here (it only filtered
// the by-base stack), so the button looked broken. Drive the existing
// #filter dropdown (which already has a 'published' case) + re-render.
// render() lives in a different IIFE, so we can't call it directly. The
// #filter <select> has a 'change' listener (→ render() + persist), so set
// its value and dispatch 'change' to re-render the main grid cross-scope.
const fsel = document.getElementById('filter');
if (fsel) {
if (pubOnly) { if (fsel.value !== 'published') fsel.dataset.prevFilter = fsel.value; fsel.value = 'published'; }
else { fsel.value = (fsel.dataset.prevFilter && fsel.dataset.prevFilter !== 'published') ? fsel.dataset.prevFilter : 'all'; }
fsel.dispatchEvent(new Event('change'));
}
}
// inject the toggle buttons into the controls bar (re-injects if DOM rebuilds)
new MutationObserver(() => {
if (document.getElementById('bbtoggle')) return;
const anchor = document.getElementById('autoall') || document.querySelector('.controls') || document.querySelector('header');
if (!anchor || !anchor.parentNode) return;
btn = document.createElement('button'); btn.id = 'bbtoggle';
btn.className = anchor.className || '';
btn.onclick = () => setMode(localStorage.getItem(LS_KEY) !== '1');
anchor.parentNode.insertBefore(btn, anchor.nextSibling);
setBtnState(localStorage.getItem(LS_KEY) === '1');
// sibling "Published only" filter button (applies inside by-base mode)
pubBtn = document.createElement('button'); pubBtn.id = 'bbpubonly';
pubBtn.className = anchor.className || '';
pubBtn.onclick = togglePubOnly;
btn.parentNode.insertBefore(pubBtn, btn.nextSibling);
setPubBtnState();
// restore mode on first paint if saved
if (localStorage.getItem(LS_KEY) === '1') setMode(true);
}).observe(document.body, { childList: true, subtree: true });
})();
// ===== Click-to-Heal modal ===================================================
// Click any card image → large lightbox; drag to mark heal regions (numbered);
// ⚕ Heal → calls inpaint, pushes current onto history, swaps in the new image;
// ↺ Back reverts to the previous version; ✓ Accept promotes the latest (via
// /api/seam-debug/heal/approve, original gets user_removed=TRUE + logged); ✕
// Close discards (heal-children remain as unpublished siblings, recoverable).
(() => {
let modal = null, original = null, current = null, history = [], manual = [];
const adm = new URLSearchParams(location.search).get('admin');
const q = u => adm ? u + (u.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(adm) : u;
function ensureModal() {
if (modal) return modal;
modal = document.createElement('div'); modal.id = 'healmodal';
modal.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(8,9,11,.97);display:none;flex-direction:column;color:#eee;font:13px ui-monospace,monospace';
modal.innerHTML = `
<header style="display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid #333">
<button id="hmback" style="background:#2a2a2a;color:#eee;border:1px solid #444;padding:7px 14px;font-weight:700;border-radius:8px;cursor:pointer" disabled>↺ Back</button>
<span id="hmtitle" style="flex:1;text-align:center;color:#aaa">—</span>
<span id="hmsel" style="color:#ffd0d0">0 regions</span>
<button id="hmheal" style="background:#3b2e08;color:#f0b400;border:1px solid #5a4710;padding:7px 16px;font-weight:800;border-radius:8px;cursor:pointer">⚕ Heal selected</button>
<button id="hmregen" style="background:#1d2c3b;color:#7ec8ff;border:1px solid #365778;padding:7px 16px;font-weight:800;border-radius:8px;cursor:pointer" title="re-run the original prompt with a new seed — guaranteed clean lines (heal can degrade quality on flat designs)">⟲ Regenerate</button>
<button id="hmaccept" style="background:#2a6b35;color:#fff;border:0;padding:7px 16px;font-weight:800;border-radius:8px;cursor:pointer" disabled>✓ Accept</button>
<button id="hmclose" style="background:#3a2222;color:#ffdada;border:1px solid #663;padding:7px 14px;font-weight:700;border-radius:8px;cursor:pointer">✕ Close</button>
</header>
<main style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:12px">
<div id="hmwrap" style="position:relative;background:#000;line-height:0">
<img id="hmimg" style="display:block;max-width:88vw;max-height:78vh;object-fit:contain;background:#fff;-webkit-user-drag:none" draggable="false">
<div id="hmovl" style="position:absolute;inset:0;cursor:crosshair"></div>
</div>
</main>
<footer id="hmfoot" style="padding:8px 14px;color:#888;border-top:1px solid #333;text-align:center;font-size:11px">drag on image to mark a heal region · click × to remove · ⚕ Heal → ↺ Back to revert or ✓ Accept to replace the original</footer>`;
document.body.appendChild(modal);
modal.querySelector('#hmclose').onclick = closeModal;
modal.querySelector('#hmback').onclick = back;
modal.querySelector('#hmheal').onclick = heal;
modal.querySelector('#hmregen').onclick = regenerate;
modal.querySelector('#hmaccept').onclick = accept;
wireDraw();
return modal;
}
function updTitle() {
modal.querySelector('#hmtitle').textContent =
`#${current} · original #${original} · history ${history.length}${history.length?' (Back to revert)':''}`;
modal.querySelector('#hmsel').textContent = `${manual.length} regions`;
modal.querySelector('#hmback').disabled = history.length === 0;
modal.querySelector('#hmaccept').disabled = current === original; // no heal applied yet
}
function drawBoxes() {
const ovl = modal.querySelector('#hmovl');
ovl.innerHTML = manual.map((b, i) =>
`<div data-idx="${i}" style="position:absolute;left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%;border:2px dashed #ff3b3b;background:rgba(255,59,59,.14);box-sizing:border-box;cursor:pointer">
<b style="position:absolute;top:-2px;left:-2px;background:#ff3b3b;color:#fff;font:800 11px sans-serif;padding:1px 6px;border-radius:3px 0 3px 0;min-width:18px;text-align:center">${i+1}</b>
<span class="rm" style="position:absolute;top:-9px;right:-9px;width:18px;height:18px;line-height:16px;text-align:center;background:#ff3b3b;color:#fff;border-radius:50%;font:800 12px sans-serif;cursor:pointer">×</span>
</div>`).join('');
ovl.querySelectorAll('.rm').forEach(x => x.onclick = e => { e.stopPropagation(); const i=+x.parentElement.dataset.idx; manual.splice(i,1); drawBoxes(); updTitle(); });
}
function wireDraw() {
const ovl = modal.querySelector('#hmovl');
ovl.addEventListener('mousedown', e => {
if (e.target.closest('div[data-idx]')) return;
const rect = ovl.getBoundingClientRect();
const x0 = (e.clientX - rect.left)/rect.width, y0 = (e.clientY - rect.top)/rect.height;
if (x0<0||x0>1||y0<0||y0>1) return; e.preventDefault();
const g = document.createElement('div');
g.style.cssText = 'position:absolute;border:2px dashed #ff3b3b;background:rgba(255,59,59,.22);pointer-events:none;box-sizing:border-box';
ovl.appendChild(g);
const mv = ev => {
const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
const L=Math.min(x0,x),T=Math.min(y0,y),W=Math.abs(x-x0),H=Math.abs(y-y0);
g.style.cssText += `;left:${L*100}%;top:${T*100}%;width:${W*100}%;height:${H*100}%`;
};
const up = ev => {
document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up);
const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
const L=Math.max(0,Math.min(x0,x)), T=Math.max(0,Math.min(y0,y));
const W=Math.min(1-L,Math.abs(x-x0)), H=Math.min(1-T,Math.abs(y-y0));
g.remove();
if (W>0.01 && H>0.01) {
manual.push({x:+L.toFixed(4),y:+T.toFixed(4),w:+W.toFixed(4),h:+H.toFixed(4),label:'manual-'+(manual.length+1)});
drawBoxes(); updTitle();
}
};
document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
});
}
function showId(id) {
current = id;
modal.querySelector('#hmimg').src = `/designs/img/by-id/${id}` + (adm?'?admin='+encodeURIComponent(adm):'');
manual = []; drawBoxes(); updTitle();
}
function openModal(id) {
ensureModal();
original = id; current = id; history = []; manual = [];
modal.style.display = 'flex';
showId(id);
}
function closeModal() {
if (modal) modal.style.display = 'none';
original = current = null; history = []; manual = [];
}
// Heal returns ok even when the result is QUALITY-DEGRADED (SDXL inpaint on a
// flat-screen-print tile often produces a worse seam than the original). Steve
// rule: "100% clean lines always — it cannot look fixed." So we compare
// scan_after vs scan_before, and if the heal degraded the tile, we auto-fall
// back to ⟲ Regenerate (which re-rolls from the original prompt instead).
async function heal() {
if (!manual.length) { flash('drag on image to mark a region first'); return; }
const btn = modal.querySelector('#hmheal'); const orig = btn.textContent;
btn.disabled = true; btn.textContent = `⚕ healing ${manual.length}…`;
const status = modal.querySelector('#hmfoot');
status.textContent = `inpainting ${manual.length} region(s) on #${current} via Replicate (~10-15s)…`;
try {
const r = await fetch(q(`/api/seam-debug/${current}/heal`), {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ box_ids: [], manual_boxes: manual }),
});
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
const before = j.scan_before?.scores?.overall_max, after = j.scan_after?.scores?.overall_max;
const degraded = Number.isFinite(before) && Number.isFinite(after) && after > before + 0.3;
if (degraded) {
// Don't push degraded heal onto the history — discard it. Auto-fall back
// to regenerate so the curator never has to manually undo + click again.
status.innerHTML = `⚠ heal degraded the tile (overall_max ${before.toFixed(2)} → ${after.toFixed(2)}) — discarding and ⟲ regenerating from the original prompt instead…`;
await regenerate({ silent: true });
return;
}
history.push({ id: current, boxes: manual.slice() });
showId(j.new_id);
status.textContent = `✓ heal #${j.new_id} (overall_max ${before?.toFixed(2)} → ${after?.toFixed(2)}) — ⚕ heal again, ↺ Back, or ✓ Accept`;
} catch (e) {
status.textContent = '✗ heal failed: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = orig;
}
}
// ⟲ Regenerate — re-runs generate_designs.js with the original prompt + new
// seed. Always produces clean lines (no inpaint stitching). Used as a manual
// option AND as the auto-fallback when heal degrades quality.
async function regenerate({ silent = false } = {}) {
const btn = modal.querySelector('#hmregen'); const orig = btn.textContent;
btn.disabled = true; btn.textContent = '⟲ regenerating…';
const status = modal.querySelector('#hmfoot');
if (!silent) status.textContent = `re-rolling #${current} from the original prompt with a new seed (~30-60s)…`;
try {
const r = await fetch(q(`/api/seam-debug/${current}/regenerate`), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
});
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
history.push({ id: current, boxes: manual.slice() });
manual = [];
showId(j.new_id);
status.textContent = `✓ regenerated #${j.new_id} — ⟲ again for another roll, ↺ Back to revert, ✓ Accept to replace original #${original}`;
} catch (e) {
status.textContent = '✗ regenerate failed: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = orig;
}
}
function back() {
const prev = history.pop(); if (!prev) return;
showId(prev.id);
modal.querySelector('#hmfoot').textContent = `reverted to #${prev.id} — draw new regions and ⚕ heal again`;
}
async function accept() {
if (current === original) { flash('no heal applied yet'); return; }
const status = modal.querySelector('#hmfoot');
status.textContent = `approving #${current} as the replacement for #${original}…`;
try {
const r = await fetch(q('/api/seam-debug/heal/approve'), {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ src_id: original, heal_id: current }),
});
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
status.textContent = `✓ accepted — #${original} marked removed, #${current} ${j.promoted_to_published?'now live on storefront':'kept unpublished'}. reloading page…`;
setTimeout(() => location.reload(), 700);
} catch (e) { status.textContent = '✗ accept failed: ' + e.message; }
}
function flash(msg) {
const f = modal.querySelector('#hmfoot'); const orig = f.textContent;
f.textContent = msg; f.style.color = '#ffd0d0';
setTimeout(() => { f.textContent = orig; f.style.color = '#888'; }, 2000);
}
// Intercept clicks on any card image in the catalog (flat grid or by-base
// layout). Anchor wrappers go to /admin/seam-debug — we open the modal here
// instead and let them shift-click for the new-tab nav.
addEventListener('click', e => {
if (e.shiftKey || e.metaKey || e.ctrlKey) return; // power users open in tab
const img = e.target.closest('.card img, .card.bbfam img, .bbstrip img');
if (!img) return;
const id = (() => {
const m = img.closest('a[href*="/admin/seam-debug/"]');
if (m) { const x = m.getAttribute('href').match(/\/(\d+)/); if (x) return parseInt(x[1], 10); }
const c = img.closest('[data-id],[data-base]');
return c ? parseInt(c.dataset.id || c.dataset.base, 10) : null;
})();
if (!Number.isFinite(id)) return;
e.preventDefault(); e.stopPropagation();
openModal(id);
}, true);
addEventListener('keydown', e => {
if (!modal || modal.style.display !== 'flex') return;
if (e.key === 'Escape') closeModal();
else if (e.key === 'Backspace' && history.length) back();
});
})();
</script>
<script>
// HSB + contrast live image-adjustment panel (Steve 2026-05-31).
// Non-destructive: drives a CSS filter on every design image via --img-adjust.
// Scales chosen so Steve's pasted preset (Hue 127 · Sat 158 · Bri 127 · Con 100)
// reads as "neutral except a mild +24% saturation":
// hue-rotate = (Hue-127)/127*180deg (127 = no shift)
// saturate = Sat/127 (127 = 1.0)
// brightness = Bri/127 (127 = 1.0)
// contrast = Con/100 (100 = 1.0)
(function(){
var NEUTRAL = { hue:127, sat:127, bri:127, con:100 };
var DEFAULT = { hue:127, sat:158, bri:127, con:100 }; // Steve's pasted preset
var LS = 'cc_adj_';
function get(k){ var v=localStorage.getItem(LS+k); return v===null ? DEFAULT[k] : +v; }
function set(k,v){ localStorage.setItem(LS+k, v); }
var el = function(id){ return document.getElementById(id); };
var ids = { hue:'adjHue', sat:'adjSat', bri:'adjBri', con:'adjCon' };
function filterStr(s){
var deg = ((s.hue - 127) / 127 * 180).toFixed(1);
return 'hue-rotate('+deg+'deg) saturate('+(s.sat/127).toFixed(3)+') '
+ 'brightness('+(s.bri/127).toFixed(3)+') contrast('+(s.con/100).toFixed(3)+')';
}
// A2 — human-readable derived value per slider (degrees for hue, % for the rest).
function readout(k, val){
if (k==='hue'){ var d=Math.round((val-127)/127*180); return val+' · '+(d>0?'+':'')+d+'°'; }
if (k==='con'){ return val+' · '+Math.round(val/100*100)+'%'; }
return val+' · '+Math.round(val/127*100)+'%'; // sat, bri
}
function apply(){
var s = { hue:get('hue'), sat:get('sat'), bri:get('bri'), con:get('con') };
var neutral = (s.hue===NEUTRAL.hue && s.sat===NEUTRAL.sat && s.bri===NEUTRAL.bri && s.con===NEUTRAL.con);
document.documentElement.style.setProperty('--img-adjust', neutral ? 'none' : filterStr(s));
for (var k in ids){ var r=el(ids[k]); if(r){ r.value=s[k]; } var v=el(ids[k]+'V'); if(v){ v.textContent=readout(k, s[k]); } }
if (typeof markActivePreset === 'function') markActivePreset();
}
function bind(k){
var r = el(ids[k]); if(!r) return;
r.addEventListener('input', function(){ set(k, r.value); var v=el(ids[k]+'V'); if(v) v.textContent=readout(k, r.value); apply(); });
}
// Expose the current adjustment so the modal "Bake" can render the SAME
// filter onto a canvas (pixel-parity with this live preview). Returns null
// when neutral (nothing to bake).
window.ccAdjust = {
values: function(){ return { hue:get('hue'), sat:get('sat'), bri:get('bri'), con:get('con') }; },
filterString: function(){
var s = this.values();
if (s.hue===NEUTRAL.hue && s.sat===NEUTRAL.sat && s.bri===NEUTRAL.bri && s.con===NEUTRAL.con) return null;
return filterStr(s);
}
};
// A1 — curated presets (neutral = hue127/sat127/bri127/con100).
var PRESETS = {
neutral: { hue:127, sat:127, bri:127, con:100 },
vivid: { hue:127, sat:178, bri:132, con:118 },
muted: { hue:127, sat:92, bri:126, con:92 },
warm: { hue:110, sat:148, bri:131, con:106 },
cool: { hue:150, sat:140, bri:124, con:106 },
bw: { hue:127, sat:0, bri:128, con:112 }
};
function markActivePreset(){
var v = { hue:get('hue'), sat:get('sat'), bri:get('bri'), con:get('con') };
document.querySelectorAll('#adjPresets .adj-preset').forEach(function(b){
var p = PRESETS[b.getAttribute('data-preset')];
b.classList.toggle('on', !!p && p.hue===v.hue && p.sat===v.sat && p.bri===v.bri && p.con===v.con);
});
}
function applyPreset(name){
var p = PRESETS[name]; if(!p) return;
for (var k in p) set(k, p[k]);
apply(); markActivePreset();
}
function init(){
for (var k in ids) bind(k);
document.querySelectorAll('#adjPresets .adj-preset').forEach(function(b){
b.addEventListener('click', function(){ applyPreset(b.getAttribute('data-preset')); });
});
var reset = el('adjReset');
if (reset) reset.addEventListener('click', function(){ for (var k in NEUTRAL) set(k, NEUTRAL[k]); apply(); markActivePreset(); });
// A6 — copy the current CSS filter string to clipboard
var copyBtn = el('adjCopyCss');
if (copyBtn) copyBtn.addEventListener('click', function(){
var css = 'filter: ' + (window.ccAdjust.filterString() || 'none') + ';';
var done = function(ok){ var o=copyBtn.textContent; copyBtn.textContent = ok ? '✓ copied' : '✗ copy failed'; setTimeout(function(){ copyBtn.textContent=o; }, 1300); };
try { navigator.clipboard.writeText(css).then(function(){ done(true); }, function(){ done(false); }); }
catch(e){ done(false); }
});
// A8 — keyboard shortcuts, scoped to when the Adjust panel is open.
// 0 → reset to neutral
// [ / ] → nudge the focused slider down / up by 3
document.addEventListener('keydown', function(e){
if (!panel || !panel.classList.contains('on')) return;
var isRange = e.target && e.target.type === 'range';
var tag = (e.target && e.target.tagName || '').toLowerCase();
if (tag === 'input' && !isRange) return; // don't hijack real text inputs
if (e.key === '0'){
for (var k in NEUTRAL) set(k, NEUTRAL[k]); apply(); markActivePreset(); e.preventDefault();
} else if ((e.key === '[' || e.key === ']') && isRange){
var rid = e.target.id, key = null;
for (var kk in ids){ if (ids[kk] === rid) { key = kk; break; } }
if (key){
var step = (e.key === ']' ? 1 : -1) * 3;
var nv = Math.max(+e.target.min, Math.min(+e.target.max, get(key) + step));
set(key, nv); apply(); markActivePreset(); e.preventDefault();
}
}
});
var panel = el('adjPanel'), toggle = el('adjToggle');
// A3 — pick a representative card image for the before/after preview chip.
function refreshBA(){
var ba = el('adjBA'), b = el('baBefore'), a = el('baAfter');
if (!ba || !b || !a) return;
var sample = document.querySelector('#grid .card img, #bbstack img');
var src = sample && (sample.currentSrc || sample.getAttribute('src'));
if (src){ b.src = src; a.src = src; ba.classList.add('on'); }
else { ba.classList.remove('on'); setTimeout(refreshBA, 1200); } // grid not painted yet — retry
}
function show(on){
if (panel) panel.classList.toggle('on', on);
if (toggle) toggle.classList.toggle('on', on);
localStorage.setItem(LS+'open', on ? '1' : '0');
if (on) refreshBA();
}
if (toggle) toggle.addEventListener('click', function(){ show(!(panel && panel.classList.contains('on'))); });
show(localStorage.getItem(LS+'open') === '1');
apply();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script>
<script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>