master
John Lauer Publish 1.1.0 63c1c64 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DS2SF</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<style>
@font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2');font-weight:600;font-display:swap}
@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:'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:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-700-normal.woff2') format('woff2');font-weight:700;font-display:swap}
@font-face{font-family:'JetBrains Mono';src:url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2');font-weight:400;font-display:swap}
:root{
  --bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;--line:#30363d;
  --text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;--text-3:#484f58;--muted:#8b949e;
  --accent:#00b8b1;--teal:#00b8b1;--accent-hover:#00d4cb;--accent-bright:#00e6dc;--on-accent:#05221f;
  --accent-10:rgba(0,184,177,.10);--accent-20:rgba(0,184,177,.20);--accent-35:rgba(0,184,177,.35);
  --green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#64abff;--purple:#8c6bf7;
  --green-10:rgba(63,185,80,.12);--red-10:rgba(248,81,73,.12);--yellow-10:rgba(210,153,34,.14);--neutral-10:rgba(139,148,158,.12);--blue-10:rgba(100,171,255,.12);
  --shadow:0 12px 40px rgba(0,0,0,.55);
  --brand-heading:'Familjen Grotesk',sans-serif;--brand-body:'Satoshi',sans-serif;--mono:'JetBrains Mono',ui-monospace,monospace;
  --z-hud:50;--z-toolbar:80;--z-menu:200;--z-toast:400;--z-modal:600;--z-tooltip:99999;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--text-body);font:13px/1.45 var(--brand-body);-webkit-tap-highlight-color:transparent}
*{scrollbar-width:thin;scrollbar-color:rgba(139,148,158,.38) transparent}
::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(139,148,158,.32);border-radius:999px;border:2px solid transparent;background-clip:padding-box}
::-webkit-scrollbar-thumb:hover{background:rgba(0,184,177,.55);background-clip:padding-box}
button{font-family:inherit;color:inherit;cursor:pointer;background:none;border:0}
button,select,input,.row,.pill,.tab{transition:background .15s ease,border-color .15s ease,color .15s ease,box-shadow .15s ease,transform .06s ease}
button:active:not(:disabled){transform:translateY(1px) scale(.97)}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
input:focus-visible,select:focus-visible{outline:none;border-color:var(--accent)}
button:disabled{opacity:.5;cursor:not-allowed}
a{color:var(--accent)}
code,.mono{font-family:var(--mono);font-size:.92em}
[hidden]{display:none !important}

/* ── app shell ── */
.app{position:fixed;inset:0;display:flex;flex-direction:column;background:var(--bg)}
header{flex:0 0 44px;height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;border-bottom:1px solid var(--line);background:var(--surface);position:relative;z-index:var(--z-toolbar)}
.brand{display:flex;align-items:baseline;gap:8px;flex-shrink:0;white-space:nowrap;cursor:default;position:relative;height:44px}
.brand svg.mark{width:18px;height:18px;align-self:center;color:var(--text);flex-shrink:0}
.brand .app-name{font-family:var(--brand-heading);font-weight:600;font-size:15px;color:var(--text);letter-spacing:-.01em;align-self:center}
.brand .app-sub{font-size:13px;color:var(--text-2);font-weight:400;align-self:center}
.vsep{width:1px;height:22px;background:var(--line);flex-shrink:0}
.subject{display:flex;align-items:center;gap:10px;min-width:0;flex:1}
.subject-name{font-family:var(--brand-heading);font-weight:600;font-size:14px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:36vw}
.subject-name.idle{color:var(--text-3);font-weight:400}
.subject-meta{font-family:var(--mono);font-size:11px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pill{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:500;padding:2px 9px;border-radius:999px;border:1px solid var(--line);background:var(--neutral-10);color:var(--text-2);white-space:nowrap}
.pill .dot,.led{width:8px;height:8px;border-radius:50%;background:var(--text-3);flex-shrink:0}
.pill.ok{color:var(--green);border-color:rgba(63,185,80,.35);background:var(--green-10)}.pill.ok .dot{background:var(--green)}
.pill.needs_better_pdf{color:var(--yellow);border-color:rgba(210,153,34,.35);background:var(--yellow-10)}.pill.needs_better_pdf .dot{background:var(--yellow)}
.pill.unrecoverable{color:var(--red);border-color:rgba(248,81,73,.35);background:var(--red-10)}.pill.unrecoverable .dot{background:var(--red)}
.pill.running{color:var(--accent);border-color:var(--accent-35);background:var(--accent-10)}.pill.running .dot{background:var(--accent);animation:pulse 1.2s infinite}
.pill.not_extracted .dot{background:var(--blue)}.pill.no_datasheet .dot{background:var(--text-3)}
@keyframes pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 var(--accent-35)}50%{opacity:.6;box-shadow:0 0 0 5px transparent}}
.target-chip{font-family:var(--mono);font-size:12px;color:var(--text-2);border:1px solid var(--line);border-radius:999px;padding:3px 10px;display:inline-flex;align-items:center;gap:7px;white-space:nowrap}
.target-chip b{color:var(--text);font-weight:400}
.target-chip .led.live{background:var(--accent);box-shadow:0 0 8px var(--accent)}
.actions{display:flex;align-items:center;gap:6px;margin-left:auto;flex-shrink:0}
.iconbtn{width:28px;height:28px;display:grid;place-items:center;border-radius:7px;color:var(--text-2)}
.iconbtn svg{width:18px;height:18px}
.iconbtn:hover{color:var(--teal);background:var(--accent-10)}
.iconbtn[aria-pressed="true"]{color:var(--teal);background:var(--accent-20);box-shadow:inset 0 0 0 1px var(--accent-35)}
.popover{position:absolute;top:44px;left:0;min-width:300px;background:var(--elevated);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:12px 14px;z-index:var(--z-menu);opacity:0;pointer-events:none;transform:translateY(-4px);transition:opacity .18s,transform .18s;font-size:12px;white-space:normal}
.popover.show{opacity:1;pointer-events:auto;transform:none}
.popover .pt{font-family:var(--brand-heading);font-weight:600;font-size:14px;color:var(--text);margin-bottom:6px}
.popover .pr{display:flex;gap:10px;padding:3px 0}.popover .pr .k{color:var(--text-3);width:72px;flex-shrink:0}.popover .pr .v{color:var(--text-body)}

/* ── body ── */
.body{flex:1;display:flex;min-height:0}
aside{flex:0 0 260px;border-right:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;min-height:0}
.rail-head{padding:10px 12px 8px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:8px}
.search{width:100%;background:var(--bg);border:1px solid var(--line);border-radius:7px;color:var(--text);padding:6px 9px;font:12.5px var(--brand-body)}
.filters{display:flex;flex-wrap:wrap;gap:4px}
.fpill{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--line);color:var(--text-2);background:transparent}
.fpill:hover{color:var(--text);border-color:var(--text-2)}
.fpill[aria-pressed="true"]{color:var(--teal);border-color:var(--accent-35);background:var(--accent-10)}
.fpill b{font-weight:500;color:inherit;opacity:.8;margin-left:4px}
.rail-list{flex:1;overflow:auto;padding:4px 0}
.row{display:flex;align-items:center;gap:9px;padding:7px 12px;cursor:pointer;border-left:2px solid transparent}
.row:hover{background:var(--accent-10)}
.row.cur{background:linear-gradient(90deg,var(--accent-20),transparent);border-left-color:var(--accent)}
.row .led.ok{background:var(--green)}.row .led.needs_better_pdf{background:var(--yellow)}.row .led.unrecoverable{background:var(--red)}
.row .led.not_extracted{background:var(--blue)}.row .led.no_datasheet{background:var(--text-3)}.row .led.running{background:var(--accent);animation:pulse 1.2s infinite}
.row .rmain{min-width:0;flex:1}
.row .rmpn{font-family:var(--mono);font-size:12px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row .rsub{font-size:11px;color:var(--text-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row .rbadge{font-size:10px;color:var(--text-3);font-family:var(--mono)}
.rail-foot{padding:7px 12px;border-top:1px solid var(--line);font-size:10.5px;color:var(--text-3);line-height:1.4}
main{flex:1;min-width:0;display:flex;flex-direction:column;overflow:auto}

/* ── detail ── */
.empty{margin:auto;max-width:560px;text-align:center;padding:40px 24px;color:var(--text-2)}
.empty h2{font-family:var(--brand-heading);font-size:20px;color:var(--text);margin-bottom:8px}
.empty .say{margin-top:18px;text-align:left;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:12px 14px}
.empty .say div{font-family:var(--mono);font-size:12px;color:var(--text-body);padding:4px 0}
.empty .say div::before{content:"say ";color:var(--text-3)}
.hero{display:grid;grid-template-columns:auto 1fr minmax(280px,360px);gap:16px;padding:16px 18px;border-bottom:1px solid var(--line);align-items:start}
.thumb{width:120px;height:120px;border-radius:10px;background:#404b55;border:1px solid var(--line);display:grid;place-items:center;overflow:hidden}
.thumb img{width:100%;height:100%;object-fit:contain}
.thumb .nothumb{color:rgba(230,237,243,.35);font-size:11px}
.title h1{font-family:var(--mono);font-size:22px;font-weight:400;color:var(--text);letter-spacing:-.01em}
.title .mfr{font-size:13px;color:var(--text-2);margin-top:2px}
.title .desc{margin-top:8px;color:var(--text-body);line-height:1.5;max-width:70ch}
.title .kv{display:flex;flex-wrap:wrap;gap:6px 16px;margin-top:10px;font-size:12px}
.title .kv span{color:var(--text-3)}.title .kv b{font-weight:500;color:var(--text-body);font-family:var(--mono)}
.card{background:var(--elevated);border:1px solid var(--line);border-left:3px solid var(--accent-35);border-radius:10px;padding:12px 14px}
.card .ct{font-family:var(--brand-heading);font-weight:600;font-size:13px;color:var(--text);display:flex;align-items:center;gap:10px;margin-bottom:6px}
.card .cd{font-size:12px;color:var(--text-2);line-height:1.5}
.card .kv{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;font-size:12px;margin-top:8px}
.card .kv span{color:var(--text-3)}.card .kv b{font-weight:400;color:var(--text-body);font-family:var(--mono);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.card.status-ok{border-left-color:var(--green)}.card.status-needs_better_pdf{border-left-color:var(--yellow)}.card.status-unrecoverable{border-left-color:var(--red)}.card.status-running{border-left-color:var(--accent)}
.actionbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 18px;border-bottom:1px solid var(--line);background:var(--surface)}
.btn{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:500;padding:6px 12px;border-radius:7px;border:1px solid var(--line);background:var(--overlay);color:var(--text)}
.btn:hover:not(:disabled){border-color:var(--accent);color:var(--accent-hover)}
.btn.primary{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}
.btn.primary:hover:not(:disabled){background:var(--accent-hover);color:var(--on-accent)}
.btn svg{width:14px;height:14px}
.actionbar select{background:var(--bg);border:1px solid var(--line);color:var(--text);border-radius:7px;padding:5px 8px;font:12px var(--brand-body)}
.actionbar .spacer{flex:1}
.actionbar .elapsed{font-family:var(--mono);font-size:11px;color:var(--accent)}
.hints{padding:12px 18px;display:flex;flex-direction:column;gap:8px;border-bottom:1px solid var(--line)}
.hint{display:flex;gap:12px;align-items:flex-start;background:var(--yellow-10);border:1px solid rgba(210,153,34,.3);border-radius:8px;padding:9px 12px;font-size:12px}
.hint.human_review{background:var(--blue-10);border-color:rgba(100,171,255,.3)}
.hint .ha{font-family:var(--mono);color:var(--text);font-size:11px;white-space:nowrap}
.hint .hb{flex:1;color:var(--text-body);line-height:1.5}
.hint .hb code{color:var(--text)}
.hint .btn{padding:3px 9px;font-size:11.5px}
.tabs{display:flex;gap:2px;padding:0 18px;border-bottom:1px solid var(--line);background:var(--surface);overflow-x:auto}
.tab{padding:9px 12px;font-size:12.5px;color:var(--text-2);border-bottom:2px solid transparent;white-space:nowrap}
.tab:hover{color:var(--text)}
.tab[aria-selected="true"]{color:var(--teal);border-bottom-color:var(--teal)}
.tab b{font-weight:500;font-size:10.5px;color:var(--text-3);margin-left:5px;font-family:var(--mono)}
.panel{padding:14px 18px;flex:1}
table{width:100%;border-collapse:collapse;font-size:12px}
th{text-align:left;font-weight:500;color:var(--text-3);font-size:11px;letter-spacing:.02em;padding:6px 8px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg)}
td{padding:6px 8px;border-bottom:1px solid rgba(48,54,61,.5);vertical-align:top;color:var(--text-body)}
tr:hover td{background:var(--accent-10)}
td.mono,td.num{font-family:var(--mono);font-size:11.5px;color:var(--text)}
td.num{text-align:right}
.tag{display:inline-block;font-size:10.5px;padding:1px 7px;border-radius:999px;border:1px solid var(--line);color:var(--text-2);font-family:var(--mono)}
.tag.g-POWER,.tag.g-GROUND{color:var(--yellow);border-color:rgba(210,153,34,.35)}
.tag.g-I2C,.tag.g-SPI,.tag.g-UART,.tag.g-USB,.tag.g-CAN{color:var(--blue);border-color:rgba(100,171,255,.35)}
.tag.g-GPIO{color:var(--green);border-color:rgba(63,185,80,.35)}
.tag.g-DEBUG,.tag.g-TIMING,.tag.g-CONTROL{color:var(--purple);border-color:rgba(140,107,247,.35)}
.pg{font-family:var(--mono);font-size:11px;color:var(--accent);cursor:pointer;text-decoration:none;border-bottom:1px dotted var(--accent-35)}
.pg:hover{color:var(--accent-hover)}
.conf{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:5px}
.conf.high{background:var(--green)}.conf.medium{background:var(--yellow)}.conf.low{background:var(--red)}
.groups{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px}
.group{background:var(--elevated);border:1px solid var(--line);border-radius:10px;padding:11px 13px}
.group .gn{font-family:var(--mono);font-size:13px;color:var(--text);margin-bottom:4px}
.group .gd{font-size:12px;color:var(--text-2);line-height:1.5}
.group .gp{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px}
.group .gp span{font-family:var(--mono);font-size:10.5px;padding:1px 6px;border-radius:5px;background:var(--bg);border:1px solid var(--line);color:var(--text-body)}
.svgs{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;margin-bottom:14px}
.svgbox{background:radial-gradient(circle at 50% 42%,#121a26 0%,#0d1117 72%);border:1px solid var(--line);border-radius:10px;min-height:280px;display:flex;flex-direction:column;overflow:hidden}
.svgbox .sh{display:flex;justify-content:space-between;align-items:center;padding:7px 11px;border-bottom:1px solid var(--line);font-size:11px;color:var(--text-2)}
.svgbox .sh b{font-weight:500;color:var(--text)}
.svgbox img{flex:1;object-fit:contain;max-height:420px;padding:10px;width:100%}
.svgbox .none{flex:1;display:grid;place-items:center;color:var(--text-3);font-size:12px}
.two{display:grid;grid-template-columns:1fr 1fr;gap:18px}
h3.sec{font-family:var(--brand-heading);font-weight:600;font-size:12.5px;color:var(--text-2);letter-spacing:.02em;margin:10px 0 6px}
pre.log{font-family:var(--mono);font-size:11.5px;line-height:1.5;color:var(--text-body);background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:12px 14px;white-space:pre-wrap;word-break:break-word;max-height:calc(100vh - 260px);overflow:auto}
pre.log .ok{color:var(--green)}pre.log .err{color:var(--red)}pre.log .warn{color:var(--yellow)}pre.log .stage{color:var(--accent)}
pre.json{font-family:var(--mono);font-size:11.5px;line-height:1.45;color:var(--text-body);background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:12px 14px;white-space:pre;overflow:auto;max-height:calc(100vh - 300px)}
.jsonpick{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}
.review{display:flex;flex-direction:column;gap:8px}
.rv{background:var(--elevated);border:1px solid var(--line);border-radius:8px;padding:9px 12px;font-size:12px;display:flex;gap:12px}
.rv .rr{font-family:var(--mono);color:var(--yellow);white-space:nowrap;font-size:11px}
.rv .rn{color:var(--text-body);line-height:1.5}

/* ── toasts ── */
.toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--z-toast);display:flex;flex-direction:column-reverse;gap:8px;align-items:center;pointer-events:none}
.toast{pointer-events:auto;display:flex;align-items:center;gap:12px;min-width:280px;max-width:min(520px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:12px;padding:12px 16px;box-shadow:0 18px 50px rgba(0,0,0,.5);font-size:13px;color:var(--text);animation:tin .22s ease}
.toast.success{border-left-color:var(--green)}.toast.error{border-left-color:var(--red)}
.toast .x{margin-left:auto;color:var(--text-3);font-size:16px;line-height:1;padding:0 2px}.toast .x:hover{color:var(--text)}
@keyframes tin{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}

/* ── tooltip + AI cursor ── */
.tt{position:fixed;z-index:var(--z-tooltip);background:var(--elevated);border:1px solid var(--line);color:var(--text-body);font-size:12px;line-height:1.45;padding:7px 10px;border-radius:8px;max-width:300px;box-shadow:var(--shadow);opacity:0;transform:translateY(4px);transition:opacity .18s ease,transform .18s ease;pointer-events:none;text-transform:none}
.tt.show{opacity:1;transform:none}
.aicur{position:fixed;z-index:var(--z-tooltip);width:22px;height:22px;pointer-events:none;opacity:0;transition:left .3s ease,top .3s ease,opacity .2s}
.aicur.show{opacity:1}
.aicur svg{width:22px;height:22px;filter:drop-shadow(0 2px 4px rgba(0,0,0,.6))}
.aicur .lbl{position:absolute;left:22px;top:18px;white-space:nowrap;font-size:11px;background:var(--accent);color:var(--on-accent);padding:2px 7px;border-radius:5px;font-weight:500}

/* ── settings modal ── */
dialog{border:0;background:transparent;padding:0;max-width:none;max-height:none}
dialog::backdrop{background:rgba(0,0,0,.25)}
.modal{width:min(94vw,600px);max-height:88vh;overflow:auto;background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);padding:18px 20px 14px;color:var(--text-body)}
.modal .mh{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
.modal .mh h2{font-family:var(--brand-heading);font-size:16px;font-weight:600;color:var(--text)}
.scard{background:var(--elevated);border:1px solid var(--line);border-left:3px solid var(--accent-35);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem}
.scard .st{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:10px}
.scard .ss{font-size:11px;color:var(--text-2);font-weight:400}.scard .ss.ok{color:var(--green)}
.scard .sd{font-size:12px;line-height:1.55;color:var(--text-2);margin-top:4px}
.scard .sd code{font-size:11px;background:rgba(110,118,129,.2);padding:1px 4px;border-radius:4px}
.srow{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:9px 0;border-top:1px solid var(--line);margin-top:8px}
.srow .sl{font-size:12.5px;color:var(--text)}.srow .sl em{font-style:normal;color:#8fd3d4}
.srow .sdef{font-size:10.5px;color:var(--text-3);margin-top:2px}
.srow select,.srow input{background:var(--bg);border:1px solid var(--line);color:var(--text);border-radius:7px;padding:5px 8px;font:12px var(--brand-body);min-width:150px}
.toggle{width:40px;height:22px;border-radius:999px;background:var(--overlay);border:1px solid var(--line);position:relative;flex-shrink:0}
.toggle::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--text-2);transition:left .15s,background .15s}
.toggle[aria-pressed="true"]{background:var(--accent-20);border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-35)}
.toggle[aria-pressed="true"]::after{left:20px;background:var(--accent)}
.mfoot{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--text-3);padding-top:4px}

@media (max-width:900px){.hero{grid-template-columns:auto 1fr}.hero .card{grid-column:1/-1}.two{grid-template-columns:1fr}}
@media (max-width:720px){.brand .app-sub,.subject-meta,.vsep{display:none}aside{flex-basis:200px}.thumb{width:84px;height:84px}}
@media (hover:none),(pointer:coarse){.tt{display:none}}
</style>
</head>
<body>
<div class="app">
<header>
  <div class="brand" id="brand" tabindex="0">
    <svg class="mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3.5h9l4 4V13"/><path d="M13 3.5v4h4"/><path d="M7 8.5h3M7 11.5h5"/><rect x="12" y="14" width="8" height="7" rx="1"/><path d="M14 14v-2M18 14v-2M14 21v1.5M18 21v1.5M12 16.5h-2M12 19h-2"/></svg>
    <span class="app-name">DS2SF</span>
    <span class="app-sub">datasheet to symbol and footprint</span>
    <div class="popover" id="appinfo" role="dialog" aria-label="App information">
      <div class="pt">Adom DS2SF</div>
      <div class="pr"><span class="k">about</span><span class="v">Reads a chip's manufacturer datasheet and extracts pins, groups, and the land pattern with page-level provenance, so every fact links back to the PDF page it came from.</span></div>
      <div class="pr"><span class="k">version</span><span class="v mono" id="popVersion">?</span></div>
      <div class="pr"><span class="k">library</span><span class="v mono" id="popLibrary">?</span></div>
      <div class="pr"><span class="k">thread</span><span class="v mono" id="popThread">?</span></div>
      <div class="pr"><span class="k">author</span><span class="v">Adom Industries, adom.inc</span></div>
      <div class="pr"><span class="k">wiki</span><span class="v"><a href="https://wiki.adom.inc/adom/adom-ds2sf" target="_blank" rel="noopener">wiki.adom.inc/adom/adom-ds2sf</a></span></div>
    </div>
  </div>
  <div class="vsep" aria-hidden="true"></div>
  <div class="subject">
    <span class="subject-name idle" id="subjectName">No chip selected</span>
    <span class="pill" id="subjectPill" hidden><span class="dot"></span><span id="subjectPillText"></span></span>
    <span class="target-chip" data-tooltip="Where this dashboard runs. Extraction happens in this container; the datasheet PDF is read here, not on your desktop."><span class="led live"></span>target: <b>this machine</b></span>
    <span class="subject-meta" id="subjectMeta"></span>
  </div>
  <div class="actions">
    <button class="iconbtn" id="btn-help" aria-label="Getting started" aria-pressed="false" data-tooltip="Show the say-it phrases and what each status colour means."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.5"/><path d="M9.5 9.5a2.5 2.5 0 0 1 5 0c0 1.7-2.5 2-2.5 3.7"/><path d="M12 17h.01"/></svg></button>
    <button class="iconbtn" id="btn-settings" aria-label="Settings" data-tooltip="Settings: default model, display surface, idle shutdown, dev mode."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
  </div>
</header>

<div class="body">
  <aside>
    <div class="rail-head">
      <input class="search" id="search" placeholder="Filter chips" aria-label="Filter chips" data-tooltip="Type part of an MPN, manufacturer, or package. The list narrows as you type.">
      <div class="filters" id="filters" role="toolbar" aria-label="Status filters"></div>
    </div>
    <div class="rail-list" id="list" role="listbox" aria-label="Library chips"></div>
    <div class="rail-foot" id="railFoot">Library from chip-fetcher. Green: extracted. Yellow: needs a better PDF. Red: failed. Blue: has a datasheet, not run yet. Grey: no datasheet.</div>
  </aside>
  <main id="main">
    <div class="empty" id="empty">
      <h2>Pick a chip</h2>
      <p>DS2SF reads the manufacturer datasheet and pulls out every pin, its group, and the package land pattern, each with the PDF page it came from. Select a chip on the left, or ask your AI.</p>
      <div class="say">
        <div>run ds2sf on the STM32L431RCT6TR</div>
        <div>show me the ds2sf dashboard</div>
        <div>which pins on this chip need review</div>
        <div>re-extract the footprint for this chip with opus</div>
      </div>
    </div>
    <div id="detail" hidden></div>
  </main>
</div>
</div>

<div class="toasts" id="toasts" role="status" aria-live="polite"></div>
<div class="tt" id="tt" role="tooltip"></div>
<div class="aicur" id="aicur"><svg viewBox="0 0 24 24" fill="#00e6dc" stroke="#05221f" stroke-width="1.2"><path d="M4 3l7 17 2.5-6.5L20 11z"/></svg><span class="lbl" id="aicurlbl"></span></div>

<dialog id="settings">
  <div class="modal">
    <div class="mh"><h2>Settings</h2><button class="iconbtn" id="settingsClose" aria-label="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button></div>
    <div class="scard">
      <div class="st">Extraction <span class="ss" id="ssModel"></span></div>
      <div class="sd">Which Claude model reads the datasheet. Production runs use Opus: engineers wire schematics from this output, and cheaper models misread pin orders, undercount pads, and confuse package variants. Only drop to a smaller model for a quick experiment you will re-run with Opus.</div>
      <div class="srow"><div><div class="sl">extraction <em>default model</em></div><div class="sdef">Default: claude-opus-4-7</div></div><select data-setting="default_model"></select></div>
    </div>
    <div class="scard">
      <div class="st">Display</div>
      <div class="sd">Where <code>adom-ds2sf show</code> opens this dashboard when no surface is given. <code>wv</code> is a Hydrogen webview tab beside your editor; <code>pup</code> is the AI's own Chrome window on your desktop; <code>hbw</code> is Hydrogen's standalone browser window; <code>nb</code> is your real signed-in browser.</div>
      <div class="srow"><div><div class="sl">display <em>default surface</em></div><div class="sdef">Default: wv</div></div><select data-setting="default_surface"></select></div>
      <div class="srow"><div><div class="sl">display <em>open windows in background</em></div><div class="sdef">Default: on. Windows open behind your work and flash the taskbar instead of stealing focus.</div></div><button class="toggle" data-setting="open_in_background" aria-pressed="true" aria-label="Open windows in background"></button></div>
    </div>
    <div class="scard">
      <div class="st">Lifecycle</div>
      <div class="sd">This server shuts itself down after sitting idle, so a container never fills with forgotten dashboards. Every request resets the clock; a running extraction is never interrupted. Its state is snapshotted per thread, so reopening restores what you were looking at. Start with <code>--keep-alive</code> for a daemon that must never reap.</div>
      <div class="srow"><div><div class="sl">lifecycle <em>idle shutdown</em> (hours)</div><div class="sdef">Default: 24</div></div><input type="number" min="1" max="720" data-setting="ttl_hours"></div>
    </div>
    <div class="scard">
      <div class="st">Developer</div>
      <div class="sd">Dev mode enables <code>POST /ui/eval</code>, which runs JavaScript inside this page and reports the result to <code>GET /console</code>. Useful while building the dashboard; leave it off otherwise.</div>
      <div class="srow"><div><div class="sl">developer <em>dev mode</em></div><div class="sdef">Default: off</div></div><button class="toggle" data-setting="dev_mode" aria-pressed="false" aria-label="Dev mode"></button></div>
      <div class="srow"><div><div class="sl">developer <em>library root</em></div><div class="sdef">Default: auto-detected chip-fetcher library. Leave blank to auto-detect.</div></div><input type="text" data-setting="library_root" placeholder="auto"></div>
    </div>
    <div class="mfoot"><span>adom-ds2sf <span class="mono" id="ssVersion"></span> · settings persist in ~/.config/adom-ds2sf/settings.json</span><button class="btn" id="settingsDefaults" style="padding:4px 9px;font-size:11px">Restore defaults</button></div>
  </div>
</dialog>

<script>
(() => {
// ── console relay (GET /console reads this) ──
const _post = (path, body) => fetch(path, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{})}).then(r => r.json()).catch(e => ({ok:false,error:String(e)}));
for (const lvl of ['log','warn','error']) {
  const orig = console[lvl].bind(console);
  console[lvl] = (...a) => { orig(...a); try { _post('console', {level:lvl, msg:a.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')}); } catch(e){} };
}
window.addEventListener('error', e => console.error('window.onerror: ' + e.message));
window.addEventListener('unhandledrejection', e => console.error('unhandledrejection: ' + (e.reason && e.reason.message || e.reason)));

const $ = (s, el=document) => el.querySelector(s);
const $$ = (s, el=document) => [...el.querySelectorAll(s)];
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const AI_THREAD = 'ds2sf dashboard (user click)';

let state = { chips: [], current: '', job: null, settings: {} };
let detail = null;
let filter = 'all';
let tab = localStorage.getItem('ds2sf.tab') || 'pins';
let logLines = [];
let helpOn = false;

// ── toasts ──
function toast(text, kind='info', sticky=false) {
  const box = $('#toasts');
  const t = document.createElement('div');
  t.className = 'toast ' + kind; t.setAttribute('role', kind === 'error' ? 'alert' : 'status');
  t.innerHTML = `<span>${esc(text)}</span><button class="x" aria-label="Dismiss">×</button>`;
  $('.x', t).onclick = () => t.remove();
  box.appendChild(t);
  while (box.children.length > 4) box.firstChild.remove();
  if (!sticky && kind !== 'error') {
    let left = 4000, timer = setTimeout(() => t.remove(), left), start = Date.now();
    t.onmouseenter = () => { clearTimeout(timer); left -= Date.now() - start; };
    t.onmouseleave = () => { start = Date.now(); timer = setTimeout(() => t.remove(), Math.max(left, 800)); };
  }
}

// ── tooltips (body-appended, fixed, clamped on both axes) ──
const tt = $('#tt'); let ttTimer = null;
function showTip(el, x, y) {
  tt.textContent = el.getAttribute('data-tooltip'); tt.classList.add('show');
  const M = 8, w = tt.offsetWidth, h = tt.offsetHeight;
  let left = x + 14, top = y + 14;
  if (left + w + M > innerWidth) left = x - w - 14;
  if (top + h + M > innerHeight) top = y - h - 14;
  left = Math.max(M, Math.min(left, innerWidth - w - M)); top = Math.max(M, Math.min(top, innerHeight - h - M));
  tt.style.left = left + 'px'; tt.style.top = top + 'px';
}
document.addEventListener('mouseover', e => {
  const el = e.target.closest('[data-tooltip]'); if (!el) return;
  clearTimeout(ttTimer); ttTimer = setTimeout(() => showTip(el, e.clientX, e.clientY), 600);
});
document.addEventListener('mouseout', e => { if (e.target.closest && e.target.closest('[data-tooltip]')) { clearTimeout(ttTimer); tt.classList.remove('show'); } });
document.addEventListener('mousedown', () => { clearTimeout(ttTimer); tt.classList.remove('show'); });

// ── AI cursor ──
function aiCursor(sel, label) {
  const el = document.querySelector(sel); const c = $('#aicur'); if (!el) return false;
  const r = el.getBoundingClientRect();
  c.classList.add('show'); c.style.left = (r.left + r.width/2) + 'px'; c.style.top = (r.top + r.height/2) + 'px';
  $('#aicurlbl').textContent = label || ''; $('#aicurlbl').hidden = !label;
  clearTimeout(c._t); c._t = setTimeout(() => c.classList.remove('show'), 2500);
  return true;
}

// ── brand popover ──
{ const b = $('#brand'), p = $('#appinfo'); let st, ht;
  const open = () => { clearTimeout(ht); st = setTimeout(() => p.classList.add('show'), 600); };
  const close = () => { clearTimeout(st); ht = setTimeout(() => p.classList.remove('show'), 220); };
  b.addEventListener('mouseenter', open); b.addEventListener('mouseleave', close);
  b.addEventListener('click', () => p.classList.toggle('show')); }

// ── rendering: rail ──
const REVIEW_TEXT = {
  muxed_pin_protocol_select: 'These pins are protocol-multiplexed (oscillator or bus pins that double as GPIO). The symbol carries the primary function; confirm the mode your design uses.',
  boot_select_pin: 'A boot-mode strap. Its level at reset picks the boot source; give it a defined pull in the schematic.',
  alt_function_list_truncated_may_be_incomplete: 'The alternate-function list for these pins was longer than the extractor kept. Pin names and numbers are verified; the AF detail in the descriptions may be partial.',
  package_mismatch_in_hint: 'info.json disagrees with the datasheet about the package.',
  pad_dimensions_from_example_layout: 'Land pattern came from an example board layout rather than a formal recommended pattern; cross-check against IPC-7351.',
};
const STATUS_LABEL = { ok:'extracted', needs_better_pdf:'needs better PDF', unrecoverable:'failed', not_extracted:'not run yet', no_datasheet:'no datasheet', running:'extracting' };
function chipStatus(c) { return c.running ? 'running' : c.status; }
function renderFilters() {
  const c = state.counts || {};
  const defs = [['all','All',state.chips.length],['ok','Extracted',c.ok],['needs_better_pdf','Needs PDF',c.needs_better_pdf],['unrecoverable','Failed',c.unrecoverable],['not_extracted','Not run',c.not_extracted],['no_datasheet','No datasheet',c.no_datasheet]];
  $('#filters').innerHTML = defs.map(([k,l,n]) => `<button class="fpill" data-f="${k}" aria-pressed="${filter===k}">${l}<b>${n||0}</b></button>`).join('');
  $$('#filters .fpill').forEach(b => b.onclick = () => { filter = b.dataset.f; renderFilters(); renderList(); });
}
function renderList() {
  const q = $('#search').value.trim().toLowerCase();
  const rows = state.chips.filter(c => (filter === 'all' || c.status === filter) && (!q || [c.mpn, c.manufacturer, c.package].join(' ').toLowerCase().includes(q)));
  $('#list').innerHTML = rows.map(c => {
    const st = chipStatus(c);
    const sub = [c.manufacturer, c.package].filter(Boolean).join(' · ') || (c.has_pdf ? 'datasheet on disk' : 'no datasheet');
    return `<div class="row ${c.mpn === state.current ? 'cur' : ''}" role="option" aria-selected="${c.mpn === state.current}" data-mpn="${esc(c.mpn)}" data-tooltip="${esc(c.summary || STATUS_LABEL[st])}"><span class="led ${st}"></span><div class="rmain"><div class="rmpn">${esc(c.mpn)}</div><div class="rsub">${esc(sub)}</div></div>${c.pins ? `<span class="rbadge">${c.pins} pins</span>` : ''}</div>`;
  }).join('') || `<div style="padding:16px 12px;color:var(--text-3);font-size:12px">No chips match.</div>`;
  $$('#list .row').forEach(r => r.onclick = () => select(r.dataset.mpn));
}
$('#search').addEventListener('input', renderList);

async function select(mpn) {
  const r = await _post('select', { mpn, aiThread: AI_THREAD });
  if (!r.ok) toast(r.error || 'could not select', 'error');
}

// ── rendering: header subject ──
function renderSubject() {
  const c = state.chips.find(x => x.mpn === state.current);
  const name = $('#subjectName'), pill = $('#subjectPill'), meta = $('#subjectMeta');
  if (!c) { name.textContent = 'No chip selected'; name.classList.add('idle'); pill.hidden = true; meta.textContent = ''; return; }
  name.textContent = c.mpn; name.classList.remove('idle');
  const st = chipStatus(c);
  pill.hidden = false; pill.className = 'pill ' + st; $('#subjectPillText').textContent = STATUS_LABEL[st];
  const parts = [];
  if (c.pins) parts.push(c.pins + ' pins');
  if (c.package) parts.push(c.package);
  if (detail && detail.provenance && detail.provenance.datasheet && detail.provenance.datasheet.page_count) parts.push(detail.provenance.datasheet.page_count + ' pages');
  if (c.model) parts.push(c.model);
  meta.textContent = parts.join(' · ');
  $('#popVersion').textContent = state.version; $('#popLibrary').textContent = state.library_root || 'not found'; $('#popThread').textContent = state.thread || '';
}

// ── rendering: detail ──
const pdfUrl = (mpn, name, page) => `api/chip/${encodeURIComponent(mpn)}/pdf${name ? '?name=' + encodeURIComponent(name) : ''}${page ? '#page=' + page : ''}`;
function openPdf(page, name) { window.open(pdfUrl(state.current, name, page), '_blank', 'noopener'); }
const pgLink = (p, name) => p ? `<a class="pg" href="${pdfUrl(state.current, name, p)}" target="_blank" rel="noopener" data-tooltip="Open the datasheet at page ${p}, where this fact was read.">p.${p}</a>` : '<span style="color:var(--text-3)">?</span>';

function renderDetail() {
  const c = state.chips.find(x => x.mpn === state.current);
  $('#empty').hidden = !!c; $('#detail').hidden = !c;
  if (!c || !detail) return;
  const d = detail, res = d.result || {}, sym = d.symbol || {}, fp = d.footprint || {}, prov = d.provenance || {}, info = d.info || {};
  const st = chipStatus(c);
  const desc = sym.description || info.description || '';
  const ds = prov.datasheet || {};
  const pdfName = res.datasheet_used ? res.datasheet_used.split('/').pop() : (c.pdfs[0] || '');
  const model = state.settings.default_model || 'claude-opus-4-7';
  const pins = sym.pins || [], groups = sym.groups || [];
  const pinProv = (prov.symbol && prov.symbol.pin_provenance) || [];
  const dimProv = (prov.footprint && prov.footprint.dimension_provenance) || [];
  const review = res.review_required || [];
  const hints = res.hints || [];
  const provByPin = {}; pinProv.forEach(p => provByPin[p.pin_number] = p);

  const hintHtml = hints.map(h => {
    if (h.action === 'try_alternate_pdf') return `<div class="hint"><span class="ha">try another PDF</span><div class="hb">${esc(h.reason)}. Candidates already on disk:<br>${(h.candidates||[]).map(p => { const n = p.split('/').pop(); return `<button class="btn" data-extract-pdf="${esc(n)}" data-tooltip="Re-run the extraction against ${esc(n)}">${esc(n)}</button>`; }).join(' ')}</div></div>`;
    if (h.action === 'update_upstream_metadata') return `<div class="hint"><span class="ha">upstream metadata</span><div class="hb"><code>${esc(h.file.split('/').pop())}</code> says <code>${esc(h.key)}: ${esc(h.current)}</code> but the datasheet says <code>${esc(h.extracted)}</code>. Ask your AI to patch info.json, or accept the datasheet value downstream.</div></div>`;
    if (h.action === 'use_datasheet_extracted_footprint') return `<div class="hint"><span class="ha">non-standard package</span><div class="hb"><code>${esc(h.package)}</code> is not in the KiCad standard library. Downstream footprint tools should use the body and lead dimensions in the Footprint tab directly.</div></div>`;
    const rv = review.find(r => (r.reason || r.stage_reason) === h.reason) || {};
    const pinsOf = [].concat(rv.pins || []).map(n => { const p = pins.find(x => x.number === String(n)); return p ? `<span class="tag" data-tooltip="${esc(p.description || '')}">${esc(n)} ${esc(p.name)}</span>` : `<span class="tag">${esc(n)}</span>`; }).join(' ');
    return `<div class="hint human_review"><span class="ha">${esc(h.reason || h.action)}</span><div class="hb">${esc(h.notes || rv.notes || REVIEW_TEXT[h.reason] || 'Flagged by the extractor for a human look.')}${pinsOf ? `<div style="margin-top:5px;display:flex;flex-wrap:wrap;gap:4px">${pinsOf}</div>` : ''}</div></div>`;
  }).join('');

  const tabs = [['pins','Pins',pins.length],['groups','Groups',groups.length],['footprint','Footprint',fp.padCount||0],['provenance','Provenance',pinProv.length + dimProv.length],['review','Review',review.length],['log','Log',null],['json','JSON',null]];

  $('#detail').innerHTML = `
  <div class="hero">
    <div class="thumb">${c.has_3d ? `<img src="api/chip/${encodeURIComponent(c.mpn)}/png" alt="${esc(c.mpn)} 3D">` : `<span class="nothumb">no 3D preview</span>`}</div>
    <div class="title">
      <h1>${esc(c.mpn)}</h1>
      <div class="mfr">${esc(sym.manufacturer || info.manufacturer || res.manufacturer || 'manufacturer unknown')}${info.family ? ' · ' + esc(info.family) : ''}</div>
      <div class="desc">${esc(desc) || '<span style="color:var(--text-3)">No description yet. The symbol pass writes one from the datasheet.</span>'}</div>
      <div class="kv">
        <div><span>package </span><b>${esc(sym.package || fp.package || info.package || '?')}</b></div>
        <div><span>pins </span><b>${pins.length || '?'}</b></div>
        <div><span>pads </span><b>${fp.padCount || '?'}</b></div>
        <div><span>kicad baseline </span><b>${fp.kicad_baseline && fp.kicad_baseline.name ? esc(fp.kicad_baseline.library + ':' + fp.kicad_baseline.name) : 'none'}</b></div>
        <div><span>concur </span><b>${esc(c.concur || 'not run')}</b></div>
      </div>
    </div>
    <div class="card status-${st}">
      <div class="ct">Extraction <span class="pill ${st}"><span class="dot"></span>${STATUS_LABEL[st]}</span></div>
      <div class="cd">${state.job && state.job.mpn === c.mpn ? esc(state.job.stage) + ' <span class="elapsed" id="heroElapsed"></span>' : esc(res.summary || (c.has_pdf || c.pdfs.length ? 'Datasheet on disk. Press Extract to read it.' : 'No datasheet PDF in this chip directory. Ask your AI to fetch one with chip-fetcher first.'))}</div>
      <div class="kv">
        <span>datasheet</span><b>${pdfName ? `<a class="pg" href="${pdfUrl(c.mpn, pdfName)}" target="_blank" rel="noopener">${esc(pdfName)}</a>` : 'none'}</b>
        <span>pages</span><b>${ds.page_count || '?'}</b>
        <span>source</span><b>${esc(ds.source_tier || '')}${ds.source_host ? ' · ' + esc(ds.source_host) : ''}</b>
        <span>sha256</span><b title="${esc(res.datasheet_sha256||'')}">${esc((res.datasheet_sha256||'').slice(0,16))}${res.datasheet_sha256 ? '…' : ''}</b>
        <span>model</span><b>${esc(res.claude_model || '')}</b>
        <span>extractor</span><b>${esc(res.extractor_version || '')}${c.extracted_at ? ' · ' + new Date(c.extracted_at*1000).toLocaleString() : ''}</b>
        <span>review</span><b>${review.length} item${review.length===1?'':'s'} · ${hints.length} hint${hints.length===1?'':'s'}</b>
      </div>
    </div>
  </div>
  <div class="actionbar">
    <button class="btn primary" id="btn-extract" ${state.job ? 'disabled' : ''} data-tooltip="Run adom-ds2sf extract on this chip. Reuses cached Claude responses when the outputs already exist; use Re-extract to force a fresh read."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 4l12 8-12 8z"/></svg>Extract</button>
    <button class="btn" id="btn-force" ${state.job ? 'disabled' : ''} data-tooltip="Bypass the cache and ask Claude to read the datasheet again, both passes. Costs a full Opus run."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M20 12a8 8 0 1 1-2.3-5.7"/><path d="M20 4v5h-5"/></svg>Re-extract</button>
    <button class="btn" id="btn-symbol" ${state.job ? 'disabled' : ''} data-tooltip="Symbol pass only: pins, groups, descriptions. Skips the package drawing.">Symbol only</button>
    <button class="btn" id="btn-footprint" ${state.job ? 'disabled' : ''} data-tooltip="Footprint pass only, against the cached symbol pass. Re-reads the mechanical drawing.">Footprint only</button>
    ${c.pdfs.length > 1 ? `<select id="pdfPick" data-tooltip="Which PDF in the chip directory to read. Module products often need the module datasheet, not the SoC one.">${c.pdfs.map(p => `<option ${p === pdfName ? 'selected' : ''}>${esc(p)}</option>`).join('')}</select>` : ''}
    <select id="modelPick" data-tooltip="Claude model for this run. Opus is the production default; see Settings.">${['claude-opus-4-7','claude-sonnet-4-5','claude-haiku-4-5'].map(m => `<option ${m===model?'selected':''}>${m}</option>`).join('')}</select>
    <span class="spacer"></span>
    ${state.job && state.job.mpn === c.mpn ? `<span class="pill running"><span class="dot"></span>${esc(state.job.stage)}</span><span class="elapsed" id="barElapsed"></span>` : (state.job ? `<span class="pill running"><span class="dot"></span>busy with ${esc(state.job.mpn)}</span>` : '')}
    ${pdfName ? `<button class="btn" id="btn-pdf" data-tooltip="Open the datasheet PDF in a new tab."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></svg>Datasheet</button>` : ''}
  </div>
  ${hintHtml ? `<div class="hints">${hintHtml}</div>` : ''}
  <div class="tabs" role="tablist">${tabs.map(([k,l,n]) => `<button class="tab" role="tab" data-tab="${k}" aria-selected="${tab===k}">${l}${n!=null ? `<b>${n}</b>` : ''}</button>`).join('')}</div>
  <div class="panel" id="panel"></div>`;

  $$('#detail .tab').forEach(b => b.onclick = () => { tab = b.dataset.tab; localStorage.setItem('ds2sf.tab', tab); renderDetail(); });
  $('#btn-extract').onclick = () => extract({});
  $('#btn-force').onclick = () => extract({ force: true });
  $('#btn-symbol').onclick = () => extract({ force: true, symbolOnly: true });
  $('#btn-footprint').onclick = () => extract({ force: true, footprintOnly: true });
  if ($('#btn-pdf')) $('#btn-pdf').onclick = () => openPdf(0, pdfName);
  $$('[data-extract-pdf]').forEach(b => b.onclick = () => extract({ force: true, pdf: b.dataset.extractPdf }));

  const panel = $('#panel');
  if (tab === 'pins') {
    panel.innerHTML = pins.length ? `<table><thead><tr><th>#</th><th>Name</th><th>Type</th><th>Group</th><th>Description</th><th>Page</th></tr></thead><tbody>${pins.map(p => { const pv = provByPin[p.number] || {}; return `<tr><td class="num">${esc(p.number)}</td><td class="mono">${esc(p.name)}</td><td><span class="tag">${esc(p.type)}</span></td><td><span class="tag g-${esc(p.group)}">${esc(p.group)}</span></td><td>${esc(p.description || '')}${pv.notes ? `<div style="color:var(--text-3);margin-top:2px">${esc(pv.notes)}</div>` : ''}</td><td>${pv.confidence ? `<span class="conf ${esc(pv.confidence)}" data-tooltip="confidence: ${esc(pv.confidence)}"></span>` : ''}${pgLink(pv.page, pdfName)}</td></tr>`; }).join('')}</tbody></table>` : emptyPanel('No pins extracted yet.');
  } else if (tab === 'groups') {
    panel.innerHTML = groups.length ? `<div class="groups">${groups.map(g => `<div class="group"><div class="gn"><span class="tag g-${esc(g.name)}">${esc(g.name)}</span></div><div class="gd">${esc(g.description || '')}</div><div class="gp">${pins.filter(p => p.group === g.name).map(p => `<span data-tooltip="${esc(p.description||'')}">${esc(p.number)} ${esc(p.name)}</span>`).join('')}</div></div>`).join('')}</div>` : emptyPanel('No groups yet.');
  } else if (tab === 'footprint') {
    const bd = fp.bodyDimensions || {}, ld = fp.leadDimensions || {}, pads = fp.pads || [];
    const dims = [...Object.entries(bd).map(([k,v]) => ['bodyDimensions.'+k, v]), ...Object.entries(ld).map(([k,v]) => ['leadDimensions.'+k, v])];
    const dp = {}; dimProv.forEach(x => dp[x.field] = x);
    panel.innerHTML = `<div class="svgs">
      <div class="svgbox"><div class="sh"><b>Datasheet-derived</b><span>ds2sf, from the package drawing</span></div>${c.svgs.ds2sf ? `<img src="api/chip/${encodeURIComponent(c.mpn)}/svg/ds2sf?t=${c.extracted_at}" alt="ds2sf footprint">` : `<div class="none">No footprint SVG yet</div>`}</div>
      <div class="svgbox"><div class="sh"><b>KiCad stdlib</b><span>${fp.kicad_baseline && fp.kicad_baseline.name ? esc(fp.kicad_baseline.name) : 'no baseline match'}</span></div>${c.svgs.pcbnew ? `<img src="api/chip/${encodeURIComponent(c.mpn)}/svg/pcbnew?t=${c.extracted_at}" alt="pcbnew footprint">` : `<div class="none">No stdlib comparison</div>`}</div>
    </div>
    <div class="two">
      <div><h3 class="sec">Dimensions (mm)</h3>${dims.length ? `<table><thead><tr><th>Field</th><th>Value</th><th>Figure</th><th>Page</th></tr></thead><tbody>${dims.map(([k,v]) => { const p = dp[k] || {}; return `<tr><td class="mono">${esc(k)}</td><td class="num">${typeof v === 'object' ? esc(JSON.stringify(v)) : esc(v)}</td><td style="color:var(--text-2)">${esc(p.figure_label||'')}</td><td>${pgLink(p.page, pdfName)}</td></tr>`; }).join('')}</tbody></table>` : emptyPanel('No dimensions extracted.')}</div>
      <div><h3 class="sec">Pads (${pads.length})</h3>${pads.length ? `<div style="max-height:420px;overflow:auto"><table><thead><tr><th>#</th><th>At</th><th>Size</th><th>Shape</th><th>Pin</th></tr></thead><tbody>${pads.map(p => `<tr><td class="num">${esc(p.number)}</td><td class="mono">${(p.at||[]).map(n => (+n).toFixed(3)).join(', ')}</td><td class="mono">${(p.size||[]).join(' × ')}</td><td>${esc(p.shape||'')}</td><td class="mono">${esc((fp.symbolPinMap||{})[p.number] || (fp.padDescriptions||{})[p.number] || '')}</td></tr>`).join('')}</tbody></table></div>` : emptyPanel('No per-pad data.')}</div>
    </div>`;
  } else if (tab === 'provenance') {
    panel.innerHTML = `<div class="two">
      <div><h3 class="sec">Pin provenance (${pinProv.length})</h3>${pinProv.length ? `<table><thead><tr><th>#</th><th>Pin</th><th>Table</th><th>Page</th></tr></thead><tbody>${pinProv.map(p => `<tr><td class="num">${esc(p.pin_number)}</td><td class="mono">${esc(p.pin_name)}</td><td style="color:var(--text-2)">${esc(p.table_caption||'')}</td><td><span class="conf ${esc(p.confidence||'')}"></span>${pgLink(p.page, pdfName)}</td></tr>`).join('')}</tbody></table>` : emptyPanel('No pin provenance.')}</div>
      <div><h3 class="sec">Dimension provenance (${dimProv.length})</h3>${dimProv.length ? `<table><thead><tr><th>Field</th><th>Value</th><th>Figure</th><th>Page</th></tr></thead><tbody>${dimProv.map(p => `<tr><td class="mono">${esc(p.field)}</td><td class="num">${esc(p.value)}</td><td style="color:var(--text-2)">${esc(p.figure_label||'')}</td><td>${pgLink(p.page, pdfName)}</td></tr>`).join('')}</tbody></table>` : emptyPanel('No dimension provenance.')}
      <h3 class="sec" style="margin-top:16px">Datasheet</h3><div class="card"><div class="kv"><span>path</span><b>${esc(ds.path||'')}</b><span>url</span><b>${ds.url ? `<a href="${esc(ds.url)}" target="_blank" rel="noopener">${esc(ds.url)}</a>` : ''}</b><span>sha256</span><b>${esc(ds.sha256||'')}</b><span>fetched</span><b>${esc(ds.fetched_at||'')}</b><span>tooling</span><b>${esc(JSON.stringify(prov.tooling||{}))}</b></div></div></div>
    </div>`;
  } else if (tab === 'review') {
    panel.innerHTML = review.length ? `<div class="review">${review.map(r => `<div class="rv"><span class="rr">${esc(r.reason || r.stage_reason || '')}</span><div class="rn">${esc(r.notes || r.pin || '')}${r.pins ? '<div class="mono" style="color:var(--text-2);margin-top:3px">' + esc([].concat(r.pins).join(', ')) + '</div>' : ''}</div></div>`).join('')}</div>` : emptyPanel('Nothing flagged for review.');
  } else if (tab === 'log') {
    panel.innerHTML = `<pre class="log" id="logpre"></pre>`; renderLog();
  } else if (tab === 'json') {
    const keys = ['result','symbol','footprint','provenance','validation','concur','info'];
    const pick = localStorage.getItem('ds2sf.json') || 'result';
    panel.innerHTML = `<div class="jsonpick">${keys.map(k => `<button class="btn ${k===pick?'primary':''}" data-json="${k}" style="padding:4px 9px;font-size:11.5px">${k}</button>`).join('')}${(d.cache_files||[]).length ? `<span style="align-self:center;color:var(--text-3);font-size:11px;margin-left:8px">cache: ${d.cache_files.map(f => `<a class="pg" href="api/chip/${encodeURIComponent(c.mpn)}/cache/${encodeURIComponent(f)}" target="_blank" rel="noopener">${esc(f)}</a>`).join(' ')}</span>` : ''}</div><pre class="json">${esc(JSON.stringify(d[pick], null, 2) || 'null')}</pre>`;
    $$('[data-json]').forEach(b => b.onclick = () => { localStorage.setItem('ds2sf.json', b.dataset.json); renderDetail(); });
  }
  tickElapsed();
}
const emptyPanel = m => `<div style="padding:28px;text-align:center;color:var(--text-3)">${esc(m)}</div>`;
function tickElapsed() {
  if (!state.job) return;
  const s = Math.max(0, Math.floor(Date.now()/1000 - state.job.started));
  const t = `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`;
  for (const id of ['heroElapsed','barElapsed']) { const e = document.getElementById(id); if (e) e.textContent = t; }
}
setInterval(tickElapsed, 1000);
function renderLog() {
  const pre = $('#logpre'); if (!pre) return;
  pre.innerHTML = logLines.map(l => { const c = l.startsWith('OK:') ? 'ok' : l.startsWith('ERROR:') ? 'err' : l.startsWith('WARN:') ? 'warn' : /^\[(1a|1b|svg|done)\]/.test(l) ? 'stage' : ''; return `<span class="${c}">${esc(l)}</span>`; }).join('\n') || '<span style="color:var(--text-3)">No extraction has run in this session yet.</span>';
  pre.scrollTop = pre.scrollHeight;
}

async function extract(opts) {
  const body = Object.assign({ mpn: state.current, aiThread: AI_THREAD, model: ($('#modelPick')||{}).value }, opts);
  if (!opts.pdf && $('#pdfPick')) body.pdf = $('#pdfPick').value;
  if (body.model && body.model !== 'claude-opus-4-7' && !confirm(`Run with ${body.model}? Only Opus is trusted for production pin data. Continue with ${body.model} for an experiment?`)) return;
  const r = await _post('extract', body);
  if (!r.ok) toast((r.error || 'extraction refused') + (r.hint ? ' ' + r.hint : ''), 'error');
  else { tab = 'log'; localStorage.setItem('ds2sf.tab', tab); }
}

async function loadDetail() {
  if (!state.current) { detail = null; renderDetail(); return; }
  const mpn = state.current;
  const d = await fetch(`api/chip/${encodeURIComponent(mpn)}`).then(r => r.json()).catch(() => null);
  if (state.current !== mpn) return;
  detail = d; renderDetail(); renderSubject();
}

function applyState(s) {
  const prev = state.current;
  const jobStarted = s.job && (!state.job || state.job.mpn !== s.job.mpn);
  const prevExtracted = detail && detail.summary ? detail.summary.extracted_at : null;
  const prevLogLen = state.log_len || 0;
  state = s;
  // Poll-mode parity with the live channel: execute directives we have not seen,
  // refresh the log when it grew, and reload the detail when its extraction changed.
  const uiEvents = s.ui || [];
  if (lastSeq === null) lastSeq = uiEvents.length ? uiEvents[uiEvents.length - 1].seq : 0;
  for (const e of uiEvents) if (e.seq > lastSeq) { lastSeq = e.seq; if (!(es && es.readyState === 1)) runDirective(e.event, e.data); }
  if ((s.log_len || 0) !== prevLogLen && !(es && es.readyState === 1)) fetch('log').then(r => r.json()).then(l => { logLines = l.lines || []; renderLog(); }).catch(() => {});
  const curNow = s.chips.find(c => c.mpn === s.current);
  const extractedChanged = curNow && prevExtracted !== null && curNow.extracted_at !== prevExtracted;
  // A job that just started for the chip on screen pulls the Log tab forward so the
  // person watching sees Claude's progress without hunting for it.
  if (jobStarted && s.job.mpn === s.current && tab !== 'log') { tab = 'log'; localStorage.setItem('ds2sf.tab', tab); }
  renderFilters(); renderList(); renderSubject();
  if (s.current !== prev || !detail || extractedChanged) loadDetail(); else renderDetail();
  fillSettings();
}

// ── settings ──
function fillSettings() {
  const s = state.settings || {}, en = (state.enums || {});
  $$('[data-setting]').forEach(el => {
    const k = el.dataset.setting, v = s[k];
    if (el.tagName === 'SELECT') {
      const vals = k === 'default_model' ? ['claude-opus-4-7','claude-sonnet-4-5','claude-haiku-4-5'] : ['wv','pup','hbw','nb'];
      if (!el.options.length) el.innerHTML = vals.map(x => `<option>${x}</option>`).join('');
      el.value = v;
    } else if (el.classList.contains('toggle')) el.setAttribute('aria-pressed', String(!!v));
    else el.value = v == null ? '' : v;
  });
  $('#ssModel').textContent = s.default_model === 'claude-opus-4-7' ? 'Opus (production)' : (s.default_model || '') + ' (experiment only)';
  $('#ssModel').className = 'ss ' + (s.default_model === 'claude-opus-4-7' ? 'ok' : '');
  $('#ssVersion').textContent = state.version || '';
}
async function setSetting(k, v) {
  const r = await _post('settings', { [k]: v, aiThread: AI_THREAD });
  if (r.ok) toast(`${k.replace(/_/g,' ')} set to ${v === '' ? 'auto' : v}`, 'success'); else toast(r.error + (r.hint ? ' ' + r.hint : ''), 'error');
}
$$('[data-setting]').forEach(el => {
  const k = el.dataset.setting;
  if (el.classList.contains('toggle')) el.onclick = () => setSetting(k, el.getAttribute('aria-pressed') !== 'true');
  else el.onchange = () => setSetting(k, el.type === 'number' ? Number(el.value) : (el.value === '' ? null : el.value));
});
$('#btn-settings').onclick = () => $('#settings').showModal();
$('#settingsClose').onclick = () => $('#settings').close();
$('#settings').addEventListener('click', e => { if (e.target === $('#settings')) $('#settings').close(); });
let defaultsArmed = false;
$('#settingsDefaults').onclick = async () => {
  if (!defaultsArmed) { defaultsArmed = true; $('#settingsDefaults').textContent = 'Click again to confirm'; setTimeout(() => { defaultsArmed = false; $('#settingsDefaults').textContent = 'Restore defaults'; }, 3000); return; }
  await _post('settings', { ttl_hours: 24, default_surface: 'wv', open_in_background: true, default_model: 'claude-opus-4-7', dev_mode: false, library_root: null, aiThread: AI_THREAD });
  toast('Settings restored to defaults', 'success'); defaultsArmed = false; $('#settingsDefaults').textContent = 'Restore defaults';
};
$('#btn-help').onclick = () => { helpOn = !helpOn; $('#btn-help').setAttribute('aria-pressed', String(helpOn)); if (helpOn) { $('#railFoot').hidden = false; toast('Say to your AI: "run ds2sf on <MPN>" or "show me the pins that need review"', 'info'); } };

// ── directives (from SSE or from the poll) ──
let lastSeq = null;
function runDirective(ev, d) {
  d = d || {};
  if (ev === 'toast') toast(d.text, d.kind || 'info', !!d.sticky);
  else if (ev === 'chip') { if (d.mpn === state.current) loadDetail(); }
  else if (ev === 'click') { const el = document.querySelector(d.selector); if (el) { aiCursor(d.selector, d.label || 'clicking'); setTimeout(() => el.click(), 350); console.log('ui click ' + d.selector); } else console.warn('ui click: no element for ' + d.selector); }
  else if (ev === 'reveal') { const el = document.querySelector(d.selector); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); aiCursor(d.selector, d.label || ''); } }
  else if (ev === 'cursor') aiCursor(d.selector, d.label || '');
  else if (ev === 'tab') { tab = d.tab || d.name; localStorage.setItem('ds2sf.tab', tab); renderDetail(); }
  else if (ev === 'reload') location.reload();
  else if (ev === 'eval') { try { const r = eval(d.js || d.code); console.log('eval result: ' + JSON.stringify(r)); } catch (err) { console.error('eval error: ' + err.message); } }
}

// ── live channel ──
let es = null, pollTimer = null;
function connect() {
  try { es = new EventSource('events'); } catch (e) { startPoll(); return; }
  es.addEventListener('state', e => applyState(JSON.parse(e.data)));
  es.addEventListener('log', e => { logLines.push(JSON.parse(e.data).line); if (logLines.length > 600) logLines.shift(); renderLog(); });
  for (const ev of ['toast','chip','click','reveal','cursor','tab','reload','eval']) es.addEventListener(ev, e => { const d = JSON.parse(e.data); lastSeq = Math.max(lastSeq || 0, (state.ui || []).reduce((m, x) => Math.max(m, x.seq), 0)); runDirective(ev, d); });
  es.onopen = () => { $('.target-chip .led').classList.add('live'); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } };
  es.onerror = () => { $('.target-chip .led').classList.remove('live'); startPoll(); };
}
async function refreshState() { try { applyState(await fetch('state').then(r => r.json())); } catch (e) { console.warn('state fetch failed: ' + e.message); } }
function startPoll() { if (pollTimer) return; pollTimer = setInterval(refreshState, 2000); }
// First paint never waits on the live channel: fetch state now, poll until SSE is open,
// and if the proxy never lets the stream through, keep polling.
refreshState();
startPoll();
connect();
setTimeout(() => { if (!es || es.readyState !== 1) { console.warn('SSE not open after 5s (readyState ' + (es && es.readyState) + '); staying on the 2s poll'); startPoll(); } }, 5000);
fetch('settings').then(r => r.json()).then(s => { state.enums = s.enums; }).catch(() => {});
window.ds2sf = { state: () => state, detail: () => detail, select, extract, toast, aiCursor };
})();
</script>
</body>
</html>