← 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
      &nbsp;<kbd>G</kbd> ghost
      &nbsp;<kbd>B</kbd> seam-break
      &nbsp;<kbd>S</kbd> skip
      &nbsp;<kbd>U</kbd> undo rect
      &nbsp;<kbd>←</kbd><kbd>→</kbd> nav
      &nbsp;<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 &amp; 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>