← back to Wallco Ai

public/admin/design-curator.html

1867 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Design Curator — wallco.ai admin</title>
<style>
  :root { --cols: 5; --bg:#0e0f10; --panel:#17191b; --line:#272b2e; --ink:#e9ecef; --mut:#9aa3ab; --accent:#7bc96f; }
  * { box-sizing: border-box; }
  body { margin:0; font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
         background:var(--bg); color:var(--ink); }
  header { position:sticky; top:0; z-index:20; background:rgba(14,15,16,.96); backdrop-filter:blur(8px);
           border-bottom:1px solid var(--line); padding:10px 16px; }
  .row { display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
  h1 { font-size:16px; margin:0; font-weight:650; letter-spacing:.2px; }
  h1 .leaf { color:var(--accent); }
  .sub { color:var(--mut); font-size:12px; }
  label.ctl { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--mut); }
  select, input[type=range] { accent-color:var(--accent); }
  select { background:var(--panel); color:var(--ink); border:1px solid var(--line); border-radius:7px; padding:5px 8px; font-size:13px; }
  input[type=range] { width:130px; }
  .pill { background:var(--panel); border:1px solid var(--line); border-radius:999px; padding:3px 10px; font-size:12px; color:var(--mut); }
  .pill b { color:var(--ink); }

  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; padding:14px 16px 120px; }
  .card { background:var(--panel); border:1px solid var(--line); border-radius:10px; overflow:hidden;
          display:flex; flex-direction:column; position:relative; transition:opacity .2s, transform .1s; }
  .card.decided { opacity:.5; }
  .card.sel { outline:2px solid #4aa3ff; outline-offset:-2px; }
  .thumb { position:relative; aspect-ratio:1/1; background:#000; }
  .thumb img { width:100%; height:100%; object-fit:cover; display:block; -webkit-user-drag:none; user-select:none; }
  /* On-hover room mockup overlay — Steve 2026-05-28. Fades in over the flat
     tile so the curator can see how the pattern reads on a wall in context. */
  .thumb .card-room { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; opacity:0; transition:opacity .18s ease-out; pointer-events:none; z-index:5; }
  .card:hover .thumb .card-room { opacity:1; }
  /* Tiny corner badge so Steve sees which chips have a room mockup at all */
  .thumb .b.room { background:#0ea5e9; color:#fff; }
  .sel-box { position:absolute; top:6px; left:6px; width:20px; height:20px; border-radius:5px; border:2px solid #fff;
             background:rgba(0,0,0,.45); cursor:pointer; z-index:3; }
  .card.sel .sel-box { background:#4aa3ff; border-color:#4aa3ff; }
  .badges { position:absolute; top:6px; right:6px; display:flex; flex-direction:column; gap:4px; align-items:flex-end; z-index:3; }
  .b { font-size:10.5px; font-weight:700; padding:2px 6px; border-radius:6px; background:rgba(0,0,0,.62); color:#fff; line-height:1.3; }
  .b.rank { background:rgba(123,201,111,.92); color:#0e0f10; }
  .b.pub { background:#2e7d32; } .b.unpub { background:#5a5f63; }
  .b.fix { background:#c98a00; } .b.dig { background:#7a5bd6; } .b.web { background:#1565c0; }
  .b.tif { background:#2a6b35; color:#dff5e0; } .b.notif { background:#5a4030; color:#ffd0a8; opacity:.75; }
  .b.pass { background:#2e7d32; } .b.warn { background:#b07a00; } .b.fail { background:#a13030; } .b.none{background:#555;}
  .b.reason { background:#e11d2a; color:#fff; font-weight:800; letter-spacing:.02em; box-shadow:0 1px 4px rgba(0,0,0,.4); }
  /* Drunk-curator wins ported here — extra ID cues at the top of the queue */
  .b.fresh { background:#0b6d3a; }                                                /* ✨ created in last 24h */
  .b.toprank { background:#b5891a; color:#1a1a1a; font-weight:800; }              /* ★ top-N by composite rank */
  .state { position:absolute; bottom:0; left:0; right:0; padding:18px 6px 5px; font-size:11px; font-weight:700; text-align:center;
           color:#fff; background:linear-gradient(transparent,rgba(0,0,0,.8)); z-index:2; display:none; }
  /* Seam-mismatch callout — the common repeat-break error */
  .seam-mismatch { position:absolute; top:6px; left:6px; right:auto; z-index:4; max-width:78%;
                   background:#b3261e; color:#fff; font-size:10.5px; font-weight:700; line-height:1.2;
                   padding:4px 7px; border-radius:4px; box-shadow:0 1px 4px rgba(0,0,0,.4); }
  .meta { padding:7px 9px 4px; }
  .cw { font-size:12px; color:var(--ink); font-weight:600; text-transform:capitalize; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .scores { display:flex; align-items:center; gap:8px; margin-top:3px; color:var(--mut); font-size:11px; }
  .dot { width:11px; height:11px; border-radius:50%; border:1px solid rgba(255,255,255,.25); display:inline-block; vertical-align:-1px; }
  .when { margin-top:3px; color:var(--mut); font-size:10.5px; font-variant-numeric:tabular-nums; }
  /* SHIFT-drag "bad area" annotation boxes — id BAD elements for fixing */
  .bad-box { position:absolute; border:2px dashed #ff3b30; background:rgba(255,59,48,.14); z-index:5; box-sizing:border-box; border-radius:2px; }
  .bad-box.drawing { pointer-events:none; }
  .bad-box .x { position:absolute; top:-9px; right:-9px; width:17px; height:17px; line-height:15px; text-align:center;
                background:#ff3b30; color:#fff; border-radius:50%; font:800 12px sans-serif; cursor:pointer; z-index:6; }
  .bad-box .lbl { position:absolute; bottom:-1px; left:-1px; background:#ff3b30; color:#fff; font:800 9px sans-serif; padding:1px 5px; border-radius:0 3px 0 0; }
  body.shifting .thumb { cursor:crosshair; }
  .ctl-btn { padding:6px 12px; border:1px solid #6a5a3a; border-radius:6px; background:#221e16; color:#ffd68a;
             font:700 12px sans-serif; cursor:pointer; }
  .ctl-btn:hover { background:#ffb300; color:#1a1a1a; }
  .ctl-btn:disabled { opacity:.6; cursor:wait; }
  /* candidate (auto-detected, unconfirmed) boxes — amber dashed with confirm/delete */
  .bad-box.cand { border-color:#ffb300; background:rgba(255,179,0,.13); }
  .bad-box.cand .lbl { background:#ffb300; color:#1a1a1a; }
  .bad-box .ok { position:absolute; top:-9px; right:11px; width:17px; height:17px; line-height:15px; text-align:center;
                 background:#2e7d32; color:#fff; border-radius:50%; font:800 11px sans-serif; cursor:pointer; z-index:6; }
  /* defect-type picker after drawing a box */
  .label-picker { position:fixed; z-index:60; background:#17150f; border:1px solid #4a4636; border-radius:9px; padding:8px;
                  display:flex; flex-direction:column; gap:5px; box-shadow:0 10px 30px rgba(0,0,0,.55); min-width:152px; }
  .label-picker .lp-h { font:800 10px var(--sans,sans-serif); color:#8a7a5e; text-transform:uppercase; letter-spacing:.08em; margin:0 2px 3px; }
  .label-picker button { padding:7px 10px; border:0; border-radius:5px; background:#2a2620; color:#ece4d4; font:700 12.5px sans-serif; cursor:pointer; text-align:left; }
  .label-picker button:hover { background:#e11d2a; color:#fff; }
  .label-picker .lp-cancel { background:transparent; color:#8a7a5e; font-size:11px; font-weight:600; }
  .auto-id-btn { position:absolute; bottom:6px; left:6px; z-index:4; background:rgba(0,0,0,.6); color:#ffd68a;
                 border:1px solid #6a5a3a; border-radius:5px; font:700 10px sans-serif; padding:3px 8px; cursor:pointer; }
  .auto-id-btn:hover { background:#ffb300; color:#1a1a1a; }
  .auto-id-btn.busy { opacity:.5; cursor:wait; }
  .dbg-btn { position:absolute; bottom:6px; right:6px; z-index:4; background:rgba(0,0,0,.7); color:#ff9a9a;
             border:1px solid #6a3a3a; border-radius:5px; font:700 10px sans-serif; padding:3px 8px;
             text-decoration:none; cursor:pointer; }
  .dbg-btn:hover { background:#ff4d4d; color:#fff; border-color:#ff4d4d; }
  /* marquee rubber-band selection */
  #marquee { position:fixed; z-index:40; border:1.5px solid #4aa3ff; background:rgba(74,163,255,.16); pointer-events:none; display:none; border-radius:3px; }
  body.dragging { user-select:none; cursor:crosshair; }
  body.dragging .card { cursor:crosshair; }
  /* Action buttons collapse by default → compact cards; reveal on hover, or pin via the header toggle. */
  .acts { display:none; grid-template-columns:1fr 1fr; gap:4px; padding:6px 8px 9px; }
  .card:hover .acts, body.show-acts .acts { display:grid; }
  .acts button { font:600 11px/1.2 inherit; padding:7px 4px; border-radius:7px; border:1px solid var(--line);
                 background:#1d2023; color:var(--ink); cursor:pointer; transition:filter .1s; }
  .acts button:hover { filter:brightness(1.25); }
  .acts .bad   { border-color:#5a2222; color:#ff9a9a; }
  .acts .dig   { border-color:#3d2f63; color:#cbb8ff; }
  .acts .fix   { border-color:#5a4410; color:#ffd98a; }
  .acts .live  { border-color:#1f4d2a; color:#9ff0a6; }
  .acts .etsy  { border-color:#5a3f17; color:#ffb86b; }
  .acts .unpub { border-color:#3a3f44; color:#a8b3bc; }
  .acts .bakebtn { border-color:#1f4a5a; color:#7fe3ff; background:#13262d; }
  .acts .bakebtn:disabled { opacity:.6; }
  .acts .roombtn { border-color:#1f5a3a; color:#9af0c2; background:#102a1d; }
  .acts .roombtn:disabled { opacity:.6; }
  .roomgen { display:none; }
  .roomgen.show { display:block; padding:6px 8px 2px; }
  .roomgen-img { width:100%; border-radius:8px; display:block; }
  .roomgen-cap { font:600 11px/1.4 inherit; color:#9af0c2; padding:3px 0 2px; }
  .roomgen-status { font:600 12px/1.4 inherit; color:#9aa; text-align:center; padding:8px; }
  .roomgen-status.err { color:#ff9a9a; }

  /* Haiku-vision verdict chip — appears on cards where the haiku-weekend pass flagged a defect */
  .b.haiku { background:#4a1f5a; color:#f2c8ff; font-weight:700; }
  .b.haiku.ok            { background:#1f4d2a; color:#9ff0a6; }
  .b.haiku.ghost         { background:#5a2a2a; color:#ff9a9a; }
  .b.haiku.fuzzy_seam    { background:#5a4410; color:#ffd98a; }
  .b.haiku.tone_break    { background:#5a4410; color:#ffd98a; }
  .b.haiku.mural_misclass{ background:#5a3f17; color:#ffb86b; }
  .b.haiku.vendor_leak   { background:#5a2a2a; color:#ff9a9a; }
  .b.haiku.bleed         { background:#2a3f5a; color:#a8d8ff; }
  .b.haiku.other         { background:#2a3f5a; color:#a8d8ff; }

  /* In-card "chat" panel that appears after Fix runs — explains what the heal pipeline did + the haiku reason */
  .fix-panel { margin-top:6px; padding:8px 10px; border:1px solid var(--line); border-radius:6px;
               background:#14171a; font-size:12px; }
  .fix-row { display:flex; gap:8px; padding:3px 0; align-items:flex-start; }
  .fix-row .ico { flex:0 0 18px; font-size:14px; line-height:1.3; }
  .fix-row .msg { flex:1; color:var(--ink); line-height:1.4; word-wrap:break-word; }
  .fix-row .msg b { color:#9ff0a6; font-weight:700; }
  .fix-row.haiku .msg b { color:#f2c8ff; }
  .fix-row.err .msg, .fix-row.err .msg b { color:#ff9a9a; }
  .restore-btn { margin-top:6px; padding:5px 11px; font:600 11px inherit;
                 background:#1d2023; color:#a8b3bc; border:1px solid #3a3f44; border-radius:5px; cursor:pointer; }
  .restore-btn:hover { filter:brightness(1.25); }
  .card.moved-to-etsy { opacity:0; transform:scale(.92); transition:opacity .3s ease, transform .3s ease; }
  .acts button kbd { font:inherit; opacity:.55; }

  /* sticky bulk bar */
  #bulk { position:fixed; bottom:0; left:0; right:0; z-index:30; transform:translateY(110%); transition:transform .2s;
          background:#16181a; border-top:1px solid var(--line); padding:12px 16px; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
  #bulk.show { transform:none; }
  #bulk b { color:var(--accent); }
  #bulk button { font:600 13px inherit; padding:8px 14px; border-radius:8px; border:1px solid var(--line); background:#1d2023; color:var(--ink); cursor:pointer; }
  #bulk .bad{color:#ff9a9a;} #bulk .dig{color:#cbb8ff;} #bulk .fix{color:#ffd98a;} #bulk .live{color:#9ff0a6;} #bulk .etsy{color:#ffb86b;} #bulk .unpub{color:#a8b3bc;}
  #bulk .clear{margin-left:auto;color:var(--mut);}
  #empty,#err { padding:60px 16px; text-align:center; color:var(--mut); }
  #err { color:#ff9a9a; }

  /* detail modal */
  #modal { position:fixed; inset:0; z-index:60; background:rgba(0,0,0,.82); display:none; align-items:center; justify-content:center; padding:24px; }
  #modal.show { display:flex; }
  .modal-box { position:relative; background:var(--panel); border:1px solid var(--line); border-radius:12px; max-width:1120px; width:100%; max-height:90vh; display:flex; overflow:hidden; }
  .modal-img { flex:0 0 52%; background:#000; display:flex; align-items:center; justify-content:center; }
  .modal-img img { max-width:100%; max-height:90vh; object-fit:contain; display:block; }
  .modal-side { flex:1; padding:18px 22px; overflow:auto; }
  .modal-side h2 { margin:0 0 2px; font-size:18px; }
  .modal-close { position:absolute; top:8px; right:12px; font-size:28px; line-height:1; color:#fff; cursor:pointer; background:none; border:none; z-index:2; }
  .kv { display:grid; grid-template-columns:128px 1fr; gap:3px 12px; font-size:12.5px; margin-top:12px; }
  .kv dt { color:var(--mut); } .kv dd { margin:0; color:var(--ink); word-break:break-word; }
  .pal { display:flex; gap:4px; flex-wrap:wrap; margin-top:8px; }
  .pal i { width:22px; height:22px; border-radius:4px; border:1px solid rgba(255,255,255,.2); display:block; }
  .modal-acts { display:flex; gap:6px; margin-top:16px; flex-wrap:wrap; }
  .modal-acts button { font:600 12px inherit; padding:8px 13px; border-radius:7px; border:1px solid var(--line); background:#1d2023; color:var(--ink); cursor:pointer; }
  .modal-acts .bad{color:#ff9a9a;border-color:#5a2222;} .modal-acts .dig{color:#cbb8ff;border-color:#3d2f63;}
  .modal-acts .fix{color:#ffd98a;border-color:#5a4410;} .modal-acts .live{color:#9ff0a6;border-color:#1f4d2a;}
  .modal-acts .etsy{color:#ffb86b;border-color:#5a3f17;} .modal-acts .unpub{color:#a8b3bc;border-color:#3a3f44;}
  .modal-bake { display:flex; gap:9px; align-items:center; flex-wrap:wrap; margin-top:11px; padding-top:11px; border-top:1px solid var(--line); }
  .modal-bake .bake { font:600 12px inherit; padding:8px 13px; border-radius:7px; border:1px solid #1f4a5a; background:#13262d; color:#7fe3ff; cursor:pointer; }
  .modal-bake .bake:disabled { opacity:.6; cursor:default; }
  .legend { font-size:11px; color:var(--mut); }
  .legend code { background:var(--panel); padding:1px 5px; border-radius:4px; border:1px solid var(--line); color:var(--ink); }
  /* HSB + contrast live adjustment panel (non-destructive CSS preview, Steve 2026-05-31) */
  #adjPanel { display:none; flex-wrap:wrap; gap:16px; align-items:center; padding:9px 14px; border-bottom:1px solid var(--line); background:#16191c; }
  #adjPanel.on { display:flex; }
  #adjPanel .adj { display:flex; align-items:center; gap:7px; font:600 12px inherit; color:var(--mut); }
  #adjPanel .adj input[type=range] { width:120px; }
  #adjPanel .adjval { display:inline-block; min-width:62px; text-align:right; color:var(--ink); font-variant-numeric:tabular-nums; }
  #adjPanel .adjnote { font-size:11px; color:var(--mut); }
  #adjPanel .adj-presets { display:flex; gap:5px; align-items:center; }
  #adjPanel .adj-preset { font:600 11px inherit; padding:4px 9px; border-radius:6px; border:1px solid var(--line); background:#1d2023; color:var(--ink); cursor:pointer; }
  #adjPanel .adj-preset:hover { border-color:var(--accent); }
  #adjPanel .adj-preset.on { background:#1f3a4d; color:#7fe3ff; border-color:#1f4a5a; }
  #adjPanel .adj-ba { display:none; gap:8px; align-items:center; }
  #adjPanel .adj-ba.on { display:flex; }
  #adjPanel .ba-wrap { display:flex; flex-direction:column; align-items:center; gap:2px; }
  #adjPanel .ba-wrap em { font-style:normal; font-size:9px; color:var(--mut); text-transform:uppercase; letter-spacing:.04em; }
  #adjPanel .ba-img { width:38px; height:38px; object-fit:cover; border-radius:5px; border:1px solid var(--line); background:#0c0e10; }
  #adjPanel .ba-after { filter: var(--img-adjust, none); }
  #adjToggle.on { background:#1f4d2a; color:#9ff0a6; border-color:#1f4d2a; }
  #grid img, #modal img, #bbstack img { filter: var(--img-adjust, none); }
</style>
</head>
<body>
<header>
  <div class="row">
    <h1><span class="leaf">❖</span> Design Curator</h1>
    <span class="pill" id="stat">loading…</span>
    <label class="ctl">Collection
      <select id="collection" title="switch the curated line — navigates to ?category=&lt;line&gt;"></select>
    </label>
    <label class="ctl">Sort
      <select id="sort">
        <option value="rank">Composite rank ▼ (catalog-like)</option>
        <option value="vision">Vision quality ▼</option>
        <option value="seam">Seam quality ▼</option>
        <option value="newest">Newest</option>
        <option value="oldest">Oldest</option>
        <option value="colorway">Color (hue) ▦</option>
      </select>
    </label>
    <label class="ctl">Show
      <select id="filter">
        <option value="undecided">Undecided</option>
        <option value="all">All (live)</option>
        <option value="published">Published</option>
        <option value="unpublished">Unpublished</option>
        <option value="web">Web-viewer</option>
        <option value="fix">Needs fixing</option>
        <option value="digital">Digital file</option>
        <option value="removed">Removed</option>
      </select>
    </label>
    <label class="ctl">Density
      <input type="range" id="density" min="2" max="9" step="1" value="5">
    </label>
    <button id="adjToggle" class="ctl-btn" title="HSB + contrast live preview on all images — non-destructive, persists in localStorage">🎨 Adjust</button>
    <button id="autoall" class="ctl-btn" title="auto-detect bad areas on every visible card">🔍 Auto-ID all visible</button>
    <button id="selectall" class="ctl-btn" title="Toggle select all visible cards — click again to clear">☑ Select all visible</button>
    <button id="toggleacts" class="ctl-btn" title="show/hide the Bad/Digital/Fix/Publish buttons on every card (they always show on hover)">▸ Actions</button>
    <span class="legend"><b>drag</b> across images to select · <b>shift+drag on an image</b> = ▢ box a bad area (red) · <b>shift+click</b> a card to add one · keys on hover: <code>1</code>bad <code>2</code>digital <code>3</code>fix <code>4</code>publish <code>x</code>select</span>
  </div>
</header>

<div id="adjPanel">
  <span class="adj" style="color:var(--ink)">🎨 Image adjustments</span>
  <label class="adj">Hue <input type="range" id="adjHue" min="0" max="255" step="1"><span class="adjval" id="adjHueV"></span></label>
  <label class="adj">Sat <input type="range" id="adjSat" min="0" max="255" step="1"><span class="adjval" id="adjSatV"></span></label>
  <label class="adj">Bri <input type="range" id="adjBri" min="0" max="255" step="1"><span class="adjval" id="adjBriV"></span></label>
  <label class="adj">Con <input type="range" id="adjCon" min="0" max="200" step="1"><span class="adjval" id="adjConV"></span></label>
  <span class="adj-presets" id="adjPresets" title="quick-apply curated HSB+contrast combos">
    <button class="adj-preset" data-preset="neutral">Neutral</button>
    <button class="adj-preset" data-preset="vivid">Vivid</button>
    <button class="adj-preset" data-preset="muted">Muted</button>
    <button class="adj-preset" data-preset="warm">Warm</button>
    <button class="adj-preset" data-preset="cool">Cool</button>
    <button class="adj-preset" data-preset="bw">B&amp;W</button>
  </span>
  <button id="adjReset" class="ctl-btn" title="back to neutral (Hue 127 · Sat 127 · Bri 127 · Con 100)">Reset</button>
  <button id="adjCopyCss" class="ctl-btn" title="copy the current CSS filter string to the clipboard">⧉ Copy CSS</button>
  <span class="adj-ba" id="adjBA" title="before / after — live preview of the current adjustment on a sample card">
    <span class="ba-wrap"><img class="ba-img ba-before" id="baBefore" alt=""><em>before</em></span>
    <span class="ba-wrap"><img class="ba-img ba-after" id="baAfter" alt=""><em>after</em></span>
  </span>
  <span class="adjnote">live preview only — non-destructive, doesn't touch the stored image · keys: <b>0</b> reset, <b>[</b>/<b>]</b> nudge focused slider</span>
</div>

<div id="err" style="display:none"></div>
<div id="empty" style="display:none">No designs match this filter.</div>
<div id="hint" style="display:none;margin:8px 14px 0;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:#16191c;color:var(--mut);font-size:13px;">
  <span id="hintmsg"></span>
  <button id="hintall" style="margin-left:8px;font:600 12px inherit;padding:4px 10px;border-radius:6px;border:1px solid var(--line);background:#1d2023;color:var(--ink);cursor:pointer;">Show all →</button>
</div>
<div class="grid" id="grid"></div>
<div id="marquee"></div>
<div id="modal"><div class="modal-box" id="modalBox"></div></div>

<div id="bulk">
  <span><b id="bulkn">0</b> selected</span>
  <button class="bad"  data-a="bad">1 · Bad — remove all</button>
  <button class="dig"  data-a="digital">2 · Digital file</button>
  <button class="fix"  data-a="fix">3 · Needs fixing</button>
  <button class="live" data-a="live">4 · Publish</button>
  <button class="etsy" data-a="etsy">🛒 5 · Etsy bucket</button>
  <button class="unpub" data-a="unpublish">📴 6 · Unpublish</button>
  <button class="clear" id="bulkclear">clear selection</button>
</div>

<script>
const ADMIN = new URLSearchParams(location.search).get('admin');
// Collection is a URL param — this curator now serves cactus, drunk-animals, etc.
const CATEGORY = (new URLSearchParams(location.search).get('category') || 'cactus');
// q(path) — build a fetchable URL. Anchors '/'-prefixed paths to location.origin
// so URLs are credentials-free; the Fetch spec rejects Request construction
// when the resolved URL contains userinfo (happens when the page was loaded
// via http://admin:pw@host/...). Appends ?admin=<token> when present.
const q = s => {
  const path = s.startsWith('/') ? location.origin + s : s;
  return ADMIN ? path + (path.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(ADMIN) : path;
};
const $ = id => document.getElementById(id);
const grid = $('grid');

const LS = {
  get d(){ return +(localStorage.getItem('cc_density')||5); }, set d(v){ localStorage.setItem('cc_density',v); },
  get s(){ return localStorage.getItem('cc_sort')||'rank'; },   set s(v){ localStorage.setItem('cc_sort',v); },
  get f(){
    // One-time migration (Steve 2026-05-29): stale localStorage from earlier sessions
    // may pin filter='all', which makes Bad-removed cards keep showing up with REMOVED
    // pills and feel like the Bad click is broken. Reset 'all' → 'undecided' once.
    if (!localStorage.getItem('cc_filter_migrated_undecided_2026_05_29')
        && localStorage.getItem('cc_filter') === 'all') {
      localStorage.setItem('cc_filter', 'undecided');
    }
    localStorage.setItem('cc_filter_migrated_undecided_2026_05_29','1');
    return localStorage.getItem('cc_filter')||'undecided';
  }, set f(v){ localStorage.setItem('cc_filter',v); },
};

let ALL = [];                 // every design from the API
const decided = new Map();    // id -> last action this session (optimistic)
const selected = new Set();

function applyDensity(v){ document.documentElement.style.setProperty('--cols', v); }
$('density').value = LS.d; applyDensity(LS.d);
$('sort').value = LS.s;
$('filter').value = LS.f;

$('density').addEventListener('input', e => { LS.d = e.target.value; applyDensity(e.target.value); });

// Select-all toggle (Steve 2026-05-29 "allow user to select 1 to all in check marks").
// First click selects every visible card; second click clears the selection. Animated
// cards (already 'moved-to-etsy' transitioning out) skipped so a recent Bad/Publish
// doesn't quietly come back into the selection set.
$('selectall').addEventListener('click', () => {
  const cards = [...grid.querySelectorAll('.card[data-id]:not(.moved-to-etsy)')];
  if (!cards.length) return;
  const allSelected = cards.every(c => c.classList.contains('sel'));
  if (allSelected) {
    cards.forEach(c => { selected.delete(parseInt(c.dataset.id,10)); c.classList.remove('sel'); });
  } else {
    cards.forEach(c => { const id = parseInt(c.dataset.id,10); if (!isNaN(id)) { selected.add(id); c.classList.add('sel'); } });
  }
  $('bulkn').textContent = selected.size;
  $('bulk').classList.toggle('show', selected.size > 0);
  $('selectall').textContent = (selected.size && selected.size === cards.length) ? '✕ Clear selection' : '☑ Select all visible';
});

// Auto-ID every visible card (capped, limited concurrency so we don't hammer Gemini).
$('autoall').addEventListener('click', async () => {
  const cards = [...grid.querySelectorAll('.card')];
  if (!cards.length) return;
  const CAP = 60, todo = cards.slice(0, CAP);
  if (!confirm(`Auto-ID ${todo.length} visible card(s) via Gemini vision (~$${(todo.length*0.003).toFixed(2)})`
      + (cards.length>CAP ? ` — capped at ${CAP} of ${cards.length}; narrow the filter for the rest.` : '?'))) return;
  const btn = $('autoall'), orig = btn.textContent; btn.disabled = true;
  let done = 0, idx = 0;
  const worker = async () => {
    while (idx < todo.length) {
      const el = todo[idx++], id = parseInt(el.dataset.id, 10), d = ALL.find(x => x.id === id);
      if (d) { try { await autoId(d, el, el.querySelector('.auto-id-btn')); } catch {} }
      btn.textContent = `… ${++done}/${todo.length}`;
    }
  };
  await Promise.all(Array.from({ length: 4 }, worker));
  btn.textContent = orig; btn.disabled = false;
});
// Collapse/pin the per-card action buttons (collapsed by default; always reveal on hover).
(function(){
  const tb = $('toggleacts');
  const set = on => { document.body.classList.toggle('show-acts', on); tb.textContent = on ? '▾ Actions' : '▸ Actions'; };
  set(localStorage.getItem('cc_showacts') === '1');
  tb.addEventListener('click', () => {
    const on = !document.body.classList.contains('show-acts');
    localStorage.setItem('cc_showacts', on ? '1' : '0'); set(on);
  });
})();
$('sort').addEventListener('change', e => { LS.s = e.target.value; render(); });
$('filter').addEventListener('change', e => { LS.f = e.target.value; render(); });
// "Show all" button in the hidden-by-filter hint — one-click flip from
// undecided/etc to 'all' so the curator sees every item without hunting the
// dropdown.
$('hintall')?.addEventListener('click', () => { $('filter').value='all'; LS.f='all'; render(); });

function num(v){ return v==null ? null : Number(v); }

// dominant_hex → sort key: group by hue around the colour wheel; push near-greys
// (low saturation) to the end ordered dark→light, so colour sort reads cleanly.
function hueKey(hex){
  const m = /^#?([0-9a-f]{6})$/i.exec(String(hex||'').trim());
  if(!m) return 1000;
  const n = parseInt(m[1],16), r=(n>>16&255)/255, g=(n>>8&255)/255, b=(n&255)/255;
  const mx=Math.max(r,g,b), mn=Math.min(r,g,b), d=mx-mn, l=(mx+mn)/2;
  const sat = d===0 ? 0 : d/(1-Math.abs(2*l-1));
  let h=0; if(d){ if(mx===r)h=((g-b)/d+6)%6; else if(mx===g)h=(b-r)/d+2; else h=(r-g)/d+4; h*=60; }
  return sat<0.12 ? 720 + (1-l)*100 : h;   // greys after all hues, dark→light
}
function sortFns(mode){
  const r = d => num(d.rank_score) ?? -1, v = d => num(d.vision_score) ?? -1, s = d => num(d.seam_score) ?? -1;
  switch(mode){
    case 'vision':   return (a,b)=> v(b)-v(a) || a.id-b.id;
    case 'seam':     return (a,b)=> s(b)-s(a) || a.id-b.id;
    case 'newest':   return (a,b)=> new Date(b.created_at)-new Date(a.created_at) || b.id-a.id;
    case 'oldest':   return (a,b)=> new Date(a.created_at)-new Date(b.created_at) || a.id-b.id;
    case 'colorway': return (a,b)=> hueKey(a.dominant_hex)-hueKey(b.dominant_hex) || a.id-b.id;  // sort by COLOR (hue wheel)
    default:         return (a,b)=> r(b)-r(a) || a.id-b.id;   // composite rank
  }
}

// Mural-style categories (monterey-mural, cactus-mural, tree-mural, cactus-pine-scenic,
// any "*-mural" / "*-scenic") get web_viewer=true at creation time as part of the
// panel pipeline — so excluding web_viewer items from "undecided" wipes the whole
// category from view. For those categories, treat web_viewer as orthogonal to
// undecided-ness so the curator actually has cards to triage.
const IS_MURAL_CAT = /(?:^|\W)(mural|scenic)(?:$|\W|-)/i.test(CATEGORY);
function passesFilter(d, f){
  const act = decided.get(d.id);
  switch(f){
    case 'all':         return true;
    case 'published':   return d.is_published && act!=='bad' && act!=='digital';
    case 'unpublished': return !d.is_published && !(d.user_removed || act==='bad');
    case 'web':         return d.web_viewer || act==='live';
    case 'fix':         return !!d.needs_fixing_at || act==='fix';
    case 'digital':     return !!d.digital_file_at || act==='digital';
    case 'removed':     return d.user_removed || act==='bad';
    case 'undecided':   // not yet touched in DB and not acted on this session
    default:            return !act && !d.user_removed && !d.needs_fixing_at && !d.digital_file_at
                          && (IS_MURAL_CAT || !d.web_viewer);
  }
}

function colorway(cat){
  if(!cat) return 'cactus';
  return cat.includes('·') ? cat.split('·').slice(1).join('·').trim() : cat;
}
function verdictClass(v){ return v==='PASS'?'pass':v==='WARN'?'warn':v==='FAIL'?'fail':'none'; }
// Short red defect label shown on the card front when a design FAILs — says WHY.
// Map a bad_examples defect_tag → short human label for the red chip.
const TAG_LABEL = { seams:'Seams', repeat_break_lr:'Seams', repeat_break_tb:'Seams',
  edges_fail:'Seams', poor_edges:'Poor edges', cartoonish:'Cartoonish', blurry:'Blurry',
  ghost:'Ghost', bleed:'Bleed', heal_derivative:'Healed', manual_bad_region:'Flagged',
  no_bottle:'No bottle', not_animals:'Not animals' };
function failReason(d){
  const reasons = [];
  const hmid = num(d.seam_hmid_raw), vmid = num(d.seam_vmid_raw);
  if (d.seam_verdict === 'FAIL' || (hmid != null && hmid > 12) || (vmid != null && vmid > 12)) reasons.push('Seams');
  const vis = num(d.vision_score);
  if (vis != null && vis < 40) reasons.push('Blurry');           // low vision-quality ≈ soft/blurry/artifacted
  // Defect tags recorded in bad_examples (manual flags, audits) — surface each.
  (d.defect_tags || []).forEach(t => { if (TAG_LABEL[t]) reasons.push(TAG_LABEL[t]); });
  return [...new Set(reasons)];
}

// Top-N rank chip threshold + fresh-window — at-a-glance "this is the strongest
// few" and "this just came out of the gen pipeline" cues, ported from drunk-curator.
const TOP_RANK_N = 10;
const FRESH_MS = 24 * 3600 * 1000;
function isFreshIso(iso){ if(!iso) return false; try{ return (Date.now() - new Date(iso).getTime()) < FRESH_MS; }catch{return false;} }
function pickRoomKey(rm){
  if (!rm) return null;
  // Shape varies: array ["living_room"] OR object {"living_room":"/path"}.
  const keys = Array.isArray(rm) ? rm : (typeof rm === 'object' ? Object.keys(rm) : []);
  if (!keys.length) return null;
  return keys.includes('living_room') ? 'living_room'
       : keys.includes('bedroom')     ? 'bedroom'
       : keys.includes('office')      ? 'office'
       : keys[0];
}
// 🛋 On-demand room-setting: POST /api/room → inject the rendered room image
// below the design image in this chip. Reuses the auth-gated $/call renderer
// (admin token via q()); shows a spinner, then the image + caption.
async function renderRoomSetting(id, btn){
  const box = document.getElementById('roomgen-' + id);
  const orig = btn.textContent;
  btn.disabled = true; btn.textContent = '🛋 Rendering…';
  if (box){ box.classList.add('show'); box.innerHTML = '<div class="roomgen-status">🛋 Rendering room… (~15s)</div>'; }
  try {
    const r = await fetch(q('/api/room'), {
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({ design_id: id, roomType: 'bedroom' })
    });
    const j = await r.json().catch(()=>({}));
    if (!r.ok || !j.ok) throw new Error(j.message || j.error || ('HTTP ' + r.status));
    if (box) box.innerHTML =
      `<img class="roomgen-img" src="${j.image_url}" alt="room setting" loading="lazy">` +
      `<div class="roomgen-cap">🛋 ${ (j.roomType||'room').replace(/_/g,' ') } mockup</div>`;
    btn.textContent = '🛋 Room ✓';
  } catch (e){
    if (box) box.innerHTML = `<div class="roomgen-status err">⚠ ${e.message}</div>`;
    btn.disabled = false; btn.textContent = orig;
  }
}
function roomImgHTML(d){
  const k = pickRoomKey(d.room_mockups);
  if (!k) return '';
  return `<img class="card-room" loading="lazy" src="/designs/room/design_${d.id}_${k}.png" alt="" aria-hidden="true">`;
}
function roomBadgeHTML(d){
  const k = pickRoomKey(d.room_mockups);
  if (!k) return '';
  return `<span class="b room" title="hover for ${k.replace(/_/g,' ')} mockup">🛋</span>`;
}

function card(d, idx){
  const el = document.createElement('div');
  el.className = 'card';
  el.dataset.id = d.id;
  const act = decided.get(d.id);
  if(act) el.classList.add('decided');
  if(selected.has(d.id)) el.classList.add('sel');

  const rank = num(d.rank_score), vis = num(d.vision_score), seam = num(d.seam_score);
  // Seam-mismatch callout — the common repeat-break error. h-mid raw > tolerance
  // = the pattern doesn't connect across the horizontal midline (top/bottom halves
  // don't tile); v-mid = vertical line. Tolerance 12 (WALLCO_SEAMLESS_TOLERANCE).
  const SEAM_TOL = 12;
  const hmid = num(d.seam_hmid_raw), vmid = num(d.seam_vmid_raw);
  const seamLines = [];
  if (hmid != null && hmid > SEAM_TOL) seamLines.push('horizontal line');
  if (vmid != null && vmid > SEAM_TOL) seamLines.push('vertical line');
  const seamCallout = seamLines.length
    ? `<div class="seam-mismatch" title="h-mid ΔE ${hmid??'—'} · v-mid ΔE ${vmid??'—'} (tolerance ${SEAM_TOL})">⚠ Seam Mismatch — ${seamLines.join(' & ')}</div>`
    : '';
  const stateLabel = act ? ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED'})[act]
                    : d.web_viewer ? '✓ PUBLISHED' : d.needs_fixing_at ? '⚠ NEEDS FIXING'
                    : d.digital_file_at ? '⬇ DIGITAL FILE' : d.user_removed ? '✕ REMOVED' : '';

  el.innerHTML = `
    <div class="thumb">
      <div class="sel-box" title="select (x)"></div>
      <div class="badges">
        ${(idx != null && idx < TOP_RANK_N) ? `<span class="b toprank" title="top ${TOP_RANK_N} by current sort">★ top ${idx+1}</span>` : ''}
        ${isFreshIso(d.created_at) ? `<span class="b fresh" title="created in the last 24h">✨ fresh</span>` : ''}
        <span class="b rank">${rank!=null? '#'+Math.round(rank) : '—'}</span>
        <span class="b ${d.is_published?'pub':'unpub'}">${d.is_published?'PUB':'unpub'}</span>
        ${d.web_viewer?'<span class="b web">WEB</span>':''}
        ${d.tif_path
          ? `<span class="b tif" title="TIF archive: ${d.tif_w_px||'?'}×${d.tif_h_px||'?'}px · ${d.tif_bytes?(d.tif_bytes/1024/1024).toFixed(2)+' MB':'?'} · ${(d.tif_path||'').replace(/^.*\//,'')}">📦 TIF ${d.tif_w_px||''}</span>`
          : `<span class="b notif" title="no TIF archive — generate via /admin/etsy-bucket or scripts/archive-svg-5400.js">📦 NO TIF</span>`}
        ${roomBadgeHTML(d)}
        <span class="b ${verdictClass(d.seam_verdict)}">${d.seam_verdict||'no scan'}</span>
        ${failReason(d).map(r=>`<span class="b reason" title="failed: ${r}">${r}</span>`).join('')}
        ${(window.HAIKU_VERDICTS && window.HAIKU_VERDICTS[d.id]) ?
          `<span class="b haiku ${(window.HAIKU_VERDICTS[d.id].verdict||'').toLowerCase()}" title="Haiku 4.5 vision verdict: ${(window.HAIKU_VERDICTS[d.id].reason||'').replace(/"/g,'&quot;')}">🤖 ${window.HAIKU_VERDICTS[d.id].verdict}</span>`
          : ''}
      </div>
      <img loading="lazy" src="/designs/img/by-id/${d.id}" alt="cactus ${d.id}">
      ${roomImgHTML(d)}
      ${seamCallout}
      <button class="auto-id-btn" title="auto-detect bad areas (you confirm/delete)">🔍 Auto-ID</button>
      <a class="dbg-btn" href="${q('/admin/seam-debug/'+d.id)}" target="_blank" rel="noopener noreferrer" title="open seam-defect debugger — see where the mid-line breaks">🔬 Debug seams</a>
      <div class="state" style="${stateLabel?'display:block':''}">${stateLabel}</div>
    </div>
    <div class="roomgen" id="roomgen-${d.id}"></div>
    <div class="meta">
      <div class="cw" title="${d.category||''}">${colorway(d.category)} <span class="sub">#${d.id}</span></div>
      <div class="scores">
        <span class="dot" style="background:${d.dominant_hex||'#333'}"></span>
        <span title="vision quality">V ${vis!=null?Math.round(vis):'·'}</span>
        <span title="seam quality">S ${seam!=null?Math.round(seam):'·'}</span>
      </div>
      <div class="when" title="created ${d.created_at||''}">🕓 ${fmtDate(d.created_at)}</div>
    </div>
    <div class="acts">
      <button class="bad"   data-a="bad"       title="Bad pattern — remove from all (1)">✕ Bad <kbd>1</kbd></button>
      <button class="dig"   data-a="digital"   title="Unpublish, sell as digital file (2)">⬇ Digital <kbd>2</kbd></button>
      <button class="fix"   data-a="fix"       title="Keep but needs fixing (3)">⚠ Fix <kbd>3</kbd></button>
      <button class="live"  data-a="live"      title="Publish — go live in the web viewer (4)">✓ Publish <kbd>4</kbd></button>
      <button class="etsy"  data-a="etsy"      title="Send to Etsy bucket (5) — removes from new-design pool, queues for digital download listing">🛒 Etsy <kbd>5</kbd></button>
      <button class="unpub" data-a="unpublish" title="Take down from live catalog (6) — sets is_published=FALSE, reversible via Publish">📴 Unpublish <kbd>6</kbd></button>
      <button class="roombtn" data-room="${d.id}" style="grid-column:1/-1" title="Generate a room-setting mockup for this design and show it below the image (~15s, ~$0.10).">🛋 Room setting</button>
      <button class="bakebtn" data-bake="${d.id}" style="grid-column:1/-1" title="Bake the current 🎨 Adjust settings onto this image as a NEW unpublished design (original untouched). Set the Adjust sliders off neutral first.">⤓ Bake adjustment</button>
    </div>`;

  el.querySelector('.sel-box').addEventListener('click', e => { e.stopPropagation(); toggleSel(d.id, el); });
  // single click → admin detail modal · shift+click → add/remove from selection
  // double-click → open the customer-facing PRODUCT PAGE (PDP /design/:id) in a
  // new tab — "open it up as a new product" (Steve 2026-05-29). Single click is
  // debounced ~230ms so a double-click doesn't also flash the modal open first.
  let clickTimer = null;
  el.querySelector('.thumb').addEventListener('click', e => {
    if (e.target.classList.contains('sel-box')) return;
    if (e.target.classList.contains('auto-id-btn')) return;
    if (suppressClick) return;                      // a marquee drag just ended
    if (e.shiftKey) { e.preventDefault(); toggleSel(d.id, el); return; }
    if (clickTimer) return;                         // 2nd click of a dblclick — let dblclick win
    clickTimer = setTimeout(() => { clickTimer = null; openDetail(d.id); }, 230);
  });
  el.querySelector('.thumb').addEventListener('dblclick', e => {
    if (e.target.classList.contains('sel-box') || e.target.classList.contains('auto-id-btn')) return;
    if (e.shiftKey) return;
    if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
    e.preventDefault();
    window.open('/design/' + d.id, '_blank', 'noopener');   // PDP — customer product page, no admin token
  });
  el.querySelectorAll('.acts button[data-a]').forEach(b =>
    b.addEventListener('click', () => decide(d.id, b.dataset.a, el)));
  // A5 — per-card bake (no modal): reuse the same canvas→/api/design/:id/bake path
  el.querySelector('.acts [data-bake]')?.addEventListener('click', e => {
    e.stopPropagation();
    bakeAdjustment(d.id, e.currentTarget);
  });
  // 🛋 Room setting — generate a room mockup on demand, show it below the design image
  el.querySelector('.acts [data-room]')?.addEventListener('click', e => {
    e.stopPropagation();
    renderRoomSetting(d.id, e.currentTarget);
  });
  // SHIFT+drag on the image → draw a dotted "bad area" box (id BAD elements for fixing)
  const thumb = el.querySelector('.thumb');
  thumb.addEventListener('mousedown', e => {
    if (!e.shiftKey || e.target.classList.contains('x') || e.target.classList.contains('sel-box')) return;
    e.preventDefault(); e.stopPropagation();           // block marquee-select; this is a draw
    const rect = thumb.getBoundingClientRect();
    const sx = (e.clientX-rect.left)/rect.width, sy = (e.clientY-rect.top)/rect.height;
    const box = document.createElement('div'); box.className = 'bad-box drawing'; thumb.appendChild(box);
    let cur = null;
    const mv = ev => {
      const cx = Math.max(0,Math.min(1,(ev.clientX-rect.left)/rect.width));
      const cy = Math.max(0,Math.min(1,(ev.clientY-rect.top)/rect.height));
      cur = { x:Math.min(sx,cx), y:Math.min(sy,cy), w:Math.abs(cx-sx), h:Math.abs(cy-sy) };
      box.style.cssText = `left:${cur.x*100}%;top:${cur.y*100}%;width:${cur.w*100}%;height:${cur.h*100}%`;
    };
    const up = async (ev) => {
      window.removeEventListener('mousemove', mv); window.removeEventListener('mouseup', up);
      box.remove();
      if (cur && cur.w > 0.02 && cur.h > 0.02) {
        suppressClick = true; setTimeout(()=>{ suppressClick=false; }, 150);
        const label = await pickLabel(ev.clientX, ev.clientY);   // choose the defect type
        if (label) addAnnotation(d, { ...cur, label }, el);
      }
    };
    window.addEventListener('mousemove', mv); window.addEventListener('mouseup', up);
  });
  el.querySelector('.auto-id-btn').addEventListener('click', e => { e.stopPropagation(); autoId(d, el, e.currentTarget); });
  renderBoxes(d, el);
  el.addEventListener('mouseenter', () => hovered = d.id);
  el.addEventListener('mouseleave', () => { if(hovered===d.id) hovered=null; });
  return el;
}

// Render bad-area boxes on a card's thumb (idempotent). Two kinds:
//  • confirmed (d.annotations) — red, saved, with × to remove
//  • candidates (d.candidates) — amber dashed, auto-detected, with ✓ confirm / × reject
function makeBox(b, isCand, onX, onOk){
  const div = document.createElement('div');
  div.className = 'bad-box' + (isCand ? ' cand' : '');
  div.style.cssText = `left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%`;
  div.innerHTML = (isCand ? `<span class="ok" title="confirm">✓</span>` : '')
    + `<span class="x" title="${isCand?'reject':'remove'}">×</span>`
    + (b.label ? `<span class="lbl">${b.label}</span>` : '');
  div.querySelector('.x').addEventListener('click', e => { e.stopPropagation(); onX(); });
  if (isCand) div.querySelector('.ok').addEventListener('click', e => { e.stopPropagation(); onOk(); });
  return div;
}
function renderBoxes(d, el){
  const thumb = el.querySelector('.thumb');
  thumb.querySelectorAll('.bad-box').forEach(n => n.remove());
  (d.annotations || []).forEach((b, i) => {
    if (b.auto) {
      // auto-detected (amber): ✓ promote to confirmed, × reject
      thumb.appendChild(makeBox(b, true,
        () => { d.annotations.splice(i, 1); renderBoxes(d, el); saveAnnotations(d); },
        () => { b.auto = false; renderBoxes(d, el); saveAnnotations(d); }));
    } else {
      // human-confirmed (red): × remove
      thumb.appendChild(makeBox(b, false, () => removeAnnotation(d, i, el)));
    }
  });
}
// Auto-detect + PERSIST error boxes (auto-flagged/amber) so the squares survive
// reloads and accumulate as the learning corpus. ✓ promotes to confirmed (red).
function autoId(d, el, btn){
  if (btn) { btn.classList.add('busy'); btn.textContent = '… scanning'; }
  return fetch(q(`/api/admin/cactus/${d.id}/auto-id`))
    .then(r => r.json())
    .then(j => {
      if (!j.ok) { alert('Auto-ID failed: ' + (j.error||'unknown')); return; }
      d.annotations = j.boxes || [];           // persisted merged boxes (auto + confirmed)
      renderBoxes(d, el);
      if (btn) btn.textContent = d.annotations.some(b => b.auto) ? '🔍 Auto-ID' : '✓ clean';
    })
    .catch(err => alert('Auto-ID error: ' + err.message))
    .finally(() => { if (btn) btn.classList.remove('busy'); });
}
// Defect-type picker shown after drawing a bad-area box (collection-aware).
const BOX_LABELS = /drunk/i.test(CATEGORY)
  ? ['No bottle','Not animals','Poor edges','Cartoonish','Seams','Blurry']
  : ['Seams','Poor edges','Cartoonish','Blurry','Ghost','Bleed'];
let _picker = null;
function closePicker(){ if(_picker){ document.removeEventListener('mousedown', _picker._out, true); _picker.remove(); _picker=null; } }
function pickLabel(clientX, clientY){
  return new Promise(resolve => {
    closePicker();
    const p = document.createElement('div'); p.className='label-picker'; _picker = p;
    p.innerHTML = `<div class="lp-h">Bad element:</div>` +
      BOX_LABELS.map(l=>`<button data-l="${l}">${l}</button>`).join('') +
      `<button class="lp-cancel" data-l="">cancel</button>`;
    document.body.appendChild(p);
    p.style.left = Math.min(clientX, window.innerWidth - 190) + 'px';
    p.style.top  = Math.min(clientY, window.innerHeight - 230) + 'px';
    const done = l => { closePicker(); resolve(l || null); };
    p.addEventListener('mousedown', e => e.stopPropagation());   // don't start a marquee
    p.querySelectorAll('button').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); done(b.dataset.l); }));
    p._out = ev => { if(_picker && !_picker.contains(ev.target)) done(null); };
    setTimeout(()=> document.addEventListener('mousedown', p._out, true), 0);
  });
}
function addAnnotation(d, box, el){ d.annotations = d.annotations || []; d.annotations.push(box); renderBoxes(d, el); saveAnnotations(d); }
function removeAnnotation(d, i, el){ (d.annotations||[]).splice(i,1); renderBoxes(d, el); saveAnnotations(d); }
function saveAnnotations(d){
  fetch(q(`/api/admin/cactus/${d.id}/annotations`), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ boxes: d.annotations||[] }) })
    .catch(()=>{});
}

let hovered = null;
let suppressClick = false;   // true briefly after a marquee drag so the click doesn't open detail

function render(){
  const f = $('filter').value;
  const list = ALL.filter(d => passesFilter(d, f)).sort(sortFns($('sort').value));
  grid.innerHTML = '';
  $('empty').style.display = list.length ? 'none' : 'block';
  // Surface the hidden-by-filter count so Steve never wonders "why so few cards"
  // (monterey-mural is the canonical case — 549 items, 546 web_viewer, default
  // 'undecided' filter would hide all but 3 if IS_MURAL_CAT weren't lifting that cut).
  const hidden = ALL.length - list.length;
  const hint = $('hint');
  if (hidden > 0 && f !== 'all' && hint) {
    $('hintmsg').textContent = `${hidden} of ${ALL.length} hidden by '${f}' filter.`;
    hint.style.display = 'block';
  } else if (hint) {
    hint.style.display = 'none';
  }
  const frag = document.createDocumentFragment();
  // Pass position-in-sorted-list so card() can render the ★ top-N rank pill on the front of the queue.
  list.forEach((d, i) => frag.appendChild(card(d, i)));
  grid.appendChild(frag);
  updateStat();
}

function updateStat(){
  const total = ALL.length;
  const done = ALL.filter(d => decided.get(d.id) || d.user_removed || d.needs_fixing_at || d.digital_file_at || d.web_viewer).length;
  $('stat').innerHTML = `<b>${total}</b> ${CATEGORY} · <b>${done}</b> decided · <b>${total-done}</b> left`;
}

function toggleSel(id, el){
  // Resolve the card element across BOTH the main grid AND the by-base stack
  // (#bbstack). When toggled from a delegated handler `el` is passed; on
  // keyboard 'x' from any hovered card we look it up here.
  el = el || document.querySelector(`#grid .card[data-id="${id}"], #bbstack .card[data-id="${id}"]`);
  if(selected.has(id)){ selected.delete(id); el?.classList.remove('sel'); }
  else { selected.add(id); el?.classList.add('sel'); }
  $('bulkn').textContent = selected.size;
  $('bulk').classList.toggle('show', selected.size>0);
}

async function decide(id, action, el){
  el?.classList.add('decided');
  const st = el?.querySelector('.state');
  if(st){ st.textContent = ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET',unpublish:'📴 UNPUBLISHED'})[action]; st.style.display='block'; }
  decided.set(id, action);
  // patch local model so filters/sorts stay consistent without a full reload
  const d = ALL.find(x=>x.id===id);
  if(d){
    if(action==='bad'){ d.is_published=false; d.user_removed=true; d.web_viewer=false; }
    else if(action==='digital'){ d.is_published=false; d.web_viewer=false; d.digital_file_at=today(); }
    else if(action==='fix'){ d.needs_fixing_at=today(); }
    else if(action==='live'){ d.is_published=true; d.web_viewer=true; d.user_removed=false; d.needs_fixing_at=null; d.digital_file_at=null; }
    else if(action==='etsy'){ d.in_etsy_bucket=true; }
    else if(action==='unpublish'){ d.is_published=false; d.web_viewer=false; }
  }
  updateStat();
  // Etsy uses its own endpoint (separate table); short-circuit here.
  if(action==='etsy'){
    try{
      const r=await fetch(q('/api/etsy-bucket/add'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:[id]})});
      const j=await r.json().catch(()=>({}));
      if(!r.ok||!j.ok){ throw new Error(j.error||('HTTP '+r.status)); }
      const result=(j.results||[])[0]||{};
      if(!result.ok){ el?.classList.remove('decided'); if(st) st.style.display='none'; decided.delete(id); flash(`#${id} not added: ${result.reason||'unknown'}`); return; }
      // hide the card — it's out of the new-design pool now
      el?.classList.add('moved-to-etsy');
      setTimeout(()=>el?.remove(), 350);
    }catch(e){ el?.classList.remove('decided'); if(st) st.style.display='none'; decided.delete(id); flash(`#${id} Etsy add failed: ${e.message}`); }
    return;
  }
  try {
    const r = await fetch(q(`/api/cactus-decision/${id}`), {
      method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action })
    });
    const j = await r.json().catch(()=>({}));
    if(!r.ok || j.ok===false){ throw new Error(j.error||('HTTP '+r.status)); }
    // Live + gate-fail → the verdict IS saved (parked w/ needs_fixing) — show
    // amber "Publish anyway" toast that retries with force:true. Same pattern as
    // drunk-curator's HOT-gate-held flow ([[feedback_wallco_hot_vote_save_even_when_gate_blocks]]).
    if(j.gate_held){
      // DTD verdict 2026-05-30 (panel B, park-by-default): a Publish click on a
      // gate-failed design PARKS it (the verdict is saved server-side with
      // needs_fixing + curator-loved tags) and surfaces the reasons + an explicit
      // "Publish anyway" link. It does NOT auto-force past the gate. Only a
      // deliberate human click on that link publishes a triple-gate-failed design.
      // This reverts the 2026-05-29 auto-force behavior that silently shipped
      // #41813 (seam ΔE 25.85 · 12-color blowup · vision off-concept) live.
      // Mirrors the sibling drunk-curator forcePublish() pattern. Honors the
      // standing [[feedback_only_clean_designs_welcome]] default.
      decided.set(id,'held');
      if(st){ st.textContent='🔧 HELD — review'; st.style.display='block'; }
      el?.classList.remove('decided');
      const reasons=(j.reasons||[]).join(' · ')||'failed publish gate';
      const t=document.createElement('div');
      t.id='cactus-gatetoast';
      t.style.cssText='position:fixed;top:60px;left:50%;transform:translateX(-50%);background:#8a6d1f;color:#fff;padding:8px 16px;border-radius:8px;z-index:99;font-size:13px;max-width:90vw;box-shadow:0 4px 18px rgba(0,0,0,.5)';
      t.innerHTML=`🔧 #${id} held — not auto-published: ${escapeHtml(reasons)}. <a href="#" id="cactus-forcepub" style="color:#ffe9a8;font-weight:600;text-decoration:underline">Publish anyway</a>`;
      document.getElementById('cactus-gatetoast')?.remove();
      document.body.appendChild(t);
      setTimeout(()=>document.getElementById('cactus-gatetoast')?.remove(), 9000);
      // The override is a deliberate human action — only a click fires force:true.
      document.getElementById('cactus-forcepub')?.addEventListener('click', async ev=>{
        ev.preventDefault();
        try {
          const r2=await fetch(q(`/api/cactus-decision/${id}`),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'live',force:true})});
          const j2=await r2.json().catch(()=>({}));
          if(r2.ok && j2.ok){
            decided.set(id,'live');
            if(st){st.textContent='✓ PUBLISHED';}
            const dd=ALL.find(x=>x.id===id);
            if(dd){dd.is_published=true; dd.web_viewer=true; dd.user_removed=false; dd.needs_fixing_at=null; dd.digital_file_at=null;}
            document.getElementById('cactus-gatetoast')?.remove();
            if(el){ el.classList.add('moved-to-etsy'); setTimeout(()=>el.remove(),300); }
          } else {
            flash(`#${id} force-publish failed: ${j2.error||r2.status}`);
          }
        } catch(e){ flash(`#${id} force-publish failed: ${e.message}`); }
      });
    } else if (action === 'fix' && el) {
      // Steve 2026-05-29: the Fix button now ALSO runs the seam-heal pipeline
      // in-place (not just stamp needs_fixing_at). Result chat panel + Restore
      // button land inside the card. Original PNG is backed up alongside as
      // __preseam.png — restore-preseam reverses the swap.
      runFixAndDisplay(id, el);
    } else if (['bad','digital','unpublish','live'].includes(action) && el) {
      // Steve 2026-05-29: per-card Bad/Digital/Unpublish/Publish now animate
      // the card out of the grid on success (mirrors bulk's 300ms fade+remove).
      // Without this the card sits there with its new state-pill while the SQL
      // update already landed, making the click feel broken. Live was added
      // 2026-05-29 because Steve was hitting 'auto-force-publishing…' but
      // seeing no visible action — the card was sitting there published but
      // still in the Undecided view. Etsy keeps its own self-hide path.
      el.classList.add('moved-to-etsy');
      setTimeout(() => el.remove(), 300);
    }
  } catch(e){
    el?.classList.remove('decided');
    if(st) st.style.display='none';
    decided.delete(id);
    flash('Save failed for #'+id+': '+e.message);
  }
}

// Run /api/design/:id/fix → shift-and-blend seam heal. On promotion (an actual
// fix landed), refresh the card thumb with a cache-bust and append a chat-style
// panel showing what the pipeline did + the Haiku verdict reason + a Restore
// button that swaps the __preseam.png backup back into canonical.
async function runFixAndDisplay(id, el){
  if (!el) return;
  let panel = el.querySelector('.fix-panel');
  if (panel) panel.remove();
  panel = document.createElement('div');
  panel.className = 'fix-panel';
  panel.innerHTML = `<div class="fix-row"><span class="ico">🔧</span><div class="msg">Running fix-seam… <span class="spin">⏳</span></div></div>`;
  el.appendChild(panel);
  try {
    const r = await fetch(q(`/api/design/${id}/fix`), {
      method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'
    });
    const j = await r.json().catch(()=>({}));
    if(!r.ok || j.ok===false) throw new Error(j.error || ('HTTP '+r.status));

    if (j.promoted) {
      const img = el.querySelector('img');
      if (img) {
        const u = new URL(img.src, location.origin);
        u.searchParams.set('t', String(Date.now()));
        img.src = u.toString();
      }
    }

    const haiku = (window.HAIKU_VERDICTS||{})[id];
    let html = '';
    if (j.promoted) {
      html += `<div class="fix-row"><span class="ico">🔧</span><div class="msg"><b>Seam-healed in place.</b> fixed=${j.fixed} · ${j.duration_ms}ms<br>${escapeHtml(j.note||'')}</div></div>`;
    } else if (j.already_seamless > 0) {
      html += `<div class="fix-row"><span class="ico">✓</span><div class="msg"><b>No seam defect detected.</b> already-seamless n=${j.already_seamless}.</div></div>`;
    } else {
      html += `<div class="fix-row"><span class="ico">·</span><div class="msg"><b>No change applied.</b> ${escapeHtml(j.note||'fix-seam reported no actionable seam.')}</div></div>`;
    }
    if (haiku) {
      html += `<div class="fix-row haiku"><span class="ico">🤖</span><div class="msg"><b>Haiku ${haiku.verdict}:</b> ${escapeHtml(haiku.reason||'')}</div></div>`;
    }
    if (j.promoted) {
      html += `<button class="restore-btn">↶ Restore original</button>`;
    }
    panel.innerHTML = html;
    const rb = panel.querySelector('.restore-btn');
    if (rb) rb.addEventListener('click', e => { e.stopPropagation(); runRestore(id, el); });
  } catch(e){
    panel.innerHTML = `<div class="fix-row err"><span class="ico">⚠</span><div class="msg"><b>Fix failed:</b> ${escapeHtml(e.message)}</div></div>`;
  }
}

async function runRestore(id, el){
  const panel = el.querySelector('.fix-panel');
  try {
    const r = await fetch(q(`/api/design/${id}/restore-preseam`), {
      method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'
    });
    const j = await r.json().catch(()=>({}));
    if(!r.ok || j.ok===false) throw new Error(j.error || ('HTTP '+r.status));
    const img = el.querySelector('img');
    if (img) {
      const u = new URL(img.src, location.origin);
      u.searchParams.set('t', String(Date.now()));
      img.src = u.toString();
    }
    if (panel) panel.innerHTML = `<div class="fix-row"><span class="ico">↶</span><div class="msg"><b>Restored original.</b> ${escapeHtml(j.note||'')}</div></div>`;
  } catch(e){
    if (panel) panel.innerHTML = `<div class="fix-row err"><span class="ico">⚠</span><div class="msg"><b>Restore failed:</b> ${escapeHtml(e.message)}</div></div>`;
  }
}

function escapeHtml(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }

async function bulk(action){
  const ids = [...selected];
  if(!ids.length) return;
  if(action==='bad' && !confirm(`Remove ${ids.length} designs from all surfaces? (quarantines the PNGs + drops them from the live site + HARD-DELETES the TIF masters — irreversible)`)) return;
  if(action==='unpublish' && !confirm(`Unpublish ${ids.length} designs from the live catalog? (sets is_published=FALSE; fully reversible via Publish — no quarantine, no tagging)`)) return;

  // ONE batched request — not N un-awaited decide() calls. The old loop fired a
  // fetch per id; each spawned a server-side psql process, so a few-hundred-card
  // bulk-remove became a psql fork storm that timed out and half-applied. The
  // /api/cactus-decision/bulk endpoint now collapses bad/digital/fix to a single
  // SQL UPDATE and drops the removed designs from the live index server-side.
  const labels = {bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET',unpublish:'📴 UNPUBLISHED'};
  ids.forEach(id=>{ const el=grid.querySelector(`.card[data-id="${id}"]`); el?.classList.add('decided');
    const st=el?.querySelector('.state'); if(st){ st.textContent=labels[action]; st.style.display='block'; } });

  try{
    let j;
    if(action==='etsy'){
      const r=await fetch(q('/api/etsy-bucket/add'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids})});
      j=await r.json().catch(()=>({})); if(!r.ok||!j.ok) throw new Error(j.error||('HTTP '+r.status));
    } else {
      // DTD verdict 2026-05-30 (panel B, park-by-default): bulk Publish does NOT
      // auto-force past the publish gate. Gate-failed designs come back parked
      // (server reports them in j.results[].gate_held + j.gate_held count); the
      // post-response handler re-flags them NEEDS FIXING and a toast then offers
      // an explicit "Publish N held anyway" override. Reverts the 2026-05-29
      // unconditional force that silently shipped gate-failed designs live.
      const body = {ids,action};
      const r=await fetch(q('/api/cactus-decision/bulk'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
      j=await r.json().catch(()=>({})); if(!r.ok||j.ok===false) throw new Error(j.error||('HTTP '+r.status));
    }

    ids.forEach(id=>{
      const el=grid.querySelector(`.card[data-id="${id}"]`);
      const d=ALL.find(x=>x.id===id);
      decided.set(id, action);
      if(action==='bad'){      if(d){d.is_published=false;d.user_removed=true;d.web_viewer=false;}  el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
      else if(action==='digital'){ if(d){d.is_published=false;d.web_viewer=false;d.digital_file_at=today();} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
      else if(action==='unpublish'){ if(d){d.is_published=false;d.web_viewer=false;} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
      else if(action==='etsy'){    if(d){d.in_etsy_bucket=true;} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
      else if(action==='fix'){     if(d){d.needs_fixing_at=today();} }
      else if(action==='live'){    if(d){d.is_published=true;d.web_viewer=true;d.user_removed=false;d.needs_fixing_at=null;d.digital_file_at=null;}
        // Animate published cards out of the grid so the bulk-Publish gives the
        // SAME visible feedback as per-card Publish + the other bulk actions —
        // without this the cards published fine server-side but sat there
        // unchanged, so a bulk-Publish "did nothing" from Steve's POV (2026-05-30).
        // Skip gate-held cards (they stay, re-flagged as needs-fixing below).
        const held=(j.results||[]).some(rr=>rr&&rr.id===id&&rr.gate_held);
        if(!held){ el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); } }
    });

    // 'live' gate-held items come back parked as needs-fixing — reflect that.
    if(action==='live' && Array.isArray(j.results)){
      j.results.filter(rr=>rr&&rr.gate_held).forEach(rr=>{ decided.set(rr.id,'fix');
        const st=grid.querySelector(`.card[data-id="${rr.id}"] .state`); if(st){ st.textContent='⚠ NEEDS FIXING'; } });
    }

    let msg = action==='live' ? `${j.applied||0} published${j.gate_held?`, ${j.gate_held} parked (gate)`:''}`
            : action==='etsy' ? `${(j.results||[]).filter(x=>x&&x.ok).length||ids.length} → Etsy bucket`
            : `${j.applied??ids.length} ${({bad:'removed',digital:'→ digital file',fix:'flagged needs-fixing'})[action]}`;
    if(j.dropped_from_index!=null) msg += ` · ${j.dropped_from_index} dropped from live index`;
    if(j.errored) msg += ` · ${j.errored} errored`;
    flash(msg);

    // DTD park-by-default override: if any were parked at the gate, offer an
    // explicit one-click "Publish N held anyway" — the only path to publishing
    // gate-failed designs in bulk. Re-POSTs only the held ids with force:true.
    if(action==='live' && j.gate_held>0){
      const heldIds=(j.results||[]).filter(rr=>rr&&rr.gate_held).map(rr=>rr.id);
      document.getElementById('cactus-gatetoast')?.remove();
      const t=document.createElement('div'); t.id='cactus-gatetoast';
      t.style.cssText='position:fixed;top:60px;left:50%;transform:translateX(-50%);background:#8a6d1f;color:#fff;padding:8px 16px;border-radius:8px;z-index:99;font-size:13px;max-width:90vw;box-shadow:0 4px 18px rgba(0,0,0,.5)';
      t.innerHTML=`🔧 ${heldIds.length} held at the publish gate. <a href="#" id="cactus-bulkforce" style="color:#ffe9a8;font-weight:600;text-decoration:underline">Publish ${heldIds.length} anyway</a>`;
      document.body.appendChild(t);
      setTimeout(()=>document.getElementById('cactus-gatetoast')?.remove(), 12000);
      document.getElementById('cactus-bulkforce')?.addEventListener('click', async ev=>{
        ev.preventDefault();
        try{
          const r=await fetch(q('/api/cactus-decision/bulk'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:heldIds,action:'live',force:true})});
          const jf=await r.json().catch(()=>({})); if(!r.ok||jf.ok===false) throw new Error(jf.error||('HTTP '+r.status));
          heldIds.forEach(id=>{ const el=grid.querySelector(`.card[data-id="${id}"]`); const d=ALL.find(x=>x.id===id);
            decided.set(id,'live'); if(d){d.is_published=true;d.web_viewer=true;d.user_removed=false;d.needs_fixing_at=null;d.digital_file_at=null;}
            el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); });
          document.getElementById('cactus-gatetoast')?.remove();
          flash(`${jf.applied||heldIds.length} force-published`);
          updateStat();
        }catch(e){ flash(`Force-publish failed: ${e.message}`); }
      });
    }
  }catch(e){
    ids.forEach(id=>{ const el=grid.querySelector(`.card[data-id="${id}"]`); el?.classList.remove('decided');
      const st=el?.querySelector('.state'); if(st) st.style.display='none'; decided.delete(id); });
    flash(`Bulk ${action} failed: ${e.message}`);
  }
  updateStat();
  selected.forEach(id=>grid.querySelector(`.card[data-id="${id}"]`)?.classList.remove('sel'));
  selected.clear(); $('bulkn').textContent=0; $('bulk').classList.remove('show');
}
$('bulk').querySelectorAll('button[data-a]').forEach(b => b.addEventListener('click', ()=>bulk(b.dataset.a)));
$('bulkclear').addEventListener('click', ()=>{ selected.forEach(id=>grid.querySelector(`.card[data-id="${id}"]`)?.classList.remove('sel')); selected.clear(); $('bulk').classList.remove('show'); });

document.addEventListener('keydown', e => { if(e.key==='Shift') document.body.classList.add('shifting'); });
document.addEventListener('keyup',   e => { if(e.key==='Shift') document.body.classList.remove('shifting'); });
window.addEventListener('blur', () => document.body.classList.remove('shifting'));

document.addEventListener('keydown', e => {
  if(e.key==='Escape'){ closeDetail(); return; }
  if(e.target.tagName==='SELECT'||e.target.tagName==='INPUT') return;
  if($('modal').classList.contains('show')) return;   // don't fire grid keys behind an open modal
  const map = {'1':'bad','2':'digital','3':'fix','4':'live','5':'etsy','6':'unpublish'};
  if(hovered && map[e.key]){ const el = grid.querySelector(`.card[data-id="${hovered}"]`); decide(hovered, map[e.key], el); }
  else if(hovered && (e.key==='x'||e.key==='X')){ toggleSel(hovered, grid.querySelector(`.card[data-id="${hovered}"]`)); }
});

function today(){ return new Date().toISOString().slice(0,10); }
function fmtDate(iso){
  if(!iso) return '—';
  const d = new Date(iso); if(isNaN(d)) return '—';
  return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
}
function flash(msg){ const e=$('err'); e.textContent=msg; e.style.display='block'; setTimeout(()=>e.style.display='none', 4000); }

// ── detail modal: click a card to see every field ─────────────────────────
function paletteSwatches(pal){
  let arr = Array.isArray(pal) ? pal : (pal && Array.isArray(pal.colors) ? pal.colors : []);
  const hexes = arr.map(c => typeof c==='string' ? c : (c && (c.hex||c.color||c.value))).filter(Boolean);
  return hexes.length ? '<div class="pal">'+hexes.map(h=>`<i style="background:${h}" title="${h}"></i>`).join('')+'</div>' : '';
}
function esc(s){ return String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
function detailHTML(d){
  const row = (k,v) => (v==null||v==='') ? '' : `<dt>${k}</dt><dd>${v}</dd>`;
  const list = v => Array.isArray(v) ? v.map(esc).join(', ') : (v==null?'':esc(v));
  const seam = d.seam_score!=null
    ? `${Math.round(d.seam_score)} (${d.seam_verdict||'—'}) · edge ${d.seam_edge_raw??'—'} / mid ${d.seam_mid_raw??'—'} · h-mid ${d.seam_hmid_raw??'—'} v-mid ${d.seam_vmid_raw??'—'}`
    : '—';
  const status = d.user_removed ? '✕ removed' : d.web_viewer ? '✓ published (web viewer)'
    : d.is_published ? 'published' : d.needs_fixing_at ? '⚠ needs fixing' : d.digital_file_at ? '⬇ digital file' : 'unpublished';
  return `
    <button class="modal-close" onclick="closeDetail()" title="close (Esc)">×</button>
    <div class="modal-img"><img src="/designs/img/by-id/${d.id}" alt="cactus ${d.id}"></div>
    <div class="modal-side">
      <h2>${esc(d.ai_title || ('Cactus #'+d.id))}</h2>
      <div class="sub">${esc(d.category||'')} · #${d.id} · ${status}</div>
      ${paletteSwatches(d.palette)}
      <dl class="kv">
        ${row('Composite rank', d.rank_score!=null?Math.round(d.rank_score):'—')}
        ${row('Vision quality', d.vision_score!=null ? Math.round(d.vision_score)+(d.vision_model?` · ${esc(d.vision_model)}`:'') : 'not scored')}
        ${row('Seam quality', seam)}
        ${row('Created', d.created_at)}
        ${row('Status', status)}
        ${row('Published', d.is_published?'yes':'no')}
        ${row('Digital file', d.digital_file_at)}
        ${row('Needs fixing', d.needs_fixing_at)}
        ${row('Kind', d.kind)}
        ${row('Dominant hex', d.dominant_hex ? `<span class="dot" style="background:${d.dominant_hex}"></span> ${d.dominant_hex}` : '')}
        ${row('Pattern', d.ai_pattern)}
        ${row('Color name', d.ai_color_name)}
        ${row('Dig #', d.dig_number)}
        ${row('Product line', d.product_line)}
        ${row('Generator', d.generator)}
        ${row('Seed', d.seed)}
        ${row('Size', (d.width_in&&d.height_in) ? `${d.width_in} × ${d.height_in} in${d.panels?` · ${d.panels} panels`:''}` : '')}
        ${row('Settlement', d.settlement_verdict)}
        ${row('Motifs', list(d.motifs))}
        ${row('Tags', list(d.tags))}
        ${row('Notes', list(d.notes))}
        ${row('Prompt', list(d.prompt))}
      </dl>
      <div class="modal-acts">
        <button class="bad"  data-a="bad">✕ Bad</button>
        <button class="dig"  data-a="digital">⬇ Digital</button>
        <button class="fix"  data-a="fix">⚠ Fix</button>
        <button class="live" data-a="live">✓ Publish</button>
      </div>
      <div class="modal-bake">
        <button class="bake" data-bake="${d.id}" title="Render the current 🎨 Adjust settings onto this image and save it as a NEW unpublished design. The original is untouched.">⤓ Bake adjustment → new design</button>
        <span class="adjnote" id="bakeMsg"></span>
      </div>
    </div>`;
}
async function openDetail(id){
  const m=$('modal'), box=$('modalBox');
  box.innerHTML='<div class="modal-side">Loading…</div>';
  m.classList.add('show');
  try{
    const r=await fetch(q(`/api/admin/cactus/${id}`));
    const j=await r.json();
    if(!j.ok) throw new Error(j.error||('HTTP '+r.status));
    box.innerHTML=detailHTML(j.design);
    box.querySelectorAll('.modal-acts button').forEach(b=>b.addEventListener('click',()=>{
      decide(id, b.dataset.a, grid.querySelector(`.card[data-id="${id}"]`));
      closeDetail();
    }));
    const bakeBtn = box.querySelector('[data-bake]');
    if (bakeBtn) bakeBtn.addEventListener('click', ()=>bakeAdjustment(id, bakeBtn));
  }catch(e){ box.innerHTML='<div class="modal-side">Error loading #'+id+': '+esc(e.message)+' <button class="modal-close" onclick="closeDetail()">×</button></div>'; }
}
// Render the current 🎨 Adjust filter onto a full-res canvas (pixel-parity with
// the preview) and POST to the existing /api/design/:id/bake endpoint, which
// saves it as a NEW unpublished design — original untouched, settlement-gated.
async function bakeAdjustment(id, btn){
  const msg = document.getElementById('bakeMsg');
  const filter = (window.ccAdjust && window.ccAdjust.filterString());
  if (!filter){
    if(msg){ msg.style.color='var(--mut)'; msg.textContent='Move the 🎨 Adjust sliders off neutral first — nothing to bake.'; }
    else { const o=btn.textContent; btn.textContent='set 🎨 Adjust first'; setTimeout(()=>{btn.textContent=o;},1600); }
    return;
  }
  // A7 — extreme-adjustment guard: require a second click to confirm a wild bake.
  // (low saturation / B&W is intentional, so only HIGH sat + extreme bri/con flag.)
  const av = window.ccAdjust.values();
  const extreme = (av.sat/127 > 1.9) || (av.bri/127 < 0.5) || (av.bri/127 > 1.7) || (av.con/100 > 1.6) || (av.con/100 < 0.45);
  if (extreme && !btn._bakeArmed){
    btn._bakeArmed = true;
    const o = btn.textContent;
    btn.textContent = '⚠ extreme — click again';
    if (msg){ msg.style.color='#ffd98a'; msg.textContent='That adjustment is strong — click Bake again within 4s to confirm.'; }
    btn._disarm = setTimeout(function(){ btn._bakeArmed=false; btn.textContent=o; if(msg && /strong/.test(msg.textContent||'')) msg.textContent=''; }, 4000);
    return;
  }
  btn._bakeArmed = false; if (btn._disarm) clearTimeout(btn._disarm);
  const orig = btn.textContent; btn.disabled = true; btn.textContent = 'Baking…';
  if (msg){ msg.style.color='var(--mut)'; msg.textContent=''; }
  try{
    const img = new Image();
    await new Promise((ok,err)=>{ img.onload=ok; img.onerror=()=>err(new Error('source image failed to load')); img.src='/designs/img/by-id/'+id; });
    const w = img.naturalWidth||img.width, h = img.naturalHeight||img.height;
    if (!w || !h) throw new Error('image has no dimensions');
    const cv = document.createElement('canvas'); cv.width=w; cv.height=h;
    const ctx = cv.getContext('2d'); ctx.filter = filter; ctx.drawImage(img,0,0,w,h);
    const b64 = cv.toDataURL('image/png').split(',')[1];
    const r = await fetch('/api/design/'+id+'/bake', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ image_b64: b64 }) });
    const j = await r.json();
    if (!j.ok) throw new Error(j.error || ('HTTP '+r.status));
    const v = window.ccAdjust.values();
    if (msg){ msg.style.color='#7fe3ff'; msg.innerHTML='✓ Baked '+w+'×'+h+' (H'+v.hue+' S'+v.sat+' B'+v.bri+' C'+v.con+') → new unpublished design <b>#'+j.design_id+'</b> · find it under <i>Show ▸ Unpublished</i>'; }
    btn.textContent = '✓ Baked #'+j.design_id;
  }catch(e){
    if (msg){ msg.style.color='#ff9a9a'; msg.textContent='Bake failed: '+e.message; }
    btn.textContent = orig; btn.disabled = false;
  }
}
function closeDetail(){ $('modal').classList.remove('show'); }
$('modal').addEventListener('click', e => { if(e.target.id==='modal') closeDetail(); });

// reconcile the live selection Set + .sel classes + bulk bar to a target set
function applySelection(next){
  // Find the card in EITHER the flat grid (#grid) or the by-base stack
  // (#bbstack) so marquee-select works in both layouts.
  const findCard = id => document.querySelector(`#grid .card[data-id="${id}"], #bbstack .card[data-id="${id}"]`);
  for(const id of [...selected]) if(!next.has(id)){ selected.delete(id); findCard(id)?.classList.remove('sel'); }
  for(const id of next) if(!selected.has(id)){ selected.add(id); findCard(id)?.classList.add('sel'); }
  $('bulkn').textContent = selected.size;
  $('bulk').classList.toggle('show', selected.size>0);
}

// ── marquee rubber-band select: drag = select, shift+drag = add ────────────
// Works across BOTH the flat grid (#grid) and the by-base family stack
// (#bbstack). Mousedown can start on either; mousemove enumerates every
// .card in either container and adds any that intersect the marquee rect.
(function(){
  const mq = $('marquee'), THRESH = 5;
  let dragging=false, moved=false, sx=0, sy=0, baseSel=null;
  function startDrag(e){
    if(e.button!==0) return;
    // Don't start a drag on action buttons, the sel-box, or the colorway
    // thumb strip (.bbstrip — those <a>s are navigation, not selection).
    if(e.target.closest('.acts') || e.target.classList.contains('sel-box') || e.target.closest('.bbstrip')) return;
    dragging=true; moved=false; sx=e.clientX; sy=e.clientY;
    baseSel = e.shiftKey ? new Set(selected) : new Set();
  }
  grid.addEventListener('mousedown', startDrag);
  // Attach to the stack TOO — it's created later by ensureStack(), so we use
  // a MutationObserver to bind once it appears (and only once per session).
  function bindStack(){
    const stack = document.getElementById('bbstack');
    if (!stack || stack.__mqBound) return;
    stack.addEventListener('mousedown', startDrag);
    stack.__mqBound = true;
  }
  bindStack();
  new MutationObserver(bindStack).observe(document.body, { childList:true, subtree:true });

  window.addEventListener('mousemove', e => {
    if(!dragging) return;
    if(!moved && Math.abs(e.clientX-sx)<THRESH && Math.abs(e.clientY-sy)<THRESH) return;
    if(!moved){
      moved=true; document.body.classList.add('dragging'); mq.style.display='block';
      if(baseSel.size===0) applySelection(new Set());   // fresh (non-shift) drag clears first
    }
    e.preventDefault();
    const l=Math.min(sx,e.clientX), t=Math.min(sy,e.clientY), r=Math.max(sx,e.clientX), b=Math.max(sy,e.clientY);
    mq.style.left=l+'px'; mq.style.top=t+'px'; mq.style.width=(r-l)+'px'; mq.style.height=(b-t)+'px';
    const next = new Set(baseSel);
    // Enumerate cards in both containers — whichever is visible contributes.
    const cards = [...grid.querySelectorAll('.card[data-id]'),
                   ...document.querySelectorAll('#bbstack .card[data-id]')];
    for(const cardEl of cards){
      const cr = cardEl.getBoundingClientRect();
      if(!(cr.right<l || cr.left>r || cr.bottom<t || cr.top>b)) next.add(+cardEl.dataset.id);
    }
    applySelection(next);
  });
  window.addEventListener('mouseup', () => {
    if(!dragging) return;
    if(moved){ suppressClick=true; setTimeout(()=>{ suppressClick=false; }, 60); }  // swallow the post-drag click
    dragging=false; moved=false; baseSel=null;
    document.body.classList.remove('dragging'); mq.style.display='none';
  });
})();

async function load(){
  try {
    // Fan-out: pull the catalog list AND the Haiku-vision verdicts in parallel.
    // Haiku verdicts populate window.HAIKU_VERDICTS so renderCard can show a
    // "🤖 <verdict>" chip on each card the weekend scan flagged. Verdicts failure
    // is non-fatal — chips just won't render.
    const [listR, haikuR] = await Promise.all([
      fetch(q('/api/admin/cactus/list?include=removed&category=' + encodeURIComponent(CATEGORY))),
      fetch(q('/api/admin/cactus/haiku-verdicts')).catch(() => null),
    ]);
    if(!listR.ok) throw new Error('list '+listR.status+(listR.status===404?' (admin gate — append ?admin=TOKEN)':''));
    const j = await listR.json();
    ALL = j.items || [];
    if (haikuR && haikuR.ok) {
      try { const hj = await haikuR.json(); if (hj.ok) window.HAIKU_VERDICTS = hj.verdicts || {}; } catch {}
    }
    // Reflect the loaded collection in the title.
    const h = document.querySelector('h1'); if(h && CATEGORY!=='cactus') h.textContent = '❖ ' + CATEGORY + ' Curator';
    render();
  } catch(e){ $('err').style.display='block'; $('err').textContent = 'Failed to load: '+e.message; }
}

// Populate the Collection picker from the distinct-lines endpoint. URL is the
// only state — on change we navigate to ?category=<line> (preserving ?admin=).
async function loadCollections(){
  const sel = $('collection');
  try {
    const r = await fetch(q('/api/admin/curator/collections'));
    if(!r.ok) throw new Error('collections '+r.status);
    const j = await r.json();
    const lines = (j.collections || []).slice();
    // Always reflect the active CATEGORY even if it falls under the HAVING>=5 cut.
    if(!lines.some(c => c.line === CATEGORY)) lines.unshift({ line: CATEGORY, total: null });
    sel.innerHTML = lines.map(c => {
      const counts = c.total == null ? '' : ` (${c.total} · ${c.published}✓ · ${c.removed}✕)`;
      return `<option value="${esc(c.line)}"${c.line===CATEGORY?' selected':''}>${esc(c.line)}${counts}</option>`;
    }).join('');
  } catch(e){
    // Non-fatal — keep the picker usable with at least the current line.
    sel.innerHTML = `<option value="${esc(CATEGORY)}" selected>${esc(CATEGORY)}</option>`;
  }
}
$('collection').addEventListener('change', e => {
  const line = e.target.value;
  if(!line || line === CATEGORY) return;
  const params = new URLSearchParams();
  params.set('category', line);
  if(ADMIN) params.set('admin', ADMIN);
  location.search = '?' + params.toString();   // navigates; URL carries all state
});

loadCollections();
load();

// ===== "By-base layout" — index page mode =================================
// Swaps the flat grid for a vertical stack of FAMILY CARDS — each row is one
// base pattern shown large, with a horizontal strip of all its colorway
// children below. Works on ANY ?category= that has parent_design_id linkage
// (face-skull-damask, cactus, chinoiserie, designer-zoo-calm, designer-scenic,
// drunk-zoo-36, muybridge-plate, stripe, throttled-plaster-roses). Toggle
// state persists in localStorage so it stays on across reloads.
(() => {
  const URL_CAT = new URLSearchParams(location.search).get('category') || '';
  const LS_KEY = 'cactus_curator_bblayout';
  const LS_PUB = 'cactus_curator_bblayout_pubonly';
  let families = [], stackEl = null, loaded = false, btn = null, pubBtn = null;
  let pubOnly = localStorage.getItem(LS_PUB) !== '0';   // default ON: only show families with published content

  function setBtnState(on) {
    if (!btn) return;
    btn.textContent = (on ? '◆ ' : '🎨 ') + 'By-base layout';
    btn.style.background = on ? 'var(--ink, #eef1f3)' : '';
    btn.style.color = on ? '#111' : '';
    btn.title = on ? 'showing 1 pattern + colorways per row — click to return to flat grid'
                   : 'group designs by base pattern with their colorway thumbs underneath (works on any collection)';
  }

  function ensureStack() {
    if (stackEl) return stackEl;
    stackEl = document.createElement('div');
    stackEl.id = 'bbstack';
    // Reuse the same .grid class as the index page — same density (--cols),
    // same gap, same padding — so it reads as the existing tight grid.
    stackEl.className = 'grid';
    stackEl.style.display = 'none';
    const grid = document.getElementById('grid');
    if (grid && grid.parentNode) grid.parentNode.insertBefore(stackEl, grid.nextSibling);
    else document.body.appendChild(stackEl);
    return stackEl;
  }

  function familyCardHTML(fam) {
    const allRaw = [
      { id: fam.id, category: fam.category, dominant_hex: fam.dominant_hex, is_published: fam.is_published },
      ...(fam.children || []),
    ];
    // When "Published only" is on, hide unpublished thumbs from the strip too —
    // the family card shows only what's actually live on the storefront.
    const all = pubOnly ? allRaw.filter(c => c.is_published) : allRaw;
    // Re-use the index page's own .card / .thumb / .meta classes so this reads
    // visually identical to a regular catalog tile — base in the thumb slot,
    // colorway pills inline in the meta row + a thumb strip where .acts goes.
    const cwLabel = (fam.category || 'base');
    const dot = fam.dominant_hex || '#333';
    // Reflect any in-session decision on the base id so a re-render after a
    // bulk action shows the right state.
    const act = (typeof decided !== 'undefined' && decided.get) ? decided.get(fam.id) : null;
    const selClass = (typeof selected !== 'undefined' && selected.has && selected.has(fam.id)) ? ' sel' : '';
    const stateLabel = act ? ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET'})[act] : '';
    return `<div class="card bbfam${act?' decided':''}${selClass}" data-base="${fam.id}" data-id="${fam.id}">
      <div class="thumb">
        <div class="sel-box" title="select (x)"></div>
        <div class="badges">
          <span class="b ${fam.is_published?'pub':'unpub'}">${fam.is_published?'PUB':'unpub'}</span>
          <span class="b reason" title="${all.length} colorways in family">${all.length}cw</span>
        </div>
        <a class="bb-link" href="/admin/seam-debug/${fam.id}" target="_blank" rel="noopener noreferrer" style="display:block;width:100%;height:100%">
          <img loading="lazy" src="/designs/img/by-id/${fam.id}" alt="base ${fam.id}">
          ${roomImgHTML(fam)}
        </a>
        <div class="state" style="${stateLabel?'display:block':''}">${stateLabel}</div>
      </div>
      <div class="meta">
        <div class="cw" title="${fam.category||''}">base <span class="sub">#${fam.id}</span></div>
        <div class="scores">
          <span class="dot" style="background:${dot}"></span>
          <span>${all.length} cw</span>
        </div>
        <div class="when" title="created ${fam.created_at||''}">🕓 ${(typeof fmtDate==='function' ? fmtDate(fam.created_at) : (fam.created_at||'').slice(0,16).replace('T',' '))}</div>
      </div>
      <div class="bbstrip" style="display:flex;gap:3px;overflow-x:auto;padding:0 8px 9px;scrollbar-width:thin">
        ${all.map(c => {
          const isBase = c.id === fam.id;
          const border = isBase ? '#ffd68a' : (c.is_published ? '#3a6a3a' : '#2a2a2a');
          const cw = (c.category || '').split(' · ')[1] || 'base';
          return `<a href="/admin/seam-debug/${c.id}" target="_blank" rel="noopener noreferrer" title="#${c.id} · ${cw}${c.is_published?' · ✓ published':''}" style="flex:0 0 auto;border:1.5px solid ${border};border-radius:4px;overflow:hidden;background:#000;text-decoration:none;line-height:0">
            <img loading="lazy" src="/designs/img/by-id/${c.id}" style="display:block;width:38px;height:38px;object-fit:cover">
          </a>`;
        }).join('')}
      </div>
      <div class="acts">
        <button class="bad"   data-a="bad"       title="Bad pattern — remove from all (1)">✕ Bad <kbd>1</kbd></button>
        <button class="dig"   data-a="digital"   title="Unpublish, sell as digital file (2)">⬇ Digital <kbd>2</kbd></button>
        <button class="fix"   data-a="fix"       title="Keep but needs fixing (3)">⚠ Fix <kbd>3</kbd></button>
        <button class="live"  data-a="live"      title="Publish — go live in the web viewer (4)">✓ Publish <kbd>4</kbd></button>
        <button class="etsy"  data-a="etsy"      title="Send to Etsy bucket (5)">🛒 Etsy <kbd>5</kbd></button>
        <button class="unpub" data-a="unpublish" title="Take down from live catalog (6)">📴 Unpublish <kbd>6</kbd></button>
      </div>
    </div>`;
  }

  // Delegated handlers on the stack — survives every renderStack() innerHTML
  // rebuild. sel-box → toggleSel; .acts button → decide; shift-click thumb →
  // toggleSel (mirrors the index page's shift-click-to-select).
  function bindStackHandlers() {
    if (stackEl.__handlersBound) return;
    stackEl.addEventListener('click', e => {
      const card = e.target.closest('.card.bbfam');
      if (!card) return;
      const id = +card.dataset.id;
      // sel-box → selection toggle
      if (e.target.classList.contains('sel-box')) {
        e.preventDefault(); e.stopPropagation();
        if (typeof toggleSel === 'function') toggleSel(id, card);
        return;
      }
      // action button → decide()
      const actBtn = e.target.closest('.acts button[data-a]');
      if (actBtn) {
        e.preventDefault(); e.stopPropagation();
        if (typeof decide === 'function') decide(id, actBtn.dataset.a, card);
        return;
      }
      // shift+click on the main thumb img → toggle select (NOT open the link)
      if (e.shiftKey && e.target.closest('.bb-link')) {
        e.preventDefault(); e.stopPropagation();
        if (typeof toggleSel === 'function') toggleSel(id, card);
        return;
      }
    }, true);    // capture so we beat the <a>'s default navigation when needed
    stackEl.__handlersBound = true;
  }

  function renderStack() {
    if (!stackEl) ensureStack();
    // When "Published only" is on, drop families that have zero published rows
    // (no base published AND no published colorway) — they'd render as empty cards.
    const visible = pubOnly
      ? families.filter(f => f.is_published || (f.children || []).some(c => c.is_published))
      : families;
    if (!visible.length) {
      stackEl.innerHTML = `<div style="grid-column:1 / -1;text-align:center;padding:60px;color:var(--mut,#9aa3ab)">
        ${pubOnly ? 'no published families in this category — toggle "Published only" off to see all' : 'no bases with colorway children in this category'}
      </div>`;
      return;
    }
    stackEl.innerHTML = visible.map(familyCardHTML).join('');
    bindStackHandlers();
  }

  function showStack() {
    const grid = document.getElementById('grid'); if (grid) grid.style.display = 'none';
    // 'grid' (NOT block) — otherwise it overrides .grid class display:grid and
    // the cards stack 1-up instead of using the --cols density.
    ensureStack().style.display = 'grid';
    if (!loaded) {
      stackEl.innerHTML = '<div style="text-align:center;padding:60px;color:var(--mut,#9aa3ab)">loading families…</div>';
      fetch('/api/admin/cactus/families?category=' + encodeURIComponent(URL_CAT))
        .then(r => r.json())
        .then(j => {
          families = (j.families || []).filter(f => (f.children || []).length > 0);
          loaded = true;
          renderStack();
        })
        .catch(e => { stackEl.innerHTML = '<div style="color:#f88;padding:20px;text-align:center">load failed: ' + (e.message || e) + '</div>'; });
    }
  }
  function hideStack() {
    const grid = document.getElementById('grid'); if (grid) grid.style.display = '';
    if (stackEl) stackEl.style.display = 'none';
  }
  function setMode(on) {
    if (on) { localStorage.setItem(LS_KEY, '1'); showStack(); }
    else    { localStorage.removeItem(LS_KEY); hideStack(); }
    setBtnState(on);
  }

  function setPubBtnState() {
    if (!pubBtn) return;
    pubBtn.textContent = pubOnly ? '✓ Published only' : '◯ Published only';
    pubBtn.style.background = pubOnly ? '#1d3b1d' : '';
    pubBtn.style.color = pubOnly ? '#bdf1bd' : '';
    pubBtn.title = pubOnly
      ? 'showing only families & thumbs that are live on the storefront — click to show all'
      : 'showing all families (published + unpublished + held) — click to filter to published only';
  }
  function togglePubOnly() {
    pubOnly = !pubOnly;
    if (pubOnly) localStorage.removeItem(LS_PUB); else localStorage.setItem(LS_PUB, '0');
    setPubBtnState();
    const inStack = loaded && stackEl && stackEl.style.display !== 'none';
    if (inStack) { renderStack(); return; }
    // Main-grid mode: previously this toggle did nothing here (it only filtered
    // the by-base stack), so the button looked broken. Drive the existing
    // #filter dropdown (which already has a 'published' case) + re-render.
    // render() lives in a different IIFE, so we can't call it directly. The
    // #filter <select> has a 'change' listener (→ render() + persist), so set
    // its value and dispatch 'change' to re-render the main grid cross-scope.
    const fsel = document.getElementById('filter');
    if (fsel) {
      if (pubOnly) { if (fsel.value !== 'published') fsel.dataset.prevFilter = fsel.value; fsel.value = 'published'; }
      else { fsel.value = (fsel.dataset.prevFilter && fsel.dataset.prevFilter !== 'published') ? fsel.dataset.prevFilter : 'all'; }
      fsel.dispatchEvent(new Event('change'));
    }
  }

  // inject the toggle buttons into the controls bar (re-injects if DOM rebuilds)
  new MutationObserver(() => {
    if (document.getElementById('bbtoggle')) return;
    const anchor = document.getElementById('autoall') || document.querySelector('.controls') || document.querySelector('header');
    if (!anchor || !anchor.parentNode) return;
    btn = document.createElement('button'); btn.id = 'bbtoggle';
    btn.className = anchor.className || '';
    btn.onclick = () => setMode(localStorage.getItem(LS_KEY) !== '1');
    anchor.parentNode.insertBefore(btn, anchor.nextSibling);
    setBtnState(localStorage.getItem(LS_KEY) === '1');
    // sibling "Published only" filter button (applies inside by-base mode)
    pubBtn = document.createElement('button'); pubBtn.id = 'bbpubonly';
    pubBtn.className = anchor.className || '';
    pubBtn.onclick = togglePubOnly;
    btn.parentNode.insertBefore(pubBtn, btn.nextSibling);
    setPubBtnState();
    // restore mode on first paint if saved
    if (localStorage.getItem(LS_KEY) === '1') setMode(true);
  }).observe(document.body, { childList: true, subtree: true });
})();

// ===== Click-to-Heal modal ===================================================
// Click any card image → large lightbox; drag to mark heal regions (numbered);
// ⚕ Heal → calls inpaint, pushes current onto history, swaps in the new image;
// ↺ Back reverts to the previous version; ✓ Accept promotes the latest (via
// /api/seam-debug/heal/approve, original gets user_removed=TRUE + logged); ✕
// Close discards (heal-children remain as unpublished siblings, recoverable).
(() => {
  let modal = null, original = null, current = null, history = [], manual = [];
  const adm = new URLSearchParams(location.search).get('admin');
  const q = u => adm ? u + (u.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(adm) : u;

  function ensureModal() {
    if (modal) return modal;
    modal = document.createElement('div'); modal.id = 'healmodal';
    modal.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(8,9,11,.97);display:none;flex-direction:column;color:#eee;font:13px ui-monospace,monospace';
    modal.innerHTML = `
      <header style="display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid #333">
        <button id="hmback" style="background:#2a2a2a;color:#eee;border:1px solid #444;padding:7px 14px;font-weight:700;border-radius:8px;cursor:pointer" disabled>↺ Back</button>
        <span id="hmtitle" style="flex:1;text-align:center;color:#aaa">—</span>
        <span id="hmsel" style="color:#ffd0d0">0 regions</span>
        <button id="hmheal" style="background:#3b2e08;color:#f0b400;border:1px solid #5a4710;padding:7px 16px;font-weight:800;border-radius:8px;cursor:pointer">⚕ Heal selected</button>
        <button id="hmregen" style="background:#1d2c3b;color:#7ec8ff;border:1px solid #365778;padding:7px 16px;font-weight:800;border-radius:8px;cursor:pointer" title="re-run the original prompt with a new seed — guaranteed clean lines (heal can degrade quality on flat designs)">⟲ Regenerate</button>
        <button id="hmaccept" style="background:#2a6b35;color:#fff;border:0;padding:7px 16px;font-weight:800;border-radius:8px;cursor:pointer" disabled>✓ Accept</button>
        <button id="hmclose" style="background:#3a2222;color:#ffdada;border:1px solid #663;padding:7px 14px;font-weight:700;border-radius:8px;cursor:pointer">✕ Close</button>
      </header>
      <main style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:12px">
        <div id="hmwrap" style="position:relative;background:#000;line-height:0">
          <img id="hmimg" style="display:block;max-width:88vw;max-height:78vh;object-fit:contain;background:#fff;-webkit-user-drag:none" draggable="false">
          <div id="hmovl" style="position:absolute;inset:0;cursor:crosshair"></div>
        </div>
      </main>
      <footer id="hmfoot" style="padding:8px 14px;color:#888;border-top:1px solid #333;text-align:center;font-size:11px">drag on image to mark a heal region · click × to remove · ⚕ Heal → ↺ Back to revert or ✓ Accept to replace the original</footer>`;
    document.body.appendChild(modal);
    modal.querySelector('#hmclose').onclick = closeModal;
    modal.querySelector('#hmback').onclick = back;
    modal.querySelector('#hmheal').onclick = heal;
    modal.querySelector('#hmregen').onclick = regenerate;
    modal.querySelector('#hmaccept').onclick = accept;
    wireDraw();
    return modal;
  }

  function updTitle() {
    modal.querySelector('#hmtitle').textContent =
      `#${current}  ·  original #${original}  ·  history ${history.length}${history.length?'  (Back to revert)':''}`;
    modal.querySelector('#hmsel').textContent = `${manual.length} regions`;
    modal.querySelector('#hmback').disabled = history.length === 0;
    modal.querySelector('#hmaccept').disabled = current === original;   // no heal applied yet
  }
  function drawBoxes() {
    const ovl = modal.querySelector('#hmovl');
    ovl.innerHTML = manual.map((b, i) =>
      `<div data-idx="${i}" style="position:absolute;left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%;border:2px dashed #ff3b3b;background:rgba(255,59,59,.14);box-sizing:border-box;cursor:pointer">
        <b style="position:absolute;top:-2px;left:-2px;background:#ff3b3b;color:#fff;font:800 11px sans-serif;padding:1px 6px;border-radius:3px 0 3px 0;min-width:18px;text-align:center">${i+1}</b>
        <span class="rm" style="position:absolute;top:-9px;right:-9px;width:18px;height:18px;line-height:16px;text-align:center;background:#ff3b3b;color:#fff;border-radius:50%;font:800 12px sans-serif;cursor:pointer">×</span>
      </div>`).join('');
    ovl.querySelectorAll('.rm').forEach(x => x.onclick = e => { e.stopPropagation(); const i=+x.parentElement.dataset.idx; manual.splice(i,1); drawBoxes(); updTitle(); });
  }
  function wireDraw() {
    const ovl = modal.querySelector('#hmovl');
    ovl.addEventListener('mousedown', e => {
      if (e.target.closest('div[data-idx]')) return;
      const rect = ovl.getBoundingClientRect();
      const x0 = (e.clientX - rect.left)/rect.width, y0 = (e.clientY - rect.top)/rect.height;
      if (x0<0||x0>1||y0<0||y0>1) return; e.preventDefault();
      const g = document.createElement('div');
      g.style.cssText = 'position:absolute;border:2px dashed #ff3b3b;background:rgba(255,59,59,.22);pointer-events:none;box-sizing:border-box';
      ovl.appendChild(g);
      const mv = ev => {
        const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
        const L=Math.min(x0,x),T=Math.min(y0,y),W=Math.abs(x-x0),H=Math.abs(y-y0);
        g.style.cssText += `;left:${L*100}%;top:${T*100}%;width:${W*100}%;height:${H*100}%`;
      };
      const up = ev => {
        document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up);
        const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
        const L=Math.max(0,Math.min(x0,x)), T=Math.max(0,Math.min(y0,y));
        const W=Math.min(1-L,Math.abs(x-x0)), H=Math.min(1-T,Math.abs(y-y0));
        g.remove();
        if (W>0.01 && H>0.01) {
          manual.push({x:+L.toFixed(4),y:+T.toFixed(4),w:+W.toFixed(4),h:+H.toFixed(4),label:'manual-'+(manual.length+1)});
          drawBoxes(); updTitle();
        }
      };
      document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
    });
  }

  function showId(id) {
    current = id;
    modal.querySelector('#hmimg').src = `/designs/img/by-id/${id}` + (adm?'?admin='+encodeURIComponent(adm):'');
    manual = []; drawBoxes(); updTitle();
  }
  function openModal(id) {
    ensureModal();
    original = id; current = id; history = []; manual = [];
    modal.style.display = 'flex';
    showId(id);
  }
  function closeModal() {
    if (modal) modal.style.display = 'none';
    original = current = null; history = []; manual = [];
  }
  // Heal returns ok even when the result is QUALITY-DEGRADED (SDXL inpaint on a
  // flat-screen-print tile often produces a worse seam than the original). Steve
  // rule: "100% clean lines always — it cannot look fixed." So we compare
  // scan_after vs scan_before, and if the heal degraded the tile, we auto-fall
  // back to ⟲ Regenerate (which re-rolls from the original prompt instead).
  async function heal() {
    if (!manual.length) { flash('drag on image to mark a region first'); return; }
    const btn = modal.querySelector('#hmheal'); const orig = btn.textContent;
    btn.disabled = true; btn.textContent = `⚕ healing ${manual.length}…`;
    const status = modal.querySelector('#hmfoot');
    status.textContent = `inpainting ${manual.length} region(s) on #${current} via Replicate (~10-15s)…`;
    try {
      const r = await fetch(q(`/api/seam-debug/${current}/heal`), {
        method:'POST', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ box_ids: [], manual_boxes: manual }),
      });
      const j = await r.json();
      if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
      const before = j.scan_before?.scores?.overall_max, after = j.scan_after?.scores?.overall_max;
      const degraded = Number.isFinite(before) && Number.isFinite(after) && after > before + 0.3;
      if (degraded) {
        // Don't push degraded heal onto the history — discard it. Auto-fall back
        // to regenerate so the curator never has to manually undo + click again.
        status.innerHTML = `⚠ heal degraded the tile (overall_max ${before.toFixed(2)} → ${after.toFixed(2)}) — discarding and ⟲ regenerating from the original prompt instead…`;
        await regenerate({ silent: true });
        return;
      }
      history.push({ id: current, boxes: manual.slice() });
      showId(j.new_id);
      status.textContent = `✓ heal #${j.new_id} (overall_max ${before?.toFixed(2)} → ${after?.toFixed(2)}) — ⚕ heal again, ↺ Back, or ✓ Accept`;
    } catch (e) {
      status.textContent = '✗ heal failed: ' + e.message;
    } finally {
      btn.disabled = false; btn.textContent = orig;
    }
  }
  // ⟲ Regenerate — re-runs generate_designs.js with the original prompt + new
  // seed. Always produces clean lines (no inpaint stitching). Used as a manual
  // option AND as the auto-fallback when heal degrades quality.
  async function regenerate({ silent = false } = {}) {
    const btn = modal.querySelector('#hmregen'); const orig = btn.textContent;
    btn.disabled = true; btn.textContent = '⟲ regenerating…';
    const status = modal.querySelector('#hmfoot');
    if (!silent) status.textContent = `re-rolling #${current} from the original prompt with a new seed (~30-60s)…`;
    try {
      const r = await fetch(q(`/api/seam-debug/${current}/regenerate`), {
        method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
      });
      const j = await r.json();
      if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
      history.push({ id: current, boxes: manual.slice() });
      manual = [];
      showId(j.new_id);
      status.textContent = `✓ regenerated #${j.new_id} — ⟲ again for another roll, ↺ Back to revert, ✓ Accept to replace original #${original}`;
    } catch (e) {
      status.textContent = '✗ regenerate failed: ' + e.message;
    } finally {
      btn.disabled = false; btn.textContent = orig;
    }
  }
  function back() {
    const prev = history.pop(); if (!prev) return;
    showId(prev.id);
    modal.querySelector('#hmfoot').textContent = `reverted to #${prev.id} — draw new regions and ⚕ heal again`;
  }
  async function accept() {
    if (current === original) { flash('no heal applied yet'); return; }
    const status = modal.querySelector('#hmfoot');
    status.textContent = `approving #${current} as the replacement for #${original}…`;
    try {
      const r = await fetch(q('/api/seam-debug/heal/approve'), {
        method:'POST', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ src_id: original, heal_id: current }),
      });
      const j = await r.json();
      if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
      status.textContent = `✓ accepted — #${original} marked removed, #${current} ${j.promoted_to_published?'now live on storefront':'kept unpublished'}. reloading page…`;
      setTimeout(() => location.reload(), 700);
    } catch (e) { status.textContent = '✗ accept failed: ' + e.message; }
  }
  function flash(msg) {
    const f = modal.querySelector('#hmfoot'); const orig = f.textContent;
    f.textContent = msg; f.style.color = '#ffd0d0';
    setTimeout(() => { f.textContent = orig; f.style.color = '#888'; }, 2000);
  }

  // Intercept clicks on any card image in the catalog (flat grid or by-base
  // layout). Anchor wrappers go to /admin/seam-debug — we open the modal here
  // instead and let them shift-click for the new-tab nav.
  addEventListener('click', e => {
    if (e.shiftKey || e.metaKey || e.ctrlKey) return;     // power users open in tab
    const img = e.target.closest('.card img, .card.bbfam img, .bbstrip img');
    if (!img) return;
    const id = (() => {
      const m = img.closest('a[href*="/admin/seam-debug/"]');
      if (m) { const x = m.getAttribute('href').match(/\/(\d+)/); if (x) return parseInt(x[1], 10); }
      const c = img.closest('[data-id],[data-base]');
      return c ? parseInt(c.dataset.id || c.dataset.base, 10) : null;
    })();
    if (!Number.isFinite(id)) return;
    e.preventDefault(); e.stopPropagation();
    openModal(id);
  }, true);
  addEventListener('keydown', e => {
    if (!modal || modal.style.display !== 'flex') return;
    if (e.key === 'Escape') closeModal();
    else if (e.key === 'Backspace' && history.length) back();
  });
})();
</script>
<script>
// HSB + contrast live image-adjustment panel (Steve 2026-05-31).
// Non-destructive: drives a CSS filter on every design image via --img-adjust.
// Scales chosen so Steve's pasted preset (Hue 127 · Sat 158 · Bri 127 · Con 100)
// reads as "neutral except a mild +24% saturation":
//   hue-rotate = (Hue-127)/127*180deg   (127 = no shift)
//   saturate   = Sat/127                (127 = 1.0)
//   brightness = Bri/127                (127 = 1.0)
//   contrast   = Con/100                (100 = 1.0)
(function(){
  var NEUTRAL = { hue:127, sat:127, bri:127, con:100 };
  var DEFAULT = { hue:127, sat:158, bri:127, con:100 };   // Steve's pasted preset
  var LS = 'cc_adj_';
  function get(k){ var v=localStorage.getItem(LS+k); return v===null ? DEFAULT[k] : +v; }
  function set(k,v){ localStorage.setItem(LS+k, v); }
  var el = function(id){ return document.getElementById(id); };
  var ids = { hue:'adjHue', sat:'adjSat', bri:'adjBri', con:'adjCon' };

  function filterStr(s){
    var deg = ((s.hue - 127) / 127 * 180).toFixed(1);
    return 'hue-rotate('+deg+'deg) saturate('+(s.sat/127).toFixed(3)+') '
         + 'brightness('+(s.bri/127).toFixed(3)+') contrast('+(s.con/100).toFixed(3)+')';
  }
  // A2 — human-readable derived value per slider (degrees for hue, % for the rest).
  function readout(k, val){
    if (k==='hue'){ var d=Math.round((val-127)/127*180); return val+' · '+(d>0?'+':'')+d+'°'; }
    if (k==='con'){ return val+' · '+Math.round(val/100*100)+'%'; }
    return val+' · '+Math.round(val/127*100)+'%';   // sat, bri
  }
  function apply(){
    var s = { hue:get('hue'), sat:get('sat'), bri:get('bri'), con:get('con') };
    var neutral = (s.hue===NEUTRAL.hue && s.sat===NEUTRAL.sat && s.bri===NEUTRAL.bri && s.con===NEUTRAL.con);
    document.documentElement.style.setProperty('--img-adjust', neutral ? 'none' : filterStr(s));
    for (var k in ids){ var r=el(ids[k]); if(r){ r.value=s[k]; } var v=el(ids[k]+'V'); if(v){ v.textContent=readout(k, s[k]); } }
    if (typeof markActivePreset === 'function') markActivePreset();
  }
  function bind(k){
    var r = el(ids[k]); if(!r) return;
    r.addEventListener('input', function(){ set(k, r.value); var v=el(ids[k]+'V'); if(v) v.textContent=readout(k, r.value); apply(); });
  }
  // Expose the current adjustment so the modal "Bake" can render the SAME
  // filter onto a canvas (pixel-parity with this live preview). Returns null
  // when neutral (nothing to bake).
  window.ccAdjust = {
    values: function(){ return { hue:get('hue'), sat:get('sat'), bri:get('bri'), con:get('con') }; },
    filterString: function(){
      var s = this.values();
      if (s.hue===NEUTRAL.hue && s.sat===NEUTRAL.sat && s.bri===NEUTRAL.bri && s.con===NEUTRAL.con) return null;
      return filterStr(s);
    }
  };
  // A1 — curated presets (neutral = hue127/sat127/bri127/con100).
  var PRESETS = {
    neutral: { hue:127, sat:127, bri:127, con:100 },
    vivid:   { hue:127, sat:178, bri:132, con:118 },
    muted:   { hue:127, sat:92,  bri:126, con:92  },
    warm:    { hue:110, sat:148, bri:131, con:106 },
    cool:    { hue:150, sat:140, bri:124, con:106 },
    bw:      { hue:127, sat:0,   bri:128, con:112 }
  };
  function markActivePreset(){
    var v = { hue:get('hue'), sat:get('sat'), bri:get('bri'), con:get('con') };
    document.querySelectorAll('#adjPresets .adj-preset').forEach(function(b){
      var p = PRESETS[b.getAttribute('data-preset')];
      b.classList.toggle('on', !!p && p.hue===v.hue && p.sat===v.sat && p.bri===v.bri && p.con===v.con);
    });
  }
  function applyPreset(name){
    var p = PRESETS[name]; if(!p) return;
    for (var k in p) set(k, p[k]);
    apply(); markActivePreset();
  }
  function init(){
    for (var k in ids) bind(k);
    document.querySelectorAll('#adjPresets .adj-preset').forEach(function(b){
      b.addEventListener('click', function(){ applyPreset(b.getAttribute('data-preset')); });
    });
    var reset = el('adjReset');
    if (reset) reset.addEventListener('click', function(){ for (var k in NEUTRAL) set(k, NEUTRAL[k]); apply(); markActivePreset(); });
    // A6 — copy the current CSS filter string to clipboard
    var copyBtn = el('adjCopyCss');
    if (copyBtn) copyBtn.addEventListener('click', function(){
      var css = 'filter: ' + (window.ccAdjust.filterString() || 'none') + ';';
      var done = function(ok){ var o=copyBtn.textContent; copyBtn.textContent = ok ? '✓ copied' : '✗ copy failed'; setTimeout(function(){ copyBtn.textContent=o; }, 1300); };
      try { navigator.clipboard.writeText(css).then(function(){ done(true); }, function(){ done(false); }); }
      catch(e){ done(false); }
    });
    // A8 — keyboard shortcuts, scoped to when the Adjust panel is open.
    //   0      → reset to neutral
    //   [ / ]  → nudge the focused slider down / up by 3
    document.addEventListener('keydown', function(e){
      if (!panel || !panel.classList.contains('on')) return;
      var isRange = e.target && e.target.type === 'range';
      var tag = (e.target && e.target.tagName || '').toLowerCase();
      if (tag === 'input' && !isRange) return;   // don't hijack real text inputs
      if (e.key === '0'){
        for (var k in NEUTRAL) set(k, NEUTRAL[k]); apply(); markActivePreset(); e.preventDefault();
      } else if ((e.key === '[' || e.key === ']') && isRange){
        var rid = e.target.id, key = null;
        for (var kk in ids){ if (ids[kk] === rid) { key = kk; break; } }
        if (key){
          var step = (e.key === ']' ? 1 : -1) * 3;
          var nv = Math.max(+e.target.min, Math.min(+e.target.max, get(key) + step));
          set(key, nv); apply(); markActivePreset(); e.preventDefault();
        }
      }
    });
    var panel = el('adjPanel'), toggle = el('adjToggle');
    // A3 — pick a representative card image for the before/after preview chip.
    function refreshBA(){
      var ba = el('adjBA'), b = el('baBefore'), a = el('baAfter');
      if (!ba || !b || !a) return;
      var sample = document.querySelector('#grid .card img, #bbstack img');
      var src = sample && (sample.currentSrc || sample.getAttribute('src'));
      if (src){ b.src = src; a.src = src; ba.classList.add('on'); }
      else { ba.classList.remove('on'); setTimeout(refreshBA, 1200); }   // grid not painted yet — retry
    }
    function show(on){
      if (panel) panel.classList.toggle('on', on);
      if (toggle) toggle.classList.toggle('on', on);
      localStorage.setItem(LS+'open', on ? '1' : '0');
      if (on) refreshBA();
    }
    if (toggle) toggle.addEventListener('click', function(){ show(!(panel && panel.classList.contains('on'))); });
    show(localStorage.getItem(LS+'open') === '1');
    apply();
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
  else init();
})();
</script>
  <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>