123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chip Thumbnailer</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml" />
<style>
  /* Brand fonts: per-weight WOFF2 from adom.inc (the `/fonts/<name>.css`
     aggregate paths 404, so @font-face the real woff2 files directly). */
  @font-face { font-family: "Familjen Grotesk";
    src: url("https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2") format("woff2");
    font-weight: 700; font-display: swap; }
  @font-face { font-family: "Familjen Grotesk";
    src: url("https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2") format("woff2");
    font-weight: 500; font-display: swap; }
  @font-face { font-family: "Satoshi";
    src: url("https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2") format("woff2");
    font-weight: 400; font-display: swap; }
  @font-face { font-family: "Satoshi";
    src: url("https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2") format("woff2");
    font-weight: 500; font-display: swap; }
  @font-face { font-family: "JetBrains Mono";
    src: url("https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwhsk.woff2") format("woff2");
    font-weight: 400; font-display: swap; }

  :root {
    --bg: #0d1117;
    --panel: #161b22;
    --panel-2: #1c2230;
    --line: #283040;
    --line-soft: #222a37;
    --text: #e6edf3;
    --muted: #8b949e;
    --muted-2: #6e7886;
    --accent: #00b8b0;
    --accent-bright: #00e6dc;
    --green: #3fb950;
    --red: #f85149;
    --amber: #e3b341;
  }
  * { box-sizing: border-box; }
  html, body { margin: 0; height: 100%; }
  body {
    background:
      radial-gradient(1100px 700px at 88% -12%, rgba(0,184,176,.10), transparent 60%),
      radial-gradient(900px 700px at -8% 112%, rgba(140,107,247,.08), transparent 55%),
      var(--bg);
    /* Pin the gradient to the viewport so it doesn't scroll with the content
       (otherwise the radial edges slide past and show a harsh seam). */
    background-attachment: fixed;
    color: var(--text);
    font-family: "Satoshi", system-ui, sans-serif;
    font-size: 14px;
    -webkit-font-smoothing: antialiased;
  }
  a { color: var(--accent-bright); }

  /* canonical single-row Adom app header (adom-app-header skill, 44px) */
  .app-header { display: flex; align-items: center; gap: 12px; height: 44px;
    padding: 0 16px; border-bottom: 1px solid var(--line-soft); }
  .app-header .brand { display: flex; align-items: center; gap: 8px; cursor: default;
    font-family: "Familjen Grotesk", sans-serif; font-weight: 600; outline: none; }
  .app-header .brand .logo { width: 16px; height: 16px; color: var(--accent); flex: none; }
  .app-header .brand .app-name { color: var(--accent-bright); font-size: 14px; }
  .app-header .brand .mid { color: var(--muted-2); }
  .app-header .brand .sub { color: var(--muted); font-size: 13px; font-weight: 500; }
  .vsep { width: 1px; height: 20px; background: var(--line); flex: none; }
  .subject { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
  .subject-name { color: var(--text); font-weight: 600; font-size: 13.5px;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42vw; }
  .subject-meta { color: var(--muted); font-family: "JetBrains Mono", monospace; font-size: 11.5px; flex: none; }
  .app-header .actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
  .icon-btn { display: inline-flex; align-items: center; justify-content: center;
    width: 30px; height: 30px; border: 1px solid var(--line); border-radius: 8px;
    background: var(--panel-2); color: var(--text); cursor: pointer; transition: all .15s ease; }
  .icon-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent-bright); }
  .icon-btn:disabled { opacity: .4; cursor: not-allowed; }
  .icon-btn svg { width: 16px; height: 16px; }

  /* app-info popover (hover the brand block) */
  #popover { position: fixed; z-index: 99999; min-width: 240px; max-width: 300px;
    background: #0a0e14; border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px;
    box-shadow: 0 16px 50px rgba(0,0,0,.55); opacity: 0; pointer-events: none;
    transition: opacity .18s ease; font-size: 12.5px; line-height: 1.6; }
  #popover.show { opacity: 1; pointer-events: auto; }
  #popover .pv-title { font-family: "Familjen Grotesk", sans-serif; font-weight: 600; color: var(--text); }
  #popover .pv-desc { color: var(--muted); margin: 4px 0 9px; }
  #popover .pv-row { color: var(--muted); display: flex; justify-content: space-between; gap: 14px; }

  .wrap { padding: 16px 22px 60px; max-width: 94rem; margin: 0 auto; }
  .prov { color: var(--muted-2); font-size: 11.5px; font-style: italic; margin: 0 2px 14px; }

  /* toolbar */
  .toolbar {
    display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
    padding: 14px 16px; margin-bottom: 18px;
    background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
  }
  .btn {
    display: inline-flex; align-items: center; gap: 8px;
    font-family: "Satoshi", sans-serif; font-weight: 500; font-size: 13.5px;
    color: var(--text); background: var(--panel-2);
    border: 1px solid var(--line); border-radius: 8px;
    padding: 9px 14px; cursor: pointer; transition: all .15s ease;
  }
  .btn:hover { border-color: var(--accent); background: #20283a; }
  .btn:disabled { opacity: .4; cursor: not-allowed; }
  .btn.primary { background: rgba(0,184,176,.14); border-color: rgba(0,184,176,.5); color: var(--accent-bright); }
  .btn.primary:hover { background: rgba(0,184,176,.22); }
  .btn svg { width: 16px; height: 16px; }
  select.btn { appearance: none; padding-right: 30px;
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%238b949e' stroke-width='2'><path d='M2 4l4 4 4-4'/></svg>");
    background-repeat: no-repeat; background-position: right 11px center; }
  .spacer { flex: 1 1 auto; }

  .progress { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; }
  .bar { width: 150px; height: 6px; border-radius: 4px; background: var(--panel-2); overflow: hidden; }
  .bar > i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--accent), var(--accent-bright)); transition: width .35s ease; }
  .mono { font-family: "JetBrains Mono", monospace; }

  /* dropzone overlay */
  #drop { position: fixed; inset: 0; z-index: 50; display: none;
    background: rgba(5,8,12,.82); backdrop-filter: blur(3px);
    align-items: center; justify-content: center; }
  #drop.show { display: flex; }
  #drop .card {
    border: 2px dashed var(--accent); border-radius: 18px;
    padding: 60px 90px; text-align: center; color: var(--accent-bright);
    font-family: "Familjen Grotesk", sans-serif; font-size: 22px; font-weight: 700;
    background: rgba(0,184,176,.06);
  }

  /* empty state */
  .empty { text-align: center; color: var(--muted); padding: 70px 20px; }
  .empty .big { font-family: "Familjen Grotesk", sans-serif; font-weight: 700; font-size: 19px; color: var(--text); margin-bottom: 8px; }

  /* groups + grid */
  .group { margin: 22px 0 6px; }
  .group h3 {
    font-family: "Familjen Grotesk", sans-serif; font-weight: 700;
    font-size: 13px; letter-spacing: .4px; text-transform: none;
    color: var(--muted); margin: 0 0 12px; display: flex; align-items: center; gap: 9px;
    cursor: pointer; user-select: none;
  }
  .group h3:hover { color: var(--text); }
  .group h3 .chev { width: 12px; height: 12px; flex: none; transition: transform .15s ease; color: var(--muted-2); }
  .group.collapsed h3 .chev { transform: rotate(-90deg); }
  .group h3 .gcount { color: var(--muted-2); font-weight: 500; }
  .group h3 .rule { flex: 1; height: 1px; background: var(--line-soft); }
  .group.collapsed .grid { display: none; }

  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(178px, 1fr)); gap: 16px; }

  .cell { display: flex; flex-direction: column; gap: 8px; }
  .box {
    position: relative; aspect-ratio: 4 / 3; border-radius: 11px;
    display: flex; align-items: center; justify-content: center;
    overflow: hidden;
  }
  /* pending = dashed placeholder */
  .box.pending { border: 1.6px dashed var(--line); background: rgba(255,255,255,.012); }
  .box.rendering { border: 1.6px dashed var(--accent); background: rgba(0,184,176,.05); }
  .box.done { border: 1px solid var(--line); background: #0a0e14; }
  .box.failed { border: 1.6px dashed rgba(248,81,73,.6); background: rgba(248,81,73,.05); }
  .box.skipped { border: 1.4px dashed var(--line-soft); background: rgba(255,255,255,.008); opacity: .55; }

  .box .ph { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--muted-2); font-size: 11.5px; }
  .box.rendering .ph { color: var(--accent-bright); }
  .box.failed .ph { color: var(--red); }

  /* checkerboard shows true transparency */
  .box img { max-width: 100%; max-height: 100%; display: block; }
  .box.transparent {
    background-color: #0a0e14;
    background-image:
      linear-gradient(45deg, #161b22 25%, transparent 25%),
      linear-gradient(-45deg, #161b22 25%, transparent 25%),
      linear-gradient(45deg, transparent 75%, #161b22 75%),
      linear-gradient(-45deg, transparent 75%, #161b22 75%);
    background-size: 16px 16px;
    background-position: 0 0, 0 8px, 8px -8px, -8px 0;
  }
  /* Dark-ink outline styles (thin / bold draw #0d1117 lines) are invisible on a
     dark tile, so they sit on a light "paper" tile, the way that style is meant
     to read. Padding keeps the line art off the edges. */
  .box.lighttile { background: #e9edf2; padding: 8px; }
  .box.lighttile img { mix-blend-mode: normal; }

  .spinner { width: 20px; height: 20px; border-radius: 50%;
    border: 2.4px solid rgba(0,184,176,.25); border-top-color: var(--accent-bright);
    animation: spin .8s linear infinite; }
  @keyframes spin { to { transform: rotate(360deg); } }
  .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .5; }

  .cap { font-size: 11.5px; line-height: 1.45; }
  .cap .fn { font-family: "JetBrains Mono", monospace; font-size: 11px; color: var(--text);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .cap .meta { color: var(--muted); margin-top: 2px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
  .cap .meta .sep { color: var(--muted-2); }
  .tag { display: inline-flex; align-items: center; gap: 4px; padding: 1px 7px; border-radius: 20px; font-size: 10.5px; }
  .tag.tp { color: var(--accent-bright); background: rgba(0,184,176,.12); }
  .tag.op { color: var(--muted); background: rgba(255,255,255,.05); }
  .tag.tp .d { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-bright); }
  .tag.op .d { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }
  .lbl { color: var(--muted-2); font-size: 11px; }

  /* tooltip (app-creator §7c: 500ms hover-delay, body-appended) */
  #tip { position: fixed; z-index: 99999; max-width: 280px; pointer-events: none;
    background: #0a0e14; color: var(--text); border: 1px solid var(--line);
    border-radius: 8px; padding: 8px 11px; font-size: 12px; line-height: 1.5;
    box-shadow: 0 12px 40px rgba(0,0,0,.5); opacity: 0; transition: opacity .18s ease; }
  #tip.show { opacity: 1; }

  /* click-to-copy: any [data-copy] copies its value; brief toast confirms */
  [data-copy] { cursor: pointer; }
  [data-copy]:hover { color: var(--accent-bright); }
  .subject-name[data-copy]:hover { text-decoration: underline dotted; }
  .copybtn { display: inline-flex; align-items: center; justify-content: center;
    width: 22px; height: 22px; border: 1px solid var(--line); border-radius: 6px;
    background: var(--panel-2); color: var(--muted); cursor: pointer; flex: none;
    transition: all .15s ease; }
  .copybtn:hover { border-color: var(--accent); color: var(--accent-bright); }
  .copybtn svg { width: 13px; height: 13px; }
  #toast { position: fixed; z-index: 99999; left: 50%; bottom: 28px; transform: translateX(-50%) translateY(8px);
    background: #0a0e14; color: var(--text); border: 1px solid var(--accent); border-radius: 10px;
    padding: 9px 15px; font-size: 13px; box-shadow: 0 12px 40px rgba(0,0,0,.55);
    opacity: 0; pointer-events: none; transition: opacity .18s ease, transform .18s ease; }
  #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
  #toast b { color: var(--accent-bright); }

  /* ── up-orientation governing bar + the two-tab split ─────────────── */
  #upbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap;
    background:var(--panel-2); border:1px solid var(--line); border-radius:12px;
    padding:11px 15px; margin:0 0 14px; }
  #upbar .uplabel { color:var(--muted); font-size:13px; text-transform:uppercase; letter-spacing:.4px; }
  #upbar .upaxis { display:inline-flex; align-items:center; gap:8px; font-weight:600; color:var(--text); font-size:15px; }
  #upbar .upaxis .dot { width:8px; height:8px; border-radius:50%; background:var(--accent-bright); box-shadow:0 0 8px var(--accent); }
  #upbar .upsrc { font-size:12px; padding:2px 9px; border-radius:999px; border:1px solid var(--line); color:var(--muted); }
  #upbar .upsrc.override { color:var(--accent-bright); border-color:var(--accent); }
  #upbar .upsrc.unset { color:#ffcf5a; border-color:#7a5e18; }
  #upbar .uphint { color:var(--muted); font-size:12.5px; margin-left:auto; }
  #upbar .uphint b { color:var(--text); font-weight:600; }

  #tabbar { display:flex; gap:4px; margin:0 0 16px; border-bottom:1px solid var(--line); }
  .tab { appearance:none; background:none; border:none; cursor:pointer; color:var(--muted);
    font:inherit; font-size:14px; font-weight:600; padding:9px 15px 11px;
    border-bottom:2px solid transparent; margin-bottom:-1px; }
  .tab:hover { color:var(--text); }
  .tab.active { color:var(--accent-bright); border-bottom-color:var(--accent); }
  .tab .tcount { color:var(--muted); font-weight:400; margin-left:6px; font-size:12px; }

  .builtfrom { color:var(--muted); font-size:13px; margin:-4px 0 14px; }
  .builtfrom b { color:var(--text); }

  .group h3 .curup { margin-left:10px; font-size:11px; font-weight:600; color:var(--accent-bright);
    border:1px solid var(--accent); border-radius:999px; padding:2px 9px; white-space:nowrap; }
  .setup-btn { margin-left:10px; font-size:11.5px; font-weight:600; cursor:pointer; color:var(--muted);
    background:var(--panel-2); border:1px solid var(--line); border-radius:7px; padding:3px 10px; white-space:nowrap; }
  .setup-btn:hover { border-color:var(--accent); color:var(--accent-bright); }
</style>
</head>
<body>
<header class="app-header">
  <div class="brand" id="brandBlock" data-tooltip-target="app-info" tabindex="0">
    <svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <rect x="3" y="3" width="7.5" height="7.5" rx="1.4"/>
      <rect x="13.5" y="3" width="7.5" height="7.5" rx="1.4" stroke-dasharray="2 2"/>
      <rect x="3" y="13.5" width="7.5" height="7.5" rx="1.4" stroke-dasharray="2 2"/>
      <path d="M14.2 17.25h6M17.2 14.25v6"/>
    </svg>
    <span class="app-name">Chip Thumbnailer</span>
    <span class="mid">·</span>
    <span class="sub">chip icon factory</span>
  </div>
  <div class="vsep"></div>
  <div class="subject">
    <span class="subject-name" id="subjName" data-tooltip="Click to copy the chip name">no chip loaded</span>
    <button class="copybtn" id="copyName" data-tooltip="Copy the chip name to the clipboard" style="display:none">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
    </button>
    <span class="subject-meta" id="subjMeta"></span>
  </div>
  <div class="actions">
    <button class="icon-btn" id="regenBtn" disabled data-tooltip="Regenerate: throw away every rendered icon and render them all again from the same STEP. Use this if a render looked wrong or the source changed.">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/></svg>
    </button>
  </div>
</header>

<div id="popover">
  <div class="pv-title">Chip Thumbnailer <span style="color:var(--muted);font-weight:400">v0.6.0</span></div>
  <div class="pv-desc">Renders the full chip-icon family from a STEP: 3D orientations, transparent icons, name-lasered + vector outlines.</div>
  <div class="pv-row"><span>Engine</span><span>OCCT via service-step2glb</span></div>
  <div class="pv-row"><span>Wiki</span><a href="https://wiki.adom.inc/adom/adom-chip-thumbnailer" target="_blank" rel="noopener">wiki.adom.inc</a></div>
</div>

<div class="wrap">
  <div class="toolbar">
    <button class="btn primary" id="uploadBtn" data-tooltip="Pick a STEP (.step / .stp) file from your machine. The server stages it and renders every chip icon it can produce, popping each thumbnail into its dashed box the instant it is ready.">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 9l5-5 5 5"/><path d="M5 20h14"/></svg>
      Upload STEP
    </button>
    <input type="file" id="file" accept=".step,.stp,.STEP,.STP" style="display:none" />

    <span class="lbl">or</span>
    <select class="btn" id="libSel" data-tooltip="Pick a chip already in the chip-fetcher library and render its full icon family from the stored STEP.">
      <option value="">a library chip…</option>
    </select>

    <div class="spacer"></div>

    <div class="progress" id="prog" style="display:none" data-tooltip="How many chip icons have finished rendering out of the total this STEP can produce.">
      <div class="bar"><i id="barFill"></i></div>
      <span class="mono" id="progText">0 / 0</span>
    </div>
  </div>

  <div class="prov">Icons rendered by chip-thumbnailer (OCCT via service-step2glb). Not extracted from the datasheet.</div>

  <div id="upbar" style="display:none"></div>
  <div id="tabbar" style="display:none"></div>

  <div id="content">
    <div class="empty">
      <div class="big">No chip loaded yet</div>
      <div>Drop a STEP file anywhere on this window, click <b>Upload STEP</b>, or pick a library chip.</div>
    </div>
  </div>
</div>

<div id="drop"><div class="card">Drop the STEP file to render</div></div>
<div id="tip"></div>
<div id="toast"></div>

<script>
// ── console forwarding (app-creator §8) ───────────────────────────────
(function () {
  function fwd(level, args) {
    try {
      fetch("console", { method: "POST", headers: { "Content-Type": "text/plain" },
        body: "[" + level + "] " + Array.from(args).map(a => {
          try { return typeof a === "string" ? a : JSON.stringify(a); } catch { return String(a); }
        }).join(" ") });
    } catch (e) {}
  }
  ["log", "warn", "error", "info"].forEach(function (l) {
    const o = console[l].bind(console);
    console[l] = function () { fwd(l, arguments); o.apply(console, arguments); };
  });
  window.addEventListener("error", e => fwd("onerror", [e.message + " @ " + e.filename + ":" + e.lineno]));
  window.addEventListener("unhandledrejection", e => fwd("rejection", [String(e.reason)]));
})();

// ── tooltips: 500ms hover delay, body-appended ─────────────────────────
(function () {
  const tip = document.getElementById("tip");
  let timer = null, cur = null;
  function show(el) {
    const text = el.getAttribute("data-tooltip"); if (!text) return;
    tip.textContent = text;
    const r = el.getBoundingClientRect();
    tip.style.left = Math.min(r.left, window.innerWidth - 300) + "px";
    tip.style.top = (r.bottom + 8) + "px";
    tip.classList.add("show");
  }
  document.addEventListener("mouseover", e => {
    const el = e.target.closest("[data-tooltip]"); if (!el || el === cur) return;
    cur = el; clearTimeout(timer); timer = setTimeout(() => show(el), 600);
  });
  document.addEventListener("mouseout", e => {
    const el = e.target.closest("[data-tooltip]"); if (!el) return;
    cur = null; clearTimeout(timer); tip.classList.remove("show");
  });
  window.addEventListener("scroll", () => { tip.classList.remove("show"); }, true);
})();

// ── app-info popover on the brand block (600ms delay, 220ms grace) ─────
(function () {
  const brand = document.getElementById("brandBlock");
  const pop = document.getElementById("popover");
  if (!brand || !pop) return;
  let showT = null, hideT = null;
  function show() {
    clearTimeout(hideT);
    const r = brand.getBoundingClientRect();
    pop.style.left = r.left + "px";
    pop.style.top = (r.bottom + 8) + "px";
    pop.classList.add("show");
  }
  function scheduleHide() { hideT = setTimeout(() => pop.classList.remove("show"), 220); }
  brand.addEventListener("mouseenter", () => { clearTimeout(hideT); showT = setTimeout(show, 600); });
  brand.addEventListener("mouseleave", () => { clearTimeout(showT); scheduleHide(); });
  pop.addEventListener("mouseenter", () => clearTimeout(hideT));
  pop.addEventListener("mouseleave", scheduleHide);
})();

// ── helpers ────────────────────────────────────────────────────────────
function kb(bytes) {
  if (bytes == null) return "";
  if (bytes < 1024) return bytes + " B";
  return (bytes / 1024).toFixed(bytes < 102400 ? 1 : 0) + " KB";
}
const $ = s => document.querySelector(s);
const content = $("#content");

// ── click-to-copy: any [data-copy] copies its value to the clipboard ───
let toastTimer = null;
function toast(label, value) {
  const t = $("#toast");
  t.innerHTML = label + ' <b>' + esc(value) + '</b>';
  t.classList.add("show");
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => t.classList.remove("show"), 1800);
}
function copyText(value, label) {
  // Synchronous textarea+execCommand first — works in webviews / unfocused
  // contexts where the async Clipboard API hangs or is blocked.
  try {
    const ta = document.createElement("textarea");
    ta.value = value; ta.style.position = "fixed"; ta.style.opacity = "0";
    document.body.appendChild(ta); ta.focus(); ta.select();
    document.execCommand("copy"); ta.remove();
  } catch (e) {}
  // Best-effort modern API too (non-blocking, ignore failure).
  if (navigator.clipboard) { try { navigator.clipboard.writeText(value).catch(() => {}); } catch (e) {} }
  toast(label || "Copied", value);
}
document.addEventListener("click", e => {
  const el = e.target.closest("[data-copy]");
  if (!el) return;
  const v = el.getAttribute("data-copy");
  if (v) copyText(v, el.getAttribute("data-copy-label") || "Copied");
});
$("#copyName").addEventListener("click", () => {
  const n = $("#subjName").getAttribute("data-copy");
  if (n) copyText(n, "Copied chip");
});

// ── upload + library wiring ────────────────────────────────────────────
$("#uploadBtn").addEventListener("click", () => $("#file").click());
$("#file").addEventListener("change", e => { if (e.target.files[0]) upload(e.target.files[0]); });
$("#regenBtn").addEventListener("click", () => post("regenerate"));
$("#libSel").addEventListener("change", e => {
  if (e.target.value) generateLibrary(e.target.value);
});

async function upload(file) {
  console.log("uploading " + file.name + " (" + kb(file.size) + ")");
  const buf = await file.arrayBuffer();
  try {
    const r = await fetch("api/generate", {
      method: "POST",
      headers: { "Content-Type": "application/step", "X-Filename": file.name },
      body: buf,
    });
    if (!r.ok) { const j = await r.json().catch(() => ({})); alert(j.error || ("upload failed: " + r.status)); return; }
    poll();
  } catch (e) { console.error("upload error", e); }
  $("#file").value = "";
}
async function generateLibrary(mpn) {
  try {
    const r = await fetch("api/generate", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ mpn }),
    });
    if (!r.ok) { const j = await r.json().catch(() => ({})); alert(j.error || "failed"); return; }
    poll();
  } catch (e) { console.error(e); }
}
async function post(path) {
  try { await fetch("api/" + path, { method: "POST" }); poll(); }
  catch (e) { console.error(e); }
}

// ── drag + drop the whole window ───────────────────────────────────────
const drop = $("#drop");
let dragDepth = 0;
window.addEventListener("dragenter", e => { e.preventDefault(); dragDepth++; drop.classList.add("show"); });
window.addEventListener("dragover", e => e.preventDefault());
window.addEventListener("dragleave", e => { dragDepth--; if (dragDepth <= 0) drop.classList.remove("show"); });
window.addEventListener("drop", e => {
  e.preventDefault(); dragDepth = 0; drop.classList.remove("show");
  const f = e.dataTransfer.files[0]; if (f) upload(f);
});

// ── library dropdown ───────────────────────────────────────────────────
async function loadLibrary() {
  try {
    const r = await fetch("api/library"); const j = await r.json();
    const sel = $("#libSel");
    (j.chips || []).forEach(c => {
      const o = document.createElement("option");
      o.value = c.mpn;
      o.textContent = c.mpn + (c.hasSym && c.hasMod ? "  (sym+fp)" : "");
      sel.appendChild(o);
    });
  } catch (e) {}
}

// ── state polling + grid rendering ─────────────────────────────────────
let lastSig = "";        // structural signature (ids) to know when to rebuild
let lastStarted = 0;     // cache-bust key for <img> after a regenerate
let lastRunning = true;  // poll fast while a render is in flight, slow when idle
let activeTab = "orientation"; // "orientation" (the up-axis picker) | "icons" (derived)
let lastState = null;    // last /api/state, so a tab switch re-renders without refetching
const cellEls = {};      // id -> { box, capEls... }

async function poll() {
  let s;
  try { s = await (await fetch("api/state")).json(); }
  catch (e) { return; }

  if (!s.hasSession) {
    lastRunning = false;
    $("#upbar").style.display = "none";
    $("#tabbar").style.display = "none";
    return;
  }
  lastRunning = !!s.running;
  lastState = s;
  $("#regenBtn").disabled = false;
  renderUpbar(s);

  // subject zone (what's loaded right now) + progress
  const renderable = s.slots.filter(x => x.status !== "skipped").length;
  const sn = $("#subjName");
  sn.textContent = s.name + (s.source === "upload" ? " (uploaded)" : "");
  sn.setAttribute("data-copy", s.name);
  sn.setAttribute("data-copy-label", "Copied chip");
  $("#copyName").style.display = "inline-flex";
  $("#subjMeta").textContent = s.done + " / " + renderable + " icons" + (s.running ? "" : " · done");
  const prog = $("#prog");
  prog.style.display = "flex";
  $("#progText").textContent = s.done + " / " + renderable + (s.running ? "" : "  done");
  $("#barFill").style.width = renderable ? (100 * s.done / renderable) + "%" : "0%";

  const sig = s.started + "|" + s.slots.map(x => x.id).join(",");
  if (sig !== lastSig) { rebuild(s); lastSig = sig; lastStarted = s.started; }
  else { s.slots.forEach(updateCell); }
}

const CHEV =
  '<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>';

// The top bar: which orientation drives the derived ("Chip icons") tab, and
// where that decision came from (chip-fetcher default / your override / unset).
function renderUpbar(s) {
  const bar = $("#upbar");
  bar.style.display = "flex";
  const src = s.upAxisSource;
  const srcText = src === "override" ? "your override"
    : src === "chip-fetcher" ? "default from chip-fetcher"
    : "no metadata — pick one";
  const srcClass = src === "override" ? "override" : src === "unset" ? "unset" : "";
  const hint = src === "unset"
    ? 'no orientation chosen — pick a pose in <b>Orientation</b>'
    : 'drives every icon in <b>Chip icons</b>';
  bar.innerHTML =
    '<span class="uplabel">Up orientation</span>' +
    '<span class="upaxis"><span class="dot"></span>' + esc(s.upAxisLabel || s.upAxis) + '</span>' +
    '<span class="upsrc ' + srcClass + '">' + esc(srcText) + '</span>' +
    '<span class="uphint">' + hint + '</span>';
}

function switchTab(tab) {
  if (tab === activeTab) return;
  activeTab = tab;
  if (lastState) rebuild(lastState);
}

async function setUpAxis(axis) {
  try {
    const r = await fetch("api/up-axis", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ axis }),
    });
    if (!r.ok) { const j = await r.json().catch(() => ({})); alert(j.error || "failed"); return; }
    activeTab = "icons";   // jump to the consequence of the choice
    lastSig = "";          // force a rebuild on the next poll
    poll();
  } catch (e) { console.error(e); }
}

function rebuild(s) {
  for (const k in cellEls) delete cellEls[k];
  content.innerHTML = "";

  // Group slots (preserving order) and split into the two tabs by slot.tab.
  const order = [];
  const byGroup = {};
  s.slots.forEach(slot => {
    if (!byGroup[slot.group]) {
      byGroup[slot.group] = { group: slot.group, tab: slot.tab || "orientation", slots: [] };
      order.push(byGroup[slot.group]);
    }
    byGroup[slot.group].slots.push(slot);
  });
  const orientGroups = order.filter(g => g.tab === "orientation");
  const iconGroups = order.filter(g => g.tab === "icons");

  // Tab bar.
  const tb = $("#tabbar");
  tb.style.display = "flex";
  const oCount = orientGroups.reduce((n, g) => n + g.slots.length, 0);
  const iCount = iconGroups.reduce((n, g) => n + g.slots.length, 0);
  tb.innerHTML =
    '<button class="tab' + (activeTab === "orientation" ? " active" : "") + '" data-tab="orientation">Orientation<span class="tcount">' + oCount + '</span></button>' +
    '<button class="tab' + (activeTab === "icons" ? " active" : "") + '" data-tab="icons">Chip icons<span class="tcount">' + iCount + '</span></button>';
  tb.querySelectorAll(".tab").forEach(b => b.addEventListener("click", () => switchTab(b.dataset.tab)));

  const groups = activeTab === "icons" ? iconGroups : orientGroups;

  // The "Chip icons" tab is governed by the up-axis: say so, and show a
  // placeholder while it's being (re)built.
  if (activeTab === "icons") {
    const bf = document.createElement("div"); bf.className = "builtfrom";
    bf.innerHTML = 'Built from the <b>' + esc(s.upAxisLabel || s.upAxis) +
      '</b> orientation. Change it in the <b>Orientation</b> tab to re-render these.';
    content.appendChild(bf);
    if (groups.length === 0) {
      const ph = document.createElement("div"); ph.className = "empty";
      ph.innerHTML = s.running
        ? '<div class="big">Rebuilding chip icons…</div><div>Rendering outlines + name-laser from the ' + esc(s.upAxisLabel || s.upAxis) + ' pose.</div>'
        : '<div class="big">No chip icons yet</div><div>They generate after the orientations finish.</div>';
      content.appendChild(ph);
    }
  }

  groups.forEach(gr => {
    const g = gr.group;
    const wrap = document.createElement("div"); wrap.className = "group";
    if (isCollapsed(g)) wrap.classList.add("collapsed");
    const h = document.createElement("h3");
    h.innerHTML = CHEV +
      '<span class="gname">' + esc(g) + '</span>' +
      '<span class="gcount">' + gr.slots.length + '</span>' +
      '<span class="rule"></span>';
    // Orientation header: the active up gets a badge; the others get a
    // one-click "Set as up" that re-drives the Chip icons tab.
    if (gr.tab === "orientation") {
      const axisSlot = gr.slots.find(x => x.axis);
      const axis = axisSlot && axisSlot.axis;
      if (axis) {
        // Sit the control on the LEFT, right after the group name, so it's a
        // short cursor trip from the title — not stranded at the far right.
        const rule = h.querySelector(".rule");
        if (axis === s.upAxis) {
          const badge = document.createElement("span");
          badge.className = "curup";
          badge.textContent = "▲ current up — drives the icons";
          h.insertBefore(badge, rule);
        } else {
          const btn = document.createElement("button");
          btn.className = "setup-btn";
          btn.textContent = "Set as up";
          btn.addEventListener("click", e => { e.stopPropagation(); setUpAxis(axis); });
          h.insertBefore(btn, rule);
        }
      }
    }
    h.addEventListener("click", () => {
      groupCollapsed[g] = !wrap.classList.contains("collapsed") ? true : false;
      wrap.classList.toggle("collapsed");
    });
    wrap.appendChild(h);
    const grid = document.createElement("div"); grid.className = "grid";
    gr.slots.forEach(slot => grid.appendChild(makeCell(slot)));
    wrap.appendChild(grid);
    content.appendChild(wrap);
  });
}

// Nothing is collapsed by default — the outline icons are too important to
// hide. The user can still collapse any group via its chevron (persisted).
const groupCollapsed = {};
function defaultCollapsed(_name) { return false; }
function isCollapsed(name) { return name in groupCollapsed ? groupCollapsed[name] : defaultCollapsed(name); }

function makeCell(slot) {
  const cell = document.createElement("div"); cell.className = "cell";
  const box = document.createElement("div"); box.className = "box";
  const cap = document.createElement("div"); cap.className = "cap";
  cell.appendChild(box); cell.appendChild(cap);
  cellEls[slot.id] = { box, cap, img: null, loadedFile: null };
  updateCell(slot);
  return cell;
}

function updateCell(slot) {
  const c = cellEls[slot.id]; if (!c) return;
  // Only touch the DOM when this cell's render-relevant data actually changed.
  // Finished cells then stay put, so text stays selectable and the grid is not
  // rewritten on every poll tick.
  const sig = [slot.status, slot.file, slot.format, slot.width, slot.height,
    slot.sizeBytes, slot.transparent, slot.lightTile, slot.error, slot.label, lastStarted].join("|");
  if (c.sig === sig) return;
  c.sig = sig;
  const { box, cap } = c;
  box.className = "box " + slot.status +
    (slot.lightTile ? " lighttile" : (slot.transparent ? " transparent" : ""));

  if (slot.status === "done" && slot.file) {
    const url = "thumb/" + encodeURIComponent(slot.file) + "?s=" + lastStarted;
    if (c.loadedFile !== url) {
      box.innerHTML = "";
      const img = document.createElement("img");
      img.src = url; img.alt = slot.label; img.loading = "lazy";
      box.appendChild(img); c.img = img; c.loadedFile = url;
    }
    const dims = slot.width && slot.height ? slot.width + "×" + slot.height : "vector";
    const tp = slot.transparent
      ? '<span class="tag tp"><span class="d"></span>transparent</span>'
      : '<span class="tag op"><span class="d"></span>opaque</span>';
    cap.innerHTML =
      '<div class="fn" data-copy="' + esc(slot.file) + '" data-copy-label="Copied" data-tooltip="' + esc(slot.file) + ' (click to copy)">' + esc(slot.file) + '</div>' +
      '<div class="meta">' +
        '<b>' + (slot.format || "") + '</b><span class="sep">·</span>' +
        '<span class="mono">' + dims + '</span><span class="sep">·</span>' +
        '<span class="mono">' + kb(slot.sizeBytes) + '</span>' +
        tp +
      '</div>';
  } else if (slot.status === "rendering") {
    box.innerHTML = '<div class="ph"><div class="spinner"></div>rendering…</div>';
    cap.innerHTML = '<div class="lbl">' + esc(slot.label) + '</div>';
    c.loadedFile = null;
  } else if (slot.status === "failed") {
    box.innerHTML = '<div class="ph" data-tooltip="' + esc(slot.error || "render failed") + '">' +
      '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16.5v.01"/></svg>failed</div>';
    cap.innerHTML = '<div class="lbl">' + esc(slot.label) + '</div>';
    c.loadedFile = null;
  } else if (slot.status === "skipped") {
    box.innerHTML = '<div class="ph" data-tooltip="' + esc(slot.error || "not available") + '">n/a</div>';
    cap.innerHTML = '<div class="lbl">' + esc(slot.label) + '</div>';
    c.loadedFile = null;
  } else {
    // pending
    box.innerHTML = '<div class="ph"><div class="dot"></div>queued</div>';
    cap.innerHTML = '<div class="lbl">' + esc(slot.label) + '</div>';
    c.loadedFile = null;
  }
}

function esc(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, m => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[m])); }

loadLibrary();
// Poll fast (500ms) while rendering, slow (2.5s) when idle. The cell-signature
// guard means idle polls do not touch the DOM, so nothing flickers and text
// stays selectable.
function pollLoop() {
  poll().finally(() => setTimeout(pollLoop, lastRunning ? 500 : 2500));
}
pollLoop();
</script>
</body>
</html>