← back to Wallco Ai

public/admin/seam-debug.html

322 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Seam-defect debugger — wallco.ai admin</title>
<style>
  :root{--bg:#0e0f10;--panel:#17191b;--line:#272b2e;--ink:#e9ecef;--mut:#9aa3ab;--bad:#ff4d4d;--ok:#7bc96f;--warn:#f0b400;}
  *{box-sizing:border-box;}
  body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
  header{position:sticky;top:0;z-index:30;background:rgba(14,15,16,.96);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:9px 14px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;}
  h1{font-size:15px;margin:0;font-weight:650;}
  input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 9px;font:13px ui-monospace,monospace;width:8em;}
  button{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 10px;font-size:13px;cursor:pointer;}
  button:hover{border-color:var(--ok);}
  .verdict{font-weight:700;padding:3px 11px;border-radius:999px;font-size:12px;letter-spacing:.04em;}
  .v-PASS{background:#143b1d;color:var(--ok);border:1px solid #1f5a2a;}
  .v-WARN{background:#3b2e08;color:var(--warn);border:1px solid #5a4710;}
  .v-FAIL{background:#3b0e0e;color:var(--bad);border:1px solid #5a1515;}
  main{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:16px;padding:14px;}
  @media (max-width:900px){ main{grid-template-columns:1fr;} }
  .stage{background:#000;border:1px solid var(--line);border-radius:10px;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;min-height:520px;}
  .stage img{display:block;max-width:100%;max-height:80vh;height:auto;}
  .overlay{position:absolute;pointer-events:none;}
  .seam-line{position:absolute;}
  .seam-line.mid{background:rgba(123,201,111,.55);}
  .seam-line.edge{background:rgba(74,163,255,.55);}
  .seam-line.v{width:2px;top:0;bottom:0;}
  .seam-line.h{height:2px;left:0;right:0;}
  .box{position:absolute;border:2px solid var(--bad);background:rgba(255,77,77,.18);box-shadow:0 0 0 1px rgba(0,0,0,.65) inset, 0 0 14px rgba(255,77,77,.45);cursor:pointer;pointer-events:auto;}
  .box.edge{border-color:#ff9a3c;background:rgba(255,154,60,.18);box-shadow:0 0 0 1px rgba(0,0,0,.65) inset, 0 0 14px rgba(255,154,60,.45);}
  .box.sel{border-color:#7bc96f;background:rgba(123,201,111,.32);box-shadow:0 0 0 2px #7bc96f inset, 0 0 18px rgba(123,201,111,.6);}
  .box .lbl{position:absolute;top:-18px;left:0;background:var(--bad);color:#fff;font:11px/1 ui-monospace,monospace;padding:2px 5px;border-radius:3px;white-space:nowrap;}
  .box.edge .lbl{background:#ff9a3c;color:#1a1a1a;}
  .box.sel .lbl{background:#7bc96f;color:#0a0b0c;}
  .overlay{pointer-events:none}            /* re-enable on .box only */
  button.heal{background:#143b1d;color:#7bc96f;border:1px solid #2a6b35;border-radius:7px;padding:5px 11px;font-size:13px;cursor:pointer;font-weight:700}
  button.heal:disabled{opacity:.4;cursor:not-allowed}
  button.heal:not(:disabled):hover{background:#7bc96f;color:#0a0b0c;}
  aside{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px;font-size:13px;align-self:start;}
  aside h2{font-size:13px;margin:0 0 8px;letter-spacing:.05em;text-transform:uppercase;color:var(--mut);font-weight:600;}
  aside .row{display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px dashed var(--line);}
  aside .row:last-child{border-bottom:none;}
  aside .row b{font-variant-numeric:tabular-nums;}
  .summ{padding:10px;background:#0a0b0c;border:1px solid var(--line);border-radius:7px;color:var(--ink);font-size:12.5px;line-height:1.5;margin-top:10px;}
  .legend{margin-top:14px;font-size:11.5px;color:var(--mut);}
  .legend .sw{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:middle;}
  #err{color:#ff9a9a;padding:60px 16px;text-align:center;}
  .toggle{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--mut);cursor:pointer;}
  .lens-FAIL{color:var(--bad);} .lens-WARN{color:var(--warn);} .lens-PASS{color:var(--ok);}
</style>
</head>
<body>
<header>
  <h1>🔍 Seam-defect debugger</h1>
  <span>ID</span><input id="idin" type="text" value="">
  <button onclick="load()">Scan</button>
  <span id="verdict" class="verdict v-PASS" style="display:none"></span>
  <span class="pill" id="dims" style="font-size:12px;color:var(--mut)"></span>
  <label class="toggle"><input type="checkbox" id="t_box" checked>boxes</label>
  <label class="toggle"><input type="checkbox" id="t_seam" checked>seam-lines</label>
  <button class="heal" id="healbtn" disabled title="click boxes to select, then heal only those regions">⚕ Heal selected (0)</button>
  <button class="heal" id="healall" style="background:#3b2e08;color:#f0b400;border-color:#5a4710" title="heal every detected defect">⚕ Heal all</button>
  <button class="heal" id="autofixbtn" style="background:#143b1d;color:#9be7a0;border-color:#2a6b35" title="auto seam-heal the whole tile in place (fix-seam.py — preserves composition, reversible via __preseam backup). No box selection needed.">⚕ Fix (auto seam-heal)</button>
  <button class="heal" id="regenbtn" style="background:#1d2c3b;color:#7ec8ff;border-color:#365778" title="REDO — regenerate from the original prompt with a new seed — preview before approve">⟲ Redo (regenerate)</button>
  <a id="openraw" style="font-size:12px;color:var(--mut);text-decoration:none" target="_blank" rel="noopener noreferrer">open raw image ↗</a>
</header>
<div id="err" style="display:none"></div>
<main id="main">
  <div class="stage" id="stage">
    <img id="img" alt="" />
    <div class="overlay" id="ovl"></div>
  </div>
  <aside>
    <h2>Lens scores (ΔE76)</h2>
    <div id="lenses"></div>
    <div class="summ" id="summ"></div>
    <h2 style="margin-top:14px">Defect boxes</h2>
    <div id="boxlist" style="font-size:12px;font-family:ui-monospace,monospace;max-height:38vh;overflow:auto"></div>
    <div class="legend">
      <div><span class="sw" style="background:rgba(255,77,77,.6);border:1px solid var(--bad)"></span>internal joint (h_mid / v_mid — latent-grid)</div>
      <div><span class="sw" style="background:rgba(255,154,60,.6);border:1px solid #ff9a3c"></span>edge joint (top↔bottom · left↔right wrap)</div>
      <div><span class="sw" style="background:rgba(123,201,111,.5)"></span>internal mid-line · <span class="sw" style="background:rgba(74,163,255,.5)"></span>edge-wrap line</div>
      <div style="margin-top:6px;color:var(--mut)">PASS ≤ 5 &nbsp;·&nbsp; WARN ≤ 12 &nbsp;·&nbsp; FAIL &gt; 12. Per-row trigger 8, min run 24 px.</div>
    </div>
  </aside>
</main>
<script>
const ADMIN=new URLSearchParams(location.search).get('admin');
const ID0=new URLSearchParams(location.search).get('id')||location.pathname.match(/\/(\d+)$/)?.[1]||'';
const $=id=>document.getElementById(id);
// 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;
};
$('idin').value=ID0;
$('t_box').addEventListener('change',redraw);
$('t_seam').addEventListener('change',redraw);
addEventListener('keydown',e=>{ if(e.key==='Enter'&&document.activeElement===$('idin')) load(); });

let CUR=null;
let SEL=new Set();   // selected AUTO-DETECTED box indices for healing
let MANUAL=[];       // user-drawn boxes {x,y,w,h} in fractions (0..1) — always healed
function updHeal(){
  const n = SEL.size + MANUAL.length;
  const b = $('healbtn'); b.disabled = n === 0;
  b.textContent = `⚕ Heal selected (${n})${MANUAL.length ? ' · ' + MANUAL.length + ' manual' : ''}`;
}

function lensRow(name, obj){
  const score = obj.score!=null?obj.score:obj;
  const v = score>12?'FAIL':score>5?'WARN':'PASS';
  return `<div class="row"><span>${name}</span><b class="lens-${v}">${score.toFixed(2)}</b></div>`;
}
function redraw(){
  if(!CUR) return;
  const ovl=$('ovl'); ovl.innerHTML='';
  const img=$('img');
  const iw=img.clientWidth, ih=img.clientHeight;
  const sx=iw/CUR.width, sy=ih/CUR.height;
  ovl.style.left=img.offsetLeft+'px'; ovl.style.top=img.offsetTop+'px';
  ovl.style.width=iw+'px'; ovl.style.height=ih+'px';
  if($('t_seam').checked){
    // green crosshair = internal latent-grid joints; blue lines = edge-wrap joints
    const mk=(cls,style)=>{ const e=document.createElement('div'); e.className='seam-line '+cls; Object.assign(e.style,style); ovl.appendChild(e); };
    mk('mid v',{left:(CUR.width/2*sx)+'px'});
    mk('mid h',{top:(CUR.height/2*sy)+'px'});
    mk('edge v',{left:'0px'});
    mk('edge v',{left:(iw-2)+'px'});
    mk('edge h',{top:'0px'});
    mk('edge h',{top:(ih-2)+'px'});
  }
  if($('t_box').checked){
    (CUR.boxes||[]).forEach((b,i)=>{
      const isEdge=/_edge$/.test(b.kind);
      const d=document.createElement('div'); d.className='box'+(isEdge?' edge':'')+(SEL.has(i)?' sel':'');
      d.style.left=(b.x*sx)+'px'; d.style.top=(b.y*sy)+'px';
      d.style.width=Math.max(2,b.w*sx)+'px'; d.style.height=Math.max(2,b.h*sy)+'px';
      d.innerHTML=`<span class="lbl">${b.kind} · ΔE ${b.peak_de}</span>`;
      d.title=`click to ${SEL.has(i)?'deselect':'select for healing'} · ${b.kind}${b.edge_pair?' ['+b.edge_pair+']':''} · x=${b.x} y=${b.y} w=${b.w} h=${b.h} · peak ΔE ${b.peak_de} (mean ${b.mean_de})`;
      d.dataset.idx=i;
      d.onclick=ev=>{ ev.stopPropagation(); if(SEL.has(i)) SEL.delete(i); else SEL.add(i); redraw(); updHeal(); };
      ovl.appendChild(d);
    });
  }
  // Manual user-drawn boxes — always rendered (independent of t_box toggle) so a
  // hand-marked region is always visible while the user is curating it.
  MANUAL.forEach((m,i)=>{
    const d=document.createElement('div'); d.className='box manual';
    d.style.cssText=`left:${m.x*iw}px;top:${m.y*ih}px;width:${m.w*iw}px;height:${m.h*ih}px;border:2px dashed #ff3b3b;background:rgba(255,59,59,.14);position:absolute;box-sizing:border-box;cursor:pointer;z-index:5`;
    d.innerHTML=`<span class="lbl" style="background:#ff3b3b;color:#fff;font:800 10px sans-serif;padding:1px 6px;border-radius:3px 0 3px 0;position:absolute;top:-2px;left:-2px">M${i+1}</span>
                 <span class="x" style="position:absolute;top:-9px;right:-9px;width:17px;height:17px;line-height:15px;text-align:center;background:#ff3b3b;color:#fff;border-radius:50%;font:800 12px sans-serif">×</span>`;
    d.title=`manual mark M${i+1} — click × to remove · always healed`;
    d.querySelector('.x').onclick=ev=>{ ev.stopPropagation(); MANUAL.splice(i,1); redraw(); updHeal(); };
    ovl.appendChild(d);
  });
}
// Drag-to-draw a manual heal region on the image. Hand-marked boxes are added
// to MANUAL[] as fractions and always sent to the heal endpoint (don't need to
// be in SEL — they're a separate channel).
(function wireManualDraw(){
  const ovl=$('ovl');
  ovl.addEventListener('mousedown', e => {
    if(!CUR) return;
    if(e.button!==0) return;
    if(e.target.closest('.box')) return;          // clicking an existing box = toggle, not draw
    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 ghost=document.createElement('div');
    ghost.style.cssText='position:absolute;border:2px dashed #ff3b3b;background:rgba(255,59,59,.22);pointer-events:none;box-sizing:border-box;z-index:6';
    ovl.appendChild(ghost);
    const W=rect.width, H=rect.height;
    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),WW=Math.abs(x-x0),HH=Math.abs(y-y0);
      ghost.style.cssText+=`;left:${L*W}px;top:${T*H}px;width:${WW*W}px;height:${HH*H}px`;
    };
    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 WW=Math.min(1-L,Math.abs(x-x0)),HH=Math.min(1-T,Math.abs(y-y0));
      ghost.remove();
      if(WW>0.01 && HH>0.01){
        MANUAL.push({x:+L.toFixed(4),y:+T.toFixed(4),w:+WW.toFixed(4),h:+HH.toFixed(4),label:'manual-'+(MANUAL.length+1)});
        redraw(); updHeal();
      }
    };
    document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
  });
})();

async function load(){
  $('err').style.display='none';
  const id=$('idin').value.trim(); if(!id) return;
  history.replaceState(null,'',q(location.pathname.replace(/\/\d+$/,'')+'/'+id));
  try{
    const r=await fetch(q(`/api/seam-debug/${id}`));
    const j=await r.json();
    if(!r.ok||!j.ok){ $('err').style.display='block'; $('err').textContent='Failed: '+(j.error||r.status); return; }
    CUR=j;
    $('verdict').textContent=j.verdict; $('verdict').className='verdict v-'+j.verdict; $('verdict').style.display='inline-block';
    $('dims').textContent=`${j.width}×${j.height}px`;
    $('summ').textContent=j.summary;
    const order=['h_mid','v_mid','top','bottom','left','right'];
    $('lenses').innerHTML=order.map(k=>{
      const s=j.scores[k]; const v=s>12?'FAIL':s>5?'WARN':'PASS';
      return `<div class="row"><span>${k}</span><b class="lens-${v}">${s.toFixed(2)}</b></div>`;
    }).join('') + `<div class="row" style="margin-top:6px;border-top:1px solid var(--line);padding-top:6px"><span>edges_max</span><b>${j.scores.edges_max.toFixed(2)}</b></div>` +
      `<div class="row"><span>mids_max</span><b class="lens-${j.scores.mids_max>12?'FAIL':j.scores.mids_max>5?'WARN':'PASS'}">${j.scores.mids_max.toFixed(2)}</b></div>`;
    $('boxlist').innerHTML = (j.boxes||[]).length
      ? j.boxes.map((b,i)=>`<div>${String(i+1).padStart(2,'0')}. <b class="lens-FAIL">${b.kind}</b> · x=${b.x} y=${b.y} ${b.w}×${b.h}px · peak ΔE ${b.peak_de} · mean ${b.mean_de}</div>`).join('')
      : '<div style="color:var(--mut)">no localized defects (mid-line ΔE never exceeded trigger 8 for ≥24 px)</div>';
    $('img').src=j.image_url; $('openraw').href=j.image_url;
    $('img').onload=redraw;
    addEventListener('resize',redraw);
  }catch(e){ $('err').style.display='block'; $('err').textContent='Failed: '+e.message; }
}
async function healSelected(boxIds){
  const manual = MANUAL.slice();
  if(!boxIds.length && !manual.length){ alert('Click auto-detected boxes OR drag on the image to mark a region, then heal.'); return; }
  const id=$('idin').value.trim(); if(!id) return;
  const total = boxIds.length + manual.length;
  const btn=$('healbtn'); const orig=btn.textContent; btn.disabled=true; btn.textContent=`⚕ healing ${total}…`;
  try{
    const r=await fetch(q(`/api/seam-debug/${id}/heal`),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({box_ids:boxIds, manual_boxes:manual})});
    const j=await r.json();
    if(!r.ok||!j.ok){ alert('Heal failed: '+(j.error||r.status)+(j.stderr?'\n\n'+j.stderr:'')); btn.textContent=orig; updHeal(); return; }
    showApproveModal({ kind: 'heal', src_id: id, new_id: j.new_id, scan_before: j.scan_before, scan_after: j.scan_after });
    SEL.clear(); MANUAL = []; redraw(); updHeal();
  }catch(e){ alert('Heal failed: '+e.message); btn.textContent=orig; updHeal(); }
}
// === Preview/Approve modal ============================================
// Shown after a heal OR regenerate completes — side-by-side before/after,
// scores delta, Approve (promotes new, marks original removed via
// /heal/approve), Reject (just close — heal stays as unpublished sibling).
function showApproveModal({ kind, src_id, new_id, scan_before, scan_after }) {
  const sb = (scan_before && scan_before.scores) || {}, sa = (scan_after && scan_after.scores) || {};
  const delta = k => {
    if (sb[k] === undefined) return '';
    const d = (sa[k] || 0) - sb[k];
    const col = d <= 0 ? 'var(--ok)' : '#ff9a9a';
    return `<span style="color:${col}">${sb[k].toFixed(1)}→${(sa[k]||0).toFixed(1)} (${d>=0?'+':''}${d.toFixed(1)})</span>`;
  };
  const adm = ADMIN ? '?admin=' + encodeURIComponent(ADMIN) : '';
  const m = document.createElement('div');
  m.style.cssText = 'position:fixed;inset:0;background:rgba(8,9,11,.9);z-index:200;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;color:#eee;font:13px ui-monospace,monospace';
  m.innerHTML = `
    <div style="margin-bottom:10px;font-weight:700;font-size:15px">${kind==='heal'?'⚕ Inpaint heal':'⟲ Regenerated'} — preview before approve</div>
    <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;max-width:1200px;width:100%">
      <div style="text-align:center"><div style="color:#aaa;margin-bottom:4px">BEFORE — #${src_id}</div><img src="/designs/img/by-id/${src_id}${adm}" style="max-width:100%;max-height:60vh;background:#fff;border:1px solid #333;border-radius:4px"></div>
      <div style="text-align:center"><div style="color:var(--ok);margin-bottom:4px">AFTER — #${new_id}</div><img src="/designs/img/by-id/${new_id}${adm}" style="max-width:100%;max-height:60vh;background:#fff;border:1px solid #2a6b35;border-radius:4px"></div>
    </div>
    ${scan_before && scan_after ? `<div style="margin-top:10px;padding:6px 12px;background:#1a1d22;border-radius:6px">scores  v_mid: ${delta('v_mid')} · h_mid: ${delta('h_mid')} · edges_max: ${delta('edges_max')} · verdict: ${scan_before.verdict||'?'} → ${scan_after.verdict||'?'}</div>` : ''}
    <div style="margin-top:14px;display:flex;gap:10px">
      <button id="apv-ok" style="background:#2a6b35;color:#fff;border:0;padding:10px 22px;border-radius:8px;cursor:pointer;font-weight:800;font-size:14px">✓ Approve (replaces #${src_id})</button>
      <button id="apv-no" style="background:transparent;color:#ddd;border:1px solid #555;padding:10px 18px;border-radius:8px;cursor:pointer">✕ Reject (keep both as siblings)</button>
      <button id="apv-open" style="background:#1a1d22;color:#7ec8ff;border:1px solid #365778;padding:10px 18px;border-radius:8px;cursor:pointer">Open #${new_id} →</button>
    </div>
    <div id="apv-status" style="margin-top:10px;color:#aaa;font-size:11px"></div>`;
  document.body.appendChild(m);
  const status = m.querySelector('#apv-status');
  m.querySelector('#apv-no').onclick = () => m.remove();
  m.querySelector('#apv-open').onclick = () => { location.search = '?id=' + new_id + (ADMIN ? '&admin=' + encodeURIComponent(ADMIN) : ''); };
  m.querySelector('#apv-ok').onclick = async () => {
    status.textContent = 'approving…';
    try {
      const r = await fetch(q('/api/seam-debug/heal/approve'), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ src_id, heal_id: new_id }) });
      const j = await r.json();
      if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
      status.textContent = `✓ approved — #${src_id} removed, #${new_id} ${j.promoted_to_published?'published live':'kept unpublished'}`;
      setTimeout(() => { location.search = '?id=' + new_id + (ADMIN ? '&admin=' + encodeURIComponent(ADMIN) : ''); }, 800);
    } catch (e) { status.textContent = '✗ approve failed: ' + e.message; }
  };
  addEventListener('keydown', function esc(e){ if(e.key==='Escape'){ m.remove(); removeEventListener('keydown',esc); } });
}
// === Regenerate handler ================================================
async function regenerate() {
  const id = $('idin').value.trim(); if (!id) return;
  const btn = $('regenbtn'); const orig = btn.textContent;
  btn.disabled = true; btn.textContent = '⟲ regenerating…';
  try {
    const r = await fetch(q(`/api/seam-debug/${id}/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));
    showApproveModal({ kind: 'regen', src_id: parseInt(id, 10), new_id: j.new_id });
  } catch (e) { alert('Regenerate failed: ' + e.message); }
  finally { btn.disabled = false; btn.textContent = orig; }
}
// === Auto-fix handler (whole-tile seam-heal in place — no box selection) =====
// Hits /api/design/:id/fix (fix-seam.py). Preserves composition, backs the
// original up as __preseam.png, then re-runs the scan so the verdict refreshes.
async function autoFix() {
  const id = $('idin').value.trim(); if (!id) return;
  const btn = $('autofixbtn'); const orig = btn.textContent;
  btn.disabled = true; btn.textContent = '⚕ healing…';
  try {
    const r = await fetch(q(`/api/design/${id}/fix`), { 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));
    btn.textContent = j.fixed > 0 ? '⚕ healed ✓' : (j.already_seamless > 0 ? '⚕ already clean' : '⚕ no change');
    if (j.fixed > 0) setTimeout(load, 400);   // re-scan to refresh verdict + boxes
    setTimeout(() => { btn.textContent = orig; }, 1600);
  } catch (e) { alert('Auto-fix failed: ' + e.message); btn.textContent = orig; }
  finally { btn.disabled = false; }
}
$('healbtn').addEventListener('click',()=>healSelected([...SEL]));
$('healall').addEventListener('click',()=>healSelected((CUR?.boxes||[]).map((_,i)=>i)));
$('autofixbtn').addEventListener('click',autoFix);
$('regenbtn').addEventListener('click',regenerate);
if(ID0) load();
</script>
  <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>