app
Adom Chip Thumbnailer
Public Made by Adomby adom
The chip-icon factory: from a STEP file it renders the complete family of chip icons, shaded 3D orientations, transparent icons, name-lasered variants, and 11 vector outline styles, in a live web app
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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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>