← back to Wallco Ai
public/admin/ghost-review.html
2079 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Ghost Review — wallco.ai admin</title>
<style>
:root {
--ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
--bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
--green:#1f6a2c; --red:#b3261e; --orange:#c0660b; --grey:#888;
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
--serif:'Playfair Display','Didot',Georgia,serif;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
header {
padding:12px 22px; border-bottom:1px solid var(--line); background:#fff;
display:flex; gap:16px; align-items:baseline; position:sticky; top:0; z-index:5;
}
header h1 { font:400 22px/1.2 var(--serif); margin:0; }
header .meta { color:var(--faint); font-size:12px; letter-spacing:.04em; }
header .stats { margin-left:auto; display:flex; gap:16px; font:600 12px ui-monospace,Menlo,monospace; }
header .stats span b { color:var(--ink); }
header .stats .clean b { color:var(--green); }
header .stats .ghost b { color:var(--red); }
header .stats .seam b { color:var(--orange); }
header .stats .sk b { color:var(--faint); }
main { max-width:1500px; margin:0 auto; padding:14px 18px 80px; }
.controls {
display:flex; gap:14px; align-items:center; padding:9px 14px;
background:#fff; border:1px solid var(--line); border-radius:8px;
margin-bottom:12px; flex-wrap:wrap; font-size:12px;
}
.controls label { font:600 10.5px var(--sans); letter-spacing:.16em;
text-transform:uppercase; color:var(--faint); }
.controls select { padding:5px 9px; border:1px solid var(--line);
border-radius:4px; background:#fff; font:12px var(--sans); }
.controls input[type=range] { vertical-align:middle; width:160px; }
.controls .density-val { font:600 11px ui-monospace,Menlo,monospace;
color:var(--faint); min-width:32px; display:inline-block; }
.controls .legend { margin-left:auto; font:11px var(--sans); color:var(--faint); }
.controls kbd {
background:#f3eee2; border:1px solid var(--line); border-radius:3px;
padding:1px 6px; font:600 10.5px ui-monospace,Menlo,monospace; color:var(--ink);
}
.grid {
display:grid;
grid-template-columns:repeat(auto-fill,minmax(var(--card-min,200px),1fr));
gap:8px;
}
.tile {
position:relative; aspect-ratio:1; background:#f3eee2;
border:1px solid var(--line); border-radius:6px; overflow:hidden;
cursor:pointer; transition:transform .12s, box-shadow .12s;
}
.tile:hover { transform:translateY(-1px); box-shadow:0 4px 14px rgba(0,0,0,.10); }
.tile img { width:100%; height:100%; object-fit:cover; display:block; }
.tile .badge {
position:absolute; top:6px; left:6px;
background:rgba(31,24,8,.78); color:#fff;
font:600 9.5px var(--sans); letter-spacing:.06em;
padding:3px 6px; border-radius:3px;
}
.tile .conf {
position:absolute; bottom:6px; right:6px;
background:rgba(255,255,255,.92); color:var(--ink);
font:600 10px ui-monospace,Menlo,monospace;
padding:2px 5px; border-radius:3px;
}
.tile .when {
position:absolute; left:6px; bottom:6px;
background:rgba(31,24,8,.78); color:#fff;
font:600 9.5px var(--sans); letter-spacing:.02em;
font-variant-numeric:tabular-nums;
padding:2px 5px; border-radius:3px; max-width:62%;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.tile.lbl-fp { outline:3px solid var(--green); outline-offset:-3px; }
.tile.lbl-tp_ghost { outline:3px solid var(--red); outline-offset:-3px; }
.tile.lbl-tp_seam { outline:3px solid var(--orange); outline-offset:-3px; }
.tile.lbl-tp_other { outline:3px solid var(--gold); outline-offset:-3px; }
.tile.lbl-skip { opacity:.4; outline:2px dashed var(--faint); outline-offset:-2px; }
.tile.sel { outline:4px solid #3b78d8; outline-offset:-4px; box-shadow:0 0 0 2px rgba(59,120,216,.35) inset; }
.tile.sel::after {
content:"✓"; position:absolute; top:6px; right:6px;
background:#3b78d8; color:#fff; width:22px; height:22px; border-radius:50%;
display:flex; align-items:center; justify-content:center;
font:700 14px var(--sans); box-shadow:0 1px 3px rgba(0,0,0,.3);
}
body.bulk-mode .tile { cursor:cell; }
body.bulk-mode .grid { user-select:none; }
.marquee {
position:absolute; pointer-events:none;
background:rgba(59,120,216,.10); border:1.5px dashed #3b78d8;
border-radius:2px; z-index:30; display:none;
}
.bulk-bar {
position:fixed; bottom:24px; left:50%; transform:translateX(-50%);
background:#1f1808; color:#fff; padding:12px 22px; border-radius:30px;
display:none; align-items:center; gap:14px; z-index:60;
box-shadow:0 6px 24px rgba(0,0,0,.35); font:600 13px var(--sans);
}
.bulk-bar.show { display:flex; }
.bulk-bar .count { background:#3b78d8; padding:3px 10px; border-radius:14px; font:700 12px ui-monospace,Menlo,monospace; }
.bulk-bar button {
background:transparent; color:#fff; border:1px solid rgba(255,255,255,.35);
padding:7px 14px; border-radius:18px; font:600 12px var(--sans);
cursor:pointer; letter-spacing:.05em;
}
.bulk-bar button:hover { background:rgba(255,255,255,.10); }
.bulk-bar button.danger { background:#b3261e; border-color:#b3261e; }
.bulk-bar button.danger:hover { background:#d33; }
#bulk-toggle.active { background:#3b78d8; color:#fff; border-color:#3b78d8; }
.tile .verdict-pill {
position:absolute; left:0; right:0; bottom:0;
color:#fff; font:700 10px var(--sans); letter-spacing:.1em;
text-align:center; padding:4px 0; pointer-events:none;
}
.tile.lbl-fp .verdict-pill { background:var(--green); display:block; content:'✓ CLEAN'; }
.tile.lbl-tp_ghost .verdict-pill { background:var(--red); display:block; }
.tile.lbl-tp_seam .verdict-pill { background:var(--orange); display:block; }
.tile.lbl-tp_other .verdict-pill { background:var(--gold); display:block; }
.tile:not(.lbl-fp):not(.lbl-tp_ghost):not(.lbl-tp_seam):not(.lbl-tp_other) .verdict-pill { display:none; }
/* Modal */
.modal {
position:fixed; inset:0; background:rgba(20,15,5,.92);
display:none; align-items:stretch; justify-content:center; z-index:50;
}
.modal.open { display:flex; }
.modal-inner {
display:grid; grid-template-columns:1fr 340px; gap:0;
background:#fff; width:100%; max-width:1400px; margin:auto;
border-radius:10px; overflow:hidden; max-height:96vh; height:96vh;
}
.stage {
background:#0a0a0a; position:relative; overflow:hidden;
display:flex; align-items:center; justify-content:center;
cursor:crosshair;
user-select:none;
}
.stage img.main {
max-width:100%; max-height:96vh; object-fit:contain; display:block;
user-select:none; -webkit-user-drag:none; pointer-events:none;
}
.stage .img-wrap {
position:relative; display:inline-block; max-width:100%; max-height:96vh;
}
.stage .rect {
position:absolute; box-sizing:border-box; pointer-events:auto;
}
.stage .rect.r-ghost { border:2px solid #ff5252; background:rgba(255,82,82,.18); }
.stage .rect.r-seam { border:2px solid #ffae3a; background:rgba(255,174,58,.20); }
.stage .rect.r-other { border:2px solid #ffd54a; background:rgba(255,213,74,.20); }
.stage .rect.r-keep { border:3px dashed #38c779; background:rgba(56,199,121,.12); }
.stage .rect .label {
position:absolute; top:-22px; left:0;
color:#fff; font:700 10px var(--sans);
letter-spacing:.08em; padding:2px 6px; border-radius:3px;
pointer-events:none; white-space:nowrap;
}
.stage .rect.r-ghost .label { background:#ff5252; }
.stage .rect.r-seam .label { background:#ffae3a; color:#3a2a00; }
.stage .rect.r-other .label { background:#ffd54a; color:#3a2a00; }
.stage .rect.r-keep .label { background:#38c779; }
.stage .rect .x {
position:absolute; top:-10px; right:-10px;
width:22px; height:22px; border-radius:50%;
background:#fff; color:var(--ink); border:1px solid var(--line);
font:700 13px var(--sans); display:flex; align-items:center; justify-content:center;
cursor:pointer; box-shadow:0 2px 6px rgba(0,0,0,.4);
}
.stage .rect .x:hover { background:var(--red); color:#fff; border-color:var(--red); }
.stage .drag-rect {
position:absolute; box-sizing:border-box; pointer-events:none;
border:2px dashed #fff; background:rgba(255,255,255,.10);
}
.stage .nav {
position:absolute; top:50%; transform:translateY(-50%);
background:rgba(0,0,0,.55); color:#fff; border:none;
width:42px; height:60px; font-size:22px; cursor:pointer; z-index:3;
}
.stage .nav:hover { background:rgba(0,0,0,.85); }
.stage .nav.prev { left:0; border-radius:0 6px 6px 0; }
.stage .nav.next { right:0; border-radius:6px 0 0 6px; }
.stage .top-hint {
position:absolute; top:10px; left:50%; transform:translateX(-50%);
background:rgba(0,0,0,.7); color:#fff; font:600 11px var(--sans);
padding:6px 14px; border-radius:18px; letter-spacing:.04em;
pointer-events:none; z-index:3;
}
.stage .pos-hint {
position:absolute; top:10px; right:10px;
background:rgba(0,0,0,.7); color:#fff; font:600 11px ui-monospace,Menlo,monospace;
padding:5px 10px; border-radius:4px; pointer-events:none; z-index:3;
}
.stage .seam-test-btn,
.stage .wand-btn {
position:absolute; bottom:14px; z-index:4;
background:rgba(0,0,0,.75); color:#fff; border:1px solid #555;
padding:9px 14px; border-radius:6px; cursor:pointer;
font:600 12px var(--sans); letter-spacing:.06em;
display:flex; align-items:center; gap:8px;
}
.stage .seam-test-btn { right:14px; }
.stage .wand-btn { right:185px; }
.stage .seam-test-btn:hover, .stage .wand-btn:hover { background:#000; }
.stage .seam-test-btn.active { background:var(--orange); border-color:var(--orange); color:#fff; }
.stage .wand-btn.active { background:#7ad17a; border-color:#5fa85f; color:#0a2a0a; }
.stage .seam-test-btn .icon, .stage .wand-btn .icon { font-size:14px; }
.stage.wand-mode { cursor:crosshair; }
.stage canvas.wand-overlay {
position:absolute; pointer-events:none;
user-select:none; z-index:2;
image-rendering:pixelated;
}
.stage canvas.seam-canvas {
max-width:100%; max-height:96vh; display:block;
user-select:none; pointer-events:none;
image-rendering:pixelated;
}
.stage .seam-cross {
position:absolute; pointer-events:none;
border:1px dashed rgba(255,174,58,.6); box-sizing:border-box;
}
/* Auto-suggested dotted regions */
.stage .sgst {
position:absolute; box-sizing:border-box; cursor:pointer;
border:2px dashed rgba(120,180,255,.85);
background:rgba(120,180,255,.10);
transition:background .12s, border-color .12s;
}
.stage .sgst.s-seam {
border-color:rgba(255,174,58,.95);
background:rgba(255,174,58,.16);
}
.stage .sgst:hover { background:rgba(120,180,255,.30); border-color:#7cb8ff; }
.stage .sgst.s-seam:hover { background:rgba(255,174,58,.36); border-color:#ffae3a; }
.stage .sgst .sgst-label {
position:absolute; top:-18px; left:0;
background:#3b78d8; color:#fff; font:600 9px var(--sans);
letter-spacing:.06em; padding:1px 4px; border-radius:2px;
pointer-events:none; white-space:nowrap;
}
.stage .sgst.s-seam .sgst-label { background:#ffae3a; color:#3a2a00; }
/* Right panel */
.panel {
padding:14px 18px; display:flex; flex-direction:column; gap:12px;
overflow-y:auto; background:#fcfaf4;
}
.panel .closex {
align-self:flex-end; background:none; border:none;
color:var(--faint); cursor:pointer; font-size:22px; line-height:1; padding:0;
margin-top:-4px;
}
.panel h3 { font:600 11px var(--sans); letter-spacing:.12em;
text-transform:uppercase; color:var(--faint); margin:0; }
.panel .id-row { font:600 17px ui-monospace,Menlo,monospace; }
.panel .id-row .pos {
font:11px var(--sans); color:var(--faint); margin-left:8px; letter-spacing:0;
}
.panel .cat { font:13px var(--sans); color:var(--ink); }
.panel .reason {
background:#fdf6e6; border-left:3px solid var(--gold);
padding:9px 11px; font:12.5px/1.45 var(--sans); color:var(--ink);
border-radius:0 4px 4px 0;
}
.panel .conf-bar {
display:flex; align-items:center; gap:8px;
font:11px ui-monospace,Menlo,monospace; color:var(--faint);
}
.panel .conf-bar .bar {
flex:1; height:7px; background:#f3eee2; border-radius:4px; overflow:hidden;
}
.panel .conf-bar .bar-fill { height:100%; background:var(--red); }
.panel .mode-row {
display:flex; gap:6px; padding:4px; background:#f3eee2; border-radius:6px;
}
.panel .mode-row button {
flex:1; padding:7px 0; border:none; background:none; cursor:pointer;
font:600 11px var(--sans); letter-spacing:.06em; color:var(--faint);
border-radius:4px; text-transform:uppercase;
}
.panel .mode-row button.active.m-ghost { background:#ff5252; color:#fff; }
.panel .mode-row button.active.m-seam { background:#ffae3a; color:#3a2a00; }
.panel .mode-row button.active.m-other { background:#ffd54a; color:#3a2a00; }
.panel .rect-count {
text-align:center; font:600 12px ui-monospace,Menlo,monospace; color:var(--faint);
}
.panel .rect-count b.has { color:var(--red); }
.panel .actions { display:flex; flex-direction:column; gap:7px; margin-top:4px; }
.panel .actions button {
padding:13px 14px; border:1px solid var(--line); border-radius:6px;
background:#fff; cursor:pointer; font:700 13px var(--sans);
display:flex; align-items:center; justify-content:space-between; gap:10px;
transition:transform .08s;
}
.panel .actions button:active { transform:scale(0.98); }
.panel .actions button kbd {
background:#f3eee2; border:1px solid var(--line); border-radius:3px;
padding:2px 7px; font:700 11px ui-monospace,Menlo,monospace;
}
.panel .actions .b-fp { border-color:var(--green); color:var(--green); }
.panel .actions .b-fp:hover { background:var(--green); color:#fff; }
.panel .actions .b-ghost { border-color:var(--red); color:var(--red); }
.panel .actions .b-ghost:hover { background:var(--red); color:#fff; }
.panel .actions .b-seam { border-color:var(--orange); color:var(--orange); }
.panel .actions .b-seam:hover { background:var(--orange); color:#fff; }
.panel .actions .b-other { border-color:var(--gold); color:#7a5500; }
.panel .actions .b-other:hover { background:var(--gold); color:#fff; }
.panel .actions .b-skip { color:var(--faint); }
.panel .actions .b-ghost.gated,
.panel .actions .b-seam.gated,
.panel .actions .b-other.gated { opacity:.45; cursor:not-allowed; }
.panel .actions .b-fix { border-color:#3b78d8 !important; color:#3b78d8 !important; }
.panel .actions .b-fix:hover:not(.gated) { background:#3b78d8 !important; color:#fff !important; }
.panel .actions .b-fix.gated { opacity:.45; cursor:not-allowed; }
.panel .actions .b-fix.busy { background:#3b78d8 !important; color:#fff !important; cursor:wait; }
.panel .actions .b-regen { border-color:#7a4ab8; color:#7a4ab8; }
.panel .actions .b-regen:hover:not(.busy) { background:#7a4ab8; color:#fff; }
.panel .actions .b-regen.busy { background:#7a4ab8; color:#fff; cursor:wait; }
.panel .actions .b-remove { border-color:#c44b4b; color:#c44b4b; }
.panel .actions .b-remove:hover:not(.gated):not(.busy) { background:#c44b4b; color:#fff; }
.panel .actions .b-remove.gated { opacity:.45; cursor:not-allowed; }
.panel .actions .b-remove.busy { background:#c44b4b; color:#fff; cursor:wait; }
.panel .undo-row {
display:flex; gap:6px; font:11px var(--sans); color:var(--faint); align-items:center;
}
.panel .undo-row button {
background:none; border:none; color:var(--faint); cursor:pointer;
text-decoration:underline; font:11px var(--sans); padding:0;
}
.panel .notes-row {
display:flex; flex-direction:column; gap:4px;
}
.panel .notes-row textarea {
border:1px solid var(--line); border-radius:4px; padding:6px 8px;
font:12px var(--sans); resize:vertical; min-height:48px;
}
.empty {
text-align:center; padding:80px 20px; color:var(--faint);
font:14px var(--sans);
}
.empty h2 { font:400 28px var(--serif); color:var(--ink); margin-bottom:10px; }
.filter-bar { display:flex; gap:6px; flex-wrap:wrap; }
.filter-bar button {
border:1px solid var(--line); background:#fff; border-radius:14px;
padding:4px 10px; cursor:pointer; font:600 11px var(--sans);
color:var(--faint); letter-spacing:.04em;
}
.filter-bar button.active { background:var(--ink); color:#fff; border-color:var(--ink); }
.toast {
position:fixed; bottom:30px; left:50%; transform:translateX(-50%);
background:rgba(31,24,8,.95); color:#fff; padding:10px 18px;
border-radius:6px; font:600 13px var(--sans);
opacity:0; transition:opacity .2s; pointer-events:none; z-index:200;
}
.toast.show { opacity:1; }
/* Live analysis panel */
.analysis {
background:#fff; border:1px solid var(--line); border-radius:8px;
padding:12px 16px; margin-bottom:12px;
display:none; /* shown once labels >= 5 */
}
.analysis.open { display:block; }
.analysis .head {
display:flex; align-items:baseline; gap:10px; margin-bottom:10px;
}
.analysis .head h2 {
margin:0; font:600 11px var(--sans); letter-spacing:.16em;
text-transform:uppercase; color:var(--faint);
}
.analysis .head .live {
font:600 10px var(--sans); color:var(--green); letter-spacing:.1em;
}
.analysis .head .toggle {
margin-left:auto; font:11px var(--sans); color:var(--faint);
cursor:pointer; background:none; border:none; text-decoration:underline;
}
.analysis-grid {
display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));
gap:14px;
}
.ana-card {
border:1px solid var(--line); border-radius:6px; padding:10px 12px;
background:#fcfaf4;
}
.ana-card h3 {
margin:0 0 6px 0; font:600 10px var(--sans); letter-spacing:.12em;
text-transform:uppercase; color:var(--faint);
}
.ana-card .big { font:700 22px ui-monospace,Menlo,monospace; }
.ana-card .sub { font:11px var(--sans); color:var(--faint); margin-top:2px; }
.ana-card .bar {
height:6px; background:#f3eee2; border-radius:3px; overflow:hidden; margin:6px 0;
}
.ana-card .bar-fill { height:100%; }
.ana-card .bf-good { background:var(--green); }
.ana-card .bf-bad { background:var(--red); }
.ana-card .bf-mid { background:var(--orange); }
.ana-card table { width:100%; font:11px var(--sans); border-collapse:collapse; }
.ana-card td { padding:2px 0; }
.ana-card td.r { text-align:right; font-family:ui-monospace,Menlo,monospace; color:var(--faint); }
.ana-card td.n { width:42px; text-align:right; color:var(--faint); }
.ana-card .mini-bar {
display:inline-block; height:5px; background:#f3eee2; border-radius:2px;
vertical-align:middle; margin-right:5px; width:60px; overflow:hidden;
}
.ana-card .mini-bar .mbf { display:block; height:100%; background:var(--red); }
</style>
</head>
<body>
<header>
<h1>Ghost Review</h1>
<span class="meta">draw rects · pick a defect · go fast</span>
<div class="stats">
<span class="clean">CLEAN <b id="stat-fp">0</b></span>
<span class="ghost">GHOST <b id="stat-ghost">0</b></span>
<span class="seam">SEAM <b id="stat-seam">0</b></span>
<span class="sk">SKIP <b id="stat-skip">0</b></span>
<span>UNLABELED <b id="stat-todo">—</b></span>
<span>TOTAL <b id="stat-total">—</b></span>
</div>
</header>
<main>
<div class="controls">
<label>Category</label>
<select id="filter-cat"><option value="">all</option></select>
<label>Sort</label>
<select id="filter-sort">
<optgroup label="Detector confidence">
<option value="conf-desc">Confidence ↓ (worst first)</option>
<option value="conf-asc">Confidence ↑</option>
</optgroup>
<optgroup label="Age">
<option value="id-desc">Newest</option>
<option value="id-asc">Oldest</option>
</optgroup>
<optgroup label="Grouping">
<option value="cat-asc">Category A→Z (group by category)</option>
<option value="verdict">By verdict (unlabeled first)</option>
<option value="reason-len">Reason length (long = uncertain)</option>
</optgroup>
</select>
<label>Density</label>
<input type="range" id="density" min="120" max="360" step="20" value="200">
<span class="density-val" id="density-val">200px</span>
<div class="filter-bar">
<button class="active" data-filter="unlabeled">Unlabeled</button>
<button data-filter="fp">Clean</button>
<button data-filter="ghost">Ghost</button>
<button data-filter="seam">Seam</button>
<button data-filter="all">All</button>
</div>
<button id="bulk-toggle" style="padding:5px 12px;border:1px solid var(--line);border-radius:14px;background:#fff;font:600 11px var(--sans);cursor:pointer;letter-spacing:.06em;">🔲 BULK SELECT</button>
<div class="legend">
<kbd>F</kbd> clean
<kbd>G</kbd> ghost
<kbd>B</kbd> seam-break
<kbd>S</kbd> skip
<kbd>U</kbd> undo rect
<kbd>←</kbd><kbd>→</kbd> nav
<kbd>Esc</kbd> close
</div>
</div>
<section class="analysis" id="analysis">
<div class="head">
<h2>Live Detector Analysis</h2>
<span class="live">● updates as you label</span>
<button class="toggle" id="ana-toggle">hide</button>
</div>
<div class="analysis-grid" id="ana-grid"></div>
</section>
<div id="grid-wrap" style="position:relative;">
<div id="grid" class="grid"></div>
<div id="marquee" class="marquee"></div>
</div>
<div id="empty" class="empty" style="display:none">
<h2>Nothing to review</h2>
<p>All flagged items in this filter have been labeled. Switch filter above.</p>
</div>
</main>
<div id="bulk-bar" class="bulk-bar" role="region" aria-label="Bulk selection actions">
<span><span class="count" id="bulk-count">0</span> selected</span>
<button id="bulk-all-page" title="select every tile currently rendered">Select all on page</button>
<button id="bulk-clear">Clear</button>
<button id="bulk-delete" class="danger" title="soft-delete every selected design + save its prompt/motifs to bad-aesthetic-patterns.jsonl so the next build avoids that aesthetic">🗑 Delete & learn</button>
</div>
<div class="modal" id="modal" aria-hidden="true">
<div class="modal-inner">
<div class="stage" id="stage">
<button class="nav prev" id="prev-btn" title="prev (←)">‹</button>
<div class="img-wrap" id="img-wrap">
<img class="main" id="main-img" alt="">
<canvas class="seam-canvas" id="seam-canvas" style="display:none"></canvas>
</div>
<button class="nav next" id="next-btn" title="next (→)">›</button>
<div class="top-hint" id="top-hint">Drag to mark defect regions — then press <b>G</b> ghost or <b>B</b> seam-break</div>
<div class="pos-hint" id="pos-hint">—</div>
<button class="seam-test-btn" id="seam-test-btn" title="quadrant-swap to bring outer edges to the center (X)">
<span class="icon">⊞</span> Seam Test <kbd style="background:#222;color:#fff;border:1px solid #555;padding:0 5px;border-radius:3px;font:600 10px ui-monospace,Menlo,monospace;margin-left:4px">X</kbd>
</button>
<button class="wand-btn" id="wand-btn" title="click any motif to isolate it via flood-fill (W)">
<span class="icon">🪄</span> Wand <kbd style="background:#222;color:#fff;border:1px solid #555;padding:0 5px;border-radius:3px;font:600 10px ui-monospace,Menlo,monospace;margin-left:4px">W</kbd>
</button>
</div>
<div class="panel">
<button class="closex" id="close-btn" title="close (esc)">×</button>
<div class="id-row">#<span id="d-id">—</span><span class="pos" id="d-pos"></span></div>
<div class="cat" id="d-cat">—</div>
<h3>Detector's reason</h3>
<div class="reason" id="d-reason">—</div>
<div class="conf-bar">
<span>conf</span>
<div class="bar"><div class="bar-fill" id="d-conf-fill" style="width:0%"></div></div>
<span id="d-conf-num">0.00</span>
</div>
<h3>Defect type (for drawing)</h3>
<div class="mode-row">
<button class="m-ghost active" data-mode="ghost">Ghost</button>
<button class="m-seam" data-mode="seam">Seam-break</button>
<button class="m-other" data-mode="other">Other</button>
</div>
<div class="rect-count" id="rect-count">no regions marked yet</div>
<h3>Auto-detect suggestions
<span style="margin-left:8px"><input type="checkbox" id="auto-on" checked> <label for="auto-on" style="font:11px var(--sans); color:var(--faint); text-transform:none; letter-spacing:0;">show dotted previews</label></span>
</h3>
<div style="display:flex; gap:8px; align-items:center; font:11px ui-monospace,Menlo,monospace;">
<span style="color:var(--faint)">strict</span>
<input type="range" id="tolerance" min="0" max="100" step="5" value="40" style="flex:1">
<span style="color:var(--faint)">loose</span>
<span id="tol-val" style="min-width:30px; text-align:right">40</span>
</div>
<div style="font:11px var(--sans); color:var(--faint); margin-top:-4px">
<span id="sgst-count">—</span> suggested regions · click a dotted box to accept it as <span id="sgst-mode-label" style="font-weight:600">ghost</span>
</div>
<div class="undo-row">
<button id="undo-btn">↶ undo last rect (U)</button>
<span style="margin-left:auto">·</span>
<button id="clear-btn">clear all</button>
</div>
<h3 style="color:var(--ink); font-size:12px;">⚡ Repair Actions</h3>
<div class="actions">
<button class="b-smart" id="btn-smart" style="border:2px solid #c79a3a; color:#a07000; background:linear-gradient(180deg, #fff7e0, #fef0c8);">
🎯 Smart Fix · auto-detect clean motif → re-tile <kbd>A</kbd>
</button>
<button class="b-fix gated" id="btn-fix">
🪄 Surgical Fix · marked regions <kbd>R</kbd>
</button>
<button class="b-remove gated" id="btn-remove">
🗑 Remove + Fill · erase marked regions <kbd>D</kbd>
</button>
<button class="b-regen" id="btn-regen">
↻ Reverse-Engineer + Regenerate Whole <kbd>E</kbd>
</button>
</div>
<div style="font:11px var(--sans); color:var(--faint); margin-top:-4px">
<b>Fix</b>: bbox of marked rects → Gemini image-edit, regenerates that area · ~10s<br>
<b>Remove</b>: erase the marked regions and fill in with surrounding pattern · ~10s<br>
<b>Regen</b>: Gemini Vision reverse-engineers a clean prompt → fresh roll · ~15s
</div>
<div id="fix-status" style="font:11.5px var(--sans); color:var(--faint); display:none; padding:8px 10px; background:#eef4ff; border-left:3px solid #3b78d8; border-radius:0 4px 4px 0;"></div>
<h3 style="color:var(--ink); font-size:12px;">🪄 Magic Wand <span style="margin-left:6px;font:600 10px var(--sans); color:var(--faint);">(<kbd>W</kbd> to enter wand mode · click any motif)</span></h3>
<div id="wand-panel" style="display:none; padding:10px 12px; border:1px solid #5fa85f; border-radius:6px; background:#f0f9f0; display:flex; flex-direction:column; gap:8px;">
<div style="display:flex; gap:8px; align-items:center; font:11px ui-monospace,Menlo,monospace;">
<span id="wand-swatch" style="display:inline-block; width:18px; height:18px; border-radius:3px; border:1px solid #888; vertical-align:middle"></span>
<span id="wand-stats">click a pixel</span>
</div>
<div style="display:flex; gap:6px; align-items:center;">
<span style="font:10px var(--sans); color:var(--faint); width:50px">tolerance</span>
<input type="range" id="wand-tol" min="0" max="120" step="2" value="28" style="flex:1">
<span id="wand-tol-val" style="font:600 11px ui-monospace,Menlo,monospace; color:var(--faint); width:30px; text-align:right">28</span>
</div>
<div style="display:flex; gap:6px;">
<label style="font:11px var(--sans); align-self:center;">recolor →</label>
<input type="color" id="wand-color" value="#222222" style="flex:1; height:30px; border:1px solid var(--line); border-radius:4px;">
<button id="wand-recolor" style="padding:6px 10px; border:1px solid var(--line); border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);">Apply</button>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button id="wand-erase" style="flex:1; padding:7px 8px; border:1px solid var(--red); color:var(--red); border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);">🗑 Erase</button>
<button id="wand-copy" style="flex:1; padding:7px 8px; border:1px solid #3b78d8; color:#3b78d8; border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);">⎘ Copy</button>
<button id="wand-paste" style="flex:1; padding:7px 8px; border:1px solid #3b78d8; color:#3b78d8; border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);" disabled>📋 Paste</button>
<button id="wand-clear" style="flex:1; padding:7px 8px; border:1px solid var(--line); color:var(--faint); border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);">Clear</button>
</div>
<button id="wand-apply" style="padding:9px 10px; border:1px solid #5fa85f; color:#fff; background:#5fa85f; border-radius:4px; cursor:pointer; font:700 12px var(--sans);">
💾 Apply All Edits → save as new design
</button>
<div style="font:10.5px var(--sans); color:var(--faint); line-height:1.4">
edits are non-destructive until <b>Apply</b>. you can recolor, erase, copy/paste multiple regions before committing.
</div>
</div>
<h3>Verdict (saves to training labels)</h3>
<div class="actions">
<button class="b-fp" id="btn-fp">✓ Clean (false-positive) <kbd>F</kbd></button>
<button class="b-ghost gated" id="btn-ghost">⚠ Ghost-layer · <span id="bn-ghost">0</span> regions <kbd>G</kbd></button>
<button class="b-seam gated" id="btn-seam">⤬ Seam-break · <span id="bn-seam">0</span> regions <kbd>B</kbd></button>
<button class="b-other gated" id="btn-other">◆ Other defect · <span id="bn-other">0</span> regions <kbd>O</kbd></button>
<button class="b-skip" id="btn-skip">– Skip <kbd>S</kbd></button>
</div>
<div class="notes-row" id="notes-row" style="display:none">
<label style="font:11px var(--sans); color:var(--faint)">Notes for "other" defect:</label>
<textarea id="notes" placeholder="describe the defect…"></textarea>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(() => {
const $ = (id) => document.getElementById(id);
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' });
}
const STAT = { fp:0, ghost:0, seam:0, other:0, skip:0, total:0 };
// State
let ALL = [];
const LABELS = new Map(); // id -> { verdict, crops }
let VIEW = [];
let CURRENT = null;
let FILTER = 'unlabeled';
let CAT_FILTER = '';
let SORT = 'conf-desc';
// Per-item drawing state
let RECTS = []; // [{x,y,w,h,tag}] normalized [0..1]
let DRAW_MODE = 'ghost'; // 'ghost' | 'seam' | 'other'
// Density slider
const density = $('density'), densityVal = $('density-val');
density.value = localStorage.getItem('gr-density') || 200;
densityVal.textContent = density.value + 'px';
document.documentElement.style.setProperty('--card-min', density.value + 'px');
density.oninput = () => {
document.documentElement.style.setProperty('--card-min', density.value + 'px');
densityVal.textContent = density.value + 'px';
localStorage.setItem('gr-density', density.value);
};
$('filter-sort').value = localStorage.getItem('gr-sort') || 'conf-desc';
$('filter-sort').onchange = e => { SORT = e.target.value; localStorage.setItem('gr-sort', SORT); recomputeView(); render(); };
$('filter-cat').onchange = e => { CAT_FILTER = e.target.value; recomputeView(); render(); };
document.querySelectorAll('.filter-bar button').forEach(b => {
b.onclick = () => {
document.querySelectorAll('.filter-bar button').forEach(x => x.classList.remove('active'));
b.classList.add('active');
FILTER = b.dataset.filter;
recomputeView(); render();
};
});
function recomputeView() {
VIEW = ALL.filter(item => {
if (CAT_FILTER && item.category !== CAT_FILTER) return false;
const lbl = LABELS.get(item.id);
if (FILTER === 'unlabeled') return !lbl;
if (FILTER === 'fp') return lbl && lbl.verdict === 'fp';
if (FILTER === 'ghost') return lbl && lbl.verdict === 'tp_ghost';
if (FILTER === 'seam') return lbl && lbl.verdict === 'tp_seam';
return true;
});
if (SORT === 'conf-desc') VIEW.sort((a,b) => (b.confidence||0) - (a.confidence||0));
else if (SORT === 'conf-asc') VIEW.sort((a,b) => (a.confidence||0) - (b.confidence||0));
else if (SORT === 'id-desc') VIEW.sort((a,b) => b.id - a.id);
else if (SORT === 'id-asc') VIEW.sort((a,b) => a.id - b.id);
else if (SORT === 'cat-asc') VIEW.sort((a,b) => (a.category||'').localeCompare(b.category||'') || b.id - a.id);
else if (SORT === 'verdict') {
const rank = { undefined: 0, 'fp':3, 'tp_ghost':1, 'tp_seam':2, 'tp_other':4, 'skip':5 };
VIEW.sort((a,b) => {
const va = LABELS.get(a.id)?.verdict, vb = LABELS.get(b.id)?.verdict;
return (rank[va] || 0) - (rank[vb] || 0) || b.id - a.id;
});
}
else if (SORT === 'reason-len') VIEW.sort((a,b) => ((b.reason||'').length) - ((a.reason||'').length));
}
function updateStats() {
$('stat-fp').textContent = STAT.fp;
$('stat-ghost').textContent = STAT.ghost;
$('stat-seam').textContent = STAT.seam;
$('stat-skip').textContent = STAT.skip;
$('stat-total').textContent = STAT.total.toLocaleString();
const labeled = STAT.fp + STAT.ghost + STAT.seam + STAT.other + STAT.skip;
$('stat-todo').textContent = (STAT.total - labeled).toLocaleString();
renderAnalysis();
}
// ── Live analysis (downtime use) ───────────────────────────────────────
let analysisHidden = (localStorage.getItem('gr-ana-hidden') === '1');
$('ana-toggle').onclick = () => {
analysisHidden = !analysisHidden;
localStorage.setItem('gr-ana-hidden', analysisHidden ? '1' : '0');
renderAnalysis();
};
function renderAnalysis() {
const labeledItems = ALL.filter(i => LABELS.has(i.id));
const decided = labeledItems.filter(i => {
const v = LABELS.get(i.id).verdict;
return v === 'fp' || v === 'tp_ghost' || v === 'tp_seam' || v === 'tp_other' || v === 'tp';
});
const ana = $('analysis');
if (decided.length < 5 || analysisHidden) {
if (decided.length < 5) ana.classList.remove('open');
else if (analysisHidden) {
ana.classList.add('open');
$('ana-grid').innerHTML = `<div class="ana-card"><h3>Analysis</h3><div class="sub">hidden — <button class="toggle" onclick="document.getElementById('ana-toggle').click()">show</button></div></div>`;
$('ana-toggle').textContent = 'show';
return;
}
return;
}
ana.classList.add('open');
$('ana-toggle').textContent = 'hide';
// Detector precision = TP / (TP+FP) — only items where there IS a real defect
// (Steve's labels). Detector "positive" = every item in our flagged set, so
// precision-of-detector = (decided that are TP) / decided
const tpCount = decided.filter(i => LABELS.get(i.id).verdict !== 'fp').length;
const fpCount = decided.length - tpCount;
const precision = tpCount / decided.length;
const fpRate = fpCount / decided.length;
// Defect-type split among true positives
const ghostN = decided.filter(i => LABELS.get(i.id).verdict === 'tp_ghost' || LABELS.get(i.id).verdict === 'tp').length;
const seamN = decided.filter(i => LABELS.get(i.id).verdict === 'tp_seam').length;
const otherN = decided.filter(i => LABELS.get(i.id).verdict === 'tp_other').length;
// Per-confidence-band FP rate
const bands = [
{ name: '0.50–0.69', min: 0.5, max: 0.7 },
{ name: '0.70–0.89', min: 0.7, max: 0.9 },
{ name: '0.90–1.00', min: 0.9, max: 1.01 },
];
for (const b of bands) {
const inb = decided.filter(i => i.confidence >= b.min && i.confidence < b.max);
b.n = inb.length;
b.fp = inb.filter(i => LABELS.get(i.id).verdict === 'fp').length;
b.fpRate = inb.length ? (b.fp / inb.length) : 0;
}
// Per-category FP rate (top 6 by labeled count)
const byCat = new Map();
for (const it of decided) {
const c = it.category || '—';
const v = LABELS.get(it.id).verdict;
const r = byCat.get(c) || { n: 0, fp: 0 };
r.n++;
if (v === 'fp') r.fp++;
byCat.set(c, r);
}
const topCats = [...byCat.entries()]
.map(([c, r]) => ({ c, n: r.n, fp: r.fp, fpRate: r.n ? r.fp / r.n : 0 }))
.sort((a,b) => b.n - a.n).slice(0, 6);
// Render
const precClass = precision >= 0.7 ? 'bf-good' : precision >= 0.4 ? 'bf-mid' : 'bf-bad';
const grid = $('ana-grid');
grid.innerHTML = `
<div class="ana-card">
<h3>Detector Precision</h3>
<div class="big">${(precision*100).toFixed(1)}%</div>
<div class="bar"><div class="bar-fill ${precClass}" style="width:${precision*100}%"></div></div>
<div class="sub">of ${decided.length} labeled — ${tpCount} true / ${fpCount} false</div>
</div>
<div class="ana-card">
<h3>False-Positive Rate</h3>
<div class="big" style="color:${fpRate >= 0.5 ? 'var(--red)' : 'var(--ink)'}">${(fpRate*100).toFixed(1)}%</div>
<div class="bar"><div class="bar-fill bf-bad" style="width:${fpRate*100}%"></div></div>
<div class="sub">${fpCount >= 100 ? 'enough signal to refine the prompt' : `need ~${100 - fpCount} more FP labels for prompt refinement`}</div>
</div>
<div class="ana-card">
<h3>Defect Type Mix</h3>
<table>
<tr><td>⚠ ghost-layer</td><td class="r">${ghostN}</td></tr>
<tr><td>⤬ seam-break</td><td class="r">${seamN}</td></tr>
<tr><td>◆ other</td><td class="r">${otherN}</td></tr>
</table>
<div class="sub">${(ghostN + seamN + otherN)} true defects out of ${decided.length} labeled</div>
</div>
<div class="ana-card">
<h3>FP-Rate by Confidence Band</h3>
<table>
${bands.map(b => `
<tr>
<td>${b.name}</td>
<td><span class="mini-bar"><span class="mbf" style="width:${b.fpRate*100}%"></span></span> ${(b.fpRate*100).toFixed(0)}%</td>
<td class="n">${b.n}</td>
</tr>
`).join('')}
</table>
<div class="sub">if conf=0.9+ band still has high FP, the prompt is broken (not the threshold)</div>
</div>
<div class="ana-card">
<h3>FP-Rate by Category (top 6)</h3>
<table>
${topCats.map(c => `
<tr>
<td title="${c.c}" style="max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${c.c}</td>
<td><span class="mini-bar"><span class="mbf" style="width:${c.fpRate*100}%"></span></span> ${(c.fpRate*100).toFixed(0)}%</td>
<td class="n">${c.n}</td>
</tr>
`).join('')}
</table>
<div class="sub">categories needing per-category prompt tweaks bubble up here</div>
</div>
<div class="ana-card">
<h3>Coverage Progress</h3>
<div class="big">${decided.length}/${STAT.total.toLocaleString()}</div>
<div class="bar"><div class="bar-fill bf-good" style="width:${(decided.length/STAT.total)*100}%"></div></div>
<div class="sub">${((decided.length/STAT.total)*100).toFixed(2)}% of flagged set labeled · ETA at current pace</div>
</div>
`;
}
function render() {
const grid = $('grid');
grid.innerHTML = '';
const empty = $('empty');
if (!VIEW.length) { empty.style.display = ''; return; }
empty.style.display = 'none';
const slice = VIEW.slice(0, 400);
const frag = document.createDocumentFragment();
for (const item of slice) {
const tile = document.createElement('div');
tile.className = 'tile';
tile.dataset.id = item.id;
const lbl = LABELS.get(item.id);
if (lbl) tile.classList.add('lbl-' + lbl.verdict);
if (SELECTED.has(item.id)) tile.classList.add('sel');
const pillText = lbl ?
(lbl.verdict === 'fp' ? '✓ CLEAN' :
lbl.verdict === 'tp_ghost' ? '⚠ GHOST' :
lbl.verdict === 'tp_seam' ? '⤬ SEAM' :
lbl.verdict === 'tp_other' ? '◆ OTHER' : '') : '';
tile.innerHTML = `
<img loading="lazy" src="${item.image_url}" alt="${item.id}">
<div class="badge">${item.category || ''}</div>
<div class="conf">${(item.confidence || 0).toFixed(2)}</div>
<div class="when" title="flagged ${item.created_at || ''}">🕓 ${fmtDate(item.created_at)}</div>
${pillText ? `<div class="verdict-pill">${pillText}</div>` : ''}
`;
tile.onclick = (e) => {
if (BULK_MODE) {
toggleSelection(item.id, e.shiftKey);
} else {
open(VIEW.indexOf(item));
}
};
frag.appendChild(tile);
}
grid.appendChild(frag);
}
// ── Bulk select mode ───────────────────────────────────────────────────
let BULK_MODE = false;
const SELECTED = new Set();
let LAST_CLICKED_ID = null;
function setBulkMode(on) {
BULK_MODE = !!on;
document.body.classList.toggle('bulk-mode', BULK_MODE);
$('bulk-toggle').classList.toggle('active', BULK_MODE);
$('bulk-toggle').textContent = BULK_MODE ? '✕ EXIT BULK' : '🔲 BULK SELECT';
if (!BULK_MODE) { SELECTED.clear(); refreshBulkUI(); }
refreshBulkUI();
}
function refreshBulkUI() {
$('bulk-count').textContent = SELECTED.size.toLocaleString();
$('bulk-bar').classList.toggle('show', BULK_MODE && SELECTED.size > 0);
// sync tile visuals without full re-render
document.querySelectorAll('.tile').forEach(t => {
const id = parseInt(t.dataset.id, 10);
t.classList.toggle('sel', SELECTED.has(id));
});
}
function toggleSelection(id, shiftRange) {
if (shiftRange && LAST_CLICKED_ID !== null) {
// range select on current VIEW slice
const tilesInOrder = Array.from(document.querySelectorAll('.tile')).map(t => parseInt(t.dataset.id, 10));
const a = tilesInOrder.indexOf(LAST_CLICKED_ID), b = tilesInOrder.indexOf(id);
if (a >= 0 && b >= 0) {
const [lo, hi] = a < b ? [a, b] : [b, a];
for (let i = lo; i <= hi; i++) SELECTED.add(tilesInOrder[i]);
} else SELECTED.add(id);
} else {
if (SELECTED.has(id)) SELECTED.delete(id); else SELECTED.add(id);
}
LAST_CLICKED_ID = id;
refreshBulkUI();
}
// Marquee drag-select on the grid wrapper
(function initMarquee() {
const wrap = $('grid-wrap');
const mq = $('marquee');
let dragging = false, sx = 0, sy = 0, addMode = true;
wrap.addEventListener('mousedown', (e) => {
if (!BULK_MODE) return;
// only start marquee when not clicking on a tile (or its children)
if (e.target.closest('.tile')) return;
if (e.button !== 0) return;
dragging = true;
addMode = !e.altKey; // alt-drag = deselect
const r = wrap.getBoundingClientRect();
sx = e.clientX - r.left; sy = e.clientY - r.top;
mq.style.left = sx + 'px'; mq.style.top = sy + 'px';
mq.style.width = '0px'; mq.style.height = '0px';
mq.style.display = 'block';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const r = wrap.getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
const l = Math.min(sx, x), t = Math.min(sy, y);
const w = Math.abs(x - sx), h = Math.abs(y - sy);
mq.style.left = l + 'px'; mq.style.top = t + 'px';
mq.style.width = w + 'px'; mq.style.height = h + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
mq.style.display = 'none';
const mr = { l: parseFloat(mq.style.left), t: parseFloat(mq.style.top),
r: parseFloat(mq.style.left) + parseFloat(mq.style.width),
b: parseFloat(mq.style.top) + parseFloat(mq.style.height) };
if (mr.r - mr.l < 4 && mr.b - mr.t < 4) return; // ignore tiny clicks
const wrapRect = wrap.getBoundingClientRect();
document.querySelectorAll('.tile').forEach(tile => {
const id = parseInt(tile.dataset.id, 10);
const tr = tile.getBoundingClientRect();
const lx = tr.left - wrapRect.left, ty = tr.top - wrapRect.top;
const rx = lx + tr.width, by = ty + tr.height;
const hit = !(rx < mr.l || lx > mr.r || by < mr.t || ty > mr.b);
if (hit) { if (addMode) SELECTED.add(id); else SELECTED.delete(id); }
});
refreshBulkUI();
});
})();
$('bulk-toggle').onclick = () => setBulkMode(!BULK_MODE);
$('bulk-clear').onclick = () => { SELECTED.clear(); refreshBulkUI(); };
$('bulk-all-page').onclick = () => {
document.querySelectorAll('.tile').forEach(t => SELECTED.add(parseInt(t.dataset.id, 10)));
refreshBulkUI();
};
$('bulk-delete').onclick = async () => {
if (!SELECTED.size) return;
const ids = Array.from(SELECTED);
if (!confirm(`Soft-delete ${ids.length} design${ids.length===1?'':'s'}?\n\nFor each: unpublish PG row, move PNG to quarantine, save prompt+motifs to bad-aesthetic-patterns.jsonl so the next build avoids that aesthetic.\n\nReversible via: node scripts/soft-delete-ghost-flagged.js --restore --apply`)) return;
$('bulk-delete').disabled = true;
$('bulk-delete').textContent = '… deleting';
try {
const r = await fetch('/api/ghost-review/bulk-delete?admin=' + encodeURIComponent(getAdmin()), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, learn: true }),
});
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
// Remove deleted items from VIEW + ALL + SELECTED
const del = new Set(j.unpublished_ids || ids);
window.ALL = (window.ALL || []).filter(x => !del.has(x.id));
VIEW = VIEW.filter(x => !del.has(x.id));
SELECTED.clear();
render();
refreshBulkUI();
toast(`✓ Deleted ${j.unpublished} · ${j.salvaged} salvaged to bad-aesthetic-patterns.jsonl`);
} catch (e) {
alert('Delete failed: ' + e.message);
} finally {
$('bulk-delete').disabled = false;
$('bulk-delete').textContent = '🗑 Delete & learn';
}
};
function getAdmin() {
const u = new URL(location.href);
return u.searchParams.get('admin') || '';
}
function toast(msg) {
const t = document.querySelector('.toast') || (() => {
const el = document.createElement('div'); el.className = 'toast'; document.body.appendChild(el); return el;
})();
t.textContent = msg; t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3500);
}
// ── Modal ──────────────────────────────────────────────────────────────
const modal = $('modal');
const stage = $('stage');
const mainImg = $('main-img');
const imgWrap = $('img-wrap');
const seamCanvas = $('seam-canvas');
let SEAM_VIEW = false;
const activeImageEl = () => SEAM_VIEW ? seamCanvas : mainImg;
function open(idx) {
if (idx < 0 || idx >= VIEW.length) return;
CURRENT = idx;
const item = VIEW[idx];
$('d-id').textContent = item.id;
$('d-pos').textContent = `(${idx + 1} of ${VIEW.length.toLocaleString()})`;
$('d-cat').textContent = item.category || '—';
$('d-reason').textContent = item.reason || '—';
const c = (item.confidence || 0);
$('d-conf-fill').style.width = (c * 100) + '%';
$('d-conf-num').textContent = c.toFixed(2);
// Preload existing crops if already labeled
const lbl = LABELS.get(item.id);
RECTS = (lbl && Array.isArray(lbl.crops)) ?
lbl.crops.map(c => ({ ...c, tag: c.tag || (lbl.verdict === 'tp_seam' ? 'seam' : lbl.verdict === 'tp_other' ? 'other' : 'ghost') })) :
[];
DRAW_MODE = (lbl && lbl.verdict === 'tp_seam') ? 'seam'
: (lbl && lbl.verdict === 'tp_other') ? 'other'
: 'ghost';
updateModeButtons();
mainImg.onload = () => {
drawRects();
updateRectCount();
if (SEAM_VIEW) buildSeamCanvas();
runAutoSuggest();
wandResetForNewImage();
};
mainImg.src = item.image_url;
if (mainImg.complete) {
drawRects();
updateRectCount();
if (SEAM_VIEW) buildSeamCanvas();
runAutoSuggest();
wandResetForNewImage();
}
$('notes').value = (lbl && lbl.notes) || '';
$('notes-row').style.display = DRAW_MODE === 'other' ? '' : 'none';
modal.classList.add('open');
}
function close() { modal.classList.remove('open'); CURRENT = null; }
function navigate(d) {
if (CURRENT === null) return;
const next = CURRENT + d;
if (next < 0 || next >= VIEW.length) return;
open(next);
}
$('close-btn').onclick = close;
$('prev-btn').onclick = () => navigate(-1);
$('next-btn').onclick = () => navigate(1);
// Mode buttons
document.querySelectorAll('.mode-row button').forEach(b => {
b.onclick = () => {
DRAW_MODE = b.dataset.mode;
updateModeButtons();
};
});
function updateModeButtons() {
document.querySelectorAll('.mode-row button').forEach(b => {
b.classList.toggle('active', b.dataset.mode === DRAW_MODE);
});
$('notes-row').style.display = DRAW_MODE === 'other' ? '' : 'none';
const lbl = $('sgst-mode-label');
if (lbl) lbl.textContent = DRAW_MODE;
}
// ── Drawing ────────────────────────────────────────────────────────────
let dragging = false, dragStart = null, dragGhost = null, dragKeep = false;
stage.addEventListener('mousedown', (e) => {
if (WAND_ON) return; // wand uses click handler, not drag-rect
if (e.target.tagName === 'BUTTON') return;
if (e.target.classList.contains('x')) return;
if (CURRENT === null) return;
const wrapRect = imgWrap.getBoundingClientRect();
if (e.clientX < wrapRect.left || e.clientX > wrapRect.right ||
e.clientY < wrapRect.top || e.clientY > wrapRect.bottom) return;
dragging = true;
dragKeep = !!e.shiftKey; // SHIFT-held drag = "keep this element" green dotted rect
dragStart = { x: e.clientX, y: e.clientY, wrap: wrapRect };
dragGhost = document.createElement('div');
dragGhost.className = 'drag-rect';
if (dragKeep) {
dragGhost.style.border = '3px dashed #38c779';
dragGhost.style.background = 'rgba(56,199,121,.18)';
}
dragGhost.style.left = (e.clientX - wrapRect.left) + 'px';
dragGhost.style.top = (e.clientY - wrapRect.top) + 'px';
dragGhost.style.width = '0px';
dragGhost.style.height = '0px';
imgWrap.appendChild(dragGhost);
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const wr = dragStart.wrap;
const x = Math.max(wr.left, Math.min(wr.right, Math.min(e.clientX, dragStart.x)));
const y = Math.max(wr.top, Math.min(wr.bottom, Math.min(e.clientY, dragStart.y)));
const xe = Math.max(wr.left, Math.min(wr.right, Math.max(e.clientX, dragStart.x)));
const ye = Math.max(wr.top, Math.min(wr.bottom, Math.max(e.clientY, dragStart.y)));
dragGhost.style.left = (x - wr.left) + 'px';
dragGhost.style.top = (y - wr.top) + 'px';
dragGhost.style.width = (xe - x) + 'px';
dragGhost.style.height = (ye - y) + 'px';
$('pos-hint').textContent = `${Math.round(xe-x)}×${Math.round(ye-y)}px`;
});
document.addEventListener('mouseup', (e) => {
if (!dragging) return;
dragging = false;
const wr = dragStart.wrap;
const x0 = Math.max(wr.left, Math.min(e.clientX, dragStart.x));
const y0 = Math.max(wr.top, Math.min(e.clientY, dragStart.y));
const x1 = Math.min(wr.right, Math.max(e.clientX, dragStart.x));
const y1 = Math.min(wr.bottom,Math.max(e.clientY, dragStart.y));
if (dragGhost && dragGhost.parentNode) dragGhost.parentNode.removeChild(dragGhost);
dragGhost = null;
if ((x1 - x0) < 6 || (y1 - y0) < 6) return; // ignore micro-drags
// Use ACTIVE image element's rect (canvas in seam-view, img otherwise)
// so coords normalize to the visible frame, not the wrap padding.
const activeRect = activeImageEl().getBoundingClientRect();
if (activeRect.width <= 0) return;
const wasKeep = dragKeep;
dragKeep = false;
RECTS.push({
x: (x0 - activeRect.left) / activeRect.width,
y: (y0 - activeRect.top) / activeRect.height,
w: (x1 - x0) / activeRect.width,
h: (y1 - y0) / activeRect.height,
tag: wasKeep ? 'keep' : DRAW_MODE,
view: SEAM_VIEW ? 'seam-quadrant-swap' : 'original',
});
drawRects();
updateRectCount();
});
function drawRects() {
// Remove existing rect elements
imgWrap.querySelectorAll('.rect, .seam-cross').forEach(el => el.remove());
const activeEl = activeImageEl();
const r = activeEl.getBoundingClientRect();
const wr = imgWrap.getBoundingClientRect();
const offX = r.left - wr.left, offY = r.top - wr.top;
// When in seam-view, draw a faint cross at the center to mark the seam-join location
if (SEAM_VIEW && r.width > 0) {
const vline = document.createElement('div');
vline.className = 'seam-cross';
vline.style.left = (offX + r.width / 2) + 'px';
vline.style.top = offY + 'px';
vline.style.width = '0px';
vline.style.height = r.height + 'px';
vline.style.borderLeft = '1px dashed rgba(255,174,58,.55)';
imgWrap.appendChild(vline);
const hline = document.createElement('div');
hline.className = 'seam-cross';
hline.style.left = offX + 'px';
hline.style.top = (offY + r.height / 2) + 'px';
hline.style.width = r.width + 'px';
hline.style.height = '0px';
hline.style.borderTop = '1px dashed rgba(255,174,58,.55)';
imgWrap.appendChild(hline);
}
RECTS.forEach((rc, i) => {
const el = document.createElement('div');
el.className = 'rect r-' + (rc.tag || 'ghost');
const left = offX + rc.x * r.width;
const top = offY + rc.y * r.height;
el.style.left = left + 'px';
el.style.top = top + 'px';
el.style.width = (rc.w * r.width) + 'px';
el.style.height = (rc.h * r.height) + 'px';
el.innerHTML = `<div class="label">${(rc.tag || 'ghost').toUpperCase()} #${i+1}</div><div class="x" data-i="${i}" title="delete (right-click also works)">×</div>`;
el.oncontextmenu = (ev) => { ev.preventDefault(); RECTS.splice(i,1); drawRects(); updateRectCount(); };
el.querySelector('.x').onclick = (ev) => { ev.stopPropagation(); RECTS.splice(i,1); drawRects(); updateRectCount(); };
imgWrap.appendChild(el);
});
}
// Keep rects in sync if window resizes
window.addEventListener('resize', () => { if (CURRENT !== null) drawRects(); });
function updateRectCount() {
const gn = RECTS.filter(r => r.tag === 'ghost').length;
const sn = RECTS.filter(r => r.tag === 'seam').length;
const on = RECTS.filter(r => r.tag === 'other').length;
const kn = RECTS.filter(r => r.tag === 'keep').length;
const defects = gn + sn + on;
$('rect-count').innerHTML = RECTS.length
? `<b class="has">${RECTS.length}</b> mark${RECTS.length>1?'s':''} · ${gn} ghost / ${sn} seam / ${on} other` +
(kn ? ` · <span style="color:#1f6a2c">${kn} keep 🛡</span>` : '') +
'<br><span style="font:10px var(--sans); color:var(--faint)">drag = defect · <kbd style="background:#f3eee2;border:1px solid var(--line);padding:0 4px;border-radius:2px;font:600 9px ui-monospace,Menlo,monospace">⇧ Shift</kbd>+drag = keep this element (green dotted)</span>'
: 'no regions marked yet · <b>drag</b> to mark a defect · <kbd style="background:#f3eee2;border:1px solid var(--line);padding:0 4px;border-radius:2px;font:600 9px ui-monospace,Menlo,monospace">⇧ Shift</kbd>+drag to mark an element to KEEP';
$('bn-ghost').textContent = gn;
$('bn-seam').textContent = sn;
$('bn-other').textContent = on;
$('btn-ghost').classList.toggle('gated', gn === 0);
$('btn-seam').classList.toggle('gated', sn === 0);
$('btn-other').classList.toggle('gated', on === 0);
$('btn-fix').classList.toggle('gated', defects === 0);
$('btn-remove').classList.toggle('gated', defects === 0);
}
$('undo-btn').onclick = () => { if (RECTS.length) { RECTS.pop(); drawRects(); updateRectCount(); } };
$('clear-btn').onclick = () => { RECTS = []; drawRects(); updateRectCount(); };
// ── Seam test (quadrant-swap to bring outer edges to center) ───────────
function buildSeamCanvas() {
const w = mainImg.naturalWidth, h = mainImg.naturalHeight;
if (!w || !h) return;
seamCanvas.width = w;
seamCanvas.height = h;
// Match displayed size of the original img
const ir = mainImg.getBoundingClientRect();
if (ir.width > 0) {
seamCanvas.style.width = ir.width + 'px';
seamCanvas.style.height = ir.height + 'px';
}
const ctx = seamCanvas.getContext('2d');
const hw = Math.floor(w / 2), hh = Math.floor(h / 2);
// Quadrant-swap (classic Photoshop Offset filter w/ wrap):
// new TL ← old BR | new TR ← old BL
// new BL ← old TR | new BR ← old TL
// Result: the previously-OUTER edges of the source (top/bottom/left/right)
// all meet at the new CENTER cross. A truly tileable pattern shows no
// seam at the cross; a broken tile shouts here.
ctx.drawImage(mainImg, hw, hh, w-hw, h-hh, 0, 0, w-hw, h-hh); // BR → TL
ctx.drawImage(mainImg, 0, hh, hw, h-hh, w-hw, 0, hw, h-hh); // BL → TR
ctx.drawImage(mainImg, hw, 0, w-hw, hh, 0, h-hh, w-hw, hh); // TR → BL
ctx.drawImage(mainImg, 0, 0, hw, hh, w-hw, h-hh, hw, hh); // TL → BR
}
function toggleSeamView() {
if (CURRENT === null) return;
SEAM_VIEW = !SEAM_VIEW;
const btn = $('seam-test-btn');
if (SEAM_VIEW) {
buildSeamCanvas();
mainImg.style.visibility = 'hidden';
mainImg.style.position = 'absolute'; // collapse layout so wrap fits canvas
seamCanvas.style.display = '';
btn.classList.add('active');
btn.innerHTML = '<span class="icon">⊟</span> Exit Seam Test <kbd style="background:#222;color:#fff;border:1px solid #555;padding:0 5px;border-radius:3px;font:600 10px ui-monospace,Menlo,monospace;margin-left:4px">X</kbd>';
$('top-hint').innerHTML = 'Seam-test view — outer edges now meet at the dashed center cross. Drag any visible seam to mark it as <b>seam-break</b> (B)';
} else {
mainImg.style.visibility = '';
mainImg.style.position = '';
seamCanvas.style.display = 'none';
btn.classList.remove('active');
btn.innerHTML = '<span class="icon">⊞</span> Seam Test <kbd style="background:#222;color:#fff;border:1px solid #555;padding:0 5px;border-radius:3px;font:600 10px ui-monospace,Menlo,monospace;margin-left:4px">X</kbd>';
$('top-hint').innerHTML = 'Drag to mark defect regions — then press <b>G</b> ghost or <b>B</b> seam-break';
}
// Re-render rects + suggestions in the new view
setTimeout(() => { drawRects(); drawSuggestions(); }, 0);
}
$('seam-test-btn').onclick = toggleSeamView;
// ── Auto-suggest dotted preview regions ────────────────────────────────
// Lightweight in-browser CV: downsample → grid → per-cell luminance +
// gradient magnitude. Cells with moderate luminance (foreground) but
// unusually LOW gradient (faded / low-contrast) are flagged as
// ghost-suspect. Adjacent flagged cells get merged into bounding boxes.
// Tolerance slider controls the gradient threshold — strict=few boxes,
// loose=many boxes. NOT a final-defect detector — Steve still confirms.
let SUGGESTIONS = []; // [{x,y,w,h,score}] normalized 0..1
let TOLERANCE = parseInt(localStorage.getItem('gr-tolerance') || '40', 10);
let AUTO_ON = (localStorage.getItem('gr-auto-on') || '1') === '1';
$('tolerance').value = TOLERANCE;
$('tol-val').textContent = TOLERANCE;
$('auto-on').checked = AUTO_ON;
$('tolerance').oninput = (e) => {
TOLERANCE = parseInt(e.target.value, 10);
$('tol-val').textContent = TOLERANCE;
localStorage.setItem('gr-tolerance', TOLERANCE);
runAutoSuggest();
};
$('auto-on').onchange = (e) => {
AUTO_ON = e.target.checked;
localStorage.setItem('gr-auto-on', AUTO_ON ? '1' : '0');
runAutoSuggest();
};
function runAutoSuggest() {
// Clear old suggestions
imgWrap.querySelectorAll('.sgst').forEach(el => el.remove());
SUGGESTIONS = [];
if (!AUTO_ON || CURRENT === null || !mainImg.naturalWidth) {
$('sgst-count').textContent = AUTO_ON ? '0' : 'off';
return;
}
// Update mode-label
$('sgst-mode-label').textContent = DRAW_MODE;
try {
SUGGESTIONS = computeSuggestions(mainImg, TOLERANCE);
} catch (e) {
console.warn('suggest CV failed:', e.message);
$('sgst-count').textContent = 'err';
return;
}
$('sgst-count').textContent = SUGGESTIONS.length;
drawSuggestions();
}
function computeSuggestions(imgEl, tolerance) {
// Downsample to a max edge of 320 for speed
const SZ = 320;
const nw = imgEl.naturalWidth, nh = imgEl.naturalHeight;
const sw = nw >= nh ? SZ : Math.round(SZ * nw / nh);
const sh = nh >= nw ? SZ : Math.round(SZ * nh / nw);
const cv = document.createElement('canvas');
cv.width = sw; cv.height = sh;
const ctx = cv.getContext('2d');
ctx.drawImage(imgEl, 0, 0, sw, sh);
const px = ctx.getImageData(0, 0, sw, sh).data;
// 16x16 grid; per-cell mean luminance + gradient magnitude
const GRID = 16;
const cw = Math.floor(sw / GRID), ch = Math.floor(sh / GRID);
const cells = [];
for (let cy = 0; cy < GRID; cy++) {
const row = [];
for (let cx = 0; cx < GRID; cx++) {
let sumL = 0, sumG = 0, n = 0;
const xs = cx * cw, ys = cy * ch;
for (let y = ys; y < ys + ch && y < sh - 1; y++) {
for (let x = xs; x < xs + cw && x < sw - 1; x++) {
const i = (y * sw + x) * 4;
const L = 0.299*px[i] + 0.587*px[i+1] + 0.114*px[i+2];
const Lr = 0.299*px[i+4] + 0.587*px[i+5] + 0.114*px[i+6];
const Ld = 0.299*px[i+sw*4] + 0.587*px[i+sw*4+1] + 0.114*px[i+sw*4+2];
sumL += L;
sumG += Math.abs(L - Lr) + Math.abs(L - Ld);
n++;
}
}
row.push({ cx, cy, meanL: sumL / n, meanG: sumG / n, marked: false });
}
cells.push(row);
}
const flat = cells.flat();
// Background reference = the most-common (mode-ish) luminance — assume
// a margin around the mean is "background". Anything outside that is
// motif content. Ghost = motif content with LOW gradient.
const meanL = flat.reduce((a,b) => a + b.meanL, 0) / flat.length;
const stdL = Math.sqrt(flat.reduce((a,b) => a + (b.meanL - meanL) ** 2, 0) / flat.length);
const meanG = flat.reduce((a,b) => a + b.meanG, 0) / flat.length;
const stdG = Math.sqrt(flat.reduce((a,b) => a + (b.meanG - meanG) ** 2, 0) / flat.length);
// Tolerance 0..100 → 0..1
const t = tolerance / 100;
// Strict (t=0) → only flag cells with gradient < mean - 1.5*std AND luminance unusual
// Loose (t=1) → flag cells with gradient < mean + 0.5*std
const gradThresh = meanG + stdG * (-1.5 + 2.0 * t);
const lumOff = stdL * (0.4 - 0.2 * t); // looser = smaller off requirement
for (const c of flat) {
const lumAnomaly = Math.abs(c.meanL - meanL) > lumOff;
if (c.meanG < gradThresh && lumAnomaly) c.marked = true;
}
// Group adjacent marked cells into bounding boxes (4-connectivity flood)
const visited = new Set();
const boxes = [];
function key(x, y) { return x + ',' + y; }
for (let cy = 0; cy < GRID; cy++) {
for (let cx = 0; cx < GRID; cx++) {
if (!cells[cy][cx].marked || visited.has(key(cx,cy))) continue;
const stack = [[cx,cy]];
let minX=cx, maxX=cx, minY=cy, maxY=cy, score = 0, count = 0;
while (stack.length) {
const [x,y] = stack.pop();
if (visited.has(key(x,y))) continue;
visited.add(key(x,y));
if (x<0||y<0||x>=GRID||y>=GRID) continue;
const c = cells[y][x];
if (!c.marked) continue;
if (x<minX) minX=x; if (x>maxX) maxX=x;
if (y<minY) minY=y; if (y>maxY) maxY=y;
score += (gradThresh - c.meanG);
count++;
stack.push([x+1,y]); stack.push([x-1,y]);
stack.push([x,y+1]); stack.push([x,y-1]);
}
if (count < 2) continue; // skip lone cells (noise)
boxes.push({
x: minX / GRID,
y: minY / GRID,
w: (maxX - minX + 1) / GRID,
h: (maxY - minY + 1) / GRID,
score: score / count,
cells: count,
});
}
}
// Sort by score descending, cap to 20 suggestions
boxes.sort((a,b) => b.score - a.score);
return boxes.slice(0, 20);
}
function drawSuggestions() {
imgWrap.querySelectorAll('.sgst').forEach(el => el.remove());
if (!AUTO_ON || !SUGGESTIONS.length) return;
const activeEl = activeImageEl();
const r = activeEl.getBoundingClientRect();
const wr = imgWrap.getBoundingClientRect();
const offX = r.left - wr.left, offY = r.top - wr.top;
SUGGESTIONS.forEach((s, i) => {
const el = document.createElement('div');
el.className = 'sgst';
el.style.left = (offX + s.x * r.width) + 'px';
el.style.top = (offY + s.y * r.height) + 'px';
el.style.width = (s.w * r.width) + 'px';
el.style.height = (s.h * r.height) + 'px';
el.title = `accept as ${DRAW_MODE} (click) — score ${s.score.toFixed(1)} · ${s.cells} cells`;
el.innerHTML = `<div class="sgst-label">SUGGEST #${i+1} · ${(s.score).toFixed(0)}</div>`;
el.onclick = (e) => {
e.stopPropagation();
RECTS.push({ x: s.x, y: s.y, w: s.w, h: s.h, tag: DRAW_MODE, view: SEAM_VIEW ? 'seam-quadrant-swap' : 'original', source: 'suggested' });
// Remove this suggestion (already accepted)
SUGGESTIONS = SUGGESTIONS.filter((_, j) => j !== i);
drawRects();
drawSuggestions();
updateRectCount();
$('sgst-count').textContent = SUGGESTIONS.length;
};
imgWrap.appendChild(el);
});
}
// Re-render suggestions on window resize so they stay anchored
window.addEventListener('resize', () => { drawSuggestions(); });
// ── Magic Wand (select / recolor / erase / copy / paste / apply) ──────
let WAND_ON = false;
let WAND_TOL = 28;
let SELECTION_MASK = null; // Uint8Array of working canvas size
let WORKING_CANVAS = null; // off-screen edited copy of the source
let CLIPBOARD = null; // {data: ImageData, w, h}
let LAST_CLICK = null;
let PASTE_ARMED = false;
function wandResetForNewImage() {
WORKING_CANVAS = null; SELECTION_MASK = null; LAST_CLICK = null;
CLIPBOARD = null; PASTE_ARMED = false;
$('wand-paste').disabled = true;
$('wand-stats').textContent = 'click a pixel';
$('wand-swatch').style.background = '';
imgWrap.querySelectorAll('.wand-overlay').forEach(el => el.remove());
if (WAND_ON) ensureWorkingCanvas();
}
function ensureWorkingCanvas() {
if (WORKING_CANVAS) return WORKING_CANVAS;
const w = mainImg.naturalWidth, h = mainImg.naturalHeight;
if (!w) return null;
WORKING_CANVAS = document.createElement('canvas');
WORKING_CANVAS.width = w; WORKING_CANVAS.height = h;
WORKING_CANVAS.getContext('2d').drawImage(mainImg, 0, 0, w, h);
return WORKING_CANVAS;
}
function ensureWandOverlay() {
let ov = imgWrap.querySelector('.wand-overlay');
if (!ov) {
ov = document.createElement('canvas');
ov.className = 'wand-overlay';
imgWrap.appendChild(ov);
}
positionWandOverlay(ov);
return ov;
}
function positionWandOverlay(ov) {
if (!WORKING_CANVAS) return;
const r = mainImg.getBoundingClientRect();
const wr = imgWrap.getBoundingClientRect();
ov.width = WORKING_CANVAS.width;
ov.height = WORKING_CANVAS.height;
ov.style.width = r.width + 'px';
ov.style.height = r.height + 'px';
ov.style.left = (r.left - wr.left) + 'px';
ov.style.top = (r.top - wr.top) + 'px';
}
function toggleWand() {
if (CURRENT === null) return;
WAND_ON = !WAND_ON;
$('wand-btn').classList.toggle('active', WAND_ON);
stage.classList.toggle('wand-mode', WAND_ON);
$('wand-panel').style.display = WAND_ON ? 'flex' : 'none';
if (WAND_ON) {
ensureWorkingCanvas();
ensureWandOverlay();
toast('🪄 wand mode — click any motif color to select it');
} else {
imgWrap.querySelectorAll('.wand-overlay').forEach(el => el.remove());
}
}
$('wand-btn').onclick = toggleWand;
function runFloodFill() {
if (!LAST_CLICK || !WORKING_CANVAS) return;
const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
const ctx = WORKING_CANVAS.getContext('2d');
const img = ctx.getImageData(0, 0, w, h);
const data = img.data;
const i0 = (LAST_CLICK.py * w + LAST_CLICK.px) * 4;
const tR = data[i0], tG = data[i0+1], tB = data[i0+2];
$('wand-swatch').style.background = `rgb(${tR},${tG},${tB})`;
$('wand-color').value = '#' + [tR,tG,tB].map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2,'0')).join('');
SELECTION_MASK = new Uint8Array(w * h);
const stack = [[LAST_CLICK.px, LAST_CLICK.py]];
const tol2 = WAND_TOL * WAND_TOL * 3;
while (stack.length) {
const [x, y] = stack.pop();
if (x < 0 || y < 0 || x >= w || y >= h) continue;
const idx = y * w + x;
if (SELECTION_MASK[idx]) continue;
const di = idx * 4;
const dr = data[di] - tR, dg = data[di+1] - tG, db = data[di+2] - tB;
if (dr*dr + dg*dg + db*db > tol2) continue;
SELECTION_MASK[idx] = 1;
stack.push([x+1, y]); stack.push([x-1, y]);
stack.push([x, y+1]); stack.push([x, y-1]);
}
drawSelectionOverlay();
const count = SELECTION_MASK.reduce((s,v) => s + v, 0);
$('wand-stats').textContent = `${count.toLocaleString()} px (${((count / (w*h)) * 100).toFixed(1)}% of image) · rgb(${tR},${tG},${tB})`;
}
function drawSelectionOverlay() {
const ov = ensureWandOverlay();
if (!ov || !WORKING_CANVAS) return;
positionWandOverlay(ov);
const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
const ctx = ov.getContext('2d');
ctx.clearRect(0, 0, w, h);
if (!SELECTION_MASK) return;
const out = ctx.createImageData(w, h);
for (let i = 0; i < SELECTION_MASK.length; i++) {
if (SELECTION_MASK[i]) {
const o = i * 4;
out.data[o] = 90; out.data[o+1] = 220; out.data[o+2] = 90; out.data[o+3] = 130;
}
}
ctx.putImageData(out, 0, 0);
}
function onWandClick(e) {
const r = mainImg.getBoundingClientRect();
if (e.clientX < r.left || e.clientX > r.right ||
e.clientY < r.top || e.clientY > r.bottom) return;
if (!WORKING_CANVAS) ensureWorkingCanvas();
const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
const px = Math.floor((e.clientX - r.left) / r.width * w);
const py = Math.floor((e.clientY - r.top) / r.height * h);
LAST_CLICK = { px, py };
runFloodFill();
}
function wandRecolor() {
if (!SELECTION_MASK || !WORKING_CANVAS) { toast('select first'); return; }
const hex = $('wand-color').value;
const R = parseInt(hex.slice(1,3),16), G = parseInt(hex.slice(3,5),16), B = parseInt(hex.slice(5,7),16);
const ctx = WORKING_CANVAS.getContext('2d');
const img = ctx.getImageData(0, 0, WORKING_CANVAS.width, WORKING_CANVAS.height);
let n = 0;
for (let i = 0; i < SELECTION_MASK.length; i++) {
if (SELECTION_MASK[i]) {
img.data[i*4] = R; img.data[i*4+1] = G; img.data[i*4+2] = B;
n++;
}
}
ctx.putImageData(img, 0, 0);
mainImg.src = WORKING_CANVAS.toDataURL('image/png');
toast(`✓ recolored ${n.toLocaleString()} px`);
}
function wandErase() {
if (!SELECTION_MASK || !WORKING_CANVAS) { toast('select first'); return; }
const ctx = WORKING_CANVAS.getContext('2d');
const img = ctx.getImageData(0, 0, WORKING_CANVAS.width, WORKING_CANVAS.height);
// Avg color of NON-selected pixels = bg
let aR = 0, aG = 0, aB = 0, nb = 0;
for (let i = 0; i < SELECTION_MASK.length; i++) {
if (!SELECTION_MASK[i]) { aR += img.data[i*4]; aG += img.data[i*4+1]; aB += img.data[i*4+2]; nb++; }
}
const R = Math.round(aR/nb), G = Math.round(aG/nb), B = Math.round(aB/nb);
let n = 0;
for (let i = 0; i < SELECTION_MASK.length; i++) {
if (SELECTION_MASK[i]) { img.data[i*4] = R; img.data[i*4+1] = G; img.data[i*4+2] = B; n++; }
}
ctx.putImageData(img, 0, 0);
mainImg.src = WORKING_CANVAS.toDataURL('image/png');
toast(`✓ erased ${n.toLocaleString()} px to bg rgb(${R},${G},${B})`);
}
function wandCopy() {
if (!SELECTION_MASK || !WORKING_CANVAS) { toast('select first'); return; }
const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
let minX = w, minY = h, maxX = 0, maxY = 0;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
if (SELECTION_MASK[y*w + x]) {
if (x<minX) minX=x; if (x>maxX) maxX=x;
if (y<minY) minY=y; if (y>maxY) maxY=y;
}
}
const rw = maxX - minX + 1, rh = maxY - minY + 1;
if (rw < 1 || rh < 1) { toast('empty selection'); return; }
const ctx = WORKING_CANVAS.getContext('2d');
const region = ctx.getImageData(minX, minY, rw, rh);
// Apply mask as alpha
for (let y = 0; y < rh; y++) for (let x = 0; x < rw; x++) {
if (!SELECTION_MASK[(y+minY) * w + (x+minX)]) region.data[(y*rw + x)*4 + 3] = 0;
}
CLIPBOARD = { data: region, w: rw, h: rh };
$('wand-paste').disabled = false;
toast(`⎘ copied ${rw}×${rh} region`);
}
function wandPasteArm() {
if (!CLIPBOARD) { toast('nothing copied'); return; }
PASTE_ARMED = true;
toast('click to paste · Esc to cancel');
}
function doPaste(clickX, clickY) {
if (!CLIPBOARD || !WORKING_CANVAS) return;
const r = mainImg.getBoundingClientRect();
const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
const cx = Math.floor((clickX - r.left) / r.width * w);
const cy = Math.floor((clickY - r.top) / r.height * h);
const ctx = WORKING_CANVAS.getContext('2d');
const tmp = document.createElement('canvas');
tmp.width = CLIPBOARD.w; tmp.height = CLIPBOARD.h;
tmp.getContext('2d').putImageData(CLIPBOARD.data, 0, 0);
ctx.drawImage(tmp, cx - CLIPBOARD.w/2, cy - CLIPBOARD.h/2);
PASTE_ARMED = false;
mainImg.src = WORKING_CANVAS.toDataURL('image/png');
toast(`📋 pasted at (${cx},${cy})`);
}
function wandClear() {
SELECTION_MASK = null; LAST_CLICK = null;
drawSelectionOverlay();
$('wand-stats').textContent = 'click a pixel';
$('wand-swatch').style.background = '';
}
async function wandApply() {
if (CURRENT === null || !WORKING_CANVAS) { toast('no edits to save'); return; }
if (!confirm('Save all magic-wand edits as a new design? Original will be unpublished.')) return;
const item = VIEW[CURRENT];
const stat = $('fix-status');
stat.style.display = ''; stat.style.borderLeftColor = '#5fa85f'; stat.style.background = '#f0f9f0';
stat.textContent = 'Uploading edited PNG…';
try {
const dataUrl = WORKING_CANVAS.toDataURL('image/png');
const r = await fetch(`/api/design/${item.id}/save-edited`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ data_url: dataUrl, edits_summary: 'magic-wand: recolor/erase/paste' }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || ('http ' + r.status));
stat.innerHTML = `✓ Saved as <a href="/design/${j.new_id}" target="_blank" rel="noopener noreferrer" style="color:var(--green); font-weight:600">#${j.new_id}</a> · original #${item.id} unpublished`;
toast(`✓ saved → #${j.new_id}`);
// Save a tp_other label
try {
await fetch('/api/ghost-review/label', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ id: item.id, verdict: 'tp_other', crops: [{x:0,y:0,w:1,h:1,tag:'other'}], notes: `magic-wand edit → #${j.new_id}` }),
});
} catch {}
STAT.other = (STAT.other || 0) + 1;
LABELS.set(item.id, { verdict: 'tp_other', crops: [], wand_to: j.new_id });
updateStats();
WORKING_CANVAS = null;
wandClear();
setTimeout(() => {
recomputeView(); render();
if (FILTER === 'unlabeled') {
if (CURRENT >= VIEW.length) CURRENT = VIEW.length - 1;
if (VIEW.length === 0) { close(); return; }
open(CURRENT);
} else {
if (CURRENT < VIEW.length - 1) navigate(1);
}
}, 2200);
} catch (e) {
stat.style.borderLeftColor = 'var(--red)'; stat.style.background = '#fce8e6';
stat.innerHTML = '✗ Save failed: ' + e.message;
}
}
$('wand-tol').oninput = (e) => {
WAND_TOL = parseInt(e.target.value, 10);
$('wand-tol-val').textContent = WAND_TOL;
if (LAST_CLICK) runFloodFill();
};
$('wand-recolor').onclick = wandRecolor;
$('wand-erase').onclick = wandErase;
$('wand-copy').onclick = wandCopy;
$('wand-paste').onclick = wandPasteArm;
$('wand-clear').onclick = wandClear;
$('wand-apply').onclick = wandApply;
// Stage click handler for wand (separate from rect-drag mousedown)
stage.addEventListener('click', (e) => {
if (!WAND_ON) return;
if (e.target.tagName === 'BUTTON' || e.target.classList.contains('x')) return;
if (e.target.classList.contains('rect') || e.target.classList.contains('sgst')) return;
if (PASTE_ARMED) doPaste(e.clientX, e.clientY);
else onWandClick(e);
});
// Reposition overlay on resize
window.addEventListener('resize', () => { if (WAND_ON) drawSelectionOverlay(); });
// ── Save verdict ───────────────────────────────────────────────────────
function toast(msg) {
const t = $('toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._tm);
t._tm = setTimeout(() => t.classList.remove('show'), 1400);
}
async function save(verdict, opts) {
if (CURRENT === null) return;
const item = VIEW[CURRENT];
let crops = [];
if (verdict === 'tp_ghost') crops = RECTS.filter(r => r.tag === 'ghost');
else if (verdict === 'tp_seam') crops = RECTS.filter(r => r.tag === 'seam');
else if (verdict === 'tp_other') crops = RECTS.filter(r => r.tag === 'other');
if (verdict.startsWith('tp') && !crops.length) { toast('draw at least one ' + verdict.split('_')[1] + ' region first'); return; }
const body = { id: item.id, verdict, crops, notes: $('notes').value || null };
try {
const r = await fetch('/api/ghost-review/label', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(body),
});
if (!r.ok) { const t = await r.text(); throw new Error(t.slice(0,200)); }
const prev = LABELS.get(item.id);
// Update stats — decrement old verdict if any, increment new
if (prev) {
if (prev.verdict === 'fp') STAT.fp--;
else if (prev.verdict === 'tp_ghost') STAT.ghost--;
else if (prev.verdict === 'tp_seam') STAT.seam--;
else if (prev.verdict === 'tp_other') STAT.other--;
else if (prev.verdict === 'skip') STAT.skip--;
}
if (verdict === 'fp') STAT.fp++;
else if (verdict === 'tp_ghost') STAT.ghost++;
else if (verdict === 'tp_seam') STAT.seam++;
else if (verdict === 'tp_other') STAT.other++;
else if (verdict === 'skip') STAT.skip++;
LABELS.set(item.id, { verdict, crops });
updateStats();
toast({ fp:'✓ saved CLEAN', tp_ghost:'⚠ saved GHOST', tp_seam:'⤬ saved SEAM', tp_other:'◆ saved OTHER', skip:'– skipped' }[verdict]);
// Auto-advance
if (FILTER === 'unlabeled') {
recomputeView(); render();
if (CURRENT >= VIEW.length) CURRENT = VIEW.length - 1;
if (VIEW.length === 0) { close(); return; }
open(CURRENT);
} else {
recomputeView(); render();
if (CURRENT < VIEW.length - 1) navigate(1);
}
} catch (e) {
toast('save failed: ' + e.message);
}
}
$('btn-fp').onclick = () => save('fp');
$('btn-ghost').onclick = () => save('tp_ghost');
$('btn-seam').onclick = () => save('tp_seam');
$('btn-other').onclick = () => save('tp_other');
$('btn-skip').onclick = () => save('skip');
// ── Surgical Fix (Gemini image-edit on marked regions) ─────────────────
// Computes the union bounding box of all RECTS, maps from [0..1] →
// PERCENT [0..100], picks fix_kind from the dominant rect tag, hits the
// existing /api/design/:id/crop-fix endpoint. Auto-saves a tp_* label
// before firing so the action is recorded even if the fix succeeds and
// the original design becomes unpublished.
$('btn-fix').onclick = () => doFix();
async function doFix(kindOverride) {
if (CURRENT === null) return;
if (!RECTS.length) { toast('mark at least one region first'); return; }
const seamRects = RECTS.filter(r => r.view === 'seam-quadrant-swap');
if (seamRects.length && seamRects.length !== RECTS.length) {
toast('mixed seam-view + normal-view rects — exit seam test first or clear rects');
return;
}
if (seamRects.length) {
toast('seam-view fixes not yet supported — exit seam view first');
return;
}
const item = VIEW[CURRENT];
// Defect rects only (keep rects are negative regions — preserve them)
const defectRects = RECTS.filter(r => r.tag !== 'keep');
const keepRects = RECTS.filter(r => r.tag === 'keep');
if (!defectRects.length) { toast('mark at least one defect region (non-keep)'); return; }
// Pick fix_kind by the most-marked DEFECT tag OR explicit override
const tagCounts = { ghost: 0, seam: 0, other: 0 };
defectRects.forEach(r => { tagCounts[r.tag] = (tagCounts[r.tag] || 0) + 1; });
const dominantTag = Object.entries(tagCounts).sort((a,b) => b[1] - a[1])[0][0];
const fixKind = kindOverride
|| { ghost: 'ghost-layer', seam: 'overlap', other: 'composition' }[dominantTag];
const verdict = { ghost: 'tp_ghost', seam: 'tp_seam', other: 'tp_other' }[dominantTag];
// Union bounding box across DEFECT rects (excluding keep rects)
let minX = 1, minY = 1, maxX = 0, maxY = 0;
for (const r of defectRects) {
if (r.x < minX) minX = r.x;
if (r.y < minY) minY = r.y;
if (r.x + r.w > maxX) maxX = r.x + r.w;
if (r.y + r.h > maxY) maxY = r.y + r.h;
}
// Inflate by 4% on each side so Gemini has context to blend
const PAD = 0.04;
minX = Math.max(0, minX - PAD); minY = Math.max(0, minY - PAD);
maxX = Math.min(1, maxX + PAD); maxY = Math.min(1, maxY + PAD);
const region = {
x: minX * 100,
y: minY * 100,
w: (maxX - minX) * 100,
h: (maxY - minY) * 100,
};
// 1) Save label first so the action is recorded even if fix fails or the
// original gets unpublished. crops keep the original per-rect coords.
try {
await fetch('/api/ghost-review/label', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ id: item.id, verdict, crops: RECTS, notes: `pre-fix snapshot · fix_kind=${fixKind}` }),
});
} catch {}
// 2) Fire the Gemini fix
const btn = $(fixKind === 'remove-fill' ? 'btn-remove' : 'btn-fix');
const stat = $('fix-status');
btn.classList.add('busy');
btn.classList.add('gated');
btn.innerHTML = fixKind === 'remove-fill' ? '🗑 Removing + filling… ~10s' : '🪄 Fixing… ~10s';
stat.style.display = '';
stat.textContent = `Sending region (${region.x.toFixed(1)}%, ${region.y.toFixed(1)}%, ${region.w.toFixed(1)}%, ${region.h.toFixed(1)}%) to Gemini for ${fixKind} edit…`;
const t0 = Date.now();
try {
// Map keep rects from [0..1] to PERCENT [0..100] for the server
const keep_regions = keepRects.map(k => ({
x: k.x * 100, y: k.y * 100, w: k.w * 100, h: k.h * 100,
}));
const r = await fetch(`/api/design/${item.id}/crop-fix`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ region, fix_kind: fixKind, keep_regions }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || ('http ' + r.status));
const dt = ((Date.now() - t0) / 1000).toFixed(1);
stat.style.borderLeftColor = 'var(--green)';
stat.style.background = '#eef7ed';
stat.innerHTML = `✓ Fixed in ${dt}s · new design <a href="/design/${j.new_id}" target="_blank" rel="noopener noreferrer" style="color:var(--green); font-weight:600">#${j.new_id}</a> · original #${item.id} unpublished`;
toast(`✓ fixed → #${j.new_id} (${dt}s)`);
// Reload the image to the fixed version (in case Steve wants to confirm)
mainImg.src = `/designs/img/by-id/${j.new_id}?fixed=1`;
// Update label stats — count as tp
STAT[verdict === 'tp_ghost' ? 'ghost' : verdict === 'tp_seam' ? 'seam' : 'other']++;
LABELS.set(item.id, { verdict, crops: RECTS, fixed_to: j.new_id });
updateStats();
// Auto-advance after 3s so Steve sees the fixed version briefly
setTimeout(() => {
if (FILTER === 'unlabeled') {
recomputeView(); render();
if (CURRENT >= VIEW.length) CURRENT = VIEW.length - 1;
if (VIEW.length === 0) { close(); return; }
open(CURRENT);
} else {
recomputeView(); render();
if (CURRENT < VIEW.length - 1) navigate(1);
}
}, 3000);
} catch (e) {
const dt = ((Date.now() - t0) / 1000).toFixed(1);
stat.style.borderLeftColor = 'var(--red)';
stat.style.background = '#fce8e6';
stat.innerHTML = `✗ Fix failed after ${dt}s: ${e.message}`;
toast('fix failed: ' + e.message);
btn.classList.remove('busy');
btn.classList.remove('gated');
btn.innerHTML = fixKind === 'remove-fill'
? '🗑 Retry Remove+Fill <kbd>D</kbd>'
: '🪄 Retry Fix <kbd>R</kbd>';
}
}
// ── Remove + Fill (erase marked regions, inpaint background) ──────────
$('btn-remove').onclick = () => doFix('remove-fill');
// ── Smart Fix (auto-detect clean motif → re-tile) ─────────────────────
$('btn-smart').onclick = () => doSmartFix();
async function doSmartFix() {
if (CURRENT === null) return;
const item = VIEW[CURRENT];
if (!confirm(`Auto-detect the clean hero motif in #${item.id} and rebuild as a seamless OPAQUE tile? ~15-20s.`)) return;
const btn = $('btn-smart');
const stat = $('fix-status');
btn.disabled = true;
btn.innerHTML = '🎯 Detecting hero motif + regenerating opaque tile… ~15-20s';
stat.style.display = '';
stat.style.borderLeftColor = '#c79a3a';
stat.style.background = '#fef7e0';
stat.textContent = 'Step 1/2 · Gemini Vision identifying the clean hero motif…';
const t0 = Date.now();
try {
const r = await fetch(`/api/design/${item.id}/smart-fix`, { method:'POST' });
const j = await r.json();
if (!r.ok) throw new Error(j.error || ('http ' + r.status));
const dt = ((Date.now() - t0) / 1000).toFixed(1);
stat.style.borderLeftColor = 'var(--green)';
stat.style.background = '#eef7ed';
stat.innerHTML = `✓ Smart-fixed in ${dt}s · new design <a href="/design/${j.new_id}" target="_blank" rel="noopener noreferrer" style="color:var(--green); font-weight:600">#${j.new_id}</a><br>` +
`<span style="font:11px var(--sans); color:var(--faint)">motif: ${(j.motif || '').slice(0, 180)}…</span>`;
toast(`🎯 smart-fixed → #${j.new_id} (${dt}s)`);
mainImg.src = `/designs/img/by-id/${j.new_id}?smart=1`;
const verdict = 'tp_other';
try {
await fetch('/api/ghost-review/label', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ id: item.id, verdict, crops: [{x:0,y:0,w:1,h:1,tag:'other'}], notes: `smart-fix → #${j.new_id} · ${(j.motif||'').slice(0,200)}` }),
});
} catch {}
STAT.other = (STAT.other || 0) + 1;
LABELS.set(item.id, { verdict, crops: [], smartfix_to: j.new_id });
updateStats();
setTimeout(() => {
recomputeView(); render();
if (FILTER === 'unlabeled') {
if (CURRENT >= VIEW.length) CURRENT = VIEW.length - 1;
if (VIEW.length === 0) { close(); return; }
open(CURRENT);
} else if (CURRENT < VIEW.length - 1) navigate(1);
}, 3500);
} catch (e) {
const dt = ((Date.now() - t0) / 1000).toFixed(1);
stat.style.borderLeftColor = 'var(--red)';
stat.style.background = '#fce8e6';
stat.innerHTML = `✗ Smart-fix failed after ${dt}s: ${e.message}`;
toast('smart-fix failed: ' + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = '🎯 Smart Fix · auto-detect clean motif → re-tile <kbd>A</kbd>';
}
}
// ── Reverse-engineer + regenerate whole image ─────────────────────────
$('btn-regen').onclick = () => doRegen();
async function doRegen() {
if (CURRENT === null) return;
const item = VIEW[CURRENT];
if (!confirm(`Re-roll #${item.id}? Gemini Vision will describe the image, then generate a clean version. ~15s.`)) return;
const btn = $('btn-regen');
const stat = $('fix-status');
btn.classList.add('busy');
btn.disabled = true;
btn.innerHTML = '↻ Reverse-engineering + regenerating… ~15s';
stat.style.display = '';
stat.style.borderLeftColor = '#7a4ab8';
stat.style.background = '#f3eaff';
stat.textContent = 'Gemini Vision: describing the existing image as a clean generation prompt…';
const t0 = Date.now();
try {
const r = await fetch(`/api/design/${item.id}/regenerate-reverse`, { method:'POST' });
const j = await r.json();
if (!r.ok) throw new Error(j.error || ('http ' + r.status));
const dt = ((Date.now() - t0) / 1000).toFixed(1);
stat.style.borderLeftColor = 'var(--green)';
stat.style.background = '#eef7ed';
const modeLabel = j.source_still_published ? '+ NEW SIBLING' : 'REPLACED';
stat.innerHTML = `✓ ${modeLabel} in ${dt}s · new design <a href="/design/${j.new_id}" target="_blank" rel="noopener noreferrer" style="color:var(--green); font-weight:600">#${j.new_id}</a> · original ${j.source_still_published ? '<b>still published</b>' : 'unpublished'}<br><span style="font:11px var(--sans); color:var(--faint)">new prompt: ${(j.new_prompt || '').slice(0, 200)}…</span>`;
toast(`✓ ${j.source_still_published ? 'added' : 'regenerated'} → #${j.new_id} (${dt}s)`);
mainImg.src = `/designs/img/by-id/${j.new_id}?regen=1`;
// Save a tp_other label to record the action
const verdict = 'tp_other';
try {
await fetch('/api/ghost-review/label', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ id: item.id, verdict, crops: RECTS.length ? RECTS : [{x:0,y:0,w:1,h:1,tag:'other'}], notes: `regenerated whole → #${j.new_id}` }),
});
} catch {}
STAT.other = (STAT.other || 0) + 1;
LABELS.set(item.id, { verdict, crops: RECTS, regen_to: j.new_id });
updateStats();
setTimeout(() => {
if (FILTER === 'unlabeled') {
recomputeView(); render();
if (CURRENT >= VIEW.length) CURRENT = VIEW.length - 1;
if (VIEW.length === 0) { close(); return; }
open(CURRENT);
} else {
recomputeView(); render();
if (CURRENT < VIEW.length - 1) navigate(1);
}
}, 3500);
} catch (e) {
const dt = ((Date.now() - t0) / 1000).toFixed(1);
stat.style.borderLeftColor = 'var(--red)';
stat.style.background = '#fce8e6';
stat.innerHTML = `✗ Regenerate failed after ${dt}s: ${e.message}`;
toast('regen failed: ' + e.message);
} finally {
btn.classList.remove('busy');
btn.disabled = false;
btn.innerHTML = '↻ Reverse-Engineer + Regenerate Whole <kbd>E</kbd>';
}
}
// ── Keyboard ───────────────────────────────────────────────────────────
document.addEventListener('keydown', (e) => {
if (!modal.classList.contains('open')) return;
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
const k = e.key.toLowerCase();
if (k === 'escape') {
if (PASTE_ARMED) { PASTE_ARMED = false; toast('paste cancelled'); }
else close();
}
else if (k === 'arrowleft') navigate(-1);
else if (k === 'arrowright') navigate(1);
else if (k === 'f') save('fp');
else if (k === 'g') save('tp_ghost');
else if (k === 'b') save('tp_seam');
else if (k === 'o') save('tp_other');
else if (k === 's') save('skip');
else if (k === 'u') { if (RECTS.length) { RECTS.pop(); drawRects(); updateRectCount(); } }
else if (k === '1') { DRAW_MODE = 'ghost'; updateModeButtons(); }
else if (k === '2') { DRAW_MODE = 'seam'; updateModeButtons(); }
else if (k === '3') { DRAW_MODE = 'other'; updateModeButtons(); }
else if (k === 'x') { toggleSeamView(); }
else if (k === 'r') { if (!$('btn-fix').classList.contains('gated')) doFix(); }
else if (k === 'd') { if (!$('btn-remove').classList.contains('gated')) doFix('remove-fill'); }
else if (k === 'e') { doRegen(); }
else if (k === 'a') { doSmartFix(); }
else if (k === 'w') { toggleWand(); }
});
// ── Boot ───────────────────────────────────────────────────────────────
async function boot() {
const [flagged, labels] = await Promise.all([
fetch('/api/ghost-review/flagged').then(r => r.json()),
fetch('/api/ghost-review/labels').then(r => r.json()),
]);
ALL = flagged.items;
STAT.total = ALL.length;
for (const lbl of labels.items) {
LABELS.set(lbl.id, { verdict: lbl.verdict, crops: lbl.crops || (lbl.crop ? [lbl.crop] : []), notes: lbl.notes });
if (lbl.verdict === 'fp') STAT.fp++;
else if (lbl.verdict === 'tp_ghost') STAT.ghost++;
else if (lbl.verdict === 'tp_seam') STAT.seam++;
else if (lbl.verdict === 'tp_other') STAT.other++;
else if (lbl.verdict === 'tp') STAT.ghost++; // legacy
else if (lbl.verdict === 'skip') STAT.skip++;
}
const cats = [...new Set(ALL.map(i => i.category).filter(Boolean))].sort();
const sel = $('filter-cat');
for (const c of cats) {
const opt = document.createElement('option');
opt.value = c; opt.textContent = c;
sel.appendChild(opt);
}
updateStats();
recomputeView();
render();
}
boot().catch(e => { document.body.innerHTML = '<pre style="padding:40px;color:#b00">' + e.stack + '</pre>'; });
})();
</script>
<script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>