main
John Lauer v0.1.40: restore the dashboard header CSS, add the missing favicon ac48a4c 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Altium Dashboard</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<style>
:root{
  --bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;
  --text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;
  --accent:#00b8b1;--accent-hover:#00d4cb;--on-accent:#05221f;--green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#64abff;
  --brand-heading:'Familjen Grotesk',sans-serif;--brand-body:'Satoshi',sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
@font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/FamiljenGrotesk-SemiBold.woff2') format('woff2');font-weight:600}
@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/Satoshi-Regular.woff2') format('woff2');font-weight:400}
@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/Satoshi-Medium.woff2') format('woff2');font-weight:500}
body{background:var(--bg);color:var(--text-body);font:15px/1.5 'Satoshi',sans-serif;min-height:100vh}
/* House scrollbar everywhere (scrollbars sub-skill, applied verbatim per the
   dock thread 2026-08-23: match the wiki's slim dark pill on every surface). */
*{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}
::-webkit-scrollbar-corner{background:transparent}
body{overflow-x:hidden}          /* the page never scrolls horizontally */
/* FLUID, and the FLEET GUTTER. No max-width: a dashboard fills its window like
   a native app. The 16px horizontal padding is the contract (newbie-panel
   2.3.0): the panel's border box must equal documentElement.clientWidth - 32,
   so every app's panel is the same width at the same pane and the 700px
   two-column threshold is crossed by all of them together. This rule was lost
   in an earlier CSS deletion, which left the panel full-bleed at 734 and the
   page content hugging the left edge - visible in my own frame, and I did not
   look hard enough at it. */
main{margin:0;padding:20px 16px 84px}
header{height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;margin:0;border-radius:0;background:var(--surface);border-bottom:1px solid var(--border);overflow:hidden}
/* The header was the ONLY thing overflowing narrow panes: measured at a real
   236px surface the nowrap brand + target chip + four ghost buttons pushed
   scrollWidth to 410. Everything below the header already fitted. So the bar
   shrinks: brand ellipsises, the chip caps and ellipsises, the buttons never
   shrink (they are the controls), and the subtitle drops out first. */
/* dash-header: the visual rules (restored 2026-09-01 — an earlier CSS deletion took the whole
   block; the markup and the .iconbtn:focus-visible rule outlived it, so the buttons rendered as
   UA-default white boxes and the chip as bare text. John spotted it from the dock in one glance.) */
.brand{display:flex;align-items:center;gap:8px;font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:15px;letter-spacing:-.01em;color:var(--text);white-space:nowrap;cursor:default;position:relative}
.brand svg{width:18px;height:18px;stroke:var(--text)}
.brand .sub{font-family:'Satoshi',sans-serif;font-size:13px;font-weight:400}
.vsep{width:1px;height:20px;background:var(--border);flex:none}
.target-chip{display:inline-flex;align-items:center;gap:6px;font:12px 'JetBrains Mono',ui-monospace,monospace;color:var(--text-body);background:var(--elevated);border:1px solid var(--border);border-radius:999px;padding:3px 10px;white-space:nowrap}
.actions{margin-left:auto;display:flex;gap:6px;flex:none}
.iconbtn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:8px;color:var(--text-2);cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}
.iconbtn:hover{background:var(--elevated);border-color:var(--border);color:var(--accent)}
.iconbtn:active{transform:scale(.9);background:var(--overlay)}
.brand{min-width:0;overflow:hidden;text-overflow:ellipsis}
.brand svg{flex:none}
.target-chip{min-width:0;max-width:38vw;overflow:hidden}
.target-chip #chipName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Header responsiveness (the ONLY overflow source at narrow panes: the
   nowrap brand + chip + buttons pushed a 236px surface to 410px). The module
   owns the panel; this is ours. */
@media (max-width:700px){
  .brand .sub{display:none}
  .target-chip{max-width:30vw}
}
@media (max-width:420px){
  header{gap:8px;padding:0 10px}
  .vsep{display:none}
  .target-chip{max-width:26vw}
}
/* status-leds pill row */
.leds{display:flex;flex-wrap:wrap;gap:8px}
.led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--line);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer;color:var(--text-body);transition:border-color .15s ease}
.led-pill:hover{border-color:var(--teal)}
.led-pill:active{transform:translateY(1px)}
.led-pill .dot{width:8px;height:8px;border-radius:50%;background:var(--muted);flex:none}
.led-pill[data-state=ok] .dot{background:var(--led-green)}
.led-pill[data-state=busy] .dot{background:var(--led-amber);animation:ledpulse 1.1s ease-in-out infinite}
.led-pill[data-state=stuck] .dot{background:var(--led-amber)}
.led-pill[data-state=bad] .dot{background:var(--led-red)}
.led-pill .st{color:var(--muted)}
.led-detail-pop{position:fixed;z-index:99998;max-width:360px;background:var(--overlay);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:13px;color:var(--text-body);box-shadow:0 8px 24px rgba(0,0,0,.4)}
.led-detail-pop h4{font:600 13px var(--sans);color:var(--text);margin:0 0 6px}
.led-detail-pop .hint{color:var(--muted);margin-top:6px;font-size:12.5px}
.led-detail-pop .winlist{font:12px var(--mono);color:var(--muted);margin-top:6px;white-space:pre-wrap}
/* dash-demo toolbar */
#demoToolbar{position:fixed;left:50%;bottom:56px;transform:translateX(-50%);z-index:99998;display:flex;align-items:center;gap:6px;background:var(--overlay);border:1px solid var(--line);border-radius:999px;padding:6px 12px;box-shadow:0 8px 24px rgba(0,0,0,.45)}
.dt-btn{background:none;border:1px solid transparent;border-radius:8px;color:var(--text);font-size:15px;padding:4px 8px;cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}
.dt-btn:hover{background:var(--surface2);border-color:var(--line)}
.dt-btn:active{transform:translateY(1px) scale(.95)}
.dt-btn:focus-visible{outline:2px solid var(--teal);outline-offset:2px}
.dt-sep{width:1px;height:18px;background:var(--line)}
.dt-step{font:12px var(--mono);color:var(--muted);padding:0 4px}
#dtMute.muted{opacity:.45}
#dtExit{color:var(--led-red)}
/* status-leds pill row */
.leds{display:flex;flex-wrap:wrap;gap:8px}
.led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--line);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer;color:var(--text-body);transition:border-color .15s ease}
.led-pill:hover{border-color:var(--teal)}
.led-pill:active{transform:translateY(1px)}
.led-pill .dot{width:8px;height:8px;border-radius:50%;background:var(--muted);flex:none}
.led-pill[data-state=ok] .dot{background:var(--led-green)}
.led-pill[data-state=busy] .dot{background:var(--led-amber);animation:ledpulse 1.1s ease-in-out infinite}
.led-pill[data-state=stuck] .dot{background:var(--led-amber)}
.led-pill[data-state=bad] .dot{background:var(--led-red)}
.led-pill .st{color:var(--muted)}
.led-detail-pop{position:fixed;z-index:99998;max-width:360px;background:var(--overlay);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:13px;color:var(--text-body);box-shadow:0 8px 24px rgba(0,0,0,.4)}
.led-detail-pop h4{font:600 13px var(--sans);color:var(--text);margin:0 0 6px}
.led-detail-pop .hint{color:var(--muted);margin-top:6px;font-size:12.5px}
.led-detail-pop .winlist{font:12px var(--mono);color:var(--muted);margin-top:6px;white-space:pre-wrap}
/* dash-demo toolbar */
#demoToolbar{position:fixed;left:50%;bottom:56px;transform:translateX(-50%);z-index:99998;display:flex;align-items:center;gap:6px;background:var(--overlay);border:1px solid var(--line);border-radius:999px;padding:6px 12px;box-shadow:0 8px 24px rgba(0,0,0,.45)}
.dt-btn{background:none;border:1px solid transparent;border-radius:8px;color:var(--text);font-size:15px;padding:4px 8px;cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}
.dt-btn:hover{background:var(--surface2);border-color:var(--line)}
.dt-btn:active{transform:translateY(1px) scale(.95)}
.dt-btn:focus-visible{outline:2px solid var(--teal);outline-offset:2px}
.dt-sep{width:1px;height:18px;background:var(--line)}
.dt-step{font:12px var(--mono);color:var(--muted);padding:0 4px}
#dtMute.muted{opacity:.45}
#dtExit{color:var(--led-red)}
/* ── newbie-panel (adom-ui-design 1.22.1) ──────────────────────────────────
   ONE full-width band, SIBLING of the app body, two columns: demo left,
   prompts TABLE right. Pills were retired: six phrases of different lengths in
   chips read as a ragged staircase. NO max-width anywhere - a dashboard is an
   app surface, not an article; prose is capped, the layout never is. */
  justify-content:center;background:none;border:1px solid transparent;border-radius:6px;
/* clamp(), NOT minmax() (newbie-panel 1.32.0, found by parts-search): a
   minmax() track resolves to its MAX whenever the neighbouring 1fr can shrink,
   and the content side sets min-width:0 (it must, or a nowrap phrase blows the
   grid out). So minmax(180px,220px) was a HARD 220px at every width - the
   narrow-end flex it looked like it had was unreachable by construction. A
   container percentage genuinely tracks the pane. */
/* 700px (adom-ui-design, 2026-08-25). Was 760, then 620, now 700: his panes
   measure 632, 744 and 851 SIMULTANEOUSLY - there is no single pane width -
   and at 632 the two-column layout truncated the very qualifier the strip
   exists to teach. 700 puts 632 in one column and 744/851 in two.
   which sits BELOW 760, so the two-column panel never rendered in the pane it
   was designed for: the app looked compliant and every screenshot he saw was
   the collapsed fallback. A breakpoint bug is invisible to anyone whose test
   widths straddle it, so 744 is now in my matrix. */
  border:none;border-radius:8px;padding:10px 18px;font:600 14px var(--sans);cursor:pointer;
/* prompts TABLE: one left edge, one row height, so the eye reads phrases not boxes */
/* the row is a grid so the ACTIONS TRACK THE PHRASE, not the window edge: a
   Copy button 2000px from its text on a wide monitor is the bug this fixes */
  background:none;border:1px solid transparent;border-radius:6px;color:var(--muted);cursor:pointer;
@media (pointer:coarse){.ibtn{padding:8px;width:44px;height:44px}}
/* status-leds pill row */
.leds{display:flex;flex-wrap:wrap;gap:8px}
.led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--line);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer;color:var(--text-body);transition:border-color .15s ease}
.led-pill:hover{border-color:var(--teal)}
.led-pill:active{transform:translateY(1px)}
.led-pill .dot{width:8px;height:8px;border-radius:50%;background:var(--muted);flex:none}
.led-pill[data-state=ok] .dot{background:var(--led-green)}
.led-pill[data-state=busy] .dot{background:var(--led-amber);animation:ledpulse 1.1s ease-in-out infinite}
.led-pill[data-state=stuck] .dot{background:var(--led-amber)}
.led-pill[data-state=bad] .dot{background:var(--led-red)}
.led-pill .st{color:var(--muted)}
.led-detail-pop{position:fixed;z-index:99998;max-width:360px;background:var(--overlay);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:13px;color:var(--text-body);box-shadow:0 8px 24px rgba(0,0,0,.4)}
.led-detail-pop h4{font:600 13px var(--sans);color:var(--text);margin:0 0 6px}
.led-detail-pop .hint{color:var(--muted);margin-top:6px;font-size:12.5px}
.led-detail-pop .winlist{font:12px var(--mono);color:var(--muted);margin-top:6px;white-space:pre-wrap}
/* dash-demo toolbar */
#demoToolbar{position:fixed;left:50%;bottom:56px;transform:translateX(-50%);z-index:99998;display:flex;align-items:center;gap:6px;background:var(--overlay);border:1px solid var(--line);border-radius:999px;padding:6px 12px;box-shadow:0 8px 24px rgba(0,0,0,.45)}
.dt-btn{background:none;border:1px solid transparent;border-radius:8px;color:var(--text);font-size:15px;padding:4px 8px;cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}
.dt-btn:hover{background:var(--surface2);border-color:var(--line)}
.dt-btn:active{transform:translateY(1px) scale(.95)}
.dt-btn:focus-visible{outline:2px solid var(--teal);outline-offset:2px}
.dt-sep{width:1px;height:18px;background:var(--line)}
.dt-step{font:12px var(--mono);color:var(--muted);padding:0 4px}
#dtMute.muted{opacity:.45}
#dtExit{color:var(--led-red)}
/* "Say it": the first thing on the page (John, 2026-08-23: teach users how to ASK for the app by its noun, first and foremost). */
h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);margin:26px 0 12px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px}
.led-row{display:flex;align-items:center;gap:10px}
.led{width:12px;height:12px;border-radius:999px;background:var(--text-2);flex:none;transition:background .15s ease}
.led.on{background:var(--green);box-shadow:0 0 8px rgba(63,185,80,.5)}
.led.off{background:var(--red)}
.led.warn{background:var(--yellow)}
.led.dim{background:var(--text-2)}
.led.busy{background:var(--accent);animation:ledpulse 1.1s ease-in-out infinite}
@keyframes ledpulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(0,184,177,.55)}
  50%{opacity:.55;box-shadow:0 0 10px 3px rgba(0,184,177,.25)}}
.card.busy{border-color:rgba(0,184,177,.45)}
.led-label{font-weight:500;color:var(--text)}
.led-detail{font-size:12.5px;color:var(--text);margin-top:6px}
.led-sub{font-size:12.5px;color:var(--text-2);margin-top:6px}
.btn{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 14px;border-radius:8px;border:1px solid var(--border);background:var(--elevated);color:var(--text);font:500 14px 'Satoshi',sans-serif;cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}
.btn:hover{background:var(--overlay);border-color:#4b5563}
.btn:active{transform:translateY(1px) scale(.98);background:var(--surface)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#05221f}
.btn.primary:hover{background:var(--accent-hover)}
.btn.primary:active{background:#009d97;border-color:#009d97;transform:translateY(1px) scale(.98)}
.btn:disabled{opacity:.5;cursor:not-allowed}
.btn:focus-visible,.iconbtn:focus-visible,.tpill:focus-visible,.toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btnrow{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}
.prompt{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;transition:border-color .15s ease}
.prompt:hover{border-color:#4b5563}
input:focus-visible{outline:none;border-color:var(--accent) !important}
.prompt code{font:13px 'JetBrains Mono',monospace;color:var(--text-body);flex:1;white-space:normal}
.copy{flex:none}
.bar{height:8px;background:var(--elevated);border:1px solid var(--border);border-radius:999px;overflow:hidden;margin-top:10px;display:none}
.bar>div{height:100%;width:0;background:var(--accent);transition:width .5s ease}
/* live-frame states (fusion timing contract): a non-measured estimate renders
   DIMMED so a guess never looks as authoritative as a measurement; overdue or
   waiting-on-a-human renders AMBER; percent:null renders an indeterminate
   marquee, never a fake number. */
.sbar.dim>div{opacity:.45}
.sbar.amber>div{background:#d29922}
.sbar.indet>div{width:30%!important;animation:marquee 1.2s linear infinite}
@keyframes marquee{0%{margin-left:-30%}100%{margin-left:100%}}
.barlabel{font-size:12.5px;color:var(--text-2);margin-top:6px}
.scard{display:flex;flex-direction:column;gap:8px}
.scard .btn{width:100%;justify-content:center;text-align:center}
.slog{font:12px 'JetBrains Mono',monospace;color:var(--text-2);min-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.sbar{height:6px;background:var(--elevated);border:1px solid var(--border);border-radius:999px;overflow:hidden;display:none}
.sbar>div{height:100%;width:0;background:var(--accent);transition:width .4s ease}
.sshot{border:1px solid var(--border);border-radius:8px;overflow:hidden;display:none}
.sshot img{width:100%;display:block}
.scard.err .slog{color:var(--red)}
.shots{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:12px}
.shot{background:var(--elevated);border:1px solid var(--border);border-radius:8px;overflow:hidden}
.shot img{width:100%;display:block}
.shot .cap{font-size:11.5px;color:var(--text-2);padding:6px 8px}
#toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:400}
.toast{background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--teal);
  border-radius:8px;padding:10px 14px;color:var(--text);font-size:14px;box-shadow:0 8px 24px rgba(0,0,0,.4);
  animation:toastin 180ms ease-out}
@keyframes toastin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.toast.out{animation:toastout 140ms ease-in forwards}
@keyframes toastout{to{opacity:0}}
@media (prefers-reduced-motion:reduce){.toast,.toast.out{animation:none}}
.toast.ok{border-left-color:var(--led-green)}.toast.err{border-left-color:var(--led-red)}
dialog{margin:auto;background:var(--surface);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:520px;width:92vw}
/* The backdrop law (settings-layout skill): dim 20-30%, NEVER blur, one value
   for every dialog in the app - settings often change the very thing behind
   the modal and the user must be able to watch it change. */
dialog::backdrop{background:rgba(0,0,0,.25)}

/* ---- Settings modal: the Adom settings surface (settings-layout skill) ---- */
dialog.settings{width:min(660px,92vw);max-width:none;padding:0;overflow:hidden;border-radius:12px;background:var(--surface)}
dialog.settings:focus{outline:none}
.settings-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px 12px 18px;border-bottom:1px solid var(--border);cursor:move;user-select:none}
.settings-head h2{margin:0;font:600 15px/1.3 var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:10px}
.settings-head .card-status{font-family:var(--brand-body)}
.settings-body{max-height:70vh;overflow:auto;padding:18px;scrollbar-gutter:stable}
.settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem}
.card-title{font:600 13px/1.3 var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:10px;margin:0 0 .45rem}
.card-status{font-size:11px;color:var(--text-2);font-weight:400;font-family:var(--brand-body)}
.card-status.ok{color:var(--green)}
.card-desc{font-family:var(--brand-body);font-size:12px;line-height:1.55;color:var(--text-2);max-width:60ch;margin:0 0 .55rem}
.card-desc code{font:11px 'JetBrains Mono',monospace;background:rgba(110,118,129,.2);padding:1px 4px;border-radius:4px}
.divider{height:1px;background:var(--border);margin:.55rem 0}
.wrapper{padding:.5rem 0;display:flex;flex-direction:column;gap:.25rem}
.name{margin:.125rem 0;font-family:var(--brand-heading);font-size:.9375rem;font-weight:600}
.prefix{color:var(--text)}
.title{color:#8fd3d4}
.default-hint{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}
.setting-shot{margin:0 0 .75rem}
.shot-pair{display:flex;flex-wrap:wrap;gap:.75rem}
.shot-pair .setting-shot{flex:1 1 272px;max-width:272px}
.setting-shot figcaption b{color:var(--text-body);font-weight:600}
.setting-shot img{display:block;max-width:100%;height:auto;border:1px solid var(--border);border-radius:8px;background:var(--surface)}
.setting-shot figcaption{margin-top:.35rem;font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}
.choice-set{border:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:.75rem}
.choice{flex:1 1 280px;display:flex;flex-direction:column;gap:.35rem;padding:.6rem;cursor:pointer;position:relative;background:var(--surface);border:1px solid var(--border);border-radius:8px;transition:border-color .15s ease,background .15s ease,transform .06s ease}
.choice input{position:absolute;opacity:0;pointer-events:none}
.choice img{display:block;width:100%;max-width:272px;height:auto;border-radius:4px;background:var(--bg);image-rendering:auto}
.choice-name{font-family:var(--brand-heading);font-size:.875rem;font-weight:600;color:var(--text)}
.choice-desc{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);line-height:1.5}
.choice:hover{border-color:var(--accent);background:var(--elevated)}
.choice:active{transform:translateY(1px) scale(.995)}
.choice[data-selected='true']{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}
.choice:focus-within{outline:2px solid var(--accent);outline-offset:2px}
.setting-row{display:flex;align-items:center;gap:10px}
.set-check-label{font-family:var(--brand-body);font-size:.875rem;color:var(--text-body)}
.toggle{background:none;border:none;padding:2px;cursor:pointer;flex-shrink:0;transition:transform .06s ease}
.toggle-track{display:block;width:32px;height:18px;background:var(--overlay);border:1px solid var(--border);border-radius:9px;position:relative;transition:background .15s ease,border-color .15s ease}
.toggle:hover .toggle-track{border-color:var(--accent)}
.toggle:active{transform:translateY(1px) scale(.97)}
.toggle.active .toggle-track{background:var(--accent);border-color:var(--accent)}
.toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .18s ease,width .12s ease}
.toggle.active .toggle-thumb{transform:translateX(14px)}
.toggle:active .toggle-thumb{width:16px}
.toggle.active:active .toggle-thumb{transform:translateX(10px)}
.toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:10px}
.settings-footer{margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:.5rem}
.footer-note{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);margin:0;max-width:60ch}
.restore-btn{width:fit-content;display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .85rem;background:transparent;border:1px solid var(--border);border-radius:.375rem;color:var(--text-body);font-family:var(--brand-heading);font-size:.8125rem;font-weight:600;cursor:pointer;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .06s ease}
.restore-btn:hover{background:var(--elevated)}
.restore-btn:active{transform:translateY(1px) scale(.98)}
.restore-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.restore-btn.armed{color:var(--red);border-color:rgba(255,107,107,.5);background:rgba(255,107,107,.08)}
@media (max-width:720px){.meta{display:none}.grid{grid-template-columns:1fr 1fr}}
@media (max-width:480px){.grid{grid-template-columns:1fr}.brand span{display:none}.subject{display:none}.vsep{display:none}header{gap:8px}}
#tt{position:fixed;z-index:99999;background:var(--overlay);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:12.5px;color:var(--text-body);max-width:260px;pointer-events:none;opacity:0;transform:translateY(3px);transition:opacity .18s ease,transform .18s ease}
#tt.show{opacity:1;transform:none}
#appinfo{position:fixed;z-index:99998;background:var(--overlay);border:1px solid var(--border);border-radius:10px;padding:12px 14px;font-size:13px;color:var(--text-body);box-shadow:0 8px 24px rgba(0,0,0,.4);display:none;pointer-events:auto;min-width:220px}
#appinfo.show{display:block}
#appinfo a{color:var(--accent)}
main a{color:var(--accent);text-decoration:none;border-bottom:1px solid rgba(0,184,177,.35)}
main a:hover{border-bottom-color:var(--accent)}
#targets{display:flex;gap:8px;margin:0 0 14px;flex-wrap:wrap}
.tpill{display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:999px;border:1px solid var(--border);background:var(--surface);color:var(--text-body);cursor:pointer;font:500 13.5px 'Satoshi',sans-serif;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .06s ease}
.tpill:hover{border-color:#4b5563;background:var(--elevated);color:var(--text)}
.tpill:active{transform:translateY(1px) scale(.97)}
.tpill.active{border-color:var(--accent);color:var(--text);background:var(--elevated)}
.tdot{width:8px;height:8px;border-radius:999px;background:var(--text-2)}
.tdot.on{background:var(--green)}
.note{font-size:12.5px;color:var(--text-2);margin:-6px 0 12px}
.wins{font:12.5px 'JetBrains Mono',monospace;color:var(--text-2)}
</style>
</head>
<body>
<header>
  <div class="brand" id="brand" tabindex="0">
    <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex:none"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7.5 15.5H9.5L16.5 8.5"/><circle cx="7.5" cy="15.5" r="2.5"/><circle cx="16.5" cy="8.5" r="2.5"/></svg>
    Altium Dashboard <span class="sub" style="color:var(--text-2);font-weight:400">&middot; the Altium bridge</span>
  </div>
  <div class="vsep"></div>
  <!-- dash-header: the standard target chip (which box answers), LED inside;
       versions live in the brand popover, NEVER in the bar -->
  <span class="target-chip" id="targetChip" data-tip="The box this bridge answers on. Switch boxes with the pills under Status."><span class="tdot" id="chipDot"></span><span id="chipName">connecting...</span></span>
  <div class="actions">
    <button class="iconbtn" id="btnRefresh" data-tip="Re-read every facet's status from the bridge right now (also auto-refreshes every 15s)" aria-label="Refresh"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg></button>
    <button class="iconbtn" id="btnHelp" aria-pressed="true" aria-label="Hide the prompts panel" data-tip="Hide the prompts panel"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M11 18h2v-2h-2v2m1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8m0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></svg></button>
    <button class="iconbtn" id="btnSettings" title="Settings" aria-label="Settings"><!-- the shared Adom cog, OUTLINE per the 2026-08-24 fleet ruling (mdi:cog-outline; filled is deprecated) --><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M12 8a4 4 0 0 1 4 4a4 4 0 0 1-4 4a4 4 0 0 1-4-4a4 4 0 0 1 4-4m0 2a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-2 12c-.25 0-.46-.18-.5-.42l-.37-2.65c-.63-.25-1.17-.59-1.69-.99l-2.49 1.01c-.22.08-.49 0-.61-.22l-2-3.46a.493.493 0 0 1 .12-.64l2.11-1.66L4.5 12l.07-1l-2.11-1.63a.493.493 0 0 1-.12-.64l2-3.46c.12-.22.39-.31.61-.22l2.49 1c.52-.39 1.06-.73 1.69-.98l.37-2.65c.04-.24.25-.42.5-.42h4c.25 0 .46.18.5.42l.37 2.65c.63.25 1.17.59 1.69.98l2.49-1c.22-.09.49 0 .61.22l2 3.46c.13.22.07.49-.12.64L19.43 11l.07 1l-.07 1l2.11 1.63c.19.15.25.42.12.64l-2 3.46c-.12.22-.39.31-.61.22l-2.49-1c-.52.39-1.06.73-1.69.98l-.37 2.65c-.04.24-.25.42-.5.42zm1.25-18l-.37 2.61c-1.2.25-2.26.89-3.03 1.78L5.44 7.35l-.75 1.3L6.8 10.2a5.55 5.55 0 0 0 0 3.6l-2.12 1.56l.75 1.3l2.43-1.04c.77.88 1.82 1.52 3.01 1.76l.37 2.62h1.52l.37-2.61c1.19-.25 2.24-.89 3.01-1.77l2.43 1.04l.75-1.3l-2.12-1.55c.4-1.17.4-2.44 0-3.61l2.11-1.55l-.75-1.3l-2.41 1.04a5.42 5.42 0 0 0-3.03-1.77L12.75 4z"/></svg></button>
    
  </div>
</header>
<main>
  <!-- The panel is MOUNTED, not built: adom-ui-design 2.0.0 ships the one
       implementation as a module. Seven apps hand-building from a written spec
       produced four paddings, two border colours and three Copy drawings; a
       written spec is not a mechanism. We pass phrases, noun and demo only. -->
  <div id="newbie"></div>
  <script src="newbie-panel.js"></script>

  <!-- status-leds: one compact pill row, prose on tap only -->
  <div class="leds" id="ledRow" style="margin-top:16px"></div>
  <div id="ledDetail" class="led-detail-pop" style="display:none"></div>

  <h2>Boxes &amp; actions</h2>
  <div id="targets"></div>
  <div class="note" id="boxnote" style="display:none">Many boxes, one bridge: pick where Altium should run. Everything the bridge does happens in the background; it tells you before it ever brings a window forward.</div>
  <div class="btnrow">
    <button class="btn primary" id="btnSetup">Setup</button>
    <button class="btn" id="btnLaunch">Launch Altium</button>
    <button class="btn" id="btnClose">Close Altium</button>
  </div>

  <h2>Demos</h2>
  <div class="card">
    <div class="led-label">Guided demo</div>
    <div class="led-sub">Six beats on the examples Altium ships with: symbol, footprint, 3D part, schematic, 2D board, 3D board. Runs entirely in the background through the resident agent, and each finished beat streams back a screenshot.</div>

    <div class="bar" id="demoBar"><div></div></div>
    <div class="barlabel" id="demoLabel"></div>
    <div class="shots" id="demoShots"></div>
  </div>
  <div class="card" style="margin-top:12px">
    <div class="led-label">Tour a wiki part end to end</div>
    <div class="led-sub">Importing a wiki part into Altium (symbol, footprint, STEP body, parameters) is a creative job your AI does with the altium_create_* verbs and proves with frames. Send it the phrase below.</div>
    <div class="btnrow">
      <input id="partPage" value="adom/lan7800" data-tip="Any wiki part page with a symbol, footprint and STEP body" style="flex:1;min-width:180px;background:var(--elevated);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:8px 10px;font:13px 'JetBrains Mono',monospace">
      <button class="btn" id="btnPartTour">Ask the AI to import it</button>
    </div>
    <div class="barlabel" id="partLabel"></div>
  </div>

  <h2>Altium surfaces</h2>
  <div class="note" id="surfaceIntro">Every design surface the bridge can drive, on the examples Altium ships with. Each button opens JUST that surface in the background through the resident DelphiScript agent and shows you the frame the verb returned.</div>
  <div class="grid" id="surfaceGrid"></div>

  <h2 style="display:flex;align-items:center;gap:10px">Verb times on this box
    <button class="btn" id="btnVerbTimes" style="font-size:12.5px" data-tip="Measured run times per verb from this box's append-only run log. p90 is the column for hunting slow paths.">refresh</button></h2>
  <div class="note">Every dispatch is logged with its outcome; these numbers converge just by the bridge being used. p50 15s beside p90 400s is two code paths wearing one name.</div>
  <div class="card" id="verbTimes" style="overflow-x:auto"><div class="note">not loaded - press refresh</div></div>

  <h2>Copy-paste prompts for your AI</h2>
  <div id="prompts"></div>
</main>

<dialog id="dlgSettings" class="settings" tabindex="-1" aria-labelledby="settingsTitle">
  <div class="settings-head" id="settingsHead">
    <h2 id="settingsTitle">Altium Bridge Settings <span class="card-status" id="settingsBox"></span></h2>
    <button class="iconbtn" id="btnCloseSettings" aria-label="Close settings" title="Close"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
  </div>
  <div class="settings-body" id="settingsBody">

    <section class="settings-card" id="card-activity">
      <h3 class="card-title">Taskbar activity <span class="card-status" id="stActivity"></span></h3>
      <p class="card-desc">While a verb is driving Altium, the bridge marks the Altium taskbar button so you can tell the window is busy and who is busy in it. The progress bar is painted through the Windows taskbar API (<code>ITaskbarList3</code>) and sized from this box's own measured verb timings: it fills over the time that verb usually takes here, creeps past the end when a run is overdue, and shows an indeterminate marquee when nothing has been measured yet. The flash is Windows' own attention highlight. The bridge never triggers it, it only decides whether to cancel it while a bar is showing.</p>
      <p class="card-desc">The difference is what each one asks of you. A flash stays lit until you click the button, so it reads as something you have to deal with. A bar removes itself the moment Adom stops driving, so when the bar and the overlay badge are both gone, the window is yours again. Read-only verbs such as status checks never mark activity in any mode.</p>
      <div class="wrapper">
        <h4 class="name" title="activityIndicator"><span class="prefix">Taskbar: </span><span class="title">Activity indicator</span></h4>
        <fieldset class="choice-set" role="radiogroup" aria-labelledby="activityLabel">
          <legend id="activityLabel" class="set-check-label" style="padding:0;margin-bottom:.35rem">What the Altium taskbar button does while Adom is driving it. Each picture is a 1:1 capture of this machine's taskbar at its native resolution.</legend>
          <label class="choice" data-mode="progress" data-selected="false">
            <input type="radio" name="activityIndicator" value="progress">
            <img src="assets/shots/taskbar-progress.png" width="272" height="88" loading="lazy" alt="The Altium taskbar button with a blue progress bar about two thirds full under its icon, neighbours untouched">
            <span class="choice-name">Progress bar</span>
            <span class="choice-desc">Fills over the verb's usual duration on this box, then clears itself. Cancels the OS flash.</span>
          </label>
          <label class="choice" data-mode="flash" data-selected="false">
            <input type="radio" name="activityIndicator" value="flash">
            <img src="assets/shots/taskbar-flash.png" width="272" height="88" loading="lazy" alt="The same Altium taskbar button tinted salmon with a highlighted underline, no progress bar">
            <span class="choice-name">Attention flash</span>
            <span class="choice-desc">Windows' own highlight, left alone. Says something happened, not how long is left, and stays until you click it.</span>
          </label>
          <label class="choice" data-mode="both" data-selected="false">
            <input type="radio" name="activityIndicator" value="both">
            <img src="assets/shots/taskbar-both.png" width="272" height="88" loading="lazy" alt="The same Altium taskbar button tinted salmon and carrying the blue progress bar at the same time">
            <span class="choice-name">Bar and flash</span>
            <span class="choice-desc">Both at once. The bar still clears itself; the tint stays until you click.</span>
          </label>
          <label class="choice" data-mode="none" data-selected="false">
            <input type="radio" name="activityIndicator" value="none">
            <img src="assets/shots/taskbar-none.png" width="272" height="88" loading="lazy" alt="The same Altium taskbar button with no bar and no highlight, only the running dot">
            <span class="choice-name">Nothing</span>
            <span class="choice-desc">The button never changes. You rely on the overlay badge below, or on nothing.</span>
          </label>
        </fieldset>
        <span class="default-hint">Default: Progress bar</span>
      </div>
    </section>

    <section class="settings-card" id="card-overlay">
      <h3 class="card-title">Overlay badge <span class="card-status" id="stOverlay"></span></h3>
      <p class="card-desc">The Adom mark is stamped in the corner of a Altium window's taskbar button while a verb is running against that window, and comes off by itself five minutes after the last verb, so a badge always means "Adom is driving this right now". It is a per-window overlay through the same taskbar API, so it never changes the button's own icon or how Windows groups it, and it is never stamped by read-only verbs like status checks. A bridge restart clears every badge, because a freshly started bridge is driving nothing.</p>
      <div class="shot-pair">
        <figure class="setting-shot">
          <img src="assets/shots/taskbar-overlay.png" width="272" height="88" loading="lazy" alt="The Altium taskbar button with the small teal Adom mark in its top right corner">
          <figcaption><b>On:</b> the Adom mark sits on the Altium PCB Editor button while a verb drives it.</figcaption>
        </figure>
        <figure class="setting-shot">
          <img src="assets/shots/taskbar-none.png" width="272" height="88" loading="lazy" alt="The same Altium taskbar button with no mark on it, only its own icon">
          <figcaption><b>Off:</b> the same button, same moment, nothing added.</figcaption>
        </figure>
      </div>
      <div class="wrapper">
        <h4 class="name" title="overlayBadges"><span class="prefix">Taskbar: </span><span class="title">Adom overlay badge</span></h4>
        <div class="setting-row">
          <button class="toggle" id="tglOverlay" role="switch" aria-checked="true" aria-label="Show the Adom overlay badge while driving"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>
          <span class="set-check-label">Show the badge while Adom is driving</span>
        </div>
        <span class="default-hint">Default: On</span>
      </div>
    </section>

    <footer class="settings-footer">
      <p class="footer-note">Settings are stored on the selected box in <code>%USERPROFILE%\.adom\altium-bridge-settings.json</code> and apply immediately. Restoring returns every option above to its default.</p>
      <button class="restore-btn" id="btnRestore" type="button"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path id="restoreIcon" fill="currentColor" d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18m-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8z"/></svg><span id="restoreLabel">Restore defaults</span></button>
    </footer>
  </div>
</dialog>

<dialog id="dlgSetup">
  <h2 style="margin:0 0 8px">Setup</h2>
  <div id="setupBody" class="led-sub" style="font-size:14px"></div>
  <div class="btnrow" style="justify-content:flex-end">
    <button class="btn primary" id="btnSetupNext">Run the next step</button>
    <button class="btn" id="btnCloseSetup">Close</button>
  </div>
</dialog>

<!-- dash-demo player: a remote control, fixed bottom-center while the demo runs -->
<div id="demoToolbar" style="display:none">
  <button class="dt-btn" id="dtPrev" data-tip="Previous step" aria-label="Previous step">&#9198;</button>
  <button class="dt-btn" id="dtPlay" data-tip="Pause or resume" aria-label="Pause or resume">&#9208;</button>
  <button class="dt-btn" id="dtNext" data-tip="Next step" aria-label="Next step">&#9197;</button>
  <span class="dt-sep"></span>
  <button class="dt-btn" id="dtMute" data-tip="Mute or unmute the narration" aria-label="Mute or unmute">&#128266;</button>
  <span class="dt-step" id="dtStep">step 1/6</span>
  <button class="dt-btn" id="dtExit" data-tip="Stop the demo, clear the caption, silence narration" aria-label="Exit the demo">&#10005;</button>
</div>
<div id="toasts"></div>
<div id="tt" role="tooltip"></div>
<div id="appinfo"></div>
<script>
const $=s=>document.querySelector(s);
const consoleBuf=[];
["log","warn","error"].forEach(k=>{const o=console[k].bind(console);console[k]=(...a)=>{consoleBuf.push(k+": "+a.join(" "));if(consoleBuf.length>300)consoleBuf.shift();o(...a);};});
window.onerror=(m)=>{consoleBuf.push("onerror: "+m);};
function toast(msg,kind){const t=document.createElement('div');t.className='toast '+(kind||'');
  const ICONS={ok:'M9 16.2 4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z',err:'M11 15h2v2h-2zm0-8h2v6h-2zm1-5C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18a8 8 0 0 1-8-8 8 8 0 0 1 8-8 8 8 0 0 1 8 8 8 8 0 0 1-8 8'};
  if(ICONS[kind]){t.innerHTML='<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" style="flex:none;color:var(--'+(kind==='ok'?'green':'red')+')"><path fill="currentColor" d="'+ICONS[kind]+'"/></svg><span></span>';t.lastChild.textContent=msg;t.style.display='flex';t.style.alignItems='center';t.style.gap='8px';}
  else t.textContent=msg;
  $('#toasts').appendChild(t);setTimeout(()=>t.remove(),kind==='err'?9000:4000);}
async function api(path,opts){const r=await fetch(path,opts);return r.json();}
async function act(action,args){return api('api/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,target:curTarget||undefined,args:args||{}})});}

let lastStatus=null;
let curTarget=localStorage.getItem('kdTarget')||'';
let targetsCache=[];
// The adom.cloud proxy STRIPS query strings on sub-paths, so target rides the PATH.
function statusUrl(force){return 'api/status/'+encodeURIComponent(curTarget||'-')+(force?'/force':'');}
async function loadTargets(){
  try{
    const d=await api('api/targets');
    targetsCache=d.targets||[];
    const el=$('#targets');el.innerHTML='';
    $('#boxnote').style.display=targetsCache.length>1?'':'none';
    const known=targetsCache.map(t=>t.name);
    if(targetsCache.length&&(!curTarget||!known.includes(curTarget))){
      if(curTarget)toast(curTarget+' is no longer connected - switching to '+known[0]);
      curTarget=(targetsCache.find(t=>t.altiumRunning)||targetsCache[0]).name;
      localStorage.setItem('kdTarget',curTarget);
    }
    for(const t of targetsCache){
      const b=document.createElement('button');b.className='tpill'+(t.name===curTarget?' active':'');
      const resp=(t.responding===false)?'not responding':(t.altiumRunning?'Altium is running here':'Altium is closed here');
      b.setAttribute('data-tip',resp+(t.altiumVersion?(' - Altium '+t.altiumVersion):''));
      if(t.responding===false)b.style.opacity='.55';
      b.innerHTML='<span class="tdot'+(t.altiumRunning?' on':'')+'"></span>'+t.name;
      b.onclick=()=>{curTarget=t.name;localStorage.setItem('kdTarget',curTarget);loadTargets();refresh();toast('switched to '+t.name)};
      el.appendChild(b);
    }
  }catch(e){}
}
// status-leds (1.9.1): one compact pill row; prose lives in tap-to-expand.
// A busy pill that stays busy past 15 minutes escalates to "stuck?" - a
// busy-for-a-day is broken, not busy (the fusion add-in lesson).
const BUSY_KEY='kdBusySince';
function busySince(){try{return JSON.parse(localStorage.getItem(BUSY_KEY))||{}}catch(e){return{}}}
function busySave(o){try{localStorage.setItem(BUSY_KEY,JSON.stringify(o))}catch(e){}}
function shortState(l){
  // The SERVER emits `short` (#59). The page NEVER truncates prose - that is
  // what produced the fleet's "in talled and pinned".
  return l.short || l.state || 'unknown';
}
function renderLeds(st){
  const g=$('#ledRow');g.innerHTML='';
  const leds=st.leds||{};
  const ORDER=['relay','box','bridge','altium','license','running','agent','update','suite'];
  const keys=ORDER.filter(k=>leds[k]).concat(Object.keys(leds).filter(k=>!ORDER.includes(k)));
  const bs=busySince();const now=Date.now();let bsChanged=false;
  for(const key of keys){
    const l=leds[key];
    let state=l.state==='ok'?'ok':(l.state==='busy'?'busy':(l.state==='err'?'bad':(l.state==='warn'?'busy':'dim')));
    if(state==='busy'){
      if(!bs[key]){bs[key]=now;bsChanged=true;}
      else if(now-bs[key]>15*60*1000)state='stuck';
    }else if(bs[key]){delete bs[key];bsChanged=true;}
    const el=document.createElement('button');el.className='led-pill';el.dataset.state=state;el.dataset.key=key;
    el.innerHTML=`<span class="dot"></span><b>${l.label||key}</b><span class="st">${state==='stuck'?'stuck?':shortState(l)}</span>`;
    el.onclick=(ev)=>{ev.stopPropagation();showLedDetail(el,key,l,state,st);};
    g.appendChild(el);
  }
  if(bsChanged)busySave(bs);
  const chip=$('#chipName');if(chip){chip.textContent=st.target?('target: '+st.target):'no bridge target';}
  const dot=$('#chipDot');if(dot){const ok=(leds.relay||{}).state==='ok'&&(leds.box||{}).state==='ok';dot.className='tdot'+(ok?' on':'');}
}
// tap-to-expand detail: prose, timestamps, and for a red pill the NEXT STEP
// first (the UI cousin of the CLI Hint: contract). The Plugin pill also
// carries the open-Altium-windows list (its old home was a raw mono dump).
function showLedDetail(anchor,key,l,state,st){
  const d=$('#ledDetail');
  let h='<h4>'+(l.label||key)+' &middot; '+(state==='stuck'?'stuck?':(l.state||''))+'</h4>';
  if(state==='bad'&&l._hint)h+='<div><b>Next step:</b> '+l._hint+'</div>';
  if(state==='stuck')h+='<div><b>Next step:</b> this has been busy over 15 minutes, which usually means wedged. Press Refresh; if it stays stuck, relaunch from the dock (the version-aware serve replaces a stale instance).</div>';
  if(l.detail)h+='<div style="margin-top:6px">'+l.detail+'</div>';
  if(l._hint&&state!=='bad')h+='<div class="hint">'+l._hint+'</div>';
  if(key==='running'||key==='altium'){
    const w=(st&&st.windows)||[];
    h+='<div class="winlist">'+(w.length?('open Altium windows:\n'+w.join('\n')):'no Altium windows open')+'</div>';
  }
  d.innerHTML=h;d.style.display='block';
  const r=anchor.getBoundingClientRect(),M=8;
  let x=Math.max(M,Math.min(r.left,innerWidth-d.offsetWidth-M));
  let y=r.bottom+8;if(y+d.offsetHeight>innerHeight-M)y=r.top-d.offsetHeight-8;
  d.style.left=x+'px';d.style.top=Math.max(M,y)+'px';
}
document.addEventListener('click',()=>{const d=$('#ledDetail');if(d)d.style.display='none';});
const LED_SKELETON=['relay','box','bridge','altium','license','running','agent','update'];
function renderSkeleton(){
  const g=$('#ledRow');if(g.children.length)return;   // only before the first real paint
  g.innerHTML=LED_SKELETON.map(k=>`<span class="led-pill" data-state="dim"><span class="dot"></span><b style="text-transform:capitalize">${k}</b><span class="st">checking...</span></span>`).join('');
  const chip=$('#chipName');if(chip)chip.textContent='checking '+(curTarget||'the default box')+'...';
}
let refreshing=false;
async function refresh(force){
  if(refreshing)return;refreshing=true;renderSkeleton();
  try{lastStatus=await api(statusUrl(force));renderLeds(lastStatus);maybeCountLaunch(lastStatus);}
  catch(e){
    const chip=$('#chipName');if(chip)chip.textContent='status unavailable';
    $('#ledRow').innerHTML='<button class="led-pill" data-state="bad"><span class="dot"></span><b>Status</b><span class="st">unavailable</span></button>';
    toast('status failed: '+e+' - pick another box with the pills below, or press Refresh','err');
  }
  finally{refreshing=false;}
}
$('#btnRefresh').onclick=()=>{toast('refreshing status');refresh(true);};
$('#btnLaunch').onclick=async()=>{toast('launching Altium in the background - watch the Running LED');await act('launch');};
$('#btnClose').onclick=async()=>{toast('closing Altium');await act('close');setTimeout(()=>refresh(true),4000);};

// Phrases come from dockbar.json `say` via the server, then the MODULE owns
// everything else: card, grid, table, icons, clipboard, send, toasts, X and
// the header toggle. We only tell it our noun and what the demo does.
function buildSay(){
  // Phrases are injected into the document at serve time (window.__SAY_PHRASES__),
  // so there is no fetch to race at boot. The module may still be a moment
  // behind if its <script> is slow, so poll briefly for it rather than
  // returning and leaving the page with no panel.
  const phrases=(window.__SAY_PHRASES__||[]).slice(0,6);
  if(!phrases.length)return;
  const attempt=()=>{
    if(!window.AdomNewbiePanel)return false;
    try{ doMount(phrases); }catch(e){ console.error('newbie panel mount failed',e); }
    return true;
  };
  if(attempt())return;
  let tries=0;
  const t=setInterval(()=>{ if(attempt()||++tries>20)clearInterval(t); },250);
}
function doMount(phrases){
  AdomNewbiePanel.mount({
    mount:'#newbie', app:'altium', appName:'Altium Dashboard', slug:'altium-bridge',
    toggleButton:'#btnHelp',
    demo:{label:'Play the demo', meta:'6 views, about 2 minutes',
          onPlay:function(){ startDemo(); }},
    phrases:phrases.slice(0,6)
  });
}
// Guided demo with a REAL progress bar + the stepShot stream (one screenshot per finished beat).
async function startDemo(){
  toast('starting the guided demo (background)');
  $('#demoShots').innerHTML='';
  const s=await act('demo',{muted:demoMuted()});const d=s.data||s;
  if(d.success===false){toast('demo failed to start: '+(d.error||'unknown'),'err');return;}
  $('#demoBar').style.display='block';
  $('#demoToolbar').style.display='flex';
  const poll=setInterval(async()=>{
    const p=await act('demo_progress');const q=p.data||p;
    const paused=(q.stepLabel==='Paused');
    $('#dtPlay').dataset.paused=paused?'1':'0';
    $('#dtPlay').innerHTML=paused?'&#9654;':'&#9208;';
    if(q.beatIndex&&q.beatCount)$('#dtStep').textContent='step '+q.beatIndex+'/'+q.beatCount;
    $('#demoBar>div').style.width=(q.percent||0)+'%';
    $('#demoLabel').textContent=(q.percent||0)+'% '+(q.stepLabel||'');
    if(q.stepShot&&q.stepShot.image){
      const c=document.createElement('div');c.className='shot';
      c.innerHTML='<img src="data:image/png;base64,'+q.stepShot.image+'" alt="'+(q.stepShot.title||'')+'"><div class="cap">'+(q.stepShot.title||q.stepShot.step||'')+'</div>';
      $('#demoShots').appendChild(c);
    }
    if(q.done){clearInterval(poll);$('#demoToolbar').style.display='none';toast(q.error?('demo failed: '+q.error):(q.result&&q.result.exited?'demo stopped':'demo complete'),'' + (q.error?'err':'ok'));refresh(true);}
  },3000);
}
// Demo transport: mirrors the ab toast buttons; the bridge applies commands
// between beats (a beat that is mid-open finishes first).
async function demoCtl(cmd){
  const r=await act('demo_control',{cmd});const d=(r&&r.data)||r||{};
  if(d.error){toast('demo control failed: '+d.error,'err');return;}
  if(cmd==='pause'){$('#dtPlay').dataset.paused='1';$('#dtPlay').innerHTML='&#9654;';toast('pausing after this step','ok');}
  if(cmd==='play'){$('#dtPlay').dataset.paused='0';$('#dtPlay').innerHTML='&#9208;';toast('resuming','ok');}
  if(cmd==='exit')toast('stopping the demo','ok');
  if(cmd==='next')toast('skipping ahead','ok');
  if(cmd==='prev')toast('replaying the previous step','ok');
}
$('#btnPartTour').onclick=async()=>{
  const page=$('#partPage').value.trim();
  const b=$('#btnPartTour');b.disabled=true;
  toast('touring '+page+' (clone, install, generate project)');
  $('#partLabel').textContent='working...';
  const r=await act('part_tour',{page});const d=r.data||r;
  b.disabled=false;
  if(d.success===false){toast('part tour failed: '+(d.error||'unknown'),'err');$('#partLabel').textContent=d.error||'failed';}
  else{toast('part installed and project generated','ok');$('#partLabel').textContent=(d.steps||[]).join(' · ');refresh(true);}
};

// "Say it": phrases come from dockbar.json `say` via the server, so the dock
// interstitial and this strip teach the same words. Copy on click; Try it
// stages the phrase into the app's AI thread when the Hydrogen control API is
// reachable, and copies with an honest toast when it is not.
// ---- Settings (settings-layout skill: modal, four dismissals, optimistic saves) ----
// The taskbar captures are native DEVICE pixels (2560-wide display at 150%).
// Show them at naturalWidth / devicePixelRatio so they render 1:1 - the same
// physical size as the taskbar they were cut from - instead of being
// upscaled by the browser on a HiDPI screen (caught by John: "grainy").
function sizeShotsToDevicePixels(){
  const dpr=window.devicePixelRatio||1;
  document.querySelectorAll('#dlgSettings img[width]').forEach(img=>{
    const w=parseInt(img.getAttribute('width'),10);if(!w)return;
    img.style.width=(w/dpr)+'px';img.style.height='auto';img.style.maxWidth='100%';
  });
}
sizeShotsToDevicePixels();window.addEventListener('resize',sizeShotsToDevicePixels);
const SETTINGS_DEFAULTS={activityIndicator:'progress',overlayBadges:true};
const MODE_LABEL={progress:'Progress bar',flash:'Attention flash',both:'Bar and flash',none:'Nothing'};
const dlg=$('#dlgSettings');
// Local mirror: the box's settings file is the truth, but the page reflects a
// click before the round trip and survives a stalled one (skill: local-first).
let settingsView=Object.assign({},SETTINGS_DEFAULTS,JSON.parse(localStorage.getItem('kdSettings')||'{}'));
function rememberSettings(){localStorage.setItem('kdSettings',JSON.stringify(settingsView));}
function renderSettings(){
  const mode=MODE_LABEL[settingsView.activityIndicator]?settingsView.activityIndicator:'progress';
  document.querySelectorAll('#dlgSettings .choice').forEach(c=>{
    const sel=c.dataset.mode===mode;c.dataset.selected=String(sel);c.querySelector('input').checked=sel;});
  $('#stActivity').textContent=MODE_LABEL[mode];$('#stActivity').className='card-status'+(mode!=='none'?' ok':'');
  const on=settingsView.overlayBadges!==false;
  const t=$('#tglOverlay');t.classList.toggle('active',on);t.setAttribute('aria-checked',String(on));
  $('#stOverlay').textContent=on?'on':'off';$('#stOverlay').className='card-status'+(on?' ok':'');
  $('#settingsBox').textContent=curTarget?('on '+curTarget):'';
}
// Bounded, optimistic save: the DOM already shows the click; a failed or
// stalled round trip toasts and reverts to what the box last reported.
async function saveSetting(action,args,revert){
  const ctrl=new AbortController();const timer=setTimeout(()=>ctrl.abort(),5000);
  try{
    const r=await fetch('api/action',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({action,target:curTarget||undefined,args}),signal:ctrl.signal}).then(x=>x.json());
    const d=(r&&r.data)||r||{};
    if(d.error||d.success===false)throw new Error(d.error||'the bridge refused the change');
    if(d.settings){Object.assign(settingsView,d.settings);rememberSettings();renderSettings();}
    return true;
  }catch(e){
    toast('setting not saved: '+(e.name==='AbortError'?'the box did not answer within 5s':e.message),'err');
    if(revert){Object.assign(settingsView,revert);rememberSettings();renderSettings();}
    return false;
  }finally{clearTimeout(timer);}
}
function openSettings(){
  if(lastStatus&&lastStatus.settings&&Object.keys(lastStatus.settings).length){Object.assign(settingsView,lastStatus.settings);rememberSettings();}
  renderSettings();
  dlg.style.margin='';dlg.style.left='';dlg.style.top='';
  dlg.showModal();
  // Open at the TOP: showModal autofocuses the first focusable child, which
  // scrolls to it. Focus the dialog itself and reset scroll, again next frame.
  dlg.focus();const body=$('#settingsBody');body.scrollTop=0;requestAnimationFrame(()=>{body.scrollTop=0;});
}
$('#btnSettings').onclick=openSettings;
$('#btnCloseSettings').onclick=()=>dlg.close();
// Click OUTSIDE closes: the backdrop is part of the dialog element, so gate on
// the pointer being outside the dialog's own box, or padding clicks close it.
dlg.addEventListener('click',e=>{
  if(e.target!==dlg)return;
  const r=dlg.getBoundingClientRect();
  const inside=e.clientX>=r.left&&e.clientX<=r.right&&e.clientY>=r.top&&e.clientY<=r.bottom;
  if(!inside)dlg.close();
});
// Draggable by its header, clamped to the viewport.
(()=>{const head=$('#settingsHead');let drag=null;
  head.addEventListener('pointerdown',e=>{if(e.target.closest('button'))return;
    const r=dlg.getBoundingClientRect();drag={dx:e.clientX-r.left,dy:e.clientY-r.top};head.setPointerCapture(e.pointerId);});
  head.addEventListener('pointermove',e=>{if(!drag)return;
    const r=dlg.getBoundingClientRect();
    const x=Math.max(0,Math.min(e.clientX-drag.dx,innerWidth-r.width));
    const y=Math.max(0,Math.min(e.clientY-drag.dy,innerHeight-r.height));
    dlg.style.margin='0';dlg.style.left=x+'px';dlg.style.top=y+'px';});
  head.addEventListener('pointerup',()=>{drag=null;});head.addEventListener('pointercancel',()=>{drag=null;});
})();
document.querySelectorAll('#dlgSettings .choice input').forEach(inp=>{inp.addEventListener('change',()=>{
  const prev={activityIndicator:settingsView.activityIndicator};
  settingsView.activityIndicator=inp.value;rememberSettings();renderSettings();
  saveSetting('set_activity_indicator',{mode:inp.value},prev).then(ok=>{if(ok)toast('taskbar activity: '+MODE_LABEL[inp.value],'ok');});
});});
$('#tglOverlay').onclick=()=>{
  const prev={overlayBadges:settingsView.overlayBadges!==false};
  const now=!prev.overlayBadges;
  settingsView.overlayBadges=now;rememberSettings();renderSettings();
  saveSetting('set_overlay_badges',{overlayBadges:now},prev).then(ok=>{if(ok)toast('overlay badge '+(now?'on':'off'),'ok');});
};
// Two-click Restore defaults, auto-disarming after 4s. The button IS the confirmation.
(()=>{const b=$('#btnRestore');let armed=false,timer=null;
  const RESTORE='M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18m-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8z';
  const ALERT='M11 15h2v2h-2zm0-8h2v6h-2zm1-5C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8';
  function disarm(){armed=false;b.classList.remove('armed');$('#restoreLabel').textContent='Restore defaults';$('#restoreIcon').setAttribute('d',RESTORE);clearTimeout(timer);}
  b.onclick=()=>{
    if(!armed){armed=true;b.classList.add('armed');$('#restoreLabel').textContent='Click again to confirm';$('#restoreIcon').setAttribute('d',ALERT);timer=setTimeout(disarm,4000);return;}
    disarm();
    const prev=Object.assign({},settingsView);
    Object.assign(settingsView,SETTINGS_DEFAULTS);rememberSettings();renderSettings();
    saveSetting('restore_settings',{},prev).then(ok=>{if(ok)toast('settings restored to defaults','ok');});
  };
})();

// Setup: walk the not-green facets in order, one action per click.
const SETUP_ORDER=[
  {led:'altium',action:'upgrade',label:'Install Altium (silent, per-user, no UAC)'},
  {led:'update',action:'upgrade',label:'Update Altium to the latest release'},
  {led:'plugin',action:'launch',label:'Launch Altium so the reverse-bridge plugin binds'},
];
function setupPending(){if(!lastStatus)return[];return SETUP_ORDER.filter(s=>{const l=lastStatus.leds[s.led];return l&&l.state!=='ok'&&l.state!=='off';});}
function renderSetup(){
  const p=setupPending();
  $('#setupBody').innerHTML=p.length?('Remaining steps:<br>'+p.map((s,i)=>`${i+1}. ${s.label}`).join('<br>')):'Everything is configured. You are ready to go.';
  $('#btnSetupNext').style.display=p.length?'':'none';
}
$('#btnSetup').onclick=async()=>{await refresh(true);renderSetup();$('#dlgSetup').showModal();};
$('#btnCloseSetup').onclick=()=>$('#dlgSetup').close();
$('#btnSetupNext').onclick=async()=>{
  const p=setupPending();if(!p.length)return;
  toast('running: '+p[0].label);
  await act(p[0].action);
  setTimeout(async()=>{await refresh(true);renderSetup();},5000);
};

// Copy-paste AI prompts (teach the user to drive the bridge through their AI).
const PROMPTS=[
  'run the altium demo and show me each step screenshot as it arrives',
  'tour the wiki part aravk/in-s42ata in altium: symbol, footprint, 3d chip, project, schematic, 2d board, 3d board',
  'is altium up to date on all my boxes? update whatever is behind',
  'show me the symbol and footprint for the part I just installed',
  'make a single-component project for this part and show me the 3d board',
  'export gerbers for the open board and tell me the Altium version stamp',
  'are any altium windows in my foreground? prove the bridge stayed in the background',
];
$('#prompts').innerHTML=PROMPTS.map(p=>`<div class="prompt"><code>${p}</code><button class="btn copy" onclick="navigator.clipboard.writeText('${p.replace(/'/g,"\\'")}');toast('copied','ok')">Copy</button></div>`).join('');

// Altium surfaces: the bridge's surface ladder, driven through /api/action {surface}.
// Each card owns its own log line, progress bar and rendered screenshot; the
// server streams `surface` SSE events as it stages, opens and renders.
const SURF_CARDS={};
async function buildSurfaces(){
  const r=await act('surfaces');const d=r.data||r;
  const g=$('#surfaceGrid');g.innerHTML='';
  const part=d.part||{};
  $('#surfaceIntro').innerHTML="A real wiki part, end to end: <a href='https://wiki.adom.inc/"+
    (part.page||'')+"' target='_blank' rel='noopener'>"+(part.name||'the part')+"</a>. "+
    "Each button fetches the part's files from its wiki page, installs them into your real Altium, "+
    "opens JUST that surface in the background, and renders it from altium_state. This is the whole "+
    "circle: a wiki component to the app the EE designs in.";
  const rows=(d.surfaces||[]).concat([{key:'state',label:'Show Altium state',note:'captures every Altium window as a screenshot'}]);
  for(const s of rows){
    const el=document.createElement('div');el.className='card scard';
    el.innerHTML=`<button class="btn" data-tip="${s.note}">${s.label}${s.key!=='state'?' ('+(part.name||'')+')':''}</button>`+
                 `<div class="sbar"><div></div></div><div class="slog">not run yet</div>`+
                 `<div class="sshot"><img alt="${s.label}"></div>`;
    el.querySelector('button').onclick=()=>{
      el.classList.remove('err');
      el.querySelector('.slog').textContent='starting...';
      el.querySelector('.sbar').style.display='block';
      el.querySelector('.sbar>div').style.width='5%';
      act('surface',{surface:s.key});
    };
    SURF_CARDS[s.key]=el;g.appendChild(el);
  }
}
function onSurfaceEvent(e){
  const el=SURF_CARDS[e.surface];if(!el)return;
  const bar=el.querySelector('.sbar');
  if(e.live){ // a real measured frame from the bridge (altium_progress)
    if(e.caller&&!String(e.caller).startsWith('altium-dashboard')&&!gradState().aiDriven){gradSave({aiDriven:true});}
    bar.style.display='block';
    bar.classList.toggle('indet', e.percent==null);          // unknown -> marquee, never 0%
    bar.classList.toggle('dim', e.confidence!=='measured');  // a guess never looks measured
    bar.classList.toggle('amber', !!(e.overdue||e.blockedBy));
    if(e.percent!=null)bar.querySelector('div').style.width=e.percent+'%';
    let lbl=e.stepLabel||'';
    if(e.blockedBy)lbl='waiting on you: '+e.blockedBy;
    else if(e.etaSec!=null&&!e.overdue)lbl+=' · ~'+Math.round(e.etaSec)+'s left';
    else if(e.overdue)lbl+=' · taking longer than usual';
    if(lbl)el.querySelector('.slog').textContent=lbl;
    return;
  }
  bar.classList.remove('indet','dim','amber');
  if(e.log)el.querySelector('.slog').textContent=e.log;
  if(typeof e.percent==='number'){bar.style.display='block';bar.querySelector('div').style.width=e.percent+'%';}
  if(e.status==='error'){el.classList.add('err');el.querySelector('.sbar').style.display='none';toast(e.surface+': '+e.log,'err');}
  if(e.status==='done'){
    el.classList.remove('err');
    setTimeout(()=>{el.querySelector('.sbar').style.display='none';},800);
    if(e.image){const box=el.querySelector('.sshot');box.style.display='block';
      box.querySelector('img').src='data:image/png;base64,'+e.image;}
    toast(e.log,'ok');
  }
}

// Live channel (SSE): state pushes, AI toasts, CLI click directives.
const CLICK_TARGETS={help:'#btnHelp',setup:'#btnSetup',launch:'#btnLaunch',close:'#btnClose',demo:'#btnDemo','part-tour':'#btnPartTour',settings:'#btnSettings',refresh:'#btnRefresh'};
async function loadVerbTimes(){
  const box=document.getElementById('verbTimes');
  box.innerHTML='<div class="note">loading...</div>';
  try{
    const r=await fetch('api/verb_times/'+encodeURIComponent(curTarget||''));
    const d=await r.json();
    const verbs=d.verbs||{};
    const keys=Object.keys(verbs);
    if(!keys.length){box.innerHTML='<div class="note">'+(d.error||'no runs logged yet on '+(d.target||'this box'))+'</div>';return;}
    let h='<table style="width:100%;border-collapse:collapse;font-size:12.5px">'
         +'<tr style="text-align:left;color:var(--text-2)"><th>verb</th><th>count</th><th>p50</th><th>p90</th><th>max</th><th>failures</th></tr>';
    for(const k of keys){const v=verbs[k];
      const slow=(v.p90||0)>3*(v.p50||1)&&(v.p90||0)>10; // two code paths wearing one name
      h+='<tr'+(v.failures?' style="color:#f85149"':(slow?' style="color:#d29922"':''))+'>'
        +'<td style="padding:3px 10px 3px 0">'+k+'</td><td>'+v.count+'</td>'
        +'<td>'+(v.p50==null?'-':v.p50+'s')+'</td><td>'+(v.p90==null?'-':v.p90+'s')+'</td>'
        +'<td>'+(v.max==null?'-':v.max+'s')+'</td><td>'+v.failures+'</td></tr>';}
    box.innerHTML=h+'</table>';
  }catch(e){box.innerHTML='<div class="note">verb times unavailable: '+e+'</div>';}
}
document.getElementById('btnVerbTimes').onclick=loadVerbTimes;

function connectSSE(){
  const es=new EventSource('events');
  es.addEventListener('hello',()=>{console.log('sse connected');});
  es.addEventListener('state',e=>{try{const st=JSON.parse(e.data);if(!curTarget||st.target===curTarget){lastStatus=st;renderLeds(st);}}catch(_){}});
  es.addEventListener('toast',e=>{try{const t=JSON.parse(e.data);toast(t.message,t.kind==='info'?'':t.kind);}catch(_){}});
  es.addEventListener('surface',e=>{try{onSurfaceEvent(JSON.parse(e.data));}catch(_){}});
  es.addEventListener('click',e=>{try{
    const {target}=JSON.parse(e.data);const sel=CLICK_TARGETS[target];
    if(sel&&$(sel)){$(sel).click();}else{toast('unknown click target: '+target,'err');}
  }catch(_){}});
  es.onerror=()=>{es.close();setTimeout(connectSSE,3000);};
}
connectSSE();

// Tooltips: body-appended, fixed, clamped to the viewport on BOTH axes.
const tt=$('#tt');let ttTimer=null;
document.addEventListener('mouseover',e=>{
  const el=e.target.closest('[data-tip]');
  if(!el){clearTimeout(ttTimer);tt.classList.remove('show');return;}
  clearTimeout(ttTimer);
  ttTimer=setTimeout(()=>{
    tt.textContent=el.getAttribute('data-tip');
    tt.style.left='0px';tt.style.top='0px';tt.classList.add('show');
    const r=el.getBoundingClientRect(),M=8,w=tt.offsetWidth,h=tt.offsetHeight;
    let x=r.left+r.width/2-w/2, y=r.bottom+10;
    if(y+h>innerHeight-M) y=r.top-h-10;
    x=Math.max(M,Math.min(x,innerWidth-w-M));
    y=Math.max(M,Math.min(y,innerHeight-h-M));
    tt.style.left=x+'px';tt.style.top=y+'px';
  },600);
});
document.addEventListener('mouseout',e=>{if(e.target.closest('[data-tip]')){clearTimeout(ttTimer);tt.classList.remove('show');}});

// Brand hover popover: app identity lives here, not in the header row.
const ai=$('#appinfo');const brand=document.querySelector('.brand');
let aiShow=null,aiHide=null;
function fillAppInfo(){
  const v=lastStatus?lastStatus.dashboardVersion:'';const bv=lastStatus?(lastStatus.bridgeVersion||'?'):'?';
  ai.innerHTML='<b style="color:var(--text)">Altium Dashboard</b><br>dashboard v'+(v||'?')+' \u00b7 bridge v'+bv+
    '<br>engine: altium-dashboard (Python) + adom-bridge<br>author: adom<br>'+
    '<a href="https://wiki.adom.inc/adom/altium-bridge" target="_blank" rel="noopener">wiki.adom.inc/adom/altium-bridge</a>';
}
brand.addEventListener('click',()=>{fillAppInfo();ai.classList.toggle('show');
  const r=brand.getBoundingClientRect(),M=8;
  ai.style.left=Math.max(M,Math.min(r.left,innerWidth-ai.offsetWidth-M))+'px';
  ai.style.top=Math.min(r.bottom+8,innerHeight-ai.offsetHeight-M)+'px';});
brand.addEventListener('mouseenter',()=>{clearTimeout(aiHide);aiShow=setTimeout(()=>{
  fillAppInfo();ai.classList.add('show');
  const r=brand.getBoundingClientRect(),M=8;
  let x=Math.max(M,Math.min(r.left,innerWidth-ai.offsetWidth-M));
  ai.style.left=x+'px';ai.style.top=Math.min(r.bottom+8,innerHeight-ai.offsetHeight-M)+'px';
},600);});
brand.addEventListener('mouseleave',()=>{clearTimeout(aiShow);aiHide=setTimeout(()=>ai.classList.remove('show'),220);});
ai.addEventListener('mouseenter',()=>clearTimeout(aiHide));
ai.addEventListener('mouseleave',()=>{aiHide=setTimeout(()=>ai.classList.remove('show'),220);});

// FIRST PAINT MUST NOT WAIT ON THE NETWORK (ralph iter-2: /api/targets probes
// every box, so one wedged laptop left the whole page on "connecting..." with
// no LEDs and no pills). Skeleton now, then status and targets INDEPENDENTLY.
// ── newbie-graduation (1.9.1): Fresh -> Graduated -> Recallable ────────────
// Scaffolds fold on dismissal, 3 green launches, or the first AI-driven
// action; the say strip folds LAST (5 launches). The header's ? restores
// everything for this session. Storage is per-app by origin; unreadable
// storage renders Fresh - the safe default is teaching.
const GRAD_KEY='adom-dash-graduation';
function gradState(){try{return JSON.parse(localStorage.getItem(GRAD_KEY))||{}}catch(e){return{}}}
function gradSave(patch){try{localStorage.setItem(GRAD_KEY,JSON.stringify({...gradState(),...patch}))}catch(e){}}
function recalled(){try{return sessionStorage.getItem('adom-dash-recall')==='1'}catch(e){return false}}
// The module owns the panel's open/closed memory and wires the header ?
// itself (we pass toggleButton). Nothing to graduate here any more.
// launch counting: one per page load, when the critical row goes green within 60s
let _launchCounted=false;const _loadT=Date.now();
function maybeCountLaunch(st){
  if(_launchCounted||Date.now()-_loadT>60000)return;
  const l=st.leds||{};
  if(['relay','box','bridge','altium'].every(k=>(l[k]||{}).state==='ok')){
    _launchCounted=true;gradSave({launches:(gradState().launches||0)+1});
  }
}

// ── dash-demo player: toolbar wiring ───────────────────────────────────────
const MUTE_KEY='kdDemoMuted';
function demoMuted(){try{return localStorage.getItem(MUTE_KEY)==='1'}catch(e){return false}}
function setMuteUI(){$('#dtMute').classList.toggle('muted',demoMuted());}
$('#dtMute').onclick=async()=>{
  const m=!demoMuted();try{localStorage.setItem(MUTE_KEY,m?'1':'0')}catch(e){}
  setMuteUI();await act('demo_control',{cmd:m?'mute':'unmute'});
  toast(m?'narration muted':'narration on','ok');
};
$('#dtPlay').onclick=()=>demoCtl($('#dtPlay').dataset.paused==='1'?'play':'pause');
$('#dtPrev').onclick=()=>demoCtl('prev');
$('#dtNext').onclick=()=>demoCtl('next');
$('#dtExit').onclick=()=>demoCtl('exit');
setMuteUI();

// Measure, never assume: report the real viewport of whatever surface we are
// rendered in (ah webview pane, browser, iframe) back to our own server, so
// `GET /api/viewport` can prove which side of the breakpoint we landed on.
(function reportViewport(){
  const send=()=>{try{fetch('api/viewport',{method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({innerWidth:window.innerWidth,innerHeight:window.innerHeight,scrollWidth:document.documentElement.scrollWidth,overflowX:document.documentElement.scrollWidth>window.innerWidth,offenders:(()=>{const vw=window.innerWidth,out=[];document.querySelectorAll('body *').forEach(n=>{const r=n.getBoundingClientRect();if(r.right>vw+1||r.width>vw+1){out.push((n.id?'#'+n.id:(n.className&&n.className.toString().split(' ')[0]?'.'+n.className.toString().split(' ')[0]:n.tagName))+':w'+Math.round(r.width)+',r'+Math.round(r.right));}});return out.slice(0,8)})(),
      dpr:window.devicePixelRatio,twoCol:window.innerWidth>700,
      panelCols:(()=>{const g=document.querySelector('.anp-cols')||document.querySelector('.anp');return g?getComputedStyle(g).gridTemplateColumns:null})(),
      panelOpen:!!document.querySelector('.anp'),
      ariaPressed:document.getElementById('btnHelp')?document.getElementById('btnHelp').getAttribute('aria-pressed'):null,
      helpLabel:document.getElementById('btnHelp')?document.getElementById('btnHelp').getAttribute('aria-label'):null,
      headerH:document.querySelector('header')?Math.round(document.querySelector('header').getBoundingClientRect().height):null,
      headerPadX:document.querySelector('header')?getComputedStyle(document.querySelector('header')).paddingLeft:null,
      truncatedPhrases:[...document.querySelectorAll('.anp-phrase,.p-phrase')].filter(n=>n.scrollWidth>n.clientWidth).length,
      totalPhrases:document.querySelectorAll('.anp-phrase,.p-phrase').length})}).catch(()=>{});}catch(e){}};
  send();window.addEventListener('resize',()=>{clearTimeout(window.__vpT);window.__vpT=setTimeout(send,400);});
})();
renderSkeleton();
buildSay();
buildSurfaces();
refresh();
loadTargets();
setInterval(()=>refresh(),15000);setInterval(loadTargets,60000);
</script>
</body>
</html>