aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes
diff options
context:
space:
mode:
Diffstat (limited to 'docs/prototypes')
-rw-r--r--docs/prototypes/2026-07-02-timer-panel-prototype-1.html693
-rw-r--r--docs/prototypes/2026-07-02-timer-panel-prototype-2.html553
-rw-r--r--docs/prototypes/2026-07-02-timer-panel-prototype-3.html556
-rw-r--r--docs/prototypes/2026-07-03-instrument-console-panels-prototype.html1359
-rw-r--r--docs/prototypes/2026-07-03-net-panel-rescan-prototype.html251
-rw-r--r--docs/prototypes/2026-07-03-sound-panel-prototype.html417
-rw-r--r--docs/prototypes/2026-07-03-waybar-redesign-prototype.html321
-rw-r--r--docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html133
-rw-r--r--docs/prototypes/2026-07-07-maint-console-A-two-column.html335
-rw-r--r--docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html382
-rw-r--r--docs/prototypes/2026-07-07-maint-console-C-severity-feed.html410
-rw-r--r--docs/prototypes/2026-07-07-maint-console-D-doctor-first.html364
-rw-r--r--docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html475
-rw-r--r--docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html443
-rw-r--r--docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html488
-rw-r--r--docs/prototypes/2026-07-07-maint-console-E4-interactive.html546
-rw-r--r--docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html1696
-rw-r--r--docs/prototypes/README.org92
-rw-r--r--docs/prototypes/gallery-tokens.el41
-rw-r--r--docs/prototypes/gallery-widget.el166
-rw-r--r--docs/prototypes/gen_tokens.py184
-rw-r--r--docs/prototypes/panel-widget-gallery.html1616
-rw-r--r--docs/prototypes/reference/slide-toggle.jpgbin0 -> 8023 bytes
-rw-r--r--docs/prototypes/tokens-waybar.css34
-rw-r--r--docs/prototypes/tokens.json46
-rw-r--r--docs/prototypes/widgets.js4491
26 files changed, 16092 insertions, 0 deletions
diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-1.html b/docs/prototypes/2026-07-02-timer-panel-prototype-1.html
new file mode 100644
index 0000000..6b199f9
--- /dev/null
+++ b/docs/prototypes/2026-07-02-timer-panel-prototype-1.html
@@ -0,0 +1,693 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Timer panel — three redesigns · dupre instrument console</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 6rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:1320px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:92ch}
+.masthead p b{color:var(--silver)}
+h2{color:var(--steel);font-size:.8rem;letter-spacing:.22em;text-transform:uppercase;
+ margin:2.6rem 0 .2rem;display:flex;align-items:center;gap:12px}
+h2 .tag{color:var(--panel);background:var(--gold);border-radius:4px;font-size:.62rem;padding:1px 7px;letter-spacing:.12em}
+h2::after{content:"";height:1px;background:var(--wash);flex:1}
+.blurb{color:var(--dim);font-size:.82rem;max-width:90ch;margin:.5rem 0 1.1rem}
+.blurb b{color:var(--steel);font-weight:400}
+.desk{display:flex;justify-content:center;padding:1.2rem 0 .4rem}
+
+/* ---------- faceplate ---------- */
+.panel{width:396px;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:14px;padding:15px 15px 16px;position:relative;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)}
+.panel.wide{width:660px}
+.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase}
+.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em}
+.phead .pcount b{color:var(--cream)}
+
+/* ---------- shared primitives (from the widget gallery) ---------- */
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite}
+@keyframes pulse{50%{opacity:.25}}
+
+.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.key:hover{color:var(--gold);border-color:var(--gold)}
+.key:active{transform:translateY(1px)}
+.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px}
+.key.icon{padding:6px 9px;font-size:14px;line-height:1}
+.key.wide{width:100%;text-align:center;padding:9px}
+
+.chip{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210;
+ border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em}
+.chip:hover{color:var(--silver);border-color:var(--slate)}
+.chip.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.chip .x{color:inherit;opacity:.5;margin-left:5px}
+.chip .x:hover{opacity:1;color:var(--fail)}
+
+.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase}
+.badge.red{background:var(--fail);color:var(--cream)}
+.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+.badge.dim{background:var(--wash);color:var(--steel)}
+
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden}
+.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;
+ padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em}
+.seg button:last-child{border-right:0}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+.seg.vert{flex-direction:column}
+.seg.vert button{border-right:0;border-bottom:1px solid #33302b;padding:8px 10px}
+.seg.vert button:last-child{border-bottom:0}
+
+.engrave{color:var(--steel);font-size:.6rem;letter-spacing:.28em;text-transform:uppercase;
+ display:flex;align-items:center;gap:9px;margin:2px 0}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave::before{max-width:10px}
+.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none}
+
+.readout{color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em}
+.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px;
+ padding:7px 9px;width:100%;outline:none}
+.tin:focus{border-color:var(--gold)}
+.tin::placeholder{color:var(--dim)}
+.tin.bad{border-color:var(--fail);color:var(--fail)}
+
+.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;
+ border-radius:7px;padding:6px 9px}
+.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)}
+
+/* radial ring */
+.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);
+ display:grid;place-items:center;position:relative}
+.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)}
+.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;z-index:1;text-align:center}
+
+/* linear bar */
+.bar{height:8px;background:#0d0f10;border:1px solid #231f18;border-radius:5px;overflow:hidden;position:relative}
+.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));transition:width .25s linear}
+.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+
+/* create strip common */
+.create{margin-top:13px;background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px}
+.create .row{display:flex;gap:7px;align-items:center;margin-top:8px;flex-wrap:wrap}
+.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
+
+/* toast */
+.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5}
+.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;
+ box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease}
+.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)}
+.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+
+/* empty state */
+.empty{color:var(--dim);font-size:12px;text-align:center;padding:18px 6px 12px}
+
+/* =============== A · RACK UNIT =============== */
+.qlist{display:flex;flex-direction:column;gap:8px}
+.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px}
+.qrow.prim{border-color:var(--gold);box-shadow:inset 0 0 0 1px rgba(218,181,61,.25)}
+.qrow.fire{animation:firef .6s ease-in-out 3}
+@keyframes firef{50%{background:rgba(203,107,77,.22)}}
+.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none}
+.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:1px}
+.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}
+.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.16em;text-transform:uppercase}
+.qrow .rd{margin-left:auto;font-size:19px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
+.qrow.paused .rd{color:var(--steel)}
+.qrow .pomo{font-size:.56rem;color:var(--steel);letter-spacing:.1em;text-transform:uppercase}
+.qrow .ctrls{display:flex;gap:5px;flex:none}
+
+/* =============== B · TRANSPORT DECK =============== */
+.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;gap:15px;align-items:center}
+.hero.fire{animation:firef .6s ease-in-out 3}
+.hero .lhs{flex:none}
+.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:6px}
+.hero .htype{display:flex;align-items:center;gap:8px}
+.hero .htype .g{color:var(--gold);font-size:17px}
+.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.hero .hbig{color:var(--cream);font-size:40px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.02em}
+.hero.paused .hbig{color:var(--steel)}
+.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.06em}
+.transport{display:flex;gap:7px;margin-top:2px}
+.tracks{margin-top:11px;display:flex;flex-direction:column;gap:5px}
+.track{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;border:1px solid #1c1a16;cursor:pointer;font-size:12px}
+.track:hover{background:var(--wash)}
+.track.prim{outline:1px solid var(--gold);outline-offset:-1px}
+.track .g{color:var(--gold);font-size:14px;width:16px;text-align:center}
+.track b{color:var(--cream);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.track .trd{margin-left:auto;color:var(--silver);font-variant-numeric:tabular-nums;font-weight:700}
+.track.paused .trd{color:var(--dim)}
+.track .tx{color:var(--dim);font-size:14px;padding:0 2px}
+.track .tx:hover{color:var(--fail)}
+
+/* =============== C · CHANNEL STRIP BOARD =============== */
+.board{display:flex;gap:9px;overflow-x:auto;padding:4px 2px 10px}
+.strip{flex:none;width:96px;background:#141210;border:1px solid #201d17;border-radius:10px;padding:9px 8px;
+ display:flex;flex-direction:column;align-items:center;gap:8px}
+.strip.prim{border-color:var(--gold);box-shadow:inset 0 0 0 1px rgba(218,181,61,.25)}
+.strip.fire{animation:firef .6s ease-in-out 3}
+.strip .stitle{width:100%;display:flex;align-items:center;gap:5px;cursor:pointer}
+.strip .stitle .g{color:var(--gold);font-size:13px}
+.strip .stitle b{color:var(--cream);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.strip .styp{color:var(--dim);font-size:.5rem;letter-spacing:.14em;text-transform:uppercase;width:100%;text-align:left}
+.column{width:26px;height:120px;position:relative;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden}
+.column .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#8a7524,var(--gold));transition:height .25s linear}
+.column.warn .fill{background:linear-gradient(0deg,#a35a3f,var(--fail))}
+.column .cap{position:absolute;left:-2px;right:-2px;height:3px;background:var(--gold-hi);box-shadow:0 0 5px rgba(255,215,95,.6);transition:bottom .25s linear}
+.column.sw .fill{background:linear-gradient(0deg,#3a4a5e,var(--slate-hi));animation:swpulse 1.6s ease-in-out infinite}
+@keyframes swpulse{50%{opacity:.6}}
+.strip .srd{color:var(--cream);font-size:14px;font-weight:700;font-variant-numeric:tabular-nums}
+.strip.paused .srd{color:var(--steel)}
+.strip .skeys{display:flex;gap:4px}
+.strip.addstrip{justify-content:flex-start;width:150px;background:var(--well)}
+
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div class="wrap">
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · timer</div>
+ <h1>Timer panel — three redesigns</h1>
+ <p>Three ways to shape the timer dropdown, all in the shipped instrument-console faceplate language
+ (same tokens, lamps, console keys, engraved labels, and tabular readouts as the net / bt / sound panels).
+ Each is a <b>working prototype over one shared engine</b> that mirrors wtimer + the PanelModel: add / cancel / pause /
+ resume, promote to the bar slot, per-type presets (add and delete chips), freeform entry with the same validation,
+ stopwatch lap + stop-and-save, the soonest-fire queue sort, the 10-item cap, and a real completion + notify on fire.
+ Try each: add a timer, watch one count down and fire, promote a row, pause a stopwatch, delete a preset chip.</p>
+</header>
+
+<h2><span class="tag">A</span> Rack unit — the faithful list</h2>
+<p class="blurb">The closest sibling to the net / audio panels: a vertical stack you scan top-down. Header with the live
+ count and <b>CLEAR ALL</b>; one output-well row per item, soonest-firing on top; each row carries a lamp, glyph, label,
+ the big countdown, and inline pause / promote / cancel keys. Create strip lives at the bottom — pick a type, tap a preset
+ or type a duration, name it, ADD. Safest port of what already shipped.</p>
+<div class="desk"><div class="panel" id="panelA"></div></div>
+
+<h2><span class="tag">B</span> Transport deck — one hero, a track list</h2>
+<p class="blurb">A cassette-transport shape. The <b>primary</b> item (the one in the bar glyph slot) gets a hero readout with a
+ progress ring and chunky transport keys; everything else is a compact track list underneath. Click a track to promote it into
+ the hero seat; the ‹ › keys cycle the primary. Puts the timer you care about front-and-centre, the rest one glance away.</p>
+<div class="desk"><div class="panel" id="panelB"></div></div>
+
+<h2><span class="tag">C</span> Channel-strip board — a mixing desk of timers</h2>
+<p class="blurb">The mixing-console metaphor: every item is a vertical channel strip on a board, its fader draining from the top
+ as time runs out (a stopwatch fills instead, tinted slate). Read all your timers at once like meters on a desk. Click a strip
+ header to promote it; the trailing <b>+ NEW</b> strip is the create surface. The most spatial, most stereo of the three.</p>
+<div class="desk"><div class="panel wide" id="panelC"></div></div>
+
+</div>
+
+<script>
+"use strict";
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+const el = (tag, cls, html) => { const n=document.createElement(tag); if(cls)n.className=cls; if(html!=null)n.innerHTML=html; return n; };
+
+/* nerd-font glyphs (mirrors timer/viewmodel.py GLYPH) */
+const GL = {
+ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}',
+ pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}',
+ play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}'
+};
+
+/* ---------- parsers (mirror parse.py behaviour) ---------- */
+function parseDuration(v){
+ if(v==null) return null;
+ v=String(v).trim().toLowerCase();
+ if(v==='') return null;
+ if(/^\d+$/.test(v)) return parseInt(v,10)*60; // bare number = minutes
+ if(!/^(\s*\d+\s*[hms])+$/.test(v)) return null; // only h/m/s tokens
+ let m, tot=0; const re=/(\d+)\s*([hms])/g;
+ while((m=re.exec(v))) tot += m[2]==='h'?+m[1]*3600 : m[2]==='m'?+m[1]*60 : +m[1];
+ return tot>0?tot:null;
+}
+function resolveAlarm(v, now){
+ v=String(v||'').trim().toLowerCase();
+ if(v.startsWith('+')){ const s=parseDuration(v.slice(1)); return s==null?null:now+s; }
+ if(v==='@hour'||v==='top of hour'){ const d=new Date(now*1000); d.setMinutes(0,0,0); d.setHours(d.getHours()+1); return d.getTime()/1000; }
+ const t=v.match(/^(\d{1,2}):(\d{2})$/);
+ if(t){ const hh=+t[1], mm=+t[2]; if(hh>23||mm>59) return null;
+ const d=new Date(now*1000); d.setHours(hh,mm,0,0); let e=d.getTime()/1000; if(e<=now) e+=86400; return e; }
+ return null;
+}
+function fmtTime(secs){
+ secs=Math.max(0,Math.floor(secs));
+ const h=Math.floor(secs/3600), m=Math.floor((secs%3600)/60), s=secs%60;
+ return h ? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` : `${m}:${String(s).padStart(2,'0')}`;
+}
+function fmtClock(epoch){ const d=new Date(epoch*1000); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; }
+
+/* ---------- default presets (mirror panel._default_presets) ---------- */
+const DEFAULT_PRESETS = () => ({
+ timer:[{label:'5m',value:'5m'},{label:'25m',value:'25m'},{label:'10m',value:'10m'},
+ {label:'15m',value:'15m'},{label:'30m',value:'30m'},{label:'60m',value:'60m'},{label:'2h',value:'2h'}],
+ alarm:[{label:'+30m',value:'+30m'},{label:'top of hour',value:'@hour'},{label:'07:00',value:'07:00'}],
+ pomodoro:[{label:'default cycle',value:''}],
+ stopwatch:[]
+});
+const TYPES=['timer','alarm','stopwatch','pomodoro'];
+const COUNTDOWN=['timer','alarm','pomodoro'];
+const MAX=10;
+const POMO={work:25*60, short:5*60, long:15*60, interval:4};
+
+/* ---------- the engine (mirrors PanelModel + wtimer state) ---------- */
+class Engine{
+ constructor(){ this.items=[]; this.seq=0; this.primary=null; this.presets=DEFAULT_PRESETS(); this.onEvent=()=>{}; }
+ now(){ return Date.now()/1000; }
+ count(){ return this.items.length; }
+ full(){ return this.items.length>=MAX; }
+
+ add(type,value,label){
+ if(this.full()) return {ok:false,reason:`queue full (${MAX}/${MAX})`};
+ const now=this.now(); this.seq++; const id='t'+this.seq;
+ const it={id,type,label:label||''};
+ if(type==='timer'){ const s=parseDuration(value); if(s==null) return {ok:false,reason:`bad duration: “${value}”`}; it.target=now+s; it.total=s; }
+ else if(type==='alarm'){ const e=resolveAlarm(value,now); if(e==null) return {ok:false,reason:`bad time: “${value}”`}; it.target=e; it.total=Math.max(1,e-now); }
+ else if(type==='pomodoro'){ it.phase='work'; it.cycle=1; it.interval=POMO.interval; it.target=now+POMO.work; it.total=POMO.work; }
+ else if(type==='stopwatch'){ it.start=now; it.laps=[]; }
+ else return {ok:false,reason:`unknown type: ${type}`};
+ this.items.push(it); if(!this.primary) this.primary=id;
+ return {ok:true, id};
+ }
+ find(id){ return this.items.find(i=>i.id===id); }
+ isPaused(it){ return it.type==='stopwatch' ? it.paused_elapsed!=null : it.paused_left!=null; }
+ remaining(it,ref){
+ ref=ref==null?this.now():ref;
+ if(it.type==='stopwatch') return this.isPaused(it)? it.paused_elapsed : ref-it.start;
+ return this.isPaused(it)? it.paused_left : it.target-ref;
+ }
+ toggle(id){ const it=this.find(id); if(!it) return; const now=this.now();
+ if(it.type==='stopwatch'){
+ if(this.isPaused(it)){ it.start=now-it.paused_elapsed; it.paused_elapsed=null; }
+ else it.paused_elapsed=now-it.start;
+ } else {
+ if(this.isPaused(it)){ it.target=now+it.paused_left; it.paused_left=null; }
+ else it.paused_left=it.target-now;
+ }
+ }
+ cancel(id){ const i=this.items.findIndex(x=>x.id===id); if(i<0) return; this.items.splice(i,1);
+ if(this.primary===id) this.primary=null; }
+ cancelAll(){ this.items=[]; this.primary=null; }
+ promote(id){ if(this.find(id)) this.primary=id; }
+ cycle(dir){ const ids=this.items.map(i=>i.id); if(!ids.length) return;
+ let cur=ids.indexOf(this.effectivePrimary()); cur=cur<0?0:cur;
+ const n=dir==='prev' ? (cur-1+ids.length)%ids.length : (cur+1)%ids.length;
+ this.primary=ids[n]; }
+ lap(id,name){ const it=this.find(id); if(!it||it.type!=='stopwatch') return;
+ it.laps.push({t:this.remaining(it), name:name||''}); }
+ stopSave(id){ const it=this.find(id); if(!it||it.type!=='stopwatch') return null;
+ const run={label:it.label||'run', total:this.remaining(it), laps:it.laps.slice()}; this.cancel(id); return run; }
+
+ effectivePrimary(){
+ const items=this.items; if(!items.length) return null;
+ const ids=items.map(i=>i.id);
+ if(ids.includes(this.primary)) return this.primary;
+ const now=this.now();
+ const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i));
+ if(acd.length) return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id;
+ const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));
+ if(asw.length) return asw[0].id;
+ return ids[0];
+ }
+ /* 4-bucket sort: active countdown < paused countdown < active sw < paused sw */
+ sortKey(it){ const now=this.now(), paused=this.isPaused(it), sw=it.type==='stopwatch', rem=this.remaining(it,now);
+ return sw ? [paused?3:2, -rem, +it.id.slice(1)] : [paused?1:0, rem, +it.id.slice(1)]; }
+ rows(){ const prim=this.effectivePrimary(); const now=this.now();
+ return this.items.slice().sort((a,b)=>{ const ka=this.sortKey(a),kb=this.sortKey(b);
+ for(let i=0;i<ka.length;i++){ if(ka[i]<kb[i])return -1; if(ka[i]>kb[i])return 1; } return 0; })
+ .map(it=>this.row(it,prim,now)); }
+ row(it,prim,now){
+ const rem=this.remaining(it,now), paused=this.isPaused(it);
+ let disp, sub='', warn=false, prog=null, glyph;
+ if(it.type==='alarm'){ disp=fmtClock(it.target); sub='at '+fmtClock(it.target); prog=Math.max(0,Math.min(1,rem/it.total)); glyph=GL.alarm; }
+ else if(it.type==='pomodoro'){ disp=fmtTime(rem); sub=`${it.phase} · cycle ${it.cycle}/${it.interval}`; prog=Math.max(0,Math.min(1,rem/it.total));
+ glyph=(it.phase==='work')?GL.pomo_work:GL.pomo_break; }
+ else if(it.type==='stopwatch'){ disp=fmtTime(rem); sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running'; glyph=GL.stopwatch; }
+ else { disp=fmtTime(rem); sub='timer'; prog=Math.max(0,Math.min(1,rem/it.total)); glyph=GL.timer; }
+ if(prog!=null && rem<=Math.min(30, it.total*0.15)) warn=true;
+ if(paused) glyph=GL.paused;
+ return {id:it.id, type:it.type, glyph, label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type],
+ typeLabel:it.type, disp, sub, paused, primary:it.id===prim, prog, warn, laps:it.laps?it.laps.length:0};
+ }
+
+ /* advance fired items; returns list of fire events for the view to flash/notify */
+ tick(){
+ const now=this.now(); const fired=[];
+ for(const it of this.items.slice()){
+ if(!COUNTDOWN.includes(it.type) || this.isPaused(it)) continue;
+ if(it.target-now>0) continue;
+ if(it.type==='pomodoro'){
+ if(it.phase==='work'){
+ const isLong = it.cycle % it.interval === 0;
+ fired.push({id:it.id, kind:'pomo', title:`Pomodoro · ${isLong?'long':'short'} break`, body:it.label||`cycle ${it.cycle}`});
+ it.phase = isLong?'long':'short'; const len = isLong?POMO.long:POMO.short; it.target=now+len; it.total=len;
+ } else if(it.phase==='long'){
+ fired.push({id:it.id, kind:'done', title:'Pomodoro complete', body:it.label||`${it.interval} cycles done`});
+ this.cancel(it.id);
+ } else { // short break over → next work
+ it.cycle+=1; fired.push({id:it.id, kind:'pomo', title:'Pomodoro · back to work', body:it.label||`cycle ${it.cycle}`});
+ it.phase='work'; it.target=now+POMO.work; it.total=POMO.work;
+ }
+ } else {
+ fired.push({id:it.id, kind:'done', title:(it.type==='alarm'?'Alarm':'Timer')+' · '+(it.label|| (it.type==='alarm'?fmtClock(it.target):'done')), body:'time’s up'});
+ this.cancel(it.id);
+ }
+ }
+ return fired;
+ }
+ /* presets */
+ presetsFor(t){ return (this.presets[t]||[]).map(p=>({...p})); }
+ addPreset(t,label,value){ if(!TYPES.includes(t)) return {ok:false,reason:'bad type'};
+ if(t==='timer' && parseDuration(value)==null) return {ok:false,reason:'bad duration'};
+ (this.presets[t]||(this.presets[t]=[])).push({label,value}); return {ok:true}; }
+ deletePreset(t,label){ const a=this.presets[t]||[]; const i=a.findIndex(p=>p.label===label); if(i<0) return {ok:false}; a.splice(i,1); return {ok:true}; }
+}
+
+/* ---------- browser notification (best-effort, mirrors the notify path) ---------- */
+let notifPerm = (typeof Notification!=='undefined') ? Notification.permission : 'denied';
+function tryNotify(title, body){
+ if(typeof Notification==='undefined') return;
+ if(notifPerm==='granted'){ try{ new Notification(title,{body}); }catch(e){} }
+ else if(notifPerm==='default'){ Notification.requestPermission().then(p=>notifPerm=p); }
+}
+
+/* ---------- toast helper ---------- */
+function toaster(host){
+ const wrap=el('div','toasts'); host.appendChild(wrap);
+ return (msg,kind)=>{ const t=el('div','toast'+(kind?' '+kind:''),msg); wrap.appendChild(t);
+ setTimeout(()=>{ t.style.transition='opacity .3s'; t.style.opacity='0'; setTimeout(()=>t.remove(),300); }, 2600); };
+}
+
+/* ---------- create-strip controller (shared by all three views) ---------- */
+function makeCreate(engine, toast, rerender, opts){
+ opts=opts||{};
+ const box=el('div','create');
+ const seg=el('div','seg');
+ TYPES.forEach(t=>{ const b=el('button',t==='timer'?'on':'', t[0].toUpperCase()+t.slice(1)); b.dataset.t=t; seg.appendChild(b); });
+ const chips=el('div','chips');
+ const row=el('div','row');
+ const val=el('input','tin'); val.placeholder='5m · 1h30m · 90s'; val.style.flex='2';
+ const lab=el('input','tin'); lab.placeholder='label (optional)'; lab.style.flex='2';
+ const addk=el('button','key on', GL.add+' ADD'); addk.style.flex='1';
+ row.append(val,lab,addk);
+ box.append(seg,chips,row);
+
+ let selType='timer';
+ function paintChips(){
+ chips.innerHTML='';
+ engine.presetsFor(selType).forEach(p=>{
+ const c=el('span','chip', p.label + (opts.editablePresets?` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`:''));
+ c.dataset.val=p.value; chips.appendChild(c);
+ });
+ if(opts.editablePresets){ const c=el('span','chip','+ chip'); c.dataset.newchip='1'; c.style.opacity='.7'; chips.appendChild(c); }
+ // type-specific value affordance
+ val.disabled = (selType==='stopwatch'||selType==='pomodoro');
+ val.placeholder = selType==='alarm' ? 'HH:MM · +30m · @hour'
+ : selType==='stopwatch' ? 'no value — just ADD'
+ : selType==='pomodoro' ? 'default 25/5 cycle' : '5m · 1h30m · 90s';
+ if(val.disabled) val.value='';
+ }
+ seg.addEventListener('click', e=>{ const b=e.target.closest('button'); if(!b) return;
+ selType=b.dataset.t; [...seg.children].forEach(x=>x.classList.toggle('on',x===b)); val.classList.remove('bad'); paintChips(); });
+ chips.addEventListener('click', e=>{
+ const del=e.target.closest('[data-del]');
+ if(del){ engine.deletePreset(selType, decodeURIComponent(del.dataset.del)); paintChips(); toast('preset removed','gold'); return; }
+ if(e.target.closest('[data-newchip]')){
+ const lb=prompt('Chip label (e.g. 45m):'); if(!lb) return;
+ let vv=lb; if(selType==='timer'||selType==='alarm'){ vv=prompt('Value for “'+lb+'” (e.g. 45m):', lb)||lb; }
+ const r=engine.addPreset(selType, lb, vv); paintChips(); toast(r.ok?'preset added':('preset: '+r.reason), r.ok?'gold':'red'); return;
+ }
+ const c=e.target.closest('.chip'); if(!c||c.dataset.val==null) return;
+ val.classList.remove('bad'); val.value=c.dataset.val;
+ doAdd();
+ });
+ function doAdd(){
+ const r=engine.add(selType, val.value, lab.value.trim());
+ if(!r.ok){ val.classList.add('bad'); toast(r.reason,'red'); return; }
+ val.classList.remove('bad'); if(!val.disabled) val.value=''; lab.value='';
+ toast('added '+selType, 'gold'); rerender();
+ }
+ addk.addEventListener('click', doAdd);
+ val.addEventListener('keydown', e=>{ if(e.key==='Enter') doAdd(); });
+ lab.addEventListener('keydown', e=>{ if(e.key==='Enter') doAdd(); });
+ paintChips();
+ return box;
+}
+
+/* =================================================================== */
+/* VIEW A — RACK UNIT */
+/* =================================================================== */
+function mountRack(host, engine){
+ const toast=toaster(host);
+ const head=el('div','phead',
+ `<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm', GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px';
+ head.appendChild(clear);
+ clear.addEventListener('click', ()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); });
+ const list=el('div','qlist');
+ host.append(head,list);
+ const create=makeCreate(engine, toast, ()=>render(), {editablePresets:true});
+ host.appendChild(create);
+
+ const flashing=new Set();
+ list.addEventListener('click', e=>{
+ const b=e.target.closest('[data-act]'); if(!b) return;
+ const id=b.dataset.id, act=b.dataset.act;
+ if(act==='toggle') engine.toggle(id);
+ else if(act==='promote') engine.promote(id);
+ else if(act==='cancel'){
+ if(b.dataset.armed){ engine.cancel(id); toast('cancelled'); }
+ else { b.dataset.armed='1'; b.classList.add('armed'); b.textContent='sure?'; setTimeout(()=>{ if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;} },2000); return; }
+ }
+ else if(act==='lap'){ engine.lap(id); toast('lap recorded'); }
+ else if(act==='stop'){ const run=engine.stopSave(id); if(run) toast(`saved “${run.label}” · ${run.laps.length} laps → org`,'gold'); }
+ render();
+ });
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows();
+ list.innerHTML='';
+ if(!rows.length){ list.appendChild(el('div','empty','No timers running — pick a type below and ADD.')); return; }
+ rows.forEach(r=>{
+ const row=el('div','qrow'+(r.primary?' prim':'')+(r.paused?' paused':'')+(flashing.has(r.id)?' fire':''));
+ const ctrls = r.type==='stopwatch'
+ ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button>
+ <button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button>`
+ : `<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button>`;
+ row.innerHTML=
+ `<span class="lamp ${r.paused?'off':(r.primary?'gold':(r.warn?'red':''))}"></span>
+ <span class="g">${r.glyph}</span>
+ <span class="meta"><b>${r.label}</b><span class="ty">${r.sub}</span></span>
+ <span class="rd">${r.disp}</span>
+ <span class="ctrls">
+ ${ctrls}
+ <button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot" ${r.primary?'disabled style=opacity:.4':''}>${GL.promote}</button>
+ <button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button>
+ </span>`;
+ list.appendChild(row);
+ });
+ }
+ engine._render=render;
+ engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>{flashing.delete(id);},1800); };
+ engine._toast=toast;
+ render();
+}
+
+/* =================================================================== */
+/* VIEW B — TRANSPORT DECK */
+/* =================================================================== */
+function mountTransport(host, engine){
+ const toast=toaster(host);
+ const head=el('div','phead',
+ `<span class="brand">Timer · Transport</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm',GL.clear+' CLEAR'); clear.style.marginLeft='8px';
+ clear.addEventListener('click',()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); });
+ head.appendChild(clear);
+ const hero=el('div','hero');
+ const tracks=el('div','tracks');
+ host.append(head,hero,tracks);
+ const create=makeCreate(engine,toast,()=>render(),{editablePresets:true});
+ host.appendChild(create);
+
+ const flashing=new Set();
+ function act(fn){ return e=>{ fn(); render(); }; }
+ hero.addEventListener('click', e=>{ const b=e.target.closest('[data-act]'); if(!b) return; const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');}
+ else if(a==='cycle')engine.cycle(b.dataset.dir); else if(a==='lap'){engine.lap(id);toast('lap');}
+ else if(a==='stop'){const r=engine.stopSave(id); if(r)toast(`saved “${r.label}” · ${r.laps.length} laps`,'gold');}
+ render(); });
+ tracks.addEventListener('click', e=>{
+ const x=e.target.closest('[data-cancel]'); if(x){ engine.cancel(x.dataset.cancel); toast('cancelled'); render(); return; }
+ const t=e.target.closest('[data-id]'); if(t){ engine.promote(t.dataset.id); render(); } });
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows(); const primId=engine.effectivePrimary();
+ // hero = the primary row
+ const h = rows.find(r=>r.id===primId);
+ hero.className='hero'+(h&&h.paused?' paused':'')+(h&&flashing.has(h.id)?' fire':'');
+ if(!h){ hero.innerHTML='<div class="empty" style="width:100%">No timers — add one below to load the deck.</div>'; }
+ else {
+ const ringP = h.prog!=null ? Math.round(h.prog*100) : (h.type==='stopwatch'? 100 : 0);
+ const ringInner = h.type==='stopwatch'
+ ? `<b style="color:var(--slate-hi);font-size:11px">SW</b>`
+ : `<b style="color:var(--cream);font-size:15px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b>`;
+ const transport = h.type==='stopwatch'
+ ? `<button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button>
+ <button class="key" data-act="lap" data-id="${h.id}">LAP</button>
+ <button class="key red" data-act="stop" data-id="${h.id}">STOP · SAVE</button>`
+ : `<button class="key icon" data-act="cycle" data-dir="prev">${'‹'}</button>
+ <button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button>
+ <button class="key red icon" data-act="cancel" data-id="${h.id}">${GL.cancel}</button>
+ <button class="key icon" data-act="cycle" data-dir="next">${'›'}</button>`;
+ hero.innerHTML=
+ `<div class="lhs"><span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:88px;height:88px">${ringInner}</span></div>
+ <div class="rhs">
+ <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span>
+ ${h.primary?'<span class="badge">BAR SLOT</span>':''}</div>
+ <div class="hlabel">${h.label}</div>
+ <div class="hbig">${h.disp}</div>
+ <div class="hsub">${h.sub}</div>
+ <div class="transport">${transport}</div>
+ </div>`;
+ }
+ // track list = everything except the hero
+ tracks.innerHTML='';
+ const rest=rows.filter(r=>r.id!==primId);
+ if(rest.length){ tracks.appendChild(el('div','engrave','up next <span class="cnt">· '+rest.length+'</span>')); }
+ rest.forEach(r=>{
+ const t=el('div','track'+(r.paused?' paused':''),
+ `<span class="g">${r.glyph}</span><b>${r.label}</b>
+ <span class="trd">${r.disp}</span>
+ <span class="tx" data-cancel="${r.id}" title="cancel">${GL.cancel}</span>`);
+ t.dataset.id=r.id; tracks.appendChild(t);
+ });
+ }
+ engine._render=render;
+ engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>flashing.delete(id),1800); };
+ engine._toast=toast;
+ render();
+}
+
+/* =================================================================== */
+/* VIEW C — CHANNEL STRIP BOARD */
+/* =================================================================== */
+function mountBoard(host, engine){
+ const toast=toaster(host);
+ const head=el('div','phead',
+ `<span class="brand">Timer · Board</span><span class="pcount">channels <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm',GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px';
+ clear.addEventListener('click',()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); });
+ head.appendChild(clear);
+ const board=el('div','board');
+ host.append(head,board);
+
+ // create controls live in the trailing add-strip; build once, reuse the shared controller inside it
+ const addStrip=el('div','strip addstrip');
+ const create=makeCreate(engine,toast,()=>render(),{editablePresets:true});
+ create.style.margin='0'; create.style.background='transparent'; create.style.border='0'; create.style.padding='0'; create.style.width='100%';
+ addStrip.append(el('div','styp','+ new channel'), create);
+
+ board.addEventListener('click', e=>{
+ const b=e.target.closest('[data-act]');
+ if(b){ const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');}
+ else if(a==='lap'){engine.lap(id);toast('lap');} else if(a==='stop'){const r=engine.stopSave(id); if(r)toast(`saved “${r.label}”`,'gold');}
+ render(); return; }
+ const h=e.target.closest('[data-promote]'); if(h){ engine.promote(h.dataset.promote); render(); }
+ });
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows();
+ board.innerHTML='';
+ rows.forEach(r=>{
+ const strip=el('div','strip'+(r.primary?' prim':'')+(r.paused?' paused':''));
+ const pct = r.prog!=null ? Math.round(r.prog*100) : 100;
+ const colCls = 'column'+(r.type==='stopwatch'?' sw':'')+(r.warn?' warn':'');
+ const fillH = r.type==='stopwatch' ? 100 : pct;
+ const keys = r.type==='stopwatch'
+ ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button>
+ <button class="key sm red" data-act="stop" data-id="${r.id}">${GL.cancel}</button>`
+ : `<button class="key icon" data-act="toggle" data-id="${r.id}">${r.paused?GL.play:GL.paused}</button>
+ <button class="key icon red" data-act="cancel" data-id="${r.id}">${GL.cancel}</button>`;
+ strip.innerHTML=
+ `<div class="stitle" data-promote="${r.id}" title="promote to bar slot">
+ <span class="g">${r.glyph}</span><b>${r.label}</b></div>
+ <div class="styp">${r.typeLabel}${r.primary?' · bar':''}</div>
+ <div class="${colCls}"><div class="fill" style="height:${fillH}%"></div>
+ ${r.type!=='stopwatch'?`<div class="cap" style="bottom:${fillH}%"></div>`:''}</div>
+ <div class="srd">${r.disp}</div>
+ <div class="skeys">${keys}</div>`;
+ board.appendChild(strip);
+ });
+ board.appendChild(addStrip);
+ }
+ engine._render=render;
+ engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>flashing.delete(id),1800); };
+ const flashing=new Set();
+ engine._flashSet=flashing;
+ engine._toast=toast;
+ render();
+}
+
+/* ---------- seed + wire the three panels ---------- */
+function seed(engine){
+ engine.add('pomodoro','', 'Deep work');
+ engine.add('timer','5m','Tea');
+ engine.add('timer','45s','Egg'); // fires ~45s in, demonstrates completion + notify
+ const sw=engine.add('stopwatch','','Debug run'); engine.lap(sw.id);
+ engine.add('alarm','@hour','Standup');
+}
+
+const engines=[];
+function boot(){
+ const A=new Engine(), B=new Engine(), C=new Engine();
+ seed(A); seed(B); seed(C);
+ mountRack(document.getElementById('panelA'), A);
+ mountTransport(document.getElementById('panelB'), B);
+ mountBoard(document.getElementById('panelC'), C);
+ engines.push(A,B,C);
+}
+boot();
+
+/* ---------- global tick: fire timers, flash + notify, re-render ---------- */
+function loop(){
+ for(const e of engines){
+ const fired=e.tick();
+ for(const f of fired){
+ e._flash && e._flash(f.id);
+ if(e._flashSet) e._flashSet.add(f.id), setTimeout(()=>e._flashSet.delete(f.id),1800);
+ e._toast && e._toast((f.kind==='done'?GL.alarm+' ':'')+f.title, f.kind==='done'?'red':'gold');
+ tryNotify(f.title, f.body);
+ }
+ e._render && e._render();
+ }
+}
+setInterval(loop, reduced?1000:250);
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-2.html b/docs/prototypes/2026-07-02-timer-panel-prototype-2.html
new file mode 100644
index 0000000..ffd4521
--- /dev/null
+++ b/docs/prototypes/2026-07-02-timer-panel-prototype-2.html
@@ -0,0 +1,553 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Timer panel — hero + rack (iteration 2) · dupre instrument console</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:1100px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:92ch}
+.masthead p b{color:var(--silver)}
+.cols{display:flex;gap:34px;align-items:flex-start;margin-top:1.6rem;flex-wrap:wrap}
+.side{flex:1;min-width:300px}
+.side h2{color:var(--steel);font-size:.74rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem;
+ display:flex;align-items:center;gap:10px}
+.side h2::after{content:"";height:1px;background:var(--wash);flex:1}
+.side ul{list-style:none;font-size:.8rem;color:var(--dim);display:flex;flex-direction:column;gap:7px}
+.side li{display:flex;gap:9px}
+.side li::before{content:"›";color:var(--gold);flex:none}
+.side li b{color:var(--silver);font-weight:400}
+
+/* ---------- faceplate ---------- */
+.panel{width:420px;flex:none;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:14px;padding:15px;position:relative;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)}
+.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase}
+.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em}
+.phead .pcount b{color:var(--cream)}
+
+/* ---------- primitives ---------- */
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite}
+@keyframes pulse{50%{opacity:.25}}
+
+.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.key:hover{color:var(--gold);border-color:var(--gold)}
+.key:active{transform:translateY(1px)}
+.key:disabled{opacity:.4;cursor:default}
+.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px}
+.key.icon{padding:6px 9px;font-size:14px;line-height:1}
+
+.chip{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210;
+ border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em;display:inline-flex;align-items:center}
+.chip:hover{color:var(--silver);border-color:var(--slate)}
+.chip.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.chip.lock{padding-right:10px}
+.chip .x{color:inherit;opacity:.5;margin-left:6px;font-size:13px}
+.chip .x:hover{opacity:1;color:var(--fail)}
+
+.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase}
+.badge.red{background:var(--fail);color:var(--cream)}
+.badge.dim{background:var(--wash);color:var(--steel)}
+.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden}
+.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;
+ padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em}
+.seg button:last-child{border-right:0}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+
+.switch{width:38px;height:20px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);position:relative;cursor:pointer;flex:none}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}
+.switch.on::after{left:20px;background:var(--gold)}
+
+.engrave{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;
+ display:flex;align-items:center;gap:9px;margin:2px 0}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave::before{max-width:10px}
+.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none}
+
+.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px;
+ padding:7px 9px;width:100%;outline:none}
+.tin:focus{border-color:var(--gold)}
+.tin::placeholder{color:var(--dim)}
+.tin.bad{border-color:var(--fail);color:var(--fail)}
+.tin:disabled{opacity:.45}
+.numin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:6px;
+ padding:5px 4px;width:46px;text-align:center;outline:none;font-variant-numeric:tabular-nums}
+.numin:focus{border-color:var(--gold)}
+
+.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;
+ border-radius:7px;padding:6px 9px}
+.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)}
+
+.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);display:grid;place-items:center;position:relative}
+.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)}
+.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;z-index:1;text-align:center}
+
+.dots{display:flex;gap:4px;align-items:center}
+.dots i{width:7px;height:7px;border-radius:50%;background:var(--wash);flex:none}
+.dots i.on{background:var(--steel)}
+.dots i.now{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.dots i.long{outline:1px solid var(--gold);outline-offset:1px}
+
+/* ---------- HERO (top) ---------- */
+.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;gap:15px;align-items:center;margin-bottom:12px}
+.hero.fire{animation:firef .6s ease-in-out 3}
+@keyframes firef{50%{background:rgba(203,107,77,.22)}}
+.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:5px}
+.hero .htype{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.hero .htype .g{color:var(--gold);font-size:17px}
+.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.hero .hbig{color:var(--cream);font-size:38px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums}
+.hero.paused .hbig{color:var(--steel)}
+.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.05em}
+.transport{display:flex;gap:7px;margin-top:3px;flex-wrap:wrap}
+
+/* ---------- CREATE (middle) ---------- */
+.create{background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px;margin-bottom:12px}
+.create .row{display:flex;gap:7px;align-items:center;margin-top:9px;flex-wrap:wrap}
+.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px}
+.cfg{margin-top:9px;display:flex;flex-direction:column;gap:7px}
+.cfg .crow{display:flex;align-items:center;gap:8px}
+.cfg .crow .lbl{width:58px;color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;flex:none}
+.cfg .crow .u{color:var(--dim);font-size:10px}
+.cfg .crow .sl{color:var(--steel);font-size:.58rem;letter-spacing:.1em;text-transform:uppercase;width:9px}
+
+/* ---------- LIST (bottom) ---------- */
+.qlist{display:flex;flex-direction:column;gap:8px}
+.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px}
+.qrow.fire{animation:firef .6s ease-in-out 3}
+.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none}
+.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:2px}
+.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px}
+.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase}
+.qrow .rd{margin-left:auto;font-size:18px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
+.qrow.paused .rd{color:var(--steel)}
+.qrow .ctrls{display:flex;gap:5px;flex:none}
+.empty{color:var(--dim);font-size:12px;text-align:center;padding:14px 6px}
+
+/* toast */
+.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5}
+.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease}
+.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)}
+.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div class="wrap">
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · timer · iteration 2</div>
+ <h1>Timer panel — hero + rack</h1>
+ <p>The rack unit reshaped: the <b>hero</b> from the transport deck rides on top (the primary / bar-slot item, big),
+ the <b>create strip</b> sits under it, and the <b>queue list</b> runs below. Pomodoro is now a real configurable cycle —
+ work and rest each with a short and a long duration, a long break every N cycles, auto-advance, and progress dots —
+ with its default preset locked so it can't be deleted. Everything is live: add, count down, fire + notify, pause, promote,
+ lap / stop-save. Ideas pulled from Pomofocus, Todoist, and the classic technique (see the notes column).</p>
+</header>
+
+<div class="cols">
+ <div class="panel" id="panel"></div>
+ <div class="side">
+ <h2>What changed this pass</h2>
+ <ul>
+ <li><b>Layout flipped:</b> hero on top → create strip → list (was list → create).</li>
+ <li><b>Pomodoro is configurable:</b> Work short/long, Rest short/long, long break every N, auto-advance toggle.</li>
+ <li><b>Deep cycle:</b> every Nth pomodoro uses the long work + long rest; the rest fill mark the long dots.</li>
+ <li><b>Default cycle is locked</b> — shipped presets have no ×; only chips you add are deletable.</li>
+ <li><b>Cycle dots</b> in the hero + row show where you are in the set.</li>
+ </ul>
+ <h2 style="margin-top:1.6rem">Borrowed from good pomodoro apps</h2>
+ <ul>
+ <li><b>Pomofocus:</b> separate work / short-break / long-break lengths + long-break interval.</li>
+ <li><b>Auto-start next</b> (Pomofocus, Pomodo): auto-advance rolls into the next phase; off = wait and press start.</li>
+ <li><b>Todoist / the technique:</b> long break of 15–30m after 4 pomodoros; all durations adjustable.</li>
+ <li><b>Preset cycles:</b> Classic 25/5/15, Deep 50/10/30, Sprint 15/3/10 — one tap loads the fields.</li>
+ <li><b>Task label</b> on every item; cycle progress shown as dots.</li>
+ </ul>
+ </div>
+</div>
+
+</div>
+
+<script>
+"use strict";
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+const el=(t,c,h)=>{const n=document.createElement(t);if(c)n.className=c;if(h!=null)n.innerHTML=h;return n;};
+
+const GL={ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}',
+ pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}',
+ play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}' };
+
+/* ---- parsers ---- */
+function parseDuration(v){
+ if(v==null) return null; v=String(v).trim().toLowerCase(); if(v==='') return null;
+ if(/^\d+$/.test(v)) return parseInt(v,10)*60;
+ if(!/^(\s*\d+\s*[hms])+$/.test(v)) return null;
+ let m,tot=0; const re=/(\d+)\s*([hms])/g;
+ while((m=re.exec(v))) tot+= m[2]==='h'?+m[1]*3600 : m[2]==='m'?+m[1]*60 : +m[1];
+ return tot>0?tot:null;
+}
+function resolveAlarm(v,now){
+ v=String(v||'').trim().toLowerCase();
+ if(v.startsWith('+')){const s=parseDuration(v.slice(1));return s==null?null:now+s;}
+ if(v==='@hour'||v==='top of hour'){const d=new Date(now*1000);d.setMinutes(0,0,0);d.setHours(d.getHours()+1);return d.getTime()/1000;}
+ const t=v.match(/^(\d{1,2}):(\d{2})$/);
+ if(t){const hh=+t[1],mm=+t[2];if(hh>23||mm>59)return null;const d=new Date(now*1000);d.setHours(hh,mm,0,0);let e=d.getTime()/1000;if(e<=now)e+=86400;return e;}
+ return null;
+}
+const fmtTime=s=>{s=Math.max(0,Math.floor(s));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60;
+ return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;};
+const fmtClock=e=>{const d=new Date(e*1000);return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;};
+
+/* ---- presets: shipped defaults are locked (no delete) ---- */
+const DEFAULT_PRESETS=()=>({
+ timer:[{label:'5m',value:'5m',locked:true},{label:'25m',value:'25m',locked:true},{label:'10m',value:'10m',locked:true},
+ {label:'15m',value:'15m',locked:true},{label:'30m',value:'30m',locked:true},{label:'60m',value:'60m',locked:true},{label:'2h',value:'2h',locked:true}],
+ alarm:[{label:'+30m',value:'+30m',locked:true},{label:'top of hour',value:'@hour',locked:true},{label:'07:00',value:'07:00',locked:true}],
+ stopwatch:[]
+});
+/* pomodoro preset = a full config (minutes). "Classic" is the default cycle — locked. */
+const POMO_PRESETS=[
+ {label:'Classic', ws:25, wl:50, rs:5, rl:15, iv:4, locked:true},
+ {label:'Deep', ws:50, wl:50, rs:10, rl:30, iv:3, locked:true},
+ {label:'Sprint', ws:15, wl:25, rs:3, rl:10, iv:4, locked:true},
+];
+const POMO_DEFAULT={ws:25,wl:50,rs:5,rl:15,iv:4,auto:true};
+
+const TYPES=['timer','alarm','stopwatch','pomodoro'];
+const COUNTDOWN=['timer','alarm','pomodoro'];
+const MAX=10;
+
+class Engine{
+ constructor(){ this.items=[]; this.seq=0; this.primary=null;
+ this.presets=DEFAULT_PRESETS(); this.pomoPresets=POMO_PRESETS.map(p=>({...p})); }
+ now(){return Date.now()/1000;}
+ count(){return this.items.length;}
+ full(){return this.items.length>=MAX;}
+ add(type,value,label,cfg){
+ if(this.full()) return {ok:false,reason:`queue full (${MAX}/${MAX})`};
+ const now=this.now(); this.seq++; const id='t'+this.seq; const it={id,type,label:label||''};
+ if(type==='timer'){const s=parseDuration(value);if(s==null)return {ok:false,reason:`bad duration: “${value}”`};it.target=now+s;it.total=s;}
+ else if(type==='alarm'){const e=resolveAlarm(value,now);if(e==null)return {ok:false,reason:`bad time: “${value}”`};it.target=e;it.total=Math.max(1,e-now);}
+ else if(type==='pomodoro'){
+ const c=cfg||POMO_DEFAULT;
+ it.cfg={ws:c.ws*60,wl:c.wl*60,rs:c.rs*60,rl:c.rl*60,iv:Math.max(1,c.iv),auto:c.auto!==false};
+ it.phase='work'; it.cycle=1; const deep=(1%it.cfg.iv===0);
+ const len=deep?it.cfg.wl:it.cfg.ws; it.target=now+len; it.total=len;
+ }
+ else if(type==='stopwatch'){it.start=now;it.laps=[];}
+ else return {ok:false,reason:`unknown type: ${type}`};
+ this.items.push(it); if(!this.primary) this.primary=id; return {ok:true,id};
+ }
+ find(id){return this.items.find(i=>i.id===id);}
+ isPaused(it){return it.type==='stopwatch'?it.paused_elapsed!=null:it.paused_left!=null;}
+ remaining(it,ref){ref=ref==null?this.now():ref;
+ if(it.type==='stopwatch')return this.isPaused(it)?it.paused_elapsed:ref-it.start;
+ return this.isPaused(it)?it.paused_left:it.target-ref;}
+ toggle(id){const it=this.find(id);if(!it)return;const now=this.now();
+ if(it.type==='stopwatch'){ if(this.isPaused(it)){it.start=now-it.paused_elapsed;it.paused_elapsed=null;} else it.paused_elapsed=now-it.start; }
+ else { if(this.isPaused(it)){it.target=now+it.paused_left;it.paused_left=null;it.awaiting=false;} else it.paused_left=it.target-now; } }
+ cancel(id){const i=this.items.findIndex(x=>x.id===id);if(i<0)return;this.items.splice(i,1);if(this.primary===id)this.primary=null;}
+ cancelAll(){this.items=[];this.primary=null;}
+ promote(id){if(this.find(id))this.primary=id;}
+ cycle(dir){const ids=this.items.map(i=>i.id);if(!ids.length)return;let c=ids.indexOf(this.effectivePrimary());c=c<0?0:c;
+ this.primary=ids[dir==='prev'?(c-1+ids.length)%ids.length:(c+1)%ids.length];}
+ lap(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return;it.laps.push({t:this.remaining(it)});}
+ stopSave(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return null;const run={label:it.label||'run',total:this.remaining(it),laps:it.laps.slice()};this.cancel(id);return run;}
+ effectivePrimary(){const items=this.items;if(!items.length)return null;const ids=items.map(i=>i.id);
+ if(ids.includes(this.primary))return this.primary;const now=this.now();
+ const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i));
+ if(acd.length)return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id;
+ const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));if(asw.length)return asw[0].id;return ids[0];}
+ sortKey(it){const now=this.now(),p=this.isPaused(it),sw=it.type==='stopwatch',r=this.remaining(it,now);
+ return sw?[p?3:2,-r,+it.id.slice(1)]:[p?1:0,r,+it.id.slice(1)];}
+ rows(){const prim=this.effectivePrimary(),now=this.now();
+ return this.items.slice().sort((a,b)=>{const ka=this.sortKey(a),kb=this.sortKey(b);
+ for(let i=0;i<ka.length;i++){if(ka[i]<kb[i])return -1;if(ka[i]>kb[i])return 1;}return 0;}).map(it=>this.row(it,prim,now));}
+ row(it,prim,now){
+ const rem=this.remaining(it,now),paused=this.isPaused(it);
+ let disp,sub='',warn=false,prog=null,glyph,pomo=null;
+ if(it.type==='alarm'){disp=fmtClock(it.target);sub='fires '+fmtClock(it.target);prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.alarm;}
+ else if(it.type==='pomodoro'){
+ disp=fmtTime(rem); prog=Math.max(0,Math.min(1,rem/it.total));
+ const deep=(it.cycle%it.cfg.iv===0);
+ const phLabel = it.phase==='work' ? (deep?'long work':'work')
+ : it.phase==='rest' ? ((it.cycle%it.cfg.iv===0)?'long break':'short break') : it.phase;
+ sub = it.awaiting ? `ready · start ${it.phase==='work'?'work':'break'}` : `${phLabel} · cycle ${it.cycle}/${it.cfg.iv}`;
+ glyph = it.phase==='work' ? GL.pomo_work : GL.pomo_break;
+ pomo={cycle:it.cycle, iv:it.cfg.iv, phase:it.phase, awaiting:!!it.awaiting, deep};
+ }
+ else if(it.type==='stopwatch'){disp=fmtTime(rem);sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running';glyph=GL.stopwatch;}
+ else {disp=fmtTime(rem);sub='timer';prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.timer;}
+ if(prog!=null && rem<=Math.min(30,it.total*0.15)) warn=true;
+ if(paused) glyph=GL.paused;
+ return {id:it.id,type:it.type,glyph,label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type],
+ typeLabel:it.type,disp,sub,paused,primary:it.id===prim,prog,warn,pomo,laps:it.laps?it.laps.length:0};
+ }
+ tick(){
+ const now=this.now(),fired=[];
+ for(const it of this.items.slice()){
+ if(!COUNTDOWN.includes(it.type)||this.isPaused(it))continue;
+ if(it.target-now>0)continue;
+ if(it.type==='pomodoro'){
+ const c=it.cfg;
+ if(it.phase==='work'){
+ const deep=(it.cycle%c.iv===0);
+ fired.push({id:it.id,kind:'pomo',title:`Pomodoro · ${deep?'long':'short'} break`,body:it.label||`cycle ${it.cycle}`});
+ it.phase='rest'; const len=deep?c.rl:c.rs; it.total=len;
+ if(c.auto){ it.target=now+len; } else { it.paused_left=len; it.awaiting=true; }
+ } else { // rest over → next work
+ it.cycle+=1; const deep=(it.cycle%c.iv===0);
+ fired.push({id:it.id,kind:'pomo',title:'Pomodoro · back to work',body:it.label||`cycle ${it.cycle}`});
+ it.phase='work'; const len=deep?c.wl:c.ws; it.total=len;
+ if(c.auto){ it.target=now+len; } else { it.paused_left=len; it.awaiting=true; }
+ }
+ } else {
+ fired.push({id:it.id,kind:'done',title:(it.type==='alarm'?'Alarm':'Timer')+' · '+(it.label||(it.type==='alarm'?fmtClock(it.target):'done')),body:'time’s up'});
+ this.cancel(it.id);
+ }
+ }
+ return fired;
+ }
+ presetsFor(t){return (this.presets[t]||[]).map(p=>({...p}));}
+ addPreset(t,label,value){if(!TYPES.includes(t)||t==='pomodoro'||t==='stopwatch')return {ok:false,reason:'no custom chip here'};
+ if(t==='timer'&&parseDuration(value)==null)return {ok:false,reason:'bad duration'};
+ (this.presets[t]||(this.presets[t]=[])).push({label,value,locked:false});return {ok:true};}
+ deletePreset(t,label){const a=this.presets[t]||[];const i=a.findIndex(p=>p.label===label);
+ if(i<0)return {ok:false,reason:'not found'}; if(a[i].locked)return {ok:false,reason:'default — locked'};
+ a.splice(i,1);return {ok:true};}
+}
+
+/* ---- notifications ---- */
+let notifPerm=(typeof Notification!=='undefined')?Notification.permission:'denied';
+function tryNotify(title,body){ if(typeof Notification==='undefined')return;
+ if(notifPerm==='granted'){try{new Notification(title,{body});}catch(e){}}
+ else if(notifPerm==='default'){Notification.requestPermission().then(p=>notifPerm=p);} }
+function toaster(host){const wrap=el('div','toasts');host.appendChild(wrap);
+ return (msg,kind)=>{const t=el('div','toast'+(kind?' '+kind:''),msg);wrap.appendChild(t);
+ setTimeout(()=>{t.style.transition='opacity .3s';t.style.opacity='0';setTimeout(()=>t.remove(),300);},2600);};}
+
+function dotsHTML(p){ if(!p)return ''; let h='<span class="dots">'; const pos=(p.cycle-1)%p.iv;
+ for(let i=0;i<p.iv;i++){ const isLong=(i===p.iv-1); let cls=''; if(i<pos)cls='on'; if(i===pos)cls='now'; if(isLong)cls+=' long';
+ h+=`<i class="${cls.trim()}"></i>`; } return h+'</span>'; }
+
+/* =================================================================== */
+/* THE PANEL: hero (top) · create (middle) · list (bottom) */
+/* =================================================================== */
+function mount(host, engine){
+ const toast=toaster(host);
+ const head=el('div','phead',`<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm',GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px';
+ clear.addEventListener('click',()=>{if(!engine.count())return;engine.cancelAll();toast('cleared all');render();});
+ head.appendChild(clear);
+ const hero=el('div','hero');
+ const create=buildCreate();
+ const list=el('div','qlist');
+ host.append(head,hero,create.box,list);
+
+ const flashing=new Set();
+
+ /* ---- hero + list interactions ---- */
+ hero.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');}
+ else if(a==='cycle')engine.cycle(b.dataset.dir); else if(a==='lap'){engine.lap(id);toast('lap');}
+ else if(a==='stop'){const r=engine.stopSave(id);if(r)toast(`saved “${r.label}” · ${r.laps.length} laps → org`,'gold');}
+ render();});
+ list.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id); else if(a==='promote'){engine.promote(id);toast('to bar slot');}
+ else if(a==='lap'){engine.lap(id);toast('lap recorded');} else if(a==='stop'){const r=engine.stopSave(id);if(r)toast(`saved “${r.label}” · ${r.laps.length} laps → org`,'gold');}
+ else if(a==='cancel'){ if(b.dataset.armed){engine.cancel(id);toast('cancelled');}
+ else{b.dataset.armed='1';b.classList.add('armed');b.textContent='sure?';setTimeout(()=>{if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;}},2000);return;} }
+ render();});
+
+ /* ---- create strip (swaps body by type) ---- */
+ function buildCreate(){
+ const box=el('div','create');
+ const seg=el('div','seg');
+ TYPES.forEach(t=>{const b=el('button',t==='timer'?'on':'',t[0].toUpperCase()+t.slice(1));b.dataset.t=t;seg.appendChild(b);});
+ const body=el('div');
+ box.append(seg,body);
+ let selType='timer';
+ // shared value/label/add controls (rebuilt per type)
+ seg.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
+ selType=b.dataset.t;[...seg.children].forEach(x=>x.classList.toggle('on',x===b));paintBody();});
+ function labelAdd(hasVal){
+ const row=el('div','row');
+ let val=null;
+ if(hasVal){ val=el('input','tin'); val.placeholder='5m · 1h30m · 90s'; val.style.flex='2'; }
+ const lab=el('input','tin'); lab.placeholder='label (optional)'; lab.style.flex='2';
+ const addk=el('button','key on',GL.add+' ADD'); addk.style.flex='1';
+ if(val) row.append(val); row.append(lab,addk); return {row,val,lab,addk};
+ }
+ function paintBody(){
+ body.innerHTML='';
+ if(selType==='timer'||selType==='alarm'){
+ const chips=el('div','chips');
+ engine.presetsFor(selType).forEach(p=>{
+ const c=el('span','chip'+(p.locked?' lock':''), p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));
+ c.dataset.val=p.value; chips.appendChild(c);
+ });
+ const addch=el('span','chip','+ chip'); addch.dataset.newchip='1'; addch.style.opacity='.7'; chips.appendChild(addch);
+ const {row,val,lab,addk}=labelAdd(true);
+ val.placeholder = selType==='alarm' ? 'HH:MM · +30m · @hour' : '5m · 1h30m · 90s';
+ body.append(chips,row);
+ chips.addEventListener('click',e=>{
+ const del=e.target.closest('[data-del]');
+ if(del){const r=engine.deletePreset(selType,decodeURIComponent(del.dataset.del));toast(r.ok?'chip removed':('chip: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ if(e.target.closest('[data-newchip]')){const lb=prompt('Chip label:');if(!lb)return;
+ const vv=prompt('Value for “'+lb+'”:',lb)||lb;const r=engine.addPreset(selType,lb,vv);toast(r.ok?'chip added':('chip: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ const c=e.target.closest('.chip');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;doAdd(selType,val,lab);
+ });
+ addk.addEventListener('click',()=>doAdd(selType,val,lab));
+ [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd(selType,val,lab);}));
+ }
+ else if(selType==='stopwatch'){
+ const {row,lab,addk}=labelAdd(false); // no time entry — stopwatches count up from zero
+ body.append(row);
+ addk.addEventListener('click',()=>doAdd('stopwatch',null,lab));
+ lab.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('stopwatch',null,lab);});
+ }
+ else { // pomodoro config
+ const chips=el('div','chips');
+ engine.pomoPresets.forEach(p=>{const c=el('span','chip lock',p.label);c.dataset.pp=p.label;chips.appendChild(c);});
+ const cfg=el('div','cfg');
+ const mk=(v)=>{const i=el('input','numin');i.value=v;i.inputMode='numeric';return i;};
+ const ws=mk(POMO_DEFAULT.ws),wl=mk(POMO_DEFAULT.wl),rs=mk(POMO_DEFAULT.rs),rl=mk(POMO_DEFAULT.rl),iv=mk(POMO_DEFAULT.iv);
+ const auto=el('span','switch on'); auto.dataset.on='1';
+ const rW=el('div','crow'); rW.append(el('span','lbl','Work'), el('span','sl','S'), ws, el('span','sl','L'), wl, el('span','u','min'));
+ const rR=el('div','crow'); rR.append(el('span','lbl','Rest'), el('span','sl','S'), rs, el('span','sl','L'), rl, el('span','u','min'));
+ const rI=el('div','crow'); rI.append(el('span','lbl','Long ev.'), iv, el('span','u','cycles → long work + long break'));
+ const rA=el('div','crow'); rA.append(el('span','lbl','Auto'), auto, el('span','u','advance into the next phase'));
+ cfg.append(rW,rR,rI,rA);
+ const {row,lab,addk}=labelAdd(false); lab.placeholder='label (optional)'; // pomodoro has no single value entry — config fields above
+ addk.innerHTML=GL.add+' ADD CYCLE';
+ body.append(chips,cfg,row);
+ auto.addEventListener('click',()=>{auto.classList.toggle('on');auto.dataset.on=auto.classList.contains('on')?'1':'';});
+ chips.addEventListener('click',e=>{const c=e.target.closest('[data-pp]');if(!c)return;
+ const p=engine.pomoPresets.find(x=>x.label===c.dataset.pp);if(!p)return;
+ ws.value=p.ws;wl.value=p.wl;rs.value=p.rs;rl.value=p.rl;iv.value=p.iv;
+ [...chips.children].forEach(x=>x.classList.toggle('on',x===c));toast('loaded “'+p.label+'”','gold');});
+ function pnum(inp,d){const n=parseInt(inp.value,10);return (isNaN(n)||n<1)?d:n;}
+ addk.addEventListener('click',()=>{
+ const cfgv={ws:pnum(ws,25),wl:pnum(wl,50),rs:pnum(rs,5),rl:pnum(rl,15),iv:pnum(iv,4),auto:!!auto.dataset.on};
+ const r=engine.add('pomodoro','',lab.value.trim(),cfgv);
+ if(!r.ok){toast(r.reason,'red');return;} lab.value=''; toast('pomodoro added','gold'); render();
+ });
+ lab.addEventListener('keydown',e=>{if(e.key==='Enter')addk.click();});
+ }
+ }
+ function doAdd(type,val,lab){
+ const r=engine.add(type,val?val.value:'',lab.value.trim());
+ if(!r.ok){if(val)val.classList.add('bad');toast(r.reason,'red');return;}
+ if(val){val.classList.remove('bad'); if(!val.disabled)val.value='';} lab.value='';
+ toast('added '+type,'gold'); render();
+ }
+ paintBody();
+ return {box};
+ }
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows(), primId=engine.effectivePrimary();
+ // HERO = primary
+ const h=rows.find(r=>r.id===primId);
+ hero.className='hero'+(h&&h.paused?' paused':'')+(h&&flashing.has(h.id)?' fire':'');
+ if(!h){ hero.innerHTML='<div class="empty" style="width:100%">No timers running — add one below.</div>'; }
+ else {
+ const ringP=h.prog!=null?Math.round(h.prog*100):(h.type==='stopwatch'?100:0);
+ const inner=h.type==='stopwatch'?`<b style="color:var(--slate-hi);font-size:11px">SW</b>`
+ :`<b style="color:var(--cream);font-size:14px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b>`;
+ const startLabel = h.pomo&&h.pomo.awaiting ? (GL.play+' START '+(h.pomo.phase==='work'?'WORK':'BREAK')) : (h.paused?GL.play+' RESUME':GL.paused+' PAUSE');
+ const transport = h.type==='stopwatch'
+ ? `<button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button>
+ <button class="key" data-act="lap" data-id="${h.id}">LAP</button>
+ <button class="key red" data-act="stop" data-id="${h.id}">STOP · SAVE</button>`
+ : `<button class="key icon" data-act="cycle" data-dir="prev" title="prev primary">‹</button>
+ <button class="key" data-act="toggle" data-id="${h.id}">${startLabel}</button>
+ <button class="key red icon" data-act="cancel" data-id="${h.id}" title="cancel">${GL.cancel}</button>
+ <button class="key icon" data-act="cycle" data-dir="next" title="next primary">›</button>`;
+ hero.innerHTML=
+ `<div><span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:86px;height:86px">${inner}</span></div>
+ <div class="rhs">
+ <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span>
+ <span class="badge">BAR SLOT</span>${h.pomo?dotsHTML(h.pomo):''}</div>
+ <div class="hlabel">${h.label}</div>
+ <div class="hbig">${h.disp}</div>
+ <div class="hsub">${h.sub}</div>
+ <div class="transport">${transport}</div>
+ </div>`;
+ }
+ // LIST = the rest
+ list.innerHTML='';
+ const rest=rows.filter(r=>r.id!==primId);
+ if(!rest.length){ list.appendChild(el('div','empty', h?'Only the hero is queued — add more below.':'')); }
+ list.appendChild(el('div','engrave','queue <span class="cnt">· '+rest.length+'</span>'));
+ rest.forEach(r=>{
+ const row=el('div','qrow'+(r.paused?' paused':'')+(flashing.has(r.id)?' fire':''));
+ const ctrls = r.type==='stopwatch'
+ ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button>
+ <button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button>`
+ : `<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button>`;
+ row.innerHTML=
+ `<span class="lamp ${r.paused?'off':(r.warn?'red':'')}"></span>
+ <span class="g">${r.glyph}</span>
+ <span class="meta"><b>${r.label}</b><span class="ty">${r.sub}${r.pomo?' ':''}</span></span>
+ <span class="rd">${r.disp}</span>
+ <span class="ctrls">${ctrls}
+ <button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button>
+ <button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button></span>`;
+ list.appendChild(row);
+ });
+ }
+ engine._render=render; engine._flash=id=>{flashing.add(id);setTimeout(()=>flashing.delete(id),1800);}; engine._toast=toast;
+ render();
+}
+
+/* ---- seed ---- */
+const engine=new Engine();
+const pomo=engine.add('pomodoro','', 'Deep work', {ws:25,wl:50,rs:5,rl:15,iv:4,auto:true});
+engine.add('timer','45s','Egg');
+engine.add('timer','5m','Tea');
+const sw=engine.add('stopwatch','','Debug run'); engine.lap(sw.id);
+engine.add('alarm','@hour','Standup');
+engine.promote(pomo.id); // show the pomodoro in the hero
+mount(document.getElementById('panel'), engine);
+
+/* ---- global tick ---- */
+function loop(){
+ const fired=engine.tick();
+ for(const f of fired){ engine._flash(f.id);
+ engine._toast((f.kind==='done'?GL.alarm+' ':'')+f.title, f.kind==='done'?'red':'gold'); tryNotify(f.title,f.body); }
+ engine._render();
+}
+setInterval(loop, reduced?1000:250);
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-3.html b/docs/prototypes/2026-07-02-timer-panel-prototype-3.html
new file mode 100644
index 0000000..98778fa
--- /dev/null
+++ b/docs/prototypes/2026-07-02-timer-panel-prototype-3.html
@@ -0,0 +1,556 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Timer panel — iteration 3 (waybar + hero-right) · dupre instrument console</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; --sage:#8a9a5b;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:1120px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:94ch}
+.masthead p b{color:var(--silver)}
+.cols{display:flex;gap:34px;align-items:flex-start;margin-top:1.4rem;flex-wrap:wrap}
+.side{flex:1;min-width:300px}
+.side h2{color:var(--steel);font-size:.74rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem;display:flex;align-items:center;gap:10px}
+.side h2::after{content:"";height:1px;background:var(--wash);flex:1}
+.side ul{list-style:none;font-size:.8rem;color:var(--dim);display:flex;flex-direction:column;gap:7px}
+.side li{display:flex;gap:9px}.side li::before{content:"›";color:var(--gold);flex:none}
+.side li b{color:var(--silver);font-weight:400}
+
+/* ---------- waybar preview ---------- */
+.barcap{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;margin-bottom:7px;display:flex;align-items:center;gap:9px}
+.barcap::after{content:"";height:1px;background:var(--wash);flex:1}
+.wbar{width:420px;background:linear-gradient(180deg,#141312,#0e0d0c);border:1px solid #262320;border-radius:16px;
+ padding:6px 10px;display:flex;align-items:center;gap:8px;box-shadow:0 8px 20px rgba(0,0,0,.5);margin-bottom:6px}
+.wbar .fillspace{flex:1;color:var(--dim);font-size:10px;letter-spacing:.1em;padding-left:4px}
+.wmod{display:inline-flex;align-items:center;gap:7px;color:var(--silver);background:transparent;border:1.5px solid var(--gold);
+ border-radius:14px;padding:4px 12px;cursor:pointer;font-size:12.5px;white-space:nowrap;min-width:78px;justify-content:center}
+.wmod:hover{background:var(--wash)}
+.wmod .wg{font-size:16px;line-height:1}
+.wmod .wt{font-variant-numeric:tabular-nums;font-weight:700}
+.wmod .wp{color:var(--dim);font-size:11px}
+.wmod.urgent{color:var(--fail)} .wmod.paused{color:var(--dim)}
+.wmod.pomodoro-work{color:var(--gold)} .wmod.pomodoro-break{color:var(--sage)}
+.wmod.idle{color:var(--silver);min-width:0;border-color:#4a463c}
+.wtip{width:420px;background:var(--well);border:1px solid #201d17;border-radius:8px;padding:7px 10px;font-size:11px;color:var(--dim);margin-bottom:16px}
+.wtip .th{color:var(--steel);letter-spacing:.14em;text-transform:uppercase;font-size:.56rem;margin-bottom:3px}
+.wtip .tl{color:var(--silver);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+
+/* ---------- faceplate ---------- */
+.panel{width:420px;flex:none;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:14px;padding:15px;position:relative;box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)}
+.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase}
+.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em}
+.phead .pcount b{color:var(--cream)}
+.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem;cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto}
+.x-btn:hover{background:var(--wash);color:var(--silver)}
+.panel.closed{display:none}
+.wbar.reopen{outline:1px dashed var(--slate);outline-offset:2px}
+
+/* ---------- primitives ---------- */
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.key:hover{color:var(--gold);border-color:var(--gold)}.key:active{transform:translateY(1px)}.key:disabled{opacity:.4;cursor:default}
+.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px}
+.key.icon{padding:6px 9px;font-size:14px;line-height:1}
+.preset{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210;border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em;display:inline-flex;align-items:center}
+.preset:hover{color:var(--silver);border-color:var(--slate)}
+.preset.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.preset .x{color:inherit;opacity:.5;margin-left:6px;font-size:13px}.preset .x:hover{opacity:1;color:var(--fail)}
+.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase}
+.badge.red{background:var(--fail);color:var(--cream)}.badge.dim{background:var(--wash);color:var(--steel)}.badge.sage{background:var(--sage);color:var(--panel)}
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden}
+.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em}
+.seg button:last-child{border-right:0}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+.switch{width:38px;height:20px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);position:relative;cursor:pointer;flex:none}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}.switch.on::after{left:20px;background:var(--gold)}
+.engrave{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;display:flex;align-items:center;gap:9px;margin:2px 0}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}.engrave::before{max-width:10px}.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none}
+.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px;padding:7px 9px;width:100%;outline:none}
+.tin:focus{border-color:var(--gold)}.tin::placeholder{color:var(--dim)}.tin.bad{border-color:var(--fail);color:var(--fail)}
+.numin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:6px;padding:5px 4px;width:46px;text-align:center;outline:none;font-variant-numeric:tabular-nums}
+.numin:focus{border-color:var(--gold)}
+@keyframes fieldflash{0%{border-color:var(--gold-hi);background:rgba(218,181,61,.22)}100%{border-color:#231f18;background:#0d0f10}}
+.tin.flash,.numin.flash{animation:fieldflash .7s ease}
+.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;border-radius:7px;padding:6px 9px}
+.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)}
+.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);display:grid;place-items:center;position:relative}
+.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)}
+.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;z-index:1;text-align:center}
+.dots{display:flex;gap:4px;align-items:center}
+.dots i{width:7px;height:7px;border-radius:50%;background:var(--wash);flex:none}
+.dots i.on{background:var(--steel)}.dots i.now{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}.dots i.long{outline:1px solid var(--gold);outline-offset:1px}
+.days7{display:flex;gap:4px}
+.days7 button{font:inherit;font-size:10px;width:22px;height:22px;border-radius:50%;border:1px solid #33302b;background:#141210;color:var(--dim);cursor:pointer;padding:0}
+.days7 button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);border-color:var(--gold-hi);font-weight:700}
+
+/* ---------- HERO (info left, donut right) ---------- */
+.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;flex-direction:column;gap:13px;margin-bottom:12px}
+.hero .htop{display:flex;gap:15px;align-items:center}
+.hero.fire{animation:firef .6s ease-in-out 3}
+.hero.ringing{animation:ringf .9s ease-in-out infinite;border-color:var(--fail)}
+@keyframes firef{50%{background:rgba(203,107,77,.22)}}
+@keyframes ringf{50%{background:rgba(203,107,77,.16)}}
+.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:5px}
+.hero .htype{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.hero .htype .g{color:var(--gold);font-size:17px}
+.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.hero .hbig{color:var(--cream);font-size:38px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums;display:flex;align-items:center;gap:11px}
+.hero.paused .hbig{color:var(--steel)}
+.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.05em}
+.hero .lapbadge{font-size:11px;letter-spacing:.12em;color:var(--silver);border:1px solid var(--slate);border-radius:6px;
+ padding:2px 8px;font-weight:400;font-variant-numeric:tabular-nums;background:transparent;line-height:1;align-self:center}
+/* stopwatch sweep dial — analog second-hand, one revolution per minute */
+.swdial{width:86px;height:86px;border-radius:50%;background:var(--well);border:1px solid #201d17;position:relative;display:block}
+.swdial::before{content:"";position:absolute;inset:7px;border-radius:50%;border:2px solid var(--wash)}
+.swtick{position:absolute;top:5px;left:50%;width:2px;height:8px;margin-left:-1px;background:var(--steel);border-radius:1px;transform-origin:50% 38px}
+.swtick.q{transform:rotate(90deg)}.swtick.h{transform:rotate(180deg)}.swtick.t{transform:rotate(270deg)}
+.swhand{position:absolute;left:calc(50% - 1px);bottom:50%;width:2px;height:31px;background:var(--gold-hi);
+ transform-origin:50% 100%;border-radius:1px;box-shadow:0 0 5px rgba(255,215,95,.5)}
+.swhub{position:absolute;left:50%;top:50%;width:9px;height:9px;margin:-4.5px 0 0 -4.5px;border-radius:50%;
+ background:var(--gold);box-shadow:0 0 0 2px var(--well),0 0 6px rgba(218,181,61,.5)}
+.hero .donut{flex:none}
+.transport{display:flex;gap:7px;flex-wrap:wrap;justify-content:flex-start}
+
+/* ---------- CREATE (middle) ---------- */
+.create{background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px;margin-bottom:12px}
+.create .row{display:flex;gap:7px;align-items:center;margin-top:9px;flex-wrap:wrap}
+.presets{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px}
+.cfg{margin-top:9px;display:flex;flex-direction:column;gap:7px}
+.cfg .crow{display:flex;align-items:center;gap:8px}
+.cfg .crow .lbl{width:58px;color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;flex:none}
+.cfg .crow .u{color:var(--dim);font-size:10px}
+.cfg .crow .sl{color:var(--steel);font-size:.58rem;letter-spacing:.1em;text-transform:uppercase;width:9px}
+
+/* ---------- LIST (bottom) ---------- */
+.qlist{display:flex;flex-direction:column;gap:8px}
+.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px}
+.qrow.fire{animation:firef .6s ease-in-out 3}.qrow.ringing{animation:ringf .9s ease-in-out infinite;border-color:var(--fail)}
+.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none}
+.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:2px}
+.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:104px}
+.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.1em;text-transform:uppercase}
+.qrow .rd{margin-left:auto;font-size:18px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
+.qrow.paused .rd{color:var(--steel)}
+.qrow .ctrls{display:flex;gap:5px;flex:none}
+.empty{color:var(--dim);font-size:12px;text-align:center;padding:14px 6px}
+.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5}
+.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease}
+.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)}.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div class="wrap">
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · timer · iteration 3</div>
+ <h1>Timer panel — waybar + hero-right</h1>
+ <p>Third pass. The <b>hero donut moved to the right</b> of the readout; the redundant "bar slot" badge is gone
+ (the hero <i>is</i> the bar slot). Above the panel sits a <b>live, accurate preview of the actual waybar module</b> —
+ the same glyph + countdown + "+N" and state colours <code>wtimer render</code> emits, with its hover tooltip.
+ Presets (renamed from "chips") <b>flash the fields</b> on load instead of toasting, alarms gain a <b>half-past</b> preset,
+ and each type picked up create-strip ideas from its category's best apps: timers <b>repeat</b>, alarms carry
+ <b>recurring days + snooze</b> (with a ringing state), pomodoro keeps its configurable cycle.</p>
+</header>
+
+<div class="cols">
+ <div>
+ <div class="barcap">the waybar module · live</div>
+ <div class="wbar" id="wbar"></div>
+ <div class="wtip" id="wtip"></div>
+ <div class="panel" id="panel"></div>
+ </div>
+ <div class="side">
+ <h2>This pass</h2>
+ <ul>
+ <li><b>Donut on the right</b> of the hero info; <b>no "bar slot" label</b>.</li>
+ <li><b>Live waybar preview</b> — mirrors <code>wtimer render</code>: glyph + countdown + "+N", state colour, tooltip.</li>
+ <li><b>"Preset"</b> replaces "chip"; loading one <b>flashes the field(s)</b>, no toast.</li>
+ <li><b>Half-past</b> alarm preset — next X:30, the sibling of top-of-hour's X:00.</li>
+ </ul>
+ <h2 style="margin-top:1.5rem">Borrowed per category</h2>
+ <ul>
+ <li><b>Timer</b> (MultiTimer, Multi Timer): auto-<b>repeat</b> — restart on finish. Toggle in the create row.</li>
+ <li><b>Alarm</b> (Alarm Clock Xtreme, Alarmy): <b>recurring weekdays</b> + <b>snooze</b>; fires into a ringing state with SNOOZE / DISMISS.</li>
+ <li><b>Stopwatch</b> (Stopwatch Timer): sweep dial + infinite <b>laps</b> with the last lap beside the count; run-save deferred to a vNext.</li>
+ <li><b>Pomodoro</b> (Pomofocus): configurable work/rest short+long, long-break interval, auto-advance, cycle dots.</li>
+ </ul>
+ </div>
+</div>
+</div>
+
+<script>
+"use strict";
+const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
+const el=(t,c,h)=>{const n=document.createElement(t);if(c)n.className=c;if(h!=null)n.innerHTML=h;return n;};
+
+const GL={ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}', idle:'\u{F051B}',
+ pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}',
+ play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}', repeat:'\u{F0456}', bell:'\u{F0020}' };
+
+function parseDuration(v){ if(v==null)return null;v=String(v).trim().toLowerCase();if(v==='')return null;
+ if(/^\d+$/.test(v))return parseInt(v,10)*60; if(!/^(\s*\d+\s*[hms])+$/.test(v))return null;
+ let m,tot=0;const re=/(\d+)\s*([hms])/g; while((m=re.exec(v)))tot+=m[2]==='h'?+m[1]*3600:m[2]==='m'?+m[1]*60:+m[1]; return tot>0?tot:null; }
+function resolveAlarm(v,now){ v=String(v||'').trim().toLowerCase();
+ if(v.startsWith('+')){const s=parseDuration(v.slice(1));return s==null?null:now+s;}
+ if(v==='@hour'||v==='top of hour'){const d=new Date(now*1000);d.setMinutes(0,0,0);d.setHours(d.getHours()+1);return d.getTime()/1000;}
+ if(v==='@half'||v==='half past'||v==='half-past'){const d=new Date(now*1000);d.setSeconds(0,0);d.setMinutes(30);let e=d.getTime()/1000;if(e<=now){d.setHours(d.getHours()+1);e=d.getTime()/1000;}return e;}
+ const t=v.match(/^(\d{1,2}):(\d{2})$/); if(t){const hh=+t[1],mm=+t[2];if(hh>23||mm>59)return null;const d=new Date(now*1000);d.setHours(hh,mm,0,0);let e=d.getTime()/1000;if(e<=now)e+=86400;return e;} return null; }
+function nextAlarm(hh,mm,days,now){ const base=new Date(now*1000);
+ for(let d=0;d<=7;d++){const c=new Date(base);c.setDate(base.getDate()+d);c.setHours(hh,mm,0,0);const e=c.getTime()/1000;
+ if(e<=now)continue; if(!days.length||days.includes(c.getDay()))return e;} return now+86400; }
+const fmtTime=s=>{s=Math.max(0,Math.floor(s));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60;
+ return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;};
+const fmtClock=e=>{const d=new Date(e*1000);return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;};
+const DAYNAMES=['S','M','T','W','T','F','S'];
+
+const DEFAULT_PRESETS=()=>({
+ timer:[{label:'5m',value:'5m',locked:true},{label:'25m',value:'25m',locked:true},{label:'10m',value:'10m',locked:true},
+ {label:'15m',value:'15m',locked:true},{label:'30m',value:'30m',locked:true},{label:'60m',value:'60m',locked:true},{label:'2h',value:'2h',locked:true}],
+ alarm:[{label:'+30m',value:'+30m',locked:true},{label:'top of hour',value:'@hour',locked:true},{label:'half-past',value:'@half',locked:true},{label:'07:00',value:'07:00',locked:true}],
+ stopwatch:[]
+});
+const POMO_PRESETS=[{label:'Classic',ws:25,wl:50,rs:5,rl:15,iv:4,locked:true},{label:'Deep',ws:50,wl:50,rs:10,rl:30,iv:3,locked:true},{label:'Sprint',ws:15,wl:25,rs:3,rl:10,iv:4,locked:true}];
+const POMO_DEFAULT={ws:25,wl:50,rs:5,rl:15,iv:4,auto:true};
+const TYPES=['timer','alarm','stopwatch','pomodoro'];
+const COUNTDOWN=['timer','alarm','pomodoro'];
+const MAX=10;
+
+class Engine{
+ constructor(){this.items=[];this.seq=0;this.primary=null;this.presets=DEFAULT_PRESETS();this.pomoPresets=POMO_PRESETS.map(p=>({...p}));}
+ now(){return Date.now()/1000;}
+ count(){return this.items.length;}
+ full(){return this.items.length>=MAX;}
+ add(type,value,label,opts){
+ opts=opts||{};
+ if(this.full())return {ok:false,reason:`queue full (${MAX}/${MAX})`};
+ const now=this.now();this.seq++;const id='t'+this.seq;const it={id,type,label:label||''};
+ if(type==='timer'){const s=parseDuration(value);if(s==null)return {ok:false,reason:`bad duration: “${value}”`};it.target=now+s;it.total=s;it.repeat=!!opts.repeat;}
+ else if(type==='alarm'){const e=resolveAlarm(value,now);if(e==null)return {ok:false,reason:`bad time: “${value}”`};
+ const d=new Date(e*1000);it.hh=d.getHours();it.mm=d.getMinutes();it.days=(opts.days||[]).slice();it.snooze=opts.snooze||9;
+ it.target=it.days.length?nextAlarm(it.hh,it.mm,it.days,now):e;it.total=Math.max(1,it.target-now);}
+ else if(type==='pomodoro'){const c=opts.ws?opts:POMO_DEFAULT;it.cfg={ws:c.ws*60,wl:c.wl*60,rs:c.rs*60,rl:c.rl*60,iv:Math.max(1,c.iv),auto:c.auto!==false};
+ it.phase='work';it.cycle=1;const deep=(1%it.cfg.iv===0);const len=deep?it.cfg.wl:it.cfg.ws;it.target=now+len;it.total=len;}
+ else if(type==='stopwatch'){it.start=now;it.laps=[];}
+ else return {ok:false,reason:`unknown type: ${type}`};
+ this.items.push(it);if(!this.primary)this.primary=id;return {ok:true,id};
+ }
+ find(id){return this.items.find(i=>i.id===id);}
+ isPaused(it){return it.type==='stopwatch'?it.paused_elapsed!=null:it.paused_left!=null;}
+ remaining(it,ref){ref=ref==null?this.now():ref;if(it.type==='stopwatch')return this.isPaused(it)?it.paused_elapsed:ref-it.start;return this.isPaused(it)?it.paused_left:it.target-ref;}
+ toggle(id){const it=this.find(id);if(!it)return;const now=this.now();
+ if(it.type==='stopwatch'){if(this.isPaused(it)){it.start=now-it.paused_elapsed;it.paused_elapsed=null;}else it.paused_elapsed=now-it.start;}
+ else{if(this.isPaused(it)){it.target=now+it.paused_left;it.paused_left=null;it.awaiting=false;}else it.paused_left=it.target-now;}}
+ cancel(id){const i=this.items.findIndex(x=>x.id===id);if(i<0)return;this.items.splice(i,1);if(this.primary===id)this.primary=null;}
+ cancelAll(){this.items=[];this.primary=null;}
+ promote(id){if(this.find(id))this.primary=id;}
+ cycle(dir){const ids=this.items.map(i=>i.id);if(!ids.length)return;let c=ids.indexOf(this.effectivePrimary());c=c<0?0:c;this.primary=ids[dir==='prev'?(c-1+ids.length)%ids.length:(c+1)%ids.length];}
+ lap(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return;it.laps.push({t:this.remaining(it)});}
+ snooze(id){const it=this.find(id);if(!it)return;it.ringing=false;const m=it.snooze>0?it.snooze:9;it.target=this.now()+m*60;it.total=m*60;}
+ dismiss(id){const it=this.find(id);if(!it)return;if(it.days&&it.days.length){it.ringing=false;it.target=nextAlarm(it.hh,it.mm,it.days,this.now());it.total=Math.max(1,it.target-this.now());}else this.cancel(id);}
+ effectivePrimary(){const items=this.items;if(!items.length)return null;const ids=items.map(i=>i.id);
+ const ring=items.find(i=>i.ringing);if(ring)return ring.id;
+ if(ids.includes(this.primary))return this.primary;const now=this.now();
+ const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i));
+ if(acd.length)return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id;
+ const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));if(asw.length)return asw[0].id;return ids[0];}
+ sortKey(it){const now=this.now(),p=this.isPaused(it),sw=it.type==='stopwatch',r=this.remaining(it,now);
+ if(it.ringing)return [-1,0,+it.id.slice(1)]; return sw?[p?3:2,-r,+it.id.slice(1)]:[p?1:0,r,+it.id.slice(1)];}
+ rows(){const prim=this.effectivePrimary(),now=this.now();
+ return this.items.slice().sort((a,b)=>{const ka=this.sortKey(a),kb=this.sortKey(b);for(let i=0;i<ka.length;i++){if(ka[i]<kb[i])return -1;if(ka[i]>kb[i])return 1;}return 0;}).map(it=>this.row(it,prim,now));}
+ daysLabel(it){ if(!it.days||!it.days.length)return 'once'; if(it.days.length===7)return 'daily';
+ const wk=[1,2,3,4,5],we=[0,6];
+ if(wk.every(d=>it.days.includes(d))&&it.days.length===5)return 'weekdays';
+ if(we.every(d=>it.days.includes(d))&&it.days.length===2)return 'weekends';
+ return it.days.slice().sort().map(d=>DAYNAMES[d]).join(''); }
+ row(it,prim,now){
+ const rem=this.remaining(it,now),paused=this.isPaused(it);
+ let disp,sub='',warn=false,prog=null,glyph,pomo=null,ringing=!!it.ringing,badges=[],lastLap=null,sweep=null;
+ if(it.type==='alarm'){
+ if(ringing){disp='RING';sub='alarm ringing';warn=true;prog=1;glyph=GL.alarm;}
+ else{disp=fmtClock(it.target);sub=`${this.daysLabel(it)} · fires ${fmtClock(it.target)}`;prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.alarm;}
+ if(it.days&&it.days.length)badges.push({t:this.daysLabel(it),c:'sage'});
+ }
+ else if(it.type==='pomodoro'){disp=fmtTime(rem);prog=Math.max(0,Math.min(1,rem/it.total));const deep=(it.cycle%it.cfg.iv===0);
+ const ph=it.phase==='work'?(deep?'long work':'work'):(deep?'long break':'short break');
+ sub=it.awaiting?`ready · start ${it.phase==='work'?'work':'break'}`:`${ph} · cycle ${it.cycle}/${it.cfg.iv}`;
+ glyph=it.phase==='work'?GL.pomo_work:GL.pomo_break;pomo={cycle:it.cycle,iv:it.cfg.iv,phase:it.phase,awaiting:!!it.awaiting};}
+ else if(it.type==='stopwatch'){disp=fmtTime(rem);lastLap=it.laps.length?it.laps[it.laps.length-1].t:null;
+ sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running';glyph=GL.stopwatch;sweep=(Math.max(0,rem)%60)/60;}
+ else {disp=fmtTime(rem);sub=it.repeat?'timer · repeats':'timer';prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.timer;if(it.repeat)badges.push({t:'repeat',c:''});}
+ if(prog!=null&&!ringing&&rem<=Math.min(30,it.total*0.15))warn=true;
+ if(paused)glyph=GL.paused;
+ return {id:it.id,type:it.type,glyph,label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type],
+ typeLabel:it.type,disp,sub,paused,ringing,primary:it.id===prim,prog,warn,pomo,badges,lastLap,sweep,laps:it.laps?it.laps.length:0};
+ }
+ /* mirror wtimer render_payload for the bar */
+ barPayload(){ const now=this.now(); const items=this.items;
+ if(!items.length)return {glyph:GL.idle,text:'',plus:0,cls:'idle',tip:['No timers']};
+ const pid=this.effectivePrimary(); const p=this.find(pid);
+ let cls; if(p.ringing)cls='urgent'; else if(this.isPaused(p))cls='paused';
+ else if(p.type==='pomodoro')cls=(p.phase==='work'?'pomodoro-work':'pomodoro-break');
+ else if((p.type==='timer'||p.type==='alarm')&&this.remaining(p,now)<60)cls='urgent'; else cls=p.type;
+ const glyph=this.isPaused(p)?GL.paused:(p.type==='pomodoro'?(p.phase==='work'?GL.pomo_work:GL.pomo_break):GL[p.type]);
+ const text=p.ringing?'RING':fmtTime(this.remaining(p,now));
+ const tip=[items.length!==1?`${items.length} active`:'1 timer'];
+ for(const i of items){const g=this.isPaused(i)?GL.paused:(i.type==='pomodoro'?(i.phase==='work'?GL.pomo_work:GL.pomo_break):GL[i.type]);
+ const lb=i.label||i.type;const st=this.isPaused(i)?' (paused)':(i.ringing?' (ringing)':'');
+ const val=i.type==='alarm'?(i.ringing?'RING':fmtClock(i.target)):(i.type==='pomodoro'?`${i.cycle}/${i.cfg.iv} ${fmtTime(this.remaining(i,now))}`:fmtTime(this.remaining(i,now)));
+ tip.push(`${g} ${lb} ${val}${st}`);}
+ return {glyph,text,plus:items.length-1,cls,tip};
+ }
+ presetsFor(t){return (this.presets[t]||[]).map(p=>({...p}));}
+ addPreset(t,label,value){if(!TYPES.includes(t)||t==='pomodoro'||t==='stopwatch')return {ok:false,reason:'no custom preset here'};
+ if(t==='timer'&&parseDuration(value)==null)return {ok:false,reason:'bad duration'};(this.presets[t]||(this.presets[t]=[])).push({label,value,locked:false});return {ok:true};}
+ deletePreset(t,label){const a=this.presets[t]||[];const i=a.findIndex(p=>p.label===label);if(i<0)return {ok:false,reason:'not found'};if(a[i].locked)return {ok:false,reason:'default — locked'};a.splice(i,1);return {ok:true};}
+}
+
+let notifPerm=(typeof Notification!=='undefined')?Notification.permission:'denied';
+function tryNotify(title,body){if(typeof Notification==='undefined')return;if(notifPerm==='granted'){try{new Notification(title,{body});}catch(e){}}else if(notifPerm==='default'){Notification.requestPermission().then(p=>notifPerm=p);}}
+function toaster(host){const wrap=el('div','toasts');host.appendChild(wrap);return (msg,kind)=>{const t=el('div','toast'+(kind?' '+kind:''),msg);wrap.appendChild(t);setTimeout(()=>{t.style.transition='opacity .3s';t.style.opacity='0';setTimeout(()=>t.remove(),300);},2600);};}
+function flash(...inputs){for(const i of inputs){if(!i)continue;i.classList.remove('flash');void i.offsetWidth;i.classList.add('flash');}}
+function dotsHTML(p){if(!p)return '';let h='<span class="dots">';const pos=(p.cycle-1)%p.iv;for(let i=0;i<p.iv;i++){const isLong=(i===p.iv-1);let cls='';if(i<pos)cls='on';if(i===pos)cls='now';if(isLong)cls+=' long';h+=`<i class="${cls.trim()}"></i>`;}return h+'</span>';}
+function badgesHTML(bs){return (bs||[]).map(b=>`<span class="badge ${b.c}">${b.t}</span>`).join(' ');}
+
+function mount(host, engine, bar, tip){
+ const toast=toaster(host);
+ const head=el('div','phead',`<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm',GL.clear+' CLEAR ALL');clear.style.marginLeft='8px';
+ clear.addEventListener('click',()=>{if(!engine.count())return;engine.cancelAll();toast('cleared all');render();});
+ head.appendChild(clear);
+ // close button — flat circular ✕ like the net/bt/audio panels (Close/Esc); the
+ // waybar module reopens it, mirroring the real on-click: timer-panel toggle.
+ const closeBtn=el('button','x-btn','✕');closeBtn.title='Close (Esc)';
+ const barcap=document.querySelector('.barcap');
+ function setClosed(c){host.classList.toggle('closed',c);if(barcap)barcap.textContent=c?'the waybar module · click it to reopen the panel':'the waybar module · live';}
+ closeBtn.addEventListener('click',()=>setClosed(true));
+ head.appendChild(closeBtn);
+ bar.addEventListener('click',()=>setClosed(!host.classList.contains('closed')));
+ document.addEventListener('keydown',e=>{if(e.key==='Escape')setClosed(true);});
+ const hero=el('div','hero');
+ const create=buildCreate();
+ const list=el('div','qlist');
+ host.append(head,hero,create,list);
+ const flashing=new Set();
+
+ function itemClick(e){const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id);
+ else if(a==='promote'){engine.promote(id);toast('to bar slot');}
+ else if(a==='cycle')engine.cycle(b.dataset.dir);
+ else if(a==='lap'){engine.lap(id);toast('lap recorded');}
+ else if(a==='stop'){engine.cancel(id);toast('stopped');}
+ else if(a==='snooze'){engine.snooze(id);toast('snoozed','gold');}
+ else if(a==='dismiss'){engine.dismiss(id);toast('dismissed');}
+ else if(a==='cancel'){if(b.dataset.armed){engine.cancel(id);toast('cancelled');}
+ else{b.dataset.armed='1';b.classList.add('armed');b.textContent='sure?';setTimeout(()=>{if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;}},2000);return;}}
+ render();}
+ hero.addEventListener('click',itemClick);
+ list.addEventListener('click',itemClick);
+
+ function buildCreate(){
+ const box=el('div','create');
+ const seg=el('div','seg');
+ TYPES.forEach(t=>{const b=el('button',t==='timer'?'on':'',t[0].toUpperCase()+t.slice(1));b.dataset.t=t;seg.appendChild(b);});
+ const body=el('div');box.append(seg,body);
+ let selType='timer';
+ seg.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;selType=b.dataset.t;[...seg.children].forEach(x=>x.classList.toggle('on',x===b));paintBody();});
+ function doAdd(type,val,lab,opts){const r=engine.add(type,val?val.value:'',lab.value.trim(),opts);
+ if(!r.ok){if(val)val.classList.add('bad');toast(r.reason,'red');return;}
+ if(val){val.classList.remove('bad');if(!val.disabled)val.value='';}lab.value='';toast('added '+type,'gold');render();}
+ function paintBody(){
+ body.innerHTML='';
+ if(selType==='timer'){
+ const ps=el('div','presets');
+ engine.presetsFor('timer').forEach(p=>{const c=el('span','preset'+(p.locked?'':''),p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));c.dataset.val=p.value;ps.appendChild(c);});
+ ps.appendChild(Object.assign(el('span','preset','+ preset'),{}) );ps.lastChild.dataset.newp='1';ps.lastChild.style.opacity='.7';
+ const row=el('div','row');
+ const val=el('input','tin');val.placeholder='5m · 1h30m · 90s';val.style.flex='2';
+ const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2';
+ const rep=el('span','switch');const reptag=el('span',null,'<span style="color:var(--steel);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase">repeat</span>');
+ const addk=el('button','key on',GL.add+' ADD');
+ const repwrap=el('span',null,'');repwrap.style.display='inline-flex';repwrap.style.alignItems='center';repwrap.style.gap='6px';repwrap.append(rep,reptag);
+ row.append(val,lab,repwrap,addk);
+ body.append(ps,row);
+ rep.addEventListener('click',()=>rep.classList.toggle('on'));
+ ps.addEventListener('click',e=>{const del=e.target.closest('[data-del]');
+ if(del){const r=engine.deletePreset('timer',decodeURIComponent(del.dataset.del));toast(r.ok?'preset removed':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ if(e.target.closest('[data-newp]')){const lb=prompt('Preset label:');if(!lb)return;const vv=prompt('Value for “'+lb+'”:',lb)||lb;const r=engine.addPreset('timer',lb,vv);toast(r.ok?'preset added':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ const c=e.target.closest('.preset');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;flash(val);});
+ addk.addEventListener('click',()=>doAdd('timer',val,lab,{repeat:rep.classList.contains('on')}));
+ [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('timer',val,lab,{repeat:rep.classList.contains('on')});}));
+ }
+ else if(selType==='alarm'){
+ const ps=el('div','presets');
+ engine.presetsFor('alarm').forEach(p=>{const c=el('span','preset',p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));c.dataset.val=p.value;ps.appendChild(c);});
+ const row=el('div','row');
+ const val=el('input','tin');val.placeholder='HH:MM · +30m · @hour · @half';val.style.flex='2';
+ const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2';
+ const addk=el('button','key on',GL.add+' ADD');
+ row.append(val,lab,addk);
+ const drow=el('div','row');const days=el('div','days7');const sel=new Set();
+ DAYNAMES.forEach((d,i)=>{const b=el('button',null,d);b.dataset.d=i;b.title=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][i];days.appendChild(b);});
+ const quick=el('span',null,'');const wkd=el('button','key sm','weekdays');const evd=el('button','key sm','daily');
+ quick.style.display='inline-flex';quick.style.gap='5px';quick.append(wkd,evd);
+ const srow=el('div','row');const slbl=el('span',null,'<span style="color:var(--steel);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase">snooze</span>');
+ const sn=el('input','numin');sn.value='9';sn.inputMode='numeric';const smin=el('span',null,'<span style="color:var(--dim);font-size:10px">min</span>');
+ srow.append(slbl,sn,smin);
+ drow.append(days,quick);
+ body.append(ps,row,drow,srow);
+ function paintDays(){[...days.children].forEach(b=>b.classList.toggle('on',sel.has(+b.dataset.d)));}
+ days.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;const d=+b.dataset.d;sel.has(d)?sel.delete(d):sel.add(d);paintDays();});
+ wkd.addEventListener('click',()=>{sel.clear();[1,2,3,4,5].forEach(d=>sel.add(d));paintDays();});
+ evd.addEventListener('click',()=>{sel.clear();[0,1,2,3,4,5,6].forEach(d=>sel.add(d));paintDays();});
+ function opts(){return {days:[...sel],snooze:parseInt(sn.value,10)||9};}
+ ps.addEventListener('click',e=>{const del=e.target.closest('[data-del]');
+ if(del){const r=engine.deletePreset('alarm',decodeURIComponent(del.dataset.del));toast(r.ok?'preset removed':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ const c=e.target.closest('.preset');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;flash(val);});
+ addk.addEventListener('click',()=>doAdd('alarm',val,lab,opts()));
+ [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('alarm',val,lab,opts());}));
+ }
+ else if(selType==='stopwatch'){
+ const row=el('div','row');
+ const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2';
+ const addk=el('button','key on',GL.add+' ADD');
+ row.append(lab,addk);
+ const note=el('div','engrave','counts up from zero · lap while running');
+ body.append(row,note);
+ addk.addEventListener('click',()=>doAdd('stopwatch',null,lab));
+ lab.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('stopwatch',null,lab);});
+ }
+ else {
+ const ps=el('div','presets');
+ engine.pomoPresets.forEach(p=>{const c=el('span','preset',p.label);c.dataset.pp=p.label;ps.appendChild(c);});
+ const cfg=el('div','cfg');const mk=v=>{const i=el('input','numin');i.value=v;i.inputMode='numeric';return i;};
+ const ws=mk(POMO_DEFAULT.ws),wl=mk(POMO_DEFAULT.wl),rs=mk(POMO_DEFAULT.rs),rl=mk(POMO_DEFAULT.rl),iv=mk(POMO_DEFAULT.iv);
+ const auto=el('span','switch on');auto.dataset.on='1';
+ const rW=el('div','crow');rW.append(el('span','lbl','Work'),el('span','sl','S'),ws,el('span','sl','L'),wl,el('span','u','min'));
+ const rR=el('div','crow');rR.append(el('span','lbl','Rest'),el('span','sl','S'),rs,el('span','sl','L'),rl,el('span','u','min'));
+ const rI=el('div','crow');rI.append(el('span','lbl','Long ev.'),iv,el('span','u','cycles → long work + long break'));
+ const rA=el('div','crow');rA.append(el('span','lbl','Auto'),auto,el('span','u','advance into the next phase'));
+ cfg.append(rW,rR,rI,rA);
+ const row=el('div','row');const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2';
+ const addk=el('button','key on',GL.add+' ADD CYCLE');row.append(lab,addk);
+ body.append(ps,cfg,row);
+ auto.addEventListener('click',()=>{auto.classList.toggle('on');auto.dataset.on=auto.classList.contains('on')?'1':'';});
+ ps.addEventListener('click',e=>{const c=e.target.closest('[data-pp]');if(!c)return;const p=engine.pomoPresets.find(x=>x.label===c.dataset.pp);if(!p)return;
+ ws.value=p.ws;wl.value=p.wl;rs.value=p.rs;rl.value=p.rl;iv.value=p.iv;[...ps.children].forEach(x=>x.classList.toggle('on',x===c));flash(ws,wl,rs,rl,iv);});
+ function pnum(inp,d){const n=parseInt(inp.value,10);return (isNaN(n)||n<1)?d:n;}
+ addk.addEventListener('click',()=>{const r=engine.add('pomodoro','',lab.value.trim(),{ws:pnum(ws,25),wl:pnum(wl,50),rs:pnum(rs,5),rl:pnum(rl,15),iv:pnum(iv,4),auto:!!auto.dataset.on});
+ if(!r.ok){toast(r.reason,'red');return;}lab.value='';toast('pomodoro added','gold');render();});
+ lab.addEventListener('keydown',e=>{if(e.key==='Enter')addk.click();});
+ }
+ }
+ paintBody();
+ return box;
+ }
+
+ function renderBar(){
+ const p=engine.barPayload();
+ bar.className='wmod '+p.cls;bar.title=p.tip.join('\n');
+ bar.innerHTML=`<span class="wg">${p.glyph}</span>`+(p.text?`<span class="wt">${p.text}</span>`:'')+(p.plus>0?`<span class="wp">+${p.plus}</span>`:'');
+ tip.innerHTML=`<div class="th">hover tooltip</div>`+p.tip.map((l,i)=>`<div class="tl" style="${i===0?'color:var(--steel)':''}">${l}</div>`).join('');
+ }
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows(),primId=engine.effectivePrimary();
+ const h=rows.find(r=>r.id===primId);
+ hero.className='hero'+(h&&h.paused?' paused':'')+(h&&h.ringing?' ringing':'')+(h&&flashing.has(h.id)?' fire':'');
+ if(!h){hero.innerHTML='<div class="empty" style="width:100%">No timers running — add one below.</div>';}
+ else{
+ const ringP=h.prog!=null?Math.round(h.prog*100):0;
+ const donutHTML = h.type==='stopwatch'
+ ? `<span class="swdial" title="seconds sweep"><span class="swtick"></span><span class="swtick q"></span><span class="swtick h"></span><span class="swtick t"></span><span class="swhand" style="transform:rotate(${Math.round((h.sweep||0)*360)}deg)"></span><span class="swhub"></span></span>`
+ : `<span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:86px;height:86px"><b style="color:var(--cream);font-size:14px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b></span>`;
+ let transport;
+ if(h.ringing) transport=`<button class="key" data-act="snooze" data-id="${h.id}">${GL.play} SNOOZE ${engine.find(h.id).snooze}m</button><button class="key red" data-act="dismiss" data-id="${h.id}">DISMISS</button>`;
+ else if(h.type==='stopwatch') transport=`<button class="key icon" data-act="cycle" data-dir="prev" title="prev">‹</button><button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button><button class="key" data-act="lap" data-id="${h.id}">LAP</button><button class="key red" data-act="stop" data-id="${h.id}">STOP</button><button class="key icon" data-act="cycle" data-dir="next" title="next">›</button>`;
+ else {const start=h.pomo&&h.pomo.awaiting?(GL.play+' START '+(h.pomo.phase==='work'?'WORK':'BREAK')):(h.paused?GL.play+' RESUME':GL.paused+' PAUSE');
+ transport=`<button class="key icon" data-act="cycle" data-dir="prev" title="prev">‹</button><button class="key" data-act="toggle" data-id="${h.id}">${start}</button><button class="key red icon" data-act="cancel" data-id="${h.id}" title="cancel">${GL.cancel}</button><button class="key icon" data-act="cycle" data-dir="next" title="next">›</button>`;}
+ hero.innerHTML=
+ `<div class="htop">
+ <div class="rhs">
+ <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span>${badgesHTML(h.badges)}${h.pomo?dotsHTML(h.pomo):''}</div>
+ <div class="hlabel">${h.label}</div>
+ <div class="hbig">${h.disp}${h.type==='stopwatch'&&h.lastLap!=null?`<span class="lapbadge">LAP ${fmtTime(h.lastLap)}</span>`:''}</div>
+ <div class="hsub">${h.sub}</div>
+ </div>
+ <div class="donut">${donutHTML}</div>
+ </div>
+ <div class="transport">${transport}</div>`;
+ }
+ list.innerHTML='';
+ const rest=rows.filter(r=>r.id!==primId);
+ list.appendChild(el('div','engrave','queue <span class="cnt">· '+rest.length+'</span>'));
+ if(!rest.length)list.appendChild(el('div','empty',h?'Only one item is queued. Add more above.':''));
+ rest.forEach(r=>{
+ const row=el('div','qrow'+(r.paused?' paused':'')+(r.ringing?' ringing':'')+(flashing.has(r.id)?' fire':''));
+ let ctrls;
+ if(r.ringing)ctrls=`<button class="key sm" data-act="snooze" data-id="${r.id}">SNOOZE</button><button class="key sm red" data-act="dismiss" data-id="${r.id}">OFF</button>`;
+ else if(r.type==='stopwatch')ctrls=`<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button><button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button><button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button>`;
+ else ctrls=`<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button><button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button><button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button>`;
+ row.innerHTML=`<span class="lamp ${r.ringing?'red':(r.paused?'off':(r.warn?'red':''))}"></span><span class="g">${r.glyph}</span><span class="meta"><b>${r.label}</b><span class="ty">${r.sub}</span></span><span class="rd">${r.disp}</span><span class="ctrls">${ctrls}</span>`;
+ list.appendChild(row);
+ });
+ renderBar();
+ }
+ engine._render=render;engine._flash=id=>{flashing.add(id);setTimeout(()=>flashing.delete(id),1800);};engine._toast=toast;
+ render();
+}
+
+const engine=new Engine();
+const pomo=engine.add('pomodoro','','Deep work',{ws:25,wl:50,rs:5,rl:15,iv:4,auto:true});
+engine.add('timer','45s','Egg',{repeat:false});
+engine.add('timer','5m','Tea',{repeat:true});
+const sw=engine.add('stopwatch','','Debug run');
+{const s=engine.find(sw.id);s.start=engine.now()-215;s.laps=[{t:72},{t:158}];} // ~3:35 elapsed, last lap 2:38
+engine.add('alarm','07:00','Wake',{days:[1,2,3,4,5],snooze:9});
+engine.promote(sw.id); // show the new stopwatch sweep-dial + lap badge in the hero
+mount(document.getElementById('panel'), engine, document.getElementById('wbar'), document.getElementById('wtip'));
+
+function loop(){const fired=engine.tick?engine.tick():tickEngine(engine);
+ for(const f of fired){engine._flash(f.id);engine._toast((f.kind==='done'?GL.bell+' ':'')+f.title,f.kind==='done'?'red':'gold');tryNotify(f.title,f.body);}
+ engine._render();}
+/* engine.tick lives on the class below via prototype patch to keep add()/tick together readable */
+Engine.prototype.tick=function(){const now=this.now(),fired=[];
+ for(const it of this.items.slice()){
+ if(!COUNTDOWN.includes(it.type)||this.isPaused(it)||it.ringing)continue;
+ if(it.target-now>0)continue;
+ if(it.type==='pomodoro'){const c=it.cfg;
+ if(it.phase==='work'){const deep=(it.cycle%c.iv===0);fired.push({id:it.id,kind:'pomo',title:`Pomodoro · ${deep?'long':'short'} break`,body:it.label||`cycle ${it.cycle}`});
+ it.phase='rest';const len=deep?c.rl:c.rs;it.total=len;if(c.auto)it.target=now+len;else{it.paused_left=len;it.awaiting=true;}}
+ else{it.cycle+=1;const deep=(it.cycle%c.iv===0);fired.push({id:it.id,kind:'pomo',title:'Pomodoro · back to work',body:it.label||`cycle ${it.cycle}`});
+ it.phase='work';const len=deep?c.wl:c.ws;it.total=len;if(c.auto)it.target=now+len;else{it.paused_left=len;it.awaiting=true;}}}
+ else if(it.type==='alarm'){fired.push({id:it.id,kind:'done',title:'Alarm · '+(it.label||fmtClock(it.target)),body:'alarm ringing'});it.ringing=true;}
+ else{if(it.repeat){fired.push({id:it.id,kind:'done',title:'Timer · '+(it.label||'done')+' · repeating',body:'restarted'});it.target=now+it.total;}
+ else{fired.push({id:it.id,kind:'done',title:'Timer · '+(it.label||'done'),body:'time’s up'});this.cancel(it.id);}}
+ }
+ return fired;};
+setInterval(loop, reduced?1000:250);
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html b/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html
new file mode 100644
index 0000000..0258f20
--- /dev/null
+++ b/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html
@@ -0,0 +1,1359 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Instrument consoles — network + bluetooth</title>
+<style>
+:root {
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.masthead{max-width:1280px;margin:0 auto 1.8rem}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:80ch}
+.masthead b{color:var(--silver);font-weight:700}
+
+.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1280px;margin:0 auto;align-items:flex-start}
+.slot{width:400px}
+.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem}
+.aside{flex:1 1 300px;min-width:280px}
+.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.24em;text-transform:uppercase;margin:1.1rem 0 .5rem}
+.aside h3:first-child{margin-top:.2rem}
+.aside ul{list-style:none}
+.aside li{font-size:.82rem;padding:.22rem 0 .22rem 1.1rem;position:relative}
+.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem}
+.aside li em{color:var(--dim);font-style:normal}
+.aside li b{color:var(--cream);font-weight:700}
+.demo-box{border:1px dashed var(--wash);border-radius:10px;padding:.8rem 1rem;margin-top:1rem}
+.demo-box label{display:flex;gap:.6rem;align-items:center;font-size:.82rem;cursor:pointer;color:var(--silver);margin-top:.45rem}
+.demo-box label:first-child{margin-top:0}
+.demo-box input{accent-color:#dab53d}
+.demo-box .hint{color:var(--dim);font-size:.73rem;margin:.15rem 0 0 1.5rem}
+.reset{font:inherit;font-size:.78rem;color:var(--silver);background:transparent;border:1px solid var(--wash);
+ border-radius:8px;padding:.4rem .9rem;cursor:pointer;margin-top:.8rem}
+.reset:hover{background:var(--wash)}
+
+.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px;
+ box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px;position:relative;
+ transition:opacity .25s,transform .25s}
+.panel.closed{opacity:0;transform:translateY(-8px);pointer-events:none}
+.reopen{display:none;font:inherit;font-size:.75rem;color:var(--gold);background:transparent;
+ border:1px dashed var(--gold);border-radius:8px;padding:.5rem 1rem;cursor:pointer;margin-top:.6rem}
+
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto;
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.55)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite}
+@keyframes pulse{50%{opacity:.25}}
+
+.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px}
+.b-id{display:flex;align-items:center;gap:9px}
+.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em}
+.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto}
+.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);
+ border-radius:4px;padding:1px 6px;display:none}
+.badge.show{display:inline-block}
+.badge.red{background:var(--fail);color:var(--cream)}
+.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem;
+ cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto}
+.x-btn:hover{background:var(--wash);color:var(--silver)}
+.switch{width:38px;height:20px;border-radius:10px;background:var(--wash);
+ border:1px solid var(--slate);position:relative;flex:0 0 auto;cursor:pointer}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;
+ border-radius:50%;background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}
+.switch.on::after{left:19px;background:var(--gold)}
+
+.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.32em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px;margin:12px 0 6px}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave::before{max-width:12px}
+.engrave .act{color:var(--dim);letter-spacing:.06em;text-transform:none;font-size:.72rem;cursor:pointer}
+.engrave .act:hover{color:var(--gold)}
+
+.chan .line1{display:flex;align-items:baseline;gap:9px}
+.chan .ssid{color:var(--cream);font-weight:700;font-size:14.5px}
+.chan .line2{color:var(--dim);font-size:11.5px;margin-top:2px}
+.chan .chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash)}
+.chan .chip:hover{color:var(--gold)}
+.chan .chip.on{color:var(--gold)}
+.ladder{display:inline-flex;gap:2px;align-items:flex-end;height:12px}
+.ladder i{width:4px;background:var(--wash);border-radius:1px}
+.ladder i:nth-child(1){height:4px}.ladder i:nth-child(2){height:7px}
+.ladder i:nth-child(3){height:10px}.ladder i:nth-child(4){height:12px}
+.ladder.l1 i:nth-child(-n+1){background:var(--gold)}
+.ladder.l2 i:nth-child(-n+2){background:var(--gold)}
+.ladder.l3 i:nth-child(-n+3){background:var(--gold)}
+.ladder.l4 i{background:var(--gold)}
+
+/* section row budgets: lists never grow the panel — they scroll inside it,
+ cut at a half row so the peek says "there's more" */
+.sec-scroll{overflow-y:auto;overscroll-behavior:contain}
+#networks.sec-scroll{max-height:160px}
+#tunnels.sec-scroll{max-height:131px}
+#b-paired.sec-scroll{max-height:160px}
+#b-nearby.sec-scroll{max-height:131px}
+.engrave .cnt{color:var(--dim);letter-spacing:.12em;margin-left:2px}
+.lamp-row{display:flex;align-items:center;gap:9px;padding:5px 6px;border-radius:7px;font-size:12.5px;cursor:pointer;position:relative}
+.lamp-row:hover{background:var(--wash)}
+.lamp-row .who{color:var(--silver);white-space:nowrap}
+.lamp-row .who b{color:var(--cream)}
+.lamp-row .what{margin-left:auto;color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.lamp-row.busy{pointer-events:none}
+.lamp-row .zap,.lamp-row .pen{display:none;color:var(--dim);border:0;background:transparent;font:inherit;font-size:.85rem;
+ cursor:pointer;border-radius:5px;padding:0 5px;flex:0 0 auto}
+.lamp-row:hover .zap,.lamp-row:hover .pen{display:inline-block}
+.lamp-row .zap:hover{color:var(--fail)}
+.lamp-row .pen:hover{color:var(--gold)}
+.lamp-row.armed-soft{background:rgba(218,181,61,.10)}
+.lamp-row.armed-soft .what{color:var(--gold)}
+.lamp-row.armed{background:rgba(203,107,77,.12)}
+.lamp-row.armed .what{color:var(--fail)}
+.lamp-row.armed .zap{display:inline-block;color:var(--fail)}
+
+.console-btns{display:flex;gap:8px;margin-top:2px}
+.c-btn{flex:1;text-align:center;cursor:pointer;font:inherit;font-size:11.5px;
+ background:linear-gradient(180deg,#23211e,#191715);color:var(--silver);
+ border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:8px;padding:8px 4px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.c-btn:hover{color:var(--gold);border-color:var(--gold)}
+.c-btn:active{transform:translateY(1px)}
+.c-btn:disabled{opacity:.4;pointer-events:none}
+
+.meters{display:flex;gap:12px;margin-top:10px}
+.meter{flex:1;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 10px 7px;cursor:default;position:relative}
+.meter.testing{animation:flash 1s ease-in-out infinite;border-color:var(--gold)}
+@keyframes flash{50%{box-shadow:0 0 10px 1px rgba(218,181,61,.35)}}
+.meter.held{border-color:var(--gold);cursor:pointer}
+.meter .hold-tag{display:none;position:absolute;top:6px;right:8px;font-size:.56rem;letter-spacing:.2em;
+ color:var(--panel);background:var(--gold);border-radius:3px;padding:0 4px}
+.meter .mode-tag{position:absolute;top:6px;left:8px;font-size:.56rem;letter-spacing:.2em;color:var(--pass)}
+.meter .mode-tag.test{color:var(--gold)}
+.meter .mode-tag.off{color:var(--dim)}
+.meter.held .hold-tag{display:block}
+.meter .dial{position:relative;height:52px;overflow:hidden;margin-top:13px}
+.meter .arc{position:absolute;inset:0 0 -52px 0;border:2px solid var(--wash);border-radius:50%}
+.meter .tick{position:absolute;left:50%;bottom:0;width:1.5px;height:10px;background:var(--steel);transform-origin:50% 52px}
+.meter .needle{position:absolute;left:50%;bottom:0;width:2px;height:44px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(-60deg);border-radius:2px;
+ box-shadow:0 0 6px rgba(255,215,95,.5);transition:transform .45s cubic-bezier(.3,1.3,.5,1)}
+.meter .needle.dead{background:var(--wash);box-shadow:none}
+.meter .needle.low{background:var(--fail);box-shadow:0 0 6px rgba(203,107,77,.5)}
+.meter .hub{position:absolute;left:50%;bottom:-4px;width:9px;height:9px;margin-left:-4.5px;border-radius:50%;background:var(--gold)}
+.meter .m-value{color:var(--cream);font-size:13px;text-align:center;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums}
+.meter .m-value small{color:var(--dim);font-weight:400}
+.meter .m-value.low{color:var(--fail)}
+.meter .m-label{color:var(--steel);font-size:.62rem;letter-spacing:.26em;text-align:center;margin-top:2px;
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.meter-note{color:var(--dim);font-size:10px;text-align:center;margin-top:5px;min-height:1.2em}
+
+.well{border:1px solid var(--wash);border-radius:10px;background:var(--well)}
+.outwrap{position:relative;margin-top:10px}
+.outwrap .output{margin-top:0}
+.o-clear{display:none;position:absolute;top:3px;right:5px;z-index:2;color:var(--dim);
+ border:0;background:var(--well);font:inherit;font-size:.8rem;cursor:pointer;
+ border-radius:50%;width:20px;height:20px;line-height:1}
+.o-clear:hover{color:var(--silver);background:var(--wash)}
+.outwrap.has .o-clear{display:block}
+.output{margin-top:10px;padding:8px 10px;max-height:170px;overflow-y:auto;font-size:11.5px}
+.output:empty{padding:4px 10px;min-height:10px}
+.o-step{display:flex;gap:8px;align-items:flex-start;padding:2.5px 0}
+.o-step .lamp{margin-top:4px;width:7px;height:7px}
+.o-step .t b{color:var(--cream);font-weight:700}
+.o-step .t .why{color:var(--dim);display:block;font-size:10.5px}
+.o-step .t .ev{color:var(--steel);display:block;font-size:11px}
+.o-step.repair .t b{color:var(--gold)}
+.o-line{padding:2px 0;color:var(--silver)}
+.o-line b{color:var(--steel);font-weight:400}
+.o-verdict{margin-top:5px;padding-top:5px;border-top:1px solid var(--wash);color:var(--gold);font-weight:700}
+.o-verdict.ok{color:var(--pass)}
+.o-tip{color:var(--dim);font-size:10.5px;margin-top:4px}
+
+.toast{margin-top:9px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;
+ padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em}
+.toast.show{opacity:1}
+.toast.err{background:transparent;border:1px solid var(--fail);color:var(--fail)}
+
+.overlay{position:absolute;inset:0;background:rgba(10,12,13,.82);border-radius:14px;display:none;
+ align-items:center;justify-content:center;z-index:5}
+.overlay.show{display:flex}
+.dlg{background:var(--panel);border:1px solid var(--gold);border-radius:12px;padding:16px 18px;width:300px}
+.dlg h4{color:var(--cream);font-size:13px;margin-bottom:4px}
+.dlg .sub{color:var(--dim);font-size:11px;margin-bottom:10px}
+.dlg .passkey{color:var(--gold-hi);font-size:22px;font-weight:700;letter-spacing:.18em;
+ text-align:center;margin:6px 0 12px;font-variant-numeric:tabular-nums}
+.dlg input{width:100%;font:inherit;font-size:12.5px;color:var(--silver);background:var(--well);
+ border:1px solid var(--wash);border-radius:7px;padding:7px 9px;margin-bottom:8px;caret-color:var(--gold)}
+.dlg input:focus{outline:none;border-color:var(--gold)}
+.dlg .dlg-btns{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}
+.btn{font:inherit;font-size:12px;cursor:pointer;background:var(--slate);color:var(--cream);
+ border:1px solid var(--gold);border-radius:8px;padding:5px 12px}
+.btn:hover{background:var(--slate-hi)}
+.btn.quiet{background:transparent;border-color:var(--wash);color:var(--silver)}
+.btn.quiet:hover{background:var(--wash)}
+
+*{scrollbar-width:thin;scrollbar-color:var(--slate) transparent}
+::-webkit-scrollbar{width:6px;height:6px}
+::-webkit-scrollbar-track{background:transparent}
+::-webkit-scrollbar-thumb{background:var(--slate);border-radius:4px}
+::-webkit-scrollbar-thumb:hover{background:var(--slate-hi)}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · instrument consoles</div>
+ <h1>Network + Bluetooth — the pair</h1>
+ <p>Same faceplate, same idioms: lamp rows act on click, hover reveals ✎ rename and the
+ arm-to-fire ✕, gauges under the console keys (throughput needles on NET, battery fuel
+ gauges on BT), doctor streams and repairs in the output well. <b>Try the power switch
+ on BT·01</b> — everything follows it.</p>
+</header>
+
+<div class="stage">
+
+ <!-- ============================ NET·01 ============================ -->
+ <div class="slot">
+ <div class="slot-label">net·01 — as iterated</div>
+ <div class="panel" id="p">
+ <div class="overlay" id="ov">
+ <div class="dlg">
+ <h4 id="dlg-title">Join network</h4>
+ <div class="sub" id="dlg-sub">WPA2 — password required</div>
+ <input id="dlg-ssid" placeholder="SSID" style="display:none">
+ <input id="dlg-pass" type="password" placeholder="password">
+ <div class="dlg-btns">
+ <button class="btn quiet" onclick="dlgClose()">Cancel</button>
+ <button class="btn" id="dlg-go" onclick="dlgGo()">Join</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="b-face">
+ <div class="b-id">
+ <span class="lamp" id="lamp"></span>
+ <span class="state-word" id="state">ONLINE</span>
+ <span class="badge" id="badge">TUNNEL</span>
+ <span class="badge" id="air-badge">AIRPLANE</span>
+ <span class="unit">NET·01</span>
+ <span class="switch on" id="n-power" onclick="wifiPower()" title="WiFi radio"></span>
+ <button class="x-btn" onclick="closePanel()" title="Close (Esc)">✕</button>
+ </div>
+ </div>
+
+ <div class="engrave">channel</div>
+ <div class="chan">
+ <div class="line1"><span class="ssid" id="ch-ssid">@Hyatt_WiFi</span>
+ <span class="ladder l3" id="ch-ladder"><i></i><i></i><i></i><i></i></span>
+ <span class="dim" style="font-size:11px;white-space:nowrap" id="ch-dbm">-59 dBm · 44 ms</span></div>
+ <div class="line2" id="ch-route">172.20.2.108/20 · gw 172.20.0.1 · route wlp170s0</div>
+ </div>
+
+ <div class="engrave">networks<span class="cnt" id="net-count"></span><span class="act" onclick="dlgHidden()" title="Join a hidden SSID">+ hidden</span></div>
+ <div id="networks" class="sec-scroll"></div>
+
+ <div class="engrave">tunnels<span class="cnt" id="tun-count"></span></div>
+ <div id="tunnels" class="sec-scroll"></div>
+
+ <div class="engrave">console</div>
+ <div class="console-btns">
+ <button class="c-btn" id="b-doctor" onclick="runDoctor()">DOCTOR</button>
+ <button class="c-btn" id="b-speed" onclick="runSpeed()">SPEED TEST</button>
+ </div>
+
+ <div class="meters">
+ <div class="meter" id="m-rx" onclick="release('rx')">
+ <span class="mode-tag" id="mt-rx">LIVE</span>
+ <span class="hold-tag">HOLD</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="n-rx"></div><div class="hub"></div></div>
+ <div class="m-value"><span id="v-rx">0.1</span> <small>Mbps</small></div>
+ <div class="m-label">RX · DOWN</div>
+ </div>
+ <div class="meter" id="m-tx" onclick="release('tx')">
+ <span class="mode-tag" id="mt-tx">LIVE</span>
+ <span class="hold-tag">HOLD</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="n-tx"></div><div class="hub"></div></div>
+ <div class="m-value"><span id="v-tx">0.1</span> <small>Mbps</small></div>
+ <div class="m-label">TX · UP</div>
+ </div>
+ </div>
+ <div class="meter-note" id="m-note"></div>
+
+ <div class="outwrap" id="outwrap">
+ <button class="o-clear" onclick="clearOut('out')" title="Dismiss results">✕</button>
+ <div class="well output" id="out"></div>
+ </div>
+ <div class="toast" id="toastEl"></div>
+ </div>
+ <button class="reopen" id="reopen" onclick="openPanel()">reopen NET·01 (bar click)</button>
+ </div>
+
+ <!-- ============================ BT·01 ============================ -->
+ <div class="slot">
+ <div class="slot-label">bt·01 — new, same idioms</div>
+ <div class="panel" id="bp">
+ <div class="overlay" id="bov">
+ <div class="dlg">
+ <h4 id="bdlg-title">Pair device</h4>
+ <div class="sub" id="bdlg-sub">confirm the passkey matches the device</div>
+ <div class="passkey" id="bdlg-key" style="display:none">847 291</div>
+ <input id="bdlg-name" placeholder="device name" style="display:none">
+ <div class="dlg-btns">
+ <button class="btn quiet" onclick="bdlgClose()">Cancel</button>
+ <button class="btn" id="bdlg-go" onclick="bdlgGo()">Confirm</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="b-face">
+ <div class="b-id">
+ <span class="lamp" id="b-lamp"></span>
+ <span class="state-word" id="b-state">POWERED</span>
+ <span class="badge red" id="b-badge">LOW BATT</span>
+ <span class="badge" id="b-air-badge">AIRPLANE</span>
+ <span class="unit">BT·01</span>
+ <span class="switch on" id="b-power" onclick="btPower()" title="Adapter power"></span>
+ <button class="x-btn" onclick="closeBt()" title="Close (Esc)">✕</button>
+ </div>
+ </div>
+
+ <div class="engrave">adapter</div>
+ <div class="chan">
+ <div class="line1"><span class="ssid">intel ax211</span>
+ <span class="dim" style="font-size:11px;margin-left:auto">hci0</span></div>
+ <div class="line2" id="b-adapter-line">
+ <span class="chip" id="b-disco" onclick="btDisco()">discoverable off</span>
+ <span> · </span><span id="b-conn-count">1 device connected</span></div>
+ </div>
+
+ <div class="engrave">paired<span class="cnt" id="b-paired-count"></span></div>
+ <div id="b-paired" class="sec-scroll"></div>
+
+ <div class="engrave">nearby<span class="cnt" id="b-nearby-count"></span><span class="act" id="b-scan-note"></span></div>
+ <div id="b-nearby" class="sec-scroll"></div>
+
+ <div class="engrave">console</div>
+ <div class="console-btns">
+ <button class="c-btn" id="bb-doctor" onclick="btDoctor()">DOCTOR</button>
+ <button class="c-btn" id="bb-scan" onclick="btScan()">SCAN</button>
+ </div>
+
+ <div class="meters">
+ <div class="meter" id="bm-0">
+ <span class="mode-tag" id="bmt-0">LIVE</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="bn-0"></div><div class="hub"></div></div>
+ <div class="m-value" id="bvw-0"><span id="bv-0">72</span> <small>%</small></div>
+ <div class="m-label" id="bl-0">LOGI M650</div>
+ </div>
+ <div class="meter" id="bm-1">
+ <span class="mode-tag off" id="bmt-1">—</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle dead" id="bn-1"></div><div class="hub"></div></div>
+ <div class="m-value" id="bvw-1"><span id="bv-1">—</span></div>
+ <div class="m-label" id="bl-1">NO DEVICE</div>
+ </div>
+ </div>
+ <div class="meter-note">battery · connected devices</div>
+
+ <div class="outwrap" id="b-outwrap">
+ <button class="o-clear" onclick="clearOut('b-out')" title="Dismiss results">✕</button>
+ <div class="well output" id="b-out"></div>
+ </div>
+ <div class="toast" id="b-toastEl"></div>
+ </div>
+ <button class="reopen" id="b-reopen" onclick="openBt()">reopen BT·01 (bar click)</button>
+ </div>
+
+ <!-- ============================ NOTES ============================ -->
+ <div class="aside">
+ <h3>The bt mapping</h3>
+ <ul>
+ <li><b>Power switch on the faceplate</b> — flip it: devices drop, gauges die, keys disable, state goes OFF. Flip back: the mouse auto-reconnects. <em>(the switch-placement ask, in console form)</em></li>
+ <li><b>Battery fuel gauges</b> are BT's meters — one per connected device, needle at charge, red under 15% with a LOW BATT badge on the faceplate.</li>
+ <li><b>Paired rows toggle on click</b> (connect/disconnect), exactly like tunnels. Hover: ✎ rename <em>(the rename ask)</em>, ✕ arm-to-forget.</li>
+ <li><b>Nearby rows pair on click</b> — passkey confirm dialog, then the device moves up to PAIRED and connects. SCAN refreshes the neighborhood.</li>
+ <li><b>discoverable off</b> in the adapter line is a click-toggle (gold when on).</li>
+ <li><b>Disconnect is arm-first on the active row</b> — first click arms in gold ("disconnect? click again"), second fires. Gold, not terracotta: disruptive, not destructive.</li>
+ <li><b>NET·01 grew the wifi radio switch</b> (faceplate, same spot as BT's). Airplane mode is system-level: both switches drop, AIRPLANE badges light, and a switch flipped under airplane refuses with the way out. A plugged ethernet cable keeps NET·01 online through it.</li>
+ <li><b>DOCTOR does it all here too</b>: adapter → radio → service → powered → devices → audio profile. Tick the degraded-audio switch and run it: it finds HSP, flips to A2DP, verifies the sink followed.</li>
+ </ul>
+ <div class="demo-box">
+ <label><input type="checkbox" id="cafe" onchange="setScenario(this.checked)"> net: walk into a new café</label>
+ <label><input type="checkbox" id="breakdns"> net: broken hotel DNS (then DOCTOR)</label>
+ <label><input type="checkbox" id="ethercb" onchange="setEther(this.checked)"> net: plug in an ethernet cable</label>
+ <label><input type="checkbox" id="aircb" onchange="setAirplane(this.checked)"> both: airplane mode (Super+Shift+A)</label>
+ <label><input type="checkbox" id="airportcb" onchange="setAirport(this.checked)"> both: airport terminal (crowded airspace)</label>
+ <label><input type="checkbox" id="lowbatt" onchange="btLowBatt(this.checked)"> bt: mouse battery low</label>
+ <label><input type="checkbox" id="badaudio"> bt: degraded audio profile (then DOCTOR)</label>
+ <div class="hint">the audio one needs the headphones connected — click WH-1000XM4 first.</div>
+ <button class="reset" onclick="location.search=''">reset prototypes</button>
+ </div>
+ </div>
+</div>
+
+<script>
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+const $ = id => document.getElementById(id);
+const T = f => reduced ? Math.max(10, f*0.02) : f;
+
+/* =========================================================== NET·01 */
+let busy = false;
+const HOTEL_NETS = () => [
+ {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:true, sig:3, active:true},
+ {id:'meeting', ssid:'Hyatt_Meeting', sec:'WPA2', stored:false, range:true, sig:3, active:false},
+ {id:'roku', ssid:'DIRECT-roku-882',sec:'WPA2',stored:false, range:true, sig:2, active:false},
+ {id:'xfinity', ssid:'xfinitywifi', sec:null, stored:false, range:true, sig:1, active:false},
+ {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+];
+const CAFE_NETS = () => [
+ {id:'cafe5g', ssid:'CafeAmore_5G', sec:'WPA2', stored:false, range:true, sig:4, active:false, ip:'10.11.4.27/22 · gw 10.11.4.1'},
+ {id:'cafeg', ssid:'CafeAmore_Guest',sec:null, stored:false, range:true, sig:3, active:false, ip:'10.11.8.102/22 · gw 10.11.8.1'},
+ {id:'iot', ssid:'Neighbor_IoT', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+ {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+];
+const AIRPORT_NETS = () => [
+ {id:'ord', ssid:'ORD Free Wi-Fi', sec:null, stored:false, range:true, sig:4, active:false, ip:'10.40.2.19/18 · gw 10.40.0.1'},
+ {id:'boingo',ssid:'Boingo Hotspot', sec:null, stored:false, range:true, sig:3, active:false},
+ {id:'united',ssid:'United_Club', sec:'WPA2', stored:false, range:true, sig:3, active:false},
+ {id:'sky', ssid:'SkyClub_5G', sec:'WPA2', stored:false, range:true, sig:3, active:false},
+ {id:'aa', ssid:'AmericanAir-Lounge',sec:'WPA2', stored:false, range:true, sig:2, active:false},
+ {id:'sbux', ssid:'Starbucks WiFi', sec:null, stored:false, range:true, sig:2, active:false},
+ {id:'tom', ssid:"Tom's iPhone", sec:'WPA2', stored:false, range:true, sig:2, active:false},
+ {id:'hp', ssid:'HP-Print-88-Kiosk', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'gate', ssid:'Gate B12 Display', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'clear', ssid:'CLEAR-Kiosk', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'tsa', ssid:'TSA-Ops', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'dfw', ssid:'ORD-Employee', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+ {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+];
+const AIRPORT_NEARBY = () => [
+ {id:'ap1', name:'AirPods Pro', kind:'audio', passkey:'118 402'},
+ {id:'ap2', name:'AirPods (3rd gen)', kind:'audio', passkey:'220 981'},
+ {id:'gb2', name:'Galaxy Buds2', kind:'audio', passkey:'914 555'},
+ {id:'pbp', name:'Pixel Buds Pro', kind:'audio', passkey:'551 902'},
+ {id:'xm5', name:'Sony WF-1000XM5', kind:'audio', passkey:'774 210'},
+ {id:'jab', name:'Jabra Elite 7', kind:'audio', passkey:'333 190'},
+ {id:'aw', name:'Apple Watch', kind:'wearable',passkey:'602 118'},
+ {id:'gar', name:'Garmin Fenix 8', kind:'wearable',passkey:'488 007'},
+ {id:'tile', name:'Tile Tracker', kind:'tracker', passkey:'150 129'},
+ {id:'jbl2', name:'JBL Charge 5', kind:'audio', passkey:'847 291'},
+ {id:'bose', name:'Bose QC Ultra', kind:'audio', passkey:'962 340'},
+ {id:'gate2',name:'[TV] Gate B12', kind:'display', passkey:'302 118'},
+];
+let NETS = HOTEL_NETS();
+const WG = (id, who) => ({id, who, upWhat:'10.2.0.2/32 · route owner',
+ downWhat:'wireguard (NM) · down', up:false, ownsRoute:true, dev:'wgpvpn'});
+const TUNNELS = [
+ {id:'ts', who:'tailscale · velox', upWhat:'100.127.238.103 · 4/6 peers', downWhat:'down',
+ up:true, ownsRoute:false, dev:'tailscale0'},
+ WG('usny','USNY'), WG('usdc','USDC'), WG('uscala','USCALA'), WG('uscasf','USCASF'),
+ WG('usgaat','USGAAT'), WG('szur1','switzerlan-zurich1'), WG('szur2','switzerlan-zurich2'),
+ {id:'proton', who:'Proton VPN CLI', upWhat:'', downWhat:'down', up:false, needsLogin:true},
+];
+const tinit = () => ({ts:true, usny:false, usdc:false, uscala:false, uscasf:false,
+ usgaat:false, szur1:false, szur2:false, proton:false});
+const tstate = tinit();
+let connected = true;
+let routeBase = '172.20.2.108/20 · gw 172.20.0.1';
+let ether = { present:false, routed:false,
+ ip:'172.20.7.44/20 · gw 172.20.0.1', dev:'enp3s0' };
+let armed = null, armTimer = null;
+let wifiOn = true, airplane = false;
+let armedDisc = null, armDiscTimer = null;
+let lastSsid = '@Hyatt_WiFi';
+
+function renderNets(){
+ const host = $('networks'); host.innerHTML = '';
+ if (ether.present){
+ const row = document.createElement('div');
+ row.className = 'lamp-row';
+ row.innerHTML = `<span class="${ether.routed?'lamp':'lamp gold'}" id="eth-lamp"></span>`+
+ `<span class="who">${ether.routed?'<b>'+ether.dev+'</b>':ether.dev}</span>`+
+ `<span class="what" id="eth-what">${ether.routed?'active · wired · 1.0 Gbps':'wired · standby'}</span>`;
+ row.onclick = () => toggleEther();
+ host.appendChild(row);
+ }
+ if (!wifiOn){
+ const note = document.createElement('div');
+ note.className = 'lamp-row';
+ note.style.cursor = 'default';
+ note.innerHTML = `<span class="lamp off"></span><span class="who dim">wifi radio off</span>`+
+ `<span class="what">${airplane ? 'airplane mode' : 'flip the switch to scan'}</span>`;
+ host.appendChild(note);
+ tips('networks');
+ return;
+ }
+ const inRange = NETS.filter(n => n.range).sort((a,b) => (b.active-a.active) || (b.sig-a.sig));
+ const out = NETS.filter(n => !n.range);
+ $('net-count').textContent = '· ' + inRange.length + ' in range';
+ for (const n of [...inRange, ...out]){
+ const row = document.createElement('div');
+ row.className = 'lamp-row' + (armed===n.id ? ' armed' : '')
+ + (armedDisc===n.id ? ' armed-soft' : '');
+ const lamp = n.active ? 'lamp' : (n.range ? 'lamp gold' : 'lamp off');
+ const what = armed===n.id ? 'forget? click ✕ again'
+ : armedDisc===n.id ? 'disconnect? click again'
+ : n.active ? (ether.present && ether.routed ? 'connected · standby · ' : 'active · ') + (n.sec || 'open')
+ : !n.range ? 'stored · out of range'
+ : (n.stored ? 'stored · ' : '') + (n.sec || 'open') + ' · ' + [null,'22%','44%','61%','78%'][n.sig];
+ row.innerHTML = `<span class="${lamp}"></span>`+
+ `<span class="who">${n.active?'<b>'+n.ssid+'</b>':n.ssid}</span>`+
+ (n.range && !n.active ? `<span class="ladder l${n.sig}" style="margin-left:6px"><i></i><i></i><i></i><i></i></span>` : '')+
+ `<span class="what" id="nw-${n.id}">${what}</span>`+
+ (n.stored ? `<button class="zap" title="Forget ${n.ssid}">✕</button>` : '');
+ if (n.stored) row.querySelector('.zap').onclick = (e) => { e.stopPropagation(); armForget(n.id); };
+ row.onclick = n.active ? () => armDisconnect(n.id) : () => joinNet(n.id);
+ host.appendChild(row);
+ }
+ tips('networks');
+}
+function armDisconnect(id){
+ if (busy) return;
+ const n = NETS.find(x=>x.id===id);
+ if (armedDisc === id){ // second click: disconnect
+ clearTimeout(armDiscTimer); armedDisc = null;
+ busy = true;
+ const what = $('nw-'+id);
+ if (what) what.textContent = 'disconnecting…';
+ setTimeout(() => {
+ busy = false;
+ n.active = false; connected = false;
+ if (!(ether.present && ether.routed)) tstate.ts = false;
+ netFace();
+ renderTunnels(); renderNets();
+ toast('disconnected from ' + n.ssid);
+ }, T(1100));
+ return;
+ }
+ armedDisc = id; renderNets(); // first click: arm (gold — disruptive, not destructive)
+ clearTimeout(armDiscTimer);
+ armDiscTimer = setTimeout(() => { armedDisc = null; renderNets(); }, 3000);
+}
+function armForget(id){
+ const n = NETS.find(x=>x.id===id);
+ if (armed === id){
+ clearTimeout(armTimer); armed = null;
+ NETS.splice(NETS.indexOf(n), 1); renderNets();
+ toast(`${n.ssid} forgotten`);
+ return;
+ }
+ armed = id; renderNets();
+ clearTimeout(armTimer);
+ armTimer = setTimeout(() => { armed = null; renderNets(); }, 3000);
+}
+let joining = null;
+function joinNet(id){
+ if (busy) return;
+ if (!wifiOn){ toast('wifi radio is off', true); return; }
+ const n = NETS.find(x=>x.id===id);
+ if (!n.range){ toast(n.ssid + ' is out of range', true); return; }
+ if (n.sec && !n.stored){ joining = n; dlgJoin(n); return; }
+ doJoin(n);
+}
+function doJoin(n){
+ busy = true;
+ const what = $('nw-'+n.id);
+ if (what) what.textContent = 'joining…';
+ lampState('JOINING','gold');
+ setTimeout(() => {
+ NETS.forEach(x => x.active = false);
+ n.active = true; n.stored = true;
+ busy = false;
+ $('ch-ssid').textContent = n.ssid;
+ $('ch-dbm').textContent = ['','-82 dBm','-74 dBm','-63 dBm','-55 dBm'][n.sig] + ' · 41 ms';
+ $('ch-ladder').className = 'ladder l'+n.sig;
+ connected = true;
+ lastSsid = n.ssid;
+ routeBase = n.ip || '172.20.2.108/20 · gw 172.20.0.1';
+ netFace();
+ if (!tstate.ts) tstate.ts = true;
+ renderTunnels(); renderNets();
+ toast('joined ' + n.ssid + ' — saved for next time');
+ }, T(1600));
+}
+function dlgJoin(n){
+ $('dlg-title').textContent = 'Join ' + n.ssid;
+ $('dlg-sub').textContent = n.sec + ' — password required';
+ $('dlg-ssid').style.display = 'none';
+ $('dlg-pass').value = '';
+ $('ov').classList.add('show');
+ $('dlg-pass').focus();
+}
+function dlgHidden(){
+ joining = 'hidden';
+ $('dlg-title').textContent = 'Join hidden network';
+ $('dlg-sub').textContent = 'SSID is not broadcast — enter it exactly';
+ $('dlg-ssid').style.display = 'block'; $('dlg-ssid').value = '';
+ $('dlg-pass').value = '';
+ $('ov').classList.add('show');
+ $('dlg-ssid').focus();
+}
+function dlgClose(){ $('ov').classList.remove('show'); joining = null; }
+function dlgGo(){
+ if (joining === 'hidden'){
+ const ssid = $('dlg-ssid').value.trim() || 'hidden-net';
+ const n = {id:'h'+Date.now(), ssid, sec:'WPA2', stored:true, range:true, sig:2, active:false};
+ NETS.splice(0,0,n); renderNets(); dlgClose(); doJoin(n);
+ return;
+ }
+ const n = joining; dlgClose(); if (n) doJoin(n);
+}
+
+function renderTunnels(){
+ const host = $('tunnels'); host.innerHTML = '';
+ for (const t of TUNNELS){
+ const up = tstate[t.id];
+ const row = document.createElement('div');
+ row.className = 'lamp-row';
+ row.innerHTML = `<span class="${up?'lamp':'lamp off'}" id="tl-${t.id}"></span>`+
+ `<span class="who">${t.who}</span><span class="what" id="tw-${t.id}">${up?(t.upWhat||'up'):t.downWhat}</span>`;
+ row.onclick = () => toggleTunnel(t.id);
+ host.appendChild(row);
+ }
+ $('tun-count').textContent = '· ' + TUNNELS.filter(t=>tstate[t.id]).length + ' up of ' + TUNNELS.length;
+ tips('tunnels');
+ updateRoute();
+}
+function updateRoute(){
+ if (!connected && !(ether.present && ether.routed)){
+ $('ch-ssid').textContent = wifiOn ? '— not connected' : '— wifi radio off';
+ $('ch-ladder').style.display = 'none';
+ $('ch-dbm').textContent = '';
+ $('ch-route').textContent = !wifiOn ? (airplane ? 'airplane mode' : 'flip the radio switch to scan')
+ : 'join a network below';
+ $('badge').classList.remove('show');
+ return;
+ }
+ const owner = TUNNELS.find(t => t.ownsRoute && tstate[t.id]);
+ const wired = ether.present && ether.routed;
+ const base = wired ? ether.ip : routeBase;
+ const dev = wired ? ether.dev : 'wlp170s0';
+ $('ch-route').textContent = base + ' · route ' + (owner ? owner.dev + ' (tunnel)' : dev);
+ $('badge').classList.toggle('show', !!owner);
+ /* channel headline follows the routed link */
+ if (wired){
+ $('ch-ssid').textContent = ether.dev;
+ $('ch-ladder').style.display = 'none';
+ $('ch-dbm').textContent = 'wired · 1.0 Gbps full-duplex';
+ } else if (connected){
+ const act = NETS.find(n => n.active);
+ if (act){
+ $('ch-ssid').textContent = act.ssid;
+ $('ch-ladder').style.display = '';
+ $('ch-ladder').className = 'ladder l' + act.sig;
+ $('ch-dbm').textContent = ['','-82 dBm','-74 dBm','-63 dBm','-55 dBm'][act.sig] + ' · 44 ms';
+ }
+ } else {
+ $('ch-ssid').textContent = wifiOn ? '— not connected' : '— wifi radio off';
+ $('ch-ladder').style.display = 'none';
+ $('ch-dbm').textContent = '';
+ }
+}
+/* the faceplate state word, derived from one place */
+function netFace(){
+ const wired = ether.present && ether.routed;
+ $('air-badge').classList.toggle('show', airplane);
+ if (wired){ lampState('ONLINE'); return; }
+ if (connected){ lampState('ONLINE'); return; }
+ if (airplane){ lampState('AIRPLANE','gold'); return; }
+ if (!wifiOn){ lampState('OFF','off'); $('lamp').className='lamp off'; return; }
+ lampState('OFFLINE','red');
+}
+function toggleTunnel(id){
+ if (busy) return;
+ const t = TUNNELS.find(x=>x.id===id), up = tstate[id];
+ const lamp = $('tl-'+id), what = $('tw-'+id);
+ busy = true;
+ lamp.className = 'lamp busy';
+ what.textContent = t.needsLogin && !up ? 'connecting…' : up ? 'bringing down…' : 'bringing up…';
+ setTimeout(() => {
+ busy = false;
+ if (t.needsLogin && !up){
+ lamp.className = 'lamp red';
+ what.textContent = 'sign in first: protonvpn login';
+ toast('Proton: no account signed in — run: protonvpn login', true);
+ setTimeout(() => { lamp.className = 'lamp off'; what.textContent = t.downWhat; }, 2600);
+ return;
+ }
+ tstate[id] = !up;
+ renderTunnels();
+ toast(tstate[id] ? `${t.who} up` + (t.ownsRoute ? ' — default route moved to '+t.dev : '')
+ : `${t.who} down` + (t.ownsRoute ? ' — route back on wlp170s0' : ''));
+ }, T(1500));
+}
+
+let toastTimer;
+function toast(msg, err){
+ const el = $('toastEl');
+ el.textContent = msg; el.className = 'toast show' + (err ? ' err' : '');
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => el.className = 'toast', err ? 4200 : 2600);
+}
+function lampState(word, cls){
+ $('state').textContent = word;
+ $('lamp').className = 'lamp' + (cls && cls !== 'off' ? ' '+cls : cls === 'off' ? ' off' : '');
+}
+
+const held = {rx:false, tx:false};
+let testing = false, amb = 0;
+function meter(side, val){
+ const deg = -60 + Math.min(1, val/100) * 120;
+ $('n-'+side).style.transform = `rotate(${deg}deg)`;
+ $('v-'+side).textContent = val.toFixed(1);
+}
+function setMeterMode(side, mode){
+ const m = $('m-'+side), t = $('mt-'+side);
+ m.classList.toggle('testing', mode==='test');
+ m.classList.toggle('held', mode==='hold');
+ held[side] = mode==='hold';
+ t.textContent = mode==='live' ? 'LIVE' : 'TEST';
+ t.className = 'mode-tag' + (mode==='live' ? '' : ' test');
+}
+function release(side){
+ if (!held[side]) return;
+ setMeterMode(side, 'live');
+ if (!held.rx && !held.tx) $('m-note').textContent = '';
+}
+
+function runSpeed(){
+ if (busy) return;
+ busy = true; testing = true;
+ $('b-doctor').disabled = $('b-speed').disabled = true;
+ const out = $('out'); out.innerHTML = '';
+ setMeterMode('rx','test'); setMeterMode('tx','test');
+ $('m-note').textContent = 'measuring — needles follow the live rate';
+ const line = (k, v) => {
+ const el = document.createElement('div');
+ el.className = 'o-line'; el.innerHTML = `<b>${k}</b> ${v}`;
+ out.appendChild(el); out.scrollTop = out.scrollHeight;
+ };
+ setTimeout(() => line('location', 'Tulsa, OK (US) by Encore Communications'), T(900));
+ setTimeout(() => line('ping', '44.5 ms (jitter 2.1 ms)'), T(1800));
+ let dv = 0; const dT = 25.3;
+ setTimeout(() => {
+ const dTick = setInterval(() => {
+ dv = Math.min(dT, dv + 1.3 + Math.random()*2.1);
+ meter('rx', dv);
+ if (dv >= dT){
+ clearInterval(dTick);
+ setMeterMode('rx','hold'); meter('rx', dT);
+ let uv = 0; const uT = 90.8;
+ const uTick = setInterval(() => {
+ uv = Math.min(uT, uv + 4.5 + Math.random()*7);
+ meter('tx', uv);
+ if (uv >= uT){
+ clearInterval(uTick);
+ setMeterMode('tx','hold'); meter('tx', uT);
+ line('final', '25.3↓ 90.8↑ Mbps · 44.5 ms · loss 0.0%');
+ const tip = document.createElement('div');
+ tip.className = 'o-tip';
+ tip.textContent = 'tip: download well below upload — typical of a congested or shaped venue network. Try 5 GHz, move closer, or retest off-peak.';
+ out.appendChild(tip); out.scrollTop = out.scrollHeight;
+ $('m-note').textContent = 'result held — click a meter to go back to live';
+ testing = false; busy = false;
+ $('b-doctor').disabled = $('b-speed').disabled = false;
+ }
+ }, T(200));
+ }
+ }, T(185));
+ }, T(2100));
+}
+
+const CHECKS = [
+ {t:'Link', why:'is the adapter connected to a network (every later check rides the link)',
+ ev:'wlp170s0 connected (@Hyatt_WiFi)'},
+ {t:'DHCP / IPv4', why:'did the network lease us an IP address (nothing routes without one)',
+ ev:'172.20.2.108/20'},
+ {t:'Gateway', why:'does the router (first hop) answer a ping', ev:'172.20.0.1 [5 ms]'},
+ {t:'DNS config', why:'is a DNS resolver configured on the link', ev:'172.20.0.1'},
+ {t:'DNS resolution', why:'does a known hostname resolve (catches dead DNS and portal hijacks)',
+ ev:'names resolve (captive.apple.com) [48 ms]',
+ evBroken:'no resolution (portal may be stalling DNS)', canBreak:true},
+ {t:'Internet', why:'does an HTTP probe reach the open internet (the online/captive verdict)',
+ ev:'open internet (HTTP 204) [112 ms]',
+ evBroken:'link up but no clean internet (DNS or egress issue)', canBreak:true},
+];
+const REPAIR = {t:'repair: dns-test', why:'points DNS at 1.1.1.1, tests resolution, then reverts (tells a broken venue resolver from blocked egress)',
+ ev:'1.1.1.1 resolved captive.apple.com — the hotel resolver is broken, not the link'};
+const REPAIR2 = {t:'repair: dns-override', why:'sets 1.1.1.1 on the link until reconnect (works around the broken venue resolver)',
+ ev:'DNS set to 1.1.1.1 on wlp170s0'};
+
+function addStep(host, step, repair){
+ const el = document.createElement('div');
+ el.className = 'o-step' + (repair ? ' repair' : '');
+ el.innerHTML = `<span class="lamp busy"></span><span class="t"><b>${step.t}</b>`+
+ `<span class="why">${step.why}</span><span class="ev">…</span></span>`;
+ host.appendChild(el); host.scrollTop = host.scrollHeight;
+ return el;
+}
+function landStep(el, ev, cls){
+ el.querySelector('.lamp').className = 'lamp' + (cls ? ' '+cls : '');
+ el.querySelector('.ev').textContent = ev;
+ el.closest('.output').scrollTop = 1e6;
+}
+function verdict(host, text, ok){
+ const v = document.createElement('div');
+ v.className = 'o-verdict' + (ok ? ' ok' : '');
+ v.textContent = text;
+ host.appendChild(v); host.scrollTop = host.scrollHeight;
+}
+
+function runDoctor(){
+ if (busy) return;
+ busy = true;
+ $('b-doctor').disabled = $('b-speed').disabled = true;
+ const brokenDNS = $('breakdns').checked;
+ const out = $('out'); out.innerHTML = '';
+ lampState('CHECKING','gold');
+ const gap = T(620);
+ let i = 0;
+ const next = () => {
+ if (i >= CHECKS.length){ return brokenDNS ? repairPhase() : finish('Overall — everything checks out'); }
+ const c = CHECKS[i];
+ const el = addStep(out, c);
+ setTimeout(() => {
+ const broken = brokenDNS && c.canBreak;
+ landStep(el, broken ? c.evBroken : c.ev, broken ? 'red' : '');
+ i++; setTimeout(next, gap*0.35);
+ }, gap);
+ };
+ const repairPhase = () => {
+ lampState('FIXING','gold');
+ verdict(out, 'DNS not resolving — trying the lightest repair');
+ const r1 = addStep(out, REPAIR, true);
+ setTimeout(() => {
+ landStep(r1, REPAIR.ev);
+ const r2 = addStep(out, REPAIR2, true);
+ setTimeout(() => {
+ landStep(r2, REPAIR2.ev);
+ const re = addStep(out, {t:'re-check: Internet', why:'probe again through the repaired resolver', ev:''});
+ setTimeout(() => {
+ landStep(re, 'open internet (HTTP 204) [96 ms]');
+ $('breakdns').checked = false;
+ finish('fixed — back online after dns-override');
+ }, gap);
+ }, gap*1.2);
+ }, gap*1.4);
+ };
+ const finish = (text) => {
+ verdict(out, text, true);
+ lampState('ONLINE');
+ busy = false;
+ $('b-doctor').disabled = $('b-speed').disabled = false;
+ };
+ next();
+}
+
+function setEther(present){
+ if (busy) return;
+ ether.present = present;
+ if (present){
+ ether.routed = true; // cable wins the route by metric
+ lampState('ONLINE');
+ toast('link detected on ' + ether.dev + ' — route moved to wired');
+ } else {
+ ether.routed = false;
+ toast(connected ? 'cable unplugged — route back on wlp170s0'
+ : 'cable unplugged', !connected);
+ if (!connected) lampState('OFFLINE','red');
+ }
+ renderTunnels(); renderNets();
+}
+function toggleEther(){
+ if (busy) return;
+ const lamp = $('eth-lamp'), what = $('eth-what');
+ busy = true;
+ lamp.className = 'lamp busy';
+ what.textContent = ether.routed ? 'standing by…' : 'taking the route…';
+ setTimeout(() => {
+ busy = false;
+ ether.routed = !ether.routed;
+ renderTunnels(); renderNets();
+ toast(ether.routed ? 'route moved to ' + ether.dev + ' (wired)'
+ : 'route back on wlp170s0 — ' + ether.dev + ' standing by');
+ }, T(1200));
+}
+
+function wifiPower(){
+ if (busy) return;
+ if (airplane){ toast('airplane mode is on — Super+Shift+A to leave it', true); return; }
+ setWifi(!wifiOn);
+}
+function setWifi(on, quiet){
+ wifiOn = on;
+ $('n-power').classList.toggle('on', on);
+ if (!on){
+ const act = NETS.find(n => n.active);
+ if (act){ lastSsid = act.ssid; act.active = false; }
+ connected = false;
+ if (!(ether.present && ether.routed)) tstate.ts = false;
+ netFace(); renderTunnels(); renderNets();
+ if (!quiet) toast('wifi radio off');
+ } else {
+ netFace(); renderTunnels(); renderNets();
+ if (!quiet) toast('wifi radio on — rejoining ' + lastSsid);
+ const n = NETS.find(x => x.ssid === lastSsid && x.range);
+ if (n) setTimeout(() => doJoin(n), T(700));
+ }
+}
+function setAirplane(on){
+ airplane = on;
+ if (on){
+ if (wifiOn) setWifi(false, true);
+ if (bpower) btPowerSet(false, true);
+ netFace();
+ $('b-air-badge').classList.add('show');
+ toast('airplane mode — all radios off');
+ } else {
+ $('b-air-badge').classList.remove('show');
+ setWifi(true, true);
+ btPowerSet(true, true);
+ netFace();
+ toast('airplane mode off — radios back up');
+ }
+}
+
+function setAirport(on){
+ if (busy || bbusy) return;
+ if (on && $('cafe').checked){ $('cafe').checked = false; }
+ NETS = on ? AIRPORT_NETS() : HOTEL_NETS();
+ NEARBY.length = 0;
+ NEARBY.push(...(on ? AIRPORT_NEARBY()
+ : [{id:'jbl', name:'JBL Flip 6', kind:'audio', passkey:'847 291'},
+ {id:'tv', name:'[TV] Samsung Q70', kind:'display', passkey:'302 118'}]));
+ if (on){
+ tstate.ts = false; connected = false;
+ lampState('OFFLINE','red');
+ toast('ORD concourse B — pick a network');
+ } else {
+ tstate.ts = true; connected = true;
+ routeBase = '172.20.2.108/20 · gw 172.20.0.1';
+ NETS.find(n=>n.id==='hyatt').active = true;
+ lampState('ONLINE');
+ }
+ renderTunnels(); renderNets(); renderBt();
+}
+
+function setScenario(cafe){
+ if (busy) return;
+ if (cafe && $('airportcb') && $('airportcb').checked){ $('airportcb').checked = false; }
+ NETS = cafe ? CAFE_NETS() : HOTEL_NETS();
+ if (cafe){
+ tstate.ts = false; connected = false;
+ $('ch-ssid').textContent = '— not connected';
+ $('ch-dbm').textContent = '';
+ $('ch-ladder').className = 'ladder';
+ lampState('OFFLINE','red');
+ } else {
+ tstate.ts = true; connected = true;
+ routeBase = '172.20.2.108/20 · gw 172.20.0.1';
+ $('ch-ssid').textContent = '@Hyatt_WiFi';
+ $('ch-dbm').textContent = '-59 dBm · 44 ms';
+ $('ch-ladder').className = 'ladder l3';
+ lampState('ONLINE');
+ }
+ renderTunnels(); renderNets();
+}
+
+function closePanel(){ $('p').classList.add('closed'); $('reopen').style.display='inline-block'; }
+function openPanel(){ $('p').classList.remove('closed'); $('reopen').style.display='none'; }
+
+/* =========================================================== BT·01 */
+let bbusy = false, bpower = true, bdisco = false, blow = false;
+const DEVS = [
+ {id:'m650', name:'Logi M650', kind:'mouse', paired:true, conn:true, batt:72, audio:false},
+ {id:'xm4', name:'WH-1000XM4', kind:'audio', paired:true, conn:false, batt:58, audio:true},
+ {id:'k3', name:'Keychron K3', kind:'keyboard', paired:true, conn:false, batt:34, audio:false},
+];
+const NEARBY = [
+ {id:'jbl', name:'JBL Flip 6', kind:'audio', passkey:'847 291'},
+ {id:'tv', name:'[TV] Samsung Q70', kind:'display', passkey:'302 118'},
+];
+let barmed = null, barmTimer = null;
+
+function btConnected(){ return DEVS.filter(d => d.conn); }
+
+function renderBt(){
+ /* paired rows */
+ const host = $('b-paired'); host.innerHTML = '';
+ for (const d of DEVS){
+ const row = document.createElement('div');
+ row.className = 'lamp-row' + (barmed===d.id ? ' armed' : '');
+ const lamp = !bpower ? 'lamp off' : d.conn ? 'lamp' : 'lamp off';
+ const battTxt = d.batt !== null && d.conn ? ` · battery ${d.batt}%` : '';
+ const what = barmed===d.id ? 'forget? click ✕ again'
+ : !bpower ? 'adapter off'
+ : d.conn ? d.kind + battTxt : d.kind + ' · not connected';
+ row.innerHTML = `<span class="${lamp}" id="bl-${d.id}"></span>`+
+ `<span class="who">${d.conn && bpower ? '<b>'+d.name+'</b>' : d.name}</span>`+
+ `<span class="what" id="bw-${d.id}">${what}</span>`+
+ `<button class="pen" title="Rename ${d.name}">✎</button>`+
+ `<button class="zap" title="Forget ${d.name}">✕</button>`;
+ row.querySelector('.pen').onclick = (e) => { e.stopPropagation(); bdlgRename(d.id); };
+ row.querySelector('.zap').onclick = (e) => { e.stopPropagation(); btArmForget(d.id); };
+ if (bpower) row.onclick = () => btToggleDev(d.id);
+ host.appendChild(row);
+ }
+ /* nearby rows */
+ const nb = $('b-nearby'); nb.innerHTML = '';
+ for (const n of NEARBY){
+ const row = document.createElement('div');
+ row.className = 'lamp-row';
+ row.innerHTML = `<span class="${bpower?'lamp gold':'lamp off'}" id="bnl-${n.id}"></span>`+
+ `<span class="who">${n.name}</span><span class="what" id="bnw-${n.id}">${bpower ? n.kind+' · pairable' : 'adapter off'}</span>`;
+ if (bpower) row.onclick = () => btPair(n.id);
+ nb.appendChild(row);
+ }
+ /* adapter line + faceplate */
+ const c = btConnected().length;
+ $('b-conn-count').textContent = bpower ? (c === 1 ? '1 device connected' : c + ' devices connected') : 'adapter off';
+ $('b-paired-count').textContent = '· ' + DEVS.length;
+ $('b-nearby-count').textContent = '· ' + NEARBY.length;
+ $('b-disco').textContent = 'discoverable ' + (bdisco && bpower ? 'on' : 'off');
+ $('b-disco').className = 'chip' + (bdisco && bpower ? ' on' : '');
+ renderGauges();
+ updateBtBadge();
+ tips('b-paired'); tips('b-nearby');
+ for (const el of document.querySelectorAll('#bp .m-label')) el.title = el.textContent;
+}
+
+function renderGauges(){
+ const conns = btConnected();
+ for (let i = 0; i < 2; i++){
+ const d = bpower ? conns[i] : null;
+ const needle = $('bn-'+i), val = $('bv-'+i), label = $('bl-'+i),
+ tag = $('bmt-'+i), wrap = $('bvw-'+i);
+ if (!d){
+ needle.className = 'needle dead';
+ needle.style.transform = 'rotate(-60deg)';
+ val.textContent = '—'; wrap.className = 'm-value';
+ label.textContent = bpower ? 'NO DEVICE' : 'ADAPTER OFF';
+ tag.textContent = '—'; tag.className = 'mode-tag off';
+ continue;
+ }
+ const low = d.batt < 15;
+ needle.className = 'needle' + (low ? ' low' : '');
+ needle.style.transform = `rotate(${-60 + (d.batt/100)*120}deg)`;
+ val.innerHTML = d.batt; wrap.className = 'm-value' + (low ? ' low' : '');
+ wrap.innerHTML = `<span id="bv-${i}">${d.batt}</span> <small>%</small>`;
+ label.textContent = d.name.toUpperCase();
+ tag.textContent = 'LIVE'; tag.className = 'mode-tag';
+ }
+}
+
+function updateBtBadge(){
+ const low = bpower && btConnected().some(d => d.batt < 15);
+ $('b-badge').classList.toggle('show', low);
+}
+
+function btToggleDev(id){
+ if (bbusy || !bpower) return;
+ const d = DEVS.find(x=>x.id===id);
+ const lamp = $('bl-'+id), what = $('bw-'+id);
+ bbusy = true;
+ lamp.className = 'lamp busy';
+ what.textContent = d.conn ? 'disconnecting…' : 'connecting…';
+ setTimeout(() => {
+ bbusy = false;
+ d.conn = !d.conn;
+ renderBt();
+ btToast(d.conn ? `${d.name} connected` : `${d.name} disconnected`);
+ }, T(1300));
+}
+
+function btArmForget(id){
+ const d = DEVS.find(x=>x.id===id);
+ if (barmed === id){
+ clearTimeout(barmTimer); barmed = null;
+ DEVS.splice(DEVS.indexOf(d), 1); renderBt();
+ btToast(`${d.name} forgotten`);
+ return;
+ }
+ barmed = id; renderBt();
+ clearTimeout(barmTimer);
+ barmTimer = setTimeout(() => { barmed = null; renderBt(); }, 3000);
+}
+
+/* pairing + rename dialogs */
+let bdlgMode = null, bdlgTarget = null;
+function btPair(id){
+ if (bbusy) return;
+ const n = NEARBY.find(x=>x.id===id);
+ const lamp = $('bnl-'+id), what = $('bnw-'+id);
+ bbusy = true;
+ lamp.className = 'lamp busy';
+ what.textContent = 'pairing…';
+ setTimeout(() => {
+ bdlgMode = 'pair'; bdlgTarget = n;
+ $('bdlg-title').textContent = 'Pair ' + n.name;
+ $('bdlg-sub').textContent = 'confirm this passkey shows on the device';
+ $('bdlg-key').style.display = 'block';
+ $('bdlg-key').textContent = n.passkey;
+ $('bdlg-name').style.display = 'none';
+ $('bdlg-go').textContent = 'Confirm';
+ $('bov').classList.add('show');
+ }, T(1200));
+}
+function bdlgRename(id){
+ const d = DEVS.find(x=>x.id===id);
+ bdlgMode = 'rename'; bdlgTarget = d;
+ $('bdlg-title').textContent = 'Rename device';
+ $('bdlg-sub').textContent = 'the alias lives on this machine (bluez set-alias)';
+ $('bdlg-key').style.display = 'none';
+ $('bdlg-name').style.display = 'block';
+ $('bdlg-name').value = d.name;
+ $('bdlg-go').textContent = 'Rename';
+ $('bov').classList.add('show');
+ $('bdlg-name').focus();
+}
+function bdlgClose(){
+ $('bov').classList.remove('show');
+ if (bdlgMode === 'pair'){ bbusy = false; renderBt(); btToast('pairing cancelled'); }
+ bdlgMode = null; bdlgTarget = null;
+}
+function bdlgGo(){
+ if (bdlgMode === 'pair'){
+ const n = bdlgTarget;
+ $('bov').classList.remove('show');
+ const what = $('bnw-'+n.id);
+ if (what) what.textContent = 'connecting…';
+ setTimeout(() => {
+ bbusy = false;
+ NEARBY.splice(NEARBY.indexOf(n), 1);
+ DEVS.push({id:n.id, name:n.name, kind:n.kind, paired:true, conn:true,
+ batt:n.kind==='audio' ? 91 : null, audio:n.kind==='audio'});
+ renderBt();
+ btToast(`${n.name} paired and connected`);
+ }, T(1100));
+ } else if (bdlgMode === 'rename'){
+ const d = bdlgTarget;
+ const name = $('bdlg-name').value.trim() || d.name;
+ d.name = name;
+ $('bov').classList.remove('show');
+ renderBt();
+ btToast(`renamed to ${name}`);
+ }
+ bdlgMode = null; bdlgTarget = null;
+}
+
+/* scan */
+const MORE_NEARBY = [
+ {id:'buds', name:'Pixel Buds Pro', kind:'audio', passkey:'551 902'},
+];
+function btScan(){
+ if (bbusy || !bpower) return;
+ $('b-scan-note').textContent = 'scanning…';
+ $('bb-scan').disabled = true;
+ setTimeout(() => {
+ if (MORE_NEARBY.length){
+ NEARBY.push(MORE_NEARBY.shift());
+ renderBt();
+ }
+ $('b-scan-note').textContent = '';
+ $('bb-scan').disabled = false;
+ btToast('scan complete — ' + NEARBY.length + ' nearby');
+ }, T(2200));
+}
+
+/* discoverable + power */
+function btDisco(){
+ if (!bpower) return;
+ bdisco = !bdisco;
+ renderBt();
+ btToast(bdisco ? 'discoverable for 2 minutes' : 'discoverable off');
+}
+function btPower(){
+ if (bbusy) return;
+ if (airplane){ btToast('airplane mode is on — Super+Shift+A to leave it', true); return; }
+ btPowerSet(!bpower);
+}
+function btPowerSet(on, quiet){
+ if (bbusy) return;
+ bpower = on;
+ $('b-power').classList.toggle('on', bpower);
+ if (!bpower){
+ bdisco = false;
+ DEVS.forEach(d => d._wasConn = d.conn);
+ DEVS.forEach(d => d.conn = false);
+ $('b-state').textContent = airplane ? 'AIRPLANE' : 'OFF';
+ $('b-lamp').className = airplane ? 'lamp gold' : 'lamp off';
+ $('bb-doctor').disabled = $('bb-scan').disabled = true;
+ renderBt();
+ if (!quiet) btToast('adapter powered off');
+ } else {
+ $('b-state').textContent = 'POWERED';
+ $('b-lamp').className = 'lamp';
+ $('bb-doctor').disabled = $('bb-scan').disabled = false;
+ renderBt();
+ if (quiet === true) { /* airplane restore: quiet */ }
+ /* the mouse auto-reconnects, like real life */
+ const mouse = DEVS.find(d => d._wasConn);
+ if (mouse){
+ setTimeout(() => {
+ const lamp = $('bl-'+mouse.id), what = $('bw-'+mouse.id);
+ if (lamp){ lamp.className = 'lamp busy'; what.textContent = 'reconnecting…'; }
+ setTimeout(() => { mouse.conn = true; renderBt(); btToast(mouse.name + ' reconnected'); }, T(1200));
+ }, T(600));
+ }
+ }
+}
+
+let btoastTimer;
+function btToast(msg, err){
+ const el = $('b-toastEl');
+ el.textContent = msg; el.className = 'toast show' + (err ? ' err' : '');
+ clearTimeout(btoastTimer);
+ btoastTimer = setTimeout(() => el.className = 'toast', err ? 4200 : 2600);
+}
+
+/* bt doctor — the real chain: adapter → radio → service → powered → devices → audio */
+function btChecks(){
+ const badAudio = $('badaudio').checked && DEVS.some(d => d.audio && d.conn);
+ return [
+ {t:'Adapter', why:'is a bluetooth adapter visible to the stack', ev:'intel ax211 (hci0)'},
+ {t:'Radio', why:'rfkill can block the radio in software or hardware', ev:'unblocked'},
+ {t:'Service', why:'is the bluez daemon running', ev:'bluetooth.service active'},
+ {t:'Powered', why:'radio on and ready to connect', ev:'powered on'},
+ {t:'Devices', why:'are paired devices reachable',
+ ev: btConnected().length ? btConnected().map(d=>d.name).join(', ') + ' connected' : 'none connected'},
+ {t:'Audio profile', why:'is a connected audio device on the high-quality profile (A2DP)',
+ ev: DEVS.some(d=>d.audio && d.conn) ? 'a2dp-sink' : 'no audio device connected',
+ evBroken:'stuck on headset-head-unit (HSP) — phone-call-grade audio', canBreak:badAudio},
+ ];
+}
+const BT_REPAIR = {t:'repair: a2dp-switch', why:'flips the card profile to A2DP and verifies the sink followed',
+ ev:'card profile set to a2dp-sink — sink followed'};
+
+function btDoctor(){
+ if (bbusy || !bpower) return;
+ bbusy = true;
+ $('bb-doctor').disabled = $('bb-scan').disabled = true;
+ const checks = btChecks();
+ const willRepair = checks.some(c => c.canBreak);
+ const out = $('b-out'); out.innerHTML = '';
+ $('b-state').textContent = 'CHECKING'; $('b-lamp').className = 'lamp gold';
+ const gap = T(620);
+ let i = 0;
+ const next = () => {
+ if (i >= checks.length){ return willRepair ? repairPhase() : finish('Overall — everything checks out'); }
+ const c = checks[i];
+ const el = addStep(out, c);
+ setTimeout(() => {
+ landStep(el, c.canBreak ? c.evBroken : c.ev, c.canBreak ? 'red' : '');
+ i++; setTimeout(next, gap*0.35);
+ }, gap);
+ };
+ const repairPhase = () => {
+ $('b-state').textContent = 'FIXING';
+ verdict(out, 'audio degraded — trying the lightest repair');
+ const r = addStep(out, BT_REPAIR, true);
+ setTimeout(() => {
+ landStep(r, BT_REPAIR.ev);
+ const re = addStep(out, {t:'re-check: Audio profile', why:'read the sink profile again after the switch', ev:''});
+ setTimeout(() => {
+ landStep(re, 'a2dp-sink [verified]');
+ $('badaudio').checked = false;
+ finish('fixed — high-quality audio restored');
+ }, gap);
+ }, gap*1.4);
+ };
+ const finish = (text) => {
+ verdict(out, text, true);
+ $('b-state').textContent = 'POWERED'; $('b-lamp').className = 'lamp';
+ bbusy = false;
+ $('bb-doctor').disabled = $('bb-scan').disabled = false;
+ };
+ next();
+}
+
+function btLowBatt(low){
+ blow = low;
+ const m = DEVS.find(d => d.id === 'm650');
+ if (m) m.batt = low ? 9 : 72;
+ renderBt();
+ if (low) btToast('Logi M650 battery low (9%)', true);
+}
+
+function closeBt(){ $('bp').classList.add('closed'); $('b-reopen').style.display='inline-block'; }
+function openBt(){ $('bp').classList.remove('closed'); $('b-reopen').style.display='none'; }
+
+/* shared ambience + keys */
+if (!reduced) setInterval(() => {
+ amb++;
+ if (!testing){
+ if (!held.rx) meter('rx', 0.1 + Math.abs(Math.sin(amb/3))*0.35);
+ if (!held.tx) meter('tx', 0.08 + Math.abs(Math.cos(amb/4))*0.25);
+ }
+}, 900);
+document.addEventListener('keydown', e => {
+ if (e.key === 'Escape'){
+ if ($('ov').classList.contains('show')) return dlgClose();
+ if ($('bov').classList.contains('show')) return bdlgClose();
+ closePanel(); closeBt();
+ }
+});
+
+function clearOut(id){ $(id).innerHTML = ''; }
+function tips(hostId){
+ for (const el of $(hostId).querySelectorAll('.what,.who,.m-label'))
+ el.title = el.textContent;
+}
+[['out','outwrap'],['b-out','b-outwrap']].forEach(([oid, wid]) => {
+ new MutationObserver(() => {
+ $(wid).classList.toggle('has', $(oid).childElementCount > 0);
+ }).observe($(oid), {childList: true});
+});
+
+renderNets(); renderTunnels(); renderBt();
+
+/* headless hooks */
+const auto = new URLSearchParams(location.search).get('auto');
+if (auto === 'btdoctor') btDoctor();
+if (auto === 'btdoctorfix'){
+ const xm4 = DEVS.find(d=>d.id==='xm4'); xm4.conn = true; renderBt();
+ $('badaudio').checked = true; btDoctor();
+}
+if (auto === 'btpair'){ btPair('jbl'); setTimeout(() => bdlgGo(), T(1800)); }
+if (auto === 'btpower'){ btPower(); }
+if (auto === 'btpoweron'){ btPower(); setTimeout(() => btPower(), T(600)); }
+if (auto === 'btlow'){ $('lowbatt').checked = true; btLowBatt(true); }
+if (auto === 'speed') runSpeed();
+if (auto === 'ether'){ $('ethercb').checked = true; setEther(true); }
+if (auto === 'air'){ $('aircb').checked = true; setAirplane(true); }
+if (auto === 'airether'){ $('ethercb').checked = true; setEther(true); $('aircb').checked = true; setAirplane(true); }
+if (auto === 'disc'){ armDisconnect('hyatt'); setTimeout(() => armDisconnect('hyatt'), T(600)); }
+if (auto === 'wifioff'){ setWifi(false); }
+if (auto === 'airport'){ $('airportcb').checked = true; setAirport(true); }
+if (auto === 'doctorfix'){ $('breakdns').checked = true; runDoctor(); }
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html b/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html
new file mode 100644
index 0000000..3329cdb
--- /dev/null
+++ b/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html
@@ -0,0 +1,251 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Net panel — rescan affordance</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.masthead{max-width:1200px;margin:0 auto 1.8rem}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:82ch}
+.masthead p b{color:var(--silver)}
+
+.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1200px;margin:0 auto;align-items:flex-start}
+.slot{width:400px}
+.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem}
+.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px;
+ box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px}
+
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto;box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite}
+@keyframes pulse{50%{opacity:.25}}
+
+.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px}
+.b-id{display:flex;align-items:center;gap:9px}
+.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em}
+.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto}
+
+.chan{margin-top:12px}
+.chan .line1{display:flex;align-items:baseline;gap:9px}
+.chan .ssid{color:var(--cream);font-weight:700;font-size:14.5px}
+.chan .line2{color:var(--dim);font-size:11.5px;margin-top:2px}
+.ladder{display:inline-flex;gap:2px;align-items:flex-end;height:12px}
+.ladder i{width:4px;background:var(--wash);border-radius:1px}
+.ladder i:nth-child(1){height:4px}.ladder i:nth-child(2){height:7px}
+.ladder i:nth-child(3){height:10px}.ladder i:nth-child(4){height:12px}
+.ladder.l1 i:nth-child(-n+1){background:var(--gold)}.ladder.l2 i:nth-child(-n+2){background:var(--gold)}
+.ladder.l3 i:nth-child(-n+3){background:var(--gold)}.ladder.l4 i{background:var(--gold)}
+
+/* engrave header with the rescan action */
+.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.24em;text-transform:uppercase;
+ display:flex;align-items:center;gap:8px;margin:14px 0 6px}
+.engrave::before{content:"";height:1px;background:var(--wash);width:10px;flex:0 0 auto}
+.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;cursor:pointer;
+ border-bottom:1px dotted transparent}
+.engrave .cnt:hover{color:var(--gold);border-bottom-color:var(--wash)}
+.engrave .cnt.scanning{color:var(--gold);animation:breathe 1.1s ease-in-out infinite;cursor:default;border-bottom-color:transparent}
+.engrave .spacer{flex:1;height:1px;background:var(--wash)}
+/* compact rescan glyph — sits right after the count, spins while scanning */
+.engrave .ricon{color:var(--dim);cursor:pointer;font-size:.82rem;display:inline-flex;line-height:1}
+.engrave .ricon:hover{color:var(--gold)}
+.engrave .ricon.spin{color:var(--gold);cursor:default;animation:spin .9s linear infinite}
+.engrave .act{color:var(--dim);letter-spacing:.06em;text-transform:none;font-size:.72rem;cursor:pointer}
+.engrave .act:hover{color:var(--gold)}
+@keyframes spin{to{transform:rotate(360deg)}}
+@keyframes breathe{0%,100%{opacity:1}50%{opacity:.35}}
+
+/* the list; section-breathe busy style pulses the whole well */
+#networks{border-radius:8px;transition:background .3s}
+#networks.breathe{animation:sectionbreathe 1.4s ease-in-out infinite}
+@keyframes sectionbreathe{0%,100%{background:transparent}50%{background:rgba(218,181,61,.06)}}
+
+.lamp-row{display:flex;align-items:center;gap:9px;padding:5px 6px;border-radius:7px;font-size:12.5px;cursor:pointer}
+.lamp-row:hover{background:var(--wash)}
+.lamp-row .who{color:var(--silver);white-space:nowrap}
+.lamp-row .who b{color:var(--cream)}
+.lamp-row .what{margin-left:auto;color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.lamp-row.fresh{animation:fadein .5s ease}
+@keyframes fadein{from{opacity:0;transform:translateY(-3px);background:rgba(218,181,61,.12)}to{opacity:1}}
+
+.toast{margin-top:10px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;
+ padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em}
+.toast.show{opacity:1}
+
+.aside{flex:1 1 320px;min-width:290px}
+.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:1.1rem 0 .5rem}
+.aside h3:first-child{margin-top:.2rem}
+.aside ul{list-style:none}
+.aside li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative}
+.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem}
+.aside li b{color:var(--cream);font-weight:700}
+.aside li em{color:var(--dim);font-style:normal}
+.demo{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:1rem}
+.demo .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.2em;text-transform:uppercase;margin-bottom:.5rem}
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden;margin-bottom:.7rem}
+.seg button{flex:1;font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 6px;cursor:pointer}
+.seg button:last-child{border-right:0}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+.demo .go{font:inherit;font-size:.8rem;color:var(--silver);background:transparent;border:1px solid var(--gold);
+ border-radius:8px;padding:.45rem 1rem;cursor:pointer}
+.demo .go:hover{background:rgba(218,181,61,.12)}
+.rec{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:.9rem;font-size:.82rem}
+.rec b{color:var(--gold)}
+@media (prefers-reduced-motion:reduce){*{animation:none!important}}
+</style>
+</head>
+<body>
+
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · net·01</div>
+ <h1>Networks — the rescan affordance</h1>
+ <p>Where does a WiFi rescan live, and how does it show it's working? The count "· N in range" is
+ really a live status field — the natural home for "scanning…". My lean: an explicit <b>⟳ rescan</b>
+ action in the engrave line (discoverable, same slot as "+ hidden"), with a <b>flash-and-fade</b>
+ busy state — the glyph spins, the count breathes, new rows fade in as found. The count is also
+ click-to-rescan as a shortcut. Use the selector on the right to feel each busy treatment.</p>
+</header>
+
+<div class="stage">
+ <div class="slot">
+ <div class="slot-label">net·01 — networks section</div>
+ <div class="panel">
+ <div class="b-face">
+ <div class="b-id">
+ <span class="lamp"></span>
+ <span class="state-word">ONLINE</span>
+ <span class="unit">NET·01</span>
+ </div>
+ </div>
+
+ <div class="engrave">channel</div>
+ <div class="chan">
+ <div class="line1"><span class="ssid">@Hyatt_WiFi</span>
+ <span class="ladder l3"><i></i><i></i><i></i><i></i></span>
+ <span class="dim" style="font-size:11px">-59 dBm · 44 ms</span></div>
+ <div class="line2">172.20.2.108/20 · gw 172.20.0.1 · route wlp170s0</div>
+ </div>
+
+ <div class="engrave">networks<span class="cnt" id="cnt" onclick="rescan()">· 5 in range</span>
+ <span class="ricon" id="rescan" onclick="rescan()" title="Rescan for networks">⟳</span>
+ <span class="spacer"></span>
+ <span class="act" onclick="toast('would open the hidden-SSID dialog')">+ hidden</span>
+ </div>
+ <div id="networks"></div>
+
+ <div class="toast" id="toast"></div>
+ </div>
+ </div>
+
+ <div class="aside">
+ <div class="demo">
+ <div class="lbl">busy feedback style</div>
+ <div class="seg" id="styleSeg">
+ <button class="on" data-s="all">all (rec)</button>
+ <button data-s="spin">spinner</button>
+ <button data-s="count">count</button>
+ <button data-s="section">section</button>
+ </div>
+ <button class="go" onclick="rescan()">▶ run a rescan</button>
+ </div>
+
+ <h3>The three busy signals</h3>
+ <ul>
+ <li><b>Spinner</b> — the ⟳ glyph rotates while scanning. The clearest "working" cue; universal.</li>
+ <li><b>Count breathe</b> — "· 5 in range" becomes "scanning…" and slow-pulses. Your idea: the status field animates in place, no extra chrome.</li>
+ <li><b>Section breathe</b> — the whole list gives a faint gold breath while the scan runs; found rows fade in gold. Ambient, ties the animation to what's changing.</li>
+ <li><b>All (recommended)</b> — spinner + count + fade-in together. The section breathe is optional; it can read as busy on a small panel, so it's off in "all" by default and its own option to try.</li>
+ </ul>
+
+ <h3>Why an explicit action, not only the count</h3>
+ <div class="rec">
+ Overloading the count as the sole trigger is elegant but a first look doesn't know it's clickable.
+ An explicit <b>⟳ rescan</b> in the engrave line is discoverable, sits in the same slot as "+ hidden"
+ (consistent), and doesn't cost a heavy console key. Keeping the count clickable too gives power users
+ the shortcut without hiding the affordance. A dedicated <b>RESCAN console key</b> (next to DOCTOR /
+ SPEED TEST) is the third option — heavier, and rescan is a list action, not a diagnostic, so it fits
+ the engrave line better.
+ </div>
+ </div>
+</div>
+
+<script>
+const $=id=>document.getElementById(id);
+let busy=false, style='all';
+let NETS=[
+ {ssid:'@Hyatt_WiFi', sig:3, sec:'WPA2', stored:true, active:true},
+ {ssid:'Hyatt_Meeting', sig:3, sec:'WPA2', stored:false, active:false},
+ {ssid:'DIRECT-roku-882',sig:2, sec:'WPA2', stored:false, active:false},
+ {ssid:'xfinitywifi', sig:1, sec:null, stored:false, active:false},
+ {ssid:'HomeNet', sig:0, sec:'WPA2', stored:true, active:false, oor:true},
+];
+const NEWFOUND=[
+ {ssid:'Hyatt_Guest', sig:2, sec:null, stored:false, active:false},
+ {ssid:'Marriott_CONF', sig:1, sec:'WPA2', stored:false, active:false},
+];
+const pctFor=[null,'22%','44%','61%','78%'];
+
+$('styleSeg').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
+ style=b.dataset.s;[...$('styleSeg').children].forEach(x=>x.classList.toggle('on',x===b));});
+
+function rowEl(n,fresh){
+ const r=document.createElement('div'); r.className='lamp-row'+(fresh?' fresh':'');
+ const lamp=n.active?'lamp':(n.oor?'lamp off':'lamp gold');
+ const what=n.active?'active · '+(n.sec||'open')
+ :n.oor?'stored · out of range'
+ :(n.stored?'stored · ':'')+(n.sec||'open')+' · '+pctFor[n.sig];
+ r.innerHTML=`<span class="${lamp}"></span><span class="who">${n.active?'<b>'+n.ssid+'</b>':n.ssid}</span>`+
+ (!n.active&&!n.oor?`<span class="ladder l${n.sig}" style="margin-left:6px"><i></i><i></i><i></i><i></i></span>`:'')+
+ `<span class="what">${what}</span>`;
+ r.onclick=()=>{ if(busy)return; toast(n.active?'already on '+n.ssid:'would join '+n.ssid); };
+ return r;
+}
+function render(freshSet){
+ const host=$('networks'); host.innerHTML='';
+ const inRange=NETS.filter(n=>!n.oor).sort((a,b)=>(b.active-a.active)||(b.sig-a.sig));
+ const oor=NETS.filter(n=>n.oor);
+ [...inRange,...oor].forEach(n=>host.appendChild(rowEl(n,freshSet&&freshSet.has(n.ssid))));
+ if(!busy) $('cnt').textContent='· '+inRange.length+' in range';
+}
+function rescan(){
+ if(busy) return; busy=true;
+ const useSpin = style==='all'||style==='spin';
+ const useCount= style==='all'||style==='count';
+ const useSec = style==='section';
+ if(useSpin) $('rescan').classList.add('spin');
+ if(useCount){ $('cnt').classList.add('scanning'); $('cnt').textContent='scanning…'; }
+ if(useSec) $('networks').classList.add('breathe');
+ toast('scanning for networks…');
+ // networks trickle in as "found"
+ const fresh=new Set();
+ setTimeout(()=>{ NETS.splice(1,0,NEWFOUND[0]); fresh.add(NEWFOUND[0].ssid); render(fresh); },900);
+ setTimeout(()=>{ NETS.splice(3,0,NEWFOUND[1]); fresh.add(NEWFOUND[1].ssid); render(fresh); },1700);
+ setTimeout(()=>{
+ busy=false;
+ $('rescan').classList.remove('spin');
+ $('cnt').classList.remove('scanning');
+ $('networks').classList.remove('breathe');
+ const n=NETS.filter(x=>!x.oor).length;
+ $('cnt').textContent='· '+n+' in range';
+ toast('scan complete — '+n+' networks in range');
+ // reset for the next run so the demo is repeatable
+ NETS=NETS.filter(x=>x.ssid!=='Hyatt_Guest'&&x.ssid!=='Marriott_CONF');
+ },2600);
+}
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-sound-panel-prototype.html b/docs/prototypes/2026-07-03-sound-panel-prototype.html
new file mode 100644
index 0000000..d75f566
--- /dev/null
+++ b/docs/prototypes/2026-07-03-sound-panel-prototype.html
@@ -0,0 +1,417 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Sound — instrument console (pulsemixer)</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.masthead{max-width:1280px;margin:0 auto 1.8rem}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:82ch}
+.masthead p b{color:var(--silver);font-weight:700}
+
+.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1280px;margin:0 auto;align-items:flex-start}
+.slot{width:400px}
+.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem}
+
+.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px;
+ box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px;position:relative;overflow:hidden}
+
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto;
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+
+.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px}
+.b-id{display:flex;align-items:center;gap:9px}
+.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em}
+.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto}
+.b-id .g{font-size:17px;color:var(--cream)}
+.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);
+ border-radius:4px;padding:1px 6px;display:none}
+.badge.show{display:inline-block}
+.badge.red{background:var(--fail);color:var(--cream)}
+.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem;
+ cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto}
+.x-btn:hover{background:var(--wash);color:var(--silver)}
+/* faceplate master quick-mute — same switch idiom as net wifi / bt power */
+.switch{width:38px;height:20px;border-radius:10px;background:var(--wash);
+ border:1px solid var(--slate);position:relative;flex:0 0 auto;cursor:pointer}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;
+ border-radius:50%;background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}
+.switch.on::after{left:19px;background:var(--gold)}
+.switch.muted{background:rgba(203,107,77,.2);border-color:var(--fail)}
+.switch.muted::after{background:var(--fail);left:2px}
+
+.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.32em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px;margin:13px 0 6px}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave::before{max-width:12px}
+.engrave .cnt{color:var(--dim);letter-spacing:.12em;margin-left:2px;text-transform:none;font-size:.62rem}
+
+/* device row — a FIXED grid so nothing can overflow the plate:
+ [signal] [name] [fader] [pct] [mute]. minmax(0,1fr) lets the name shrink
+ and ellipsis instead of forcing the row wider than the panel. */
+.dev{display:grid;grid-template-columns:15px minmax(0,1fr) 84px 32px 20px;
+ align-items:center;gap:8px;padding:6px 5px;border-radius:8px;cursor:default}
+.dev:hover{background:var(--wash)}
+.dev .who{min-width:0;display:flex;align-items:center;gap:6px;color:var(--silver)}
+.dev .who .g{font-size:14px;color:var(--dim);flex:0 0 auto}
+.dev.active .who .g{color:var(--gold)}
+.dev .who .nm{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.dev.active .who .nm{color:var(--cream);font-weight:700}
+.dev.muted .who .nm{color:var(--dim)}
+.dev .def{font-size:.5rem;letter-spacing:.14em;color:var(--panel);background:var(--gold);
+ border-radius:3px;padding:0 4px;flex:0 0 auto;display:none}
+.dev.active .def{display:inline-block}
+/* fader — machined slot + gold cap; width-based fill, bounded to its cell */
+.fader{height:16px;position:relative;cursor:pointer}
+.fader .trk{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:var(--well);
+ border:1px solid #231f18;overflow:hidden}
+.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold))}
+.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px;
+ background:linear-gradient(180deg,#f0d879,#caa233);border:1px solid #7a6414;box-shadow:0 1px 2px rgba(0,0,0,.5)}
+.dev.muted .fill{background:var(--wash)}
+.dev.muted .cap{background:linear-gradient(180deg,#6a6a6a,#3f3f3f);border-color:#2a2a2a}
+.pct{color:var(--cream);font-size:11.5px;font-variant-numeric:tabular-nums;text-align:right}
+.dev.muted .pct{color:var(--fail)}
+.mute-b{color:var(--dim);border:0;background:transparent;font:inherit;font-size:.9rem;cursor:pointer;
+ border-radius:5px;padding:0;justify-self:center;line-height:1}
+.mute-b:hover{color:var(--silver)}
+.dev.muted .mute-b{color:var(--fail)}
+/* per-row signal mini-meter — 4 bars that light when THIS device has a live
+ stream, so the sink/source actually playing is visible before you pick it */
+.sig{display:flex;align-items:flex-end;gap:1.5px;height:14px;justify-self:center}
+.sig i{width:2.5px;background:var(--wash);border-radius:1px}
+.sig i:nth-child(1){height:4px}.sig i:nth-child(2){height:7px}
+.sig i:nth-child(3){height:10px}.sig i:nth-child(4){height:13px}
+.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)}
+
+/* mic mode — three console keys */
+.modes{display:flex;gap:8px;margin-top:2px}
+.mode{flex:1;text-align:center;cursor:pointer;font:inherit;font-size:11px;letter-spacing:.06em;
+ background:linear-gradient(180deg,#23211e,#191715);color:var(--silver);
+ border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:8px;padding:8px 4px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.mode:hover{color:var(--gold);border-color:var(--gold)}
+.mode.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.mode.on.mute{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream)}
+.mode .k{display:block;font-size:.54rem;letter-spacing:.14em;color:var(--dim);margin-top:2px}
+.mode.on .k{color:rgba(16,15,15,.7)}
+.ptt-hint{color:var(--dim);font-size:10.5px;text-align:center;margin-top:6px;min-height:1.2em}
+.ptt-hint.live{color:var(--gold)}
+
+/* meter row 1 — the volume dials you set (needles), OUT + IN */
+.meters{display:flex;gap:12px;margin-top:10px}
+.meter{flex:1;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 10px 7px;position:relative}
+.meter .mode-tag{position:absolute;top:6px;left:8px;font-size:.56rem;letter-spacing:.2em;color:var(--pass)}
+.meter .mode-tag.mut{color:var(--fail)}
+.meter .dial{position:relative;height:50px;overflow:hidden;margin-top:13px}
+.meter .arc{position:absolute;inset:0 0 -50px 0;border:2px solid var(--wash);border-radius:50%}
+.meter .tick{position:absolute;left:50%;bottom:0;width:1.5px;height:9px;background:var(--steel);transform-origin:50% 50px}
+.meter .needle{position:absolute;left:50%;bottom:0;width:2px;height:42px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(20deg);border-radius:2px;
+ box-shadow:0 0 6px rgba(255,215,95,.5);transition:transform .3s cubic-bezier(.3,1.3,.5,1)}
+.meter .needle.mut{background:var(--fail);box-shadow:0 0 6px rgba(203,107,77,.5)}
+.meter .hub{position:absolute;left:50%;bottom:-4px;width:9px;height:9px;margin-left:-4.5px;border-radius:50%;background:var(--gold)}
+.meter .m-value{color:var(--cream);font-size:13px;text-align:center;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums}
+.meter .m-value small{color:var(--dim);font-weight:400}
+.meter .m-label{color:var(--steel);font-size:.6rem;letter-spacing:.2em;text-align:center;margin-top:2px;
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+
+/* meter row 2 — the stereo VU pair: the live SIGNAL through the selected
+ output. Confirms which device is actually carrying the audio. */
+.vupair{margin-top:10px;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 11px 8px}
+.vuhead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:7px}
+.vuhead .t{color:var(--steel);font-size:.56rem;letter-spacing:.2em;text-transform:uppercase}
+.vuhead .src{color:var(--cream);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}
+.vurow{display:flex;align-items:center;gap:8px;margin:4px 0}
+.vurow .ch{color:var(--steel);font-size:.62rem;width:9px;flex:0 0 auto}
+.vubar{flex:1;display:flex;gap:2px;height:9px}
+.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3}
+.vubar i.on{opacity:1;background:var(--pass)}
+.vubar i.hot{opacity:1;background:var(--gold)}
+.vubar i.clip{opacity:1;background:var(--fail)}
+.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px}
+.vupair.mute .vubar i{background:var(--wash);opacity:.22}
+.vupair.mute .vuhead .src{color:var(--fail)}
+
+.toast{margin-top:10px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;
+ padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em}
+.toast.show{opacity:1}
+.toast.err{background:transparent;border:1px solid var(--fail);color:var(--fail)}
+
+.aside{flex:1 1 320px;min-width:300px}
+.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:1.1rem 0 .5rem}
+.aside h3:first-child{margin-top:.2rem}
+.aside ul{list-style:none}
+.aside li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative}
+.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem}
+.aside li b{color:var(--cream);font-weight:700}
+.aside li em{color:var(--dim);font-style:normal}
+.barbits{display:flex;gap:14px;margin:.4rem 0 .2rem;align-items:center;flex-wrap:wrap}
+.barbits span{display:flex;align-items:center;gap:7px;color:var(--silver);font-size:.82rem}
+.barbits .g{font-size:19px;color:var(--cream)}
+.barbits .g.mut{color:var(--fail)}
+.rec{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:.9rem;font-size:.82rem;color:var(--silver)}
+.rec b{color:var(--gold)}
+*{scrollbar-width:thin;scrollbar-color:var(--slate) transparent}
+::-webkit-scrollbar{width:6px;height:6px}
+::-webkit-scrollbar-thumb{background:var(--slate);border-radius:4px}
+@media (prefers-reduced-motion:reduce){.needle{transition:none}}
+</style>
+</head>
+<body>
+
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · instrument consoles</div>
+ <h1>Sound — the pulsemixer console</h1>
+ <p>Same faceplate as net + bluetooth. The bar's <b>sound glyph</b> opens this. Every sink and
+ source is a row: <b>click the row body to make it default</b>, drag the fader for its volume,
+ hit the glyph to mute just it. Each row has a <b>live-signal meter</b> — the device actually
+ carrying audio dances even when it isn't the default, so you can <b>find the one playing the
+ meeting</b> and click it. The faceplate switch is the <b>master quick-mute</b>; the mic carries
+ <b>live · muted · push-to-talk</b> (hold Space). Row 1 of gauges is the volume you set; row 2 is
+ the <b>stereo VU</b> of the selected output's live signal.</p>
+</header>
+
+<div class="stage">
+ <div class="slot">
+ <div class="slot-label">snd·01 — pulsemixer in console form</div>
+ <div class="panel">
+
+ <div class="b-face">
+ <div class="b-id">
+ <span class="lamp" id="lamp"></span>
+ <span class="g" id="face-g">󰕾</span>
+ <span class="state-word" id="state">PLAYBACK</span>
+ <span class="badge red" id="mute-badge">MUTED</span>
+ <span class="unit">SND·01</span>
+ <span class="switch on" id="master" onclick="masterMute()" title="Master quick-mute (Super+Shift+M)"></span>
+ <button class="x-btn" title="Close (Esc)">✕</button>
+ </div>
+ </div>
+
+ <div class="engrave">outputs<span class="cnt" id="out-cnt"></span></div>
+ <div id="outputs"></div>
+
+ <div class="engrave">inputs<span class="cnt" id="in-cnt"></span></div>
+ <div id="inputs"></div>
+
+ <div class="engrave">mic mode</div>
+ <div class="modes">
+ <button class="mode" id="md-toggle" onclick="micToggle()">LIVE<span class="k">Super+Shift+A</span></button>
+ <button class="mode" id="md-ptt" onclick="micPtt()">PUSH·TALK<span class="k">hold Space</span></button>
+ </div>
+ <div class="ptt-hint" id="ptt-hint"></div>
+
+ <!-- meter row 1 — volume you set -->
+ <div class="meters">
+ <div class="meter">
+ <span class="mode-tag" id="vt-out">OUT VOL</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="n-out"></div><div class="hub"></div></div>
+ <div class="m-value"><span id="v-out">68</span> <small>%</small></div>
+ <div class="m-label" id="l-out">SPEAKERS</div>
+ </div>
+ <div class="meter">
+ <span class="mode-tag" id="vt-in">IN VOL</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="n-in"></div><div class="hub"></div></div>
+ <div class="m-value"><span id="v-in">54</span> <small>%</small></div>
+ <div class="m-label" id="l-in">BUILT-IN MIC</div>
+ </div>
+ </div>
+
+ <!-- meter row 2 — stereo VU of the selected output's live signal -->
+ <div class="vupair" id="vupair">
+ <div class="vuhead"><span class="t">signal · VU peak</span><span class="src" id="vu-src">SPEAKERS</span></div>
+ <div class="vurow"><span class="ch">L</span><span class="vubar" id="vu-l"></span></div>
+ <div class="vurow"><span class="ch">R</span><span class="vubar" id="vu-r"></span></div>
+ </div>
+
+ <div class="toast" id="toast"></div>
+ </div>
+ </div>
+
+ <div class="aside">
+ <h3>The bar glyph</h3>
+ <div class="barbits">
+ <span><span class="g">󰕾</span> normal — speaker + arcs</span>
+ <span><span class="g mut">󰝟</span> muted — speaker ✕</span>
+ <span><span class="g" style="color:var(--gold)">󰢮</span> ptt armed</span>
+ </div>
+ <h3>Idiom map (same as net / bt)</h3>
+ <ul>
+ <li><b>Faceplate switch = master quick-mute</b> — the net wifi / bt power switch, here muting all output. Flip: state → MUTED, lamp red, bar glyph → speaker-✕.</li>
+ <li><b>Rows are devices</b> — every sink + source. <b>Click the row body</b> to set default (gold DEF moves); the fader sets that device's volume; the trailing glyph mutes just it.</li>
+ <li><b>Per-row signal meter</b> — the 4 bars at the left of each row read that device's <b>live</b> level. A sink can carry a stream without being default, so the one playing lights up — <em>demo: the music is on WH-1000XM4 while Speakers is still default. Click WH-1000XM4 to move to it.</em></li>
+ <li><b>Two meter rows</b> — row 1 = the volume you set (OUT + IN needles); row 2 = the <b>stereo VU</b> (L/R) of the selected output's live signal, red when muted.</li>
+ <li><b>Mic = two console keys</b> — one toggles LIVE↔MUTED (the label flips to show the state), the other is PUSH·TALK: the mic sits muted and un-mutes only while Space is held.</li>
+ <li><b>Verify-everything</b> — every action re-reads pactl/wpctl state after firing, like net/bt.</li>
+ </ul>
+ <h3>Push-to-talk — the one hard part</h3>
+ <div class="rec">
+ Hold-to-talk needs a global key grab under Wayland. Two routes to spec: <b>(a)</b> a Hyprland
+ <b>bind pair</b> — <em>bindp</em> Space press → unmute, release → re-mute, armed only while PTT
+ mode is active (so it doesn't steal Space everywhere); or <b>(b)</b> an <b>evdev/libinput
+ listener</b> reading the key directly. (a) is lighter; (b) survives focus changes but needs
+ input-group permissions. Feasibility research is phase 1 of the spec.
+ </div>
+ </div>
+</div>
+
+<script>
+const $ = id => document.getElementById(id);
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+/* sig = base live level 0..1 (what stream is on this device right now).
+ Note the demo: music is on WH-1000XM4 (sig .72) while Speakers is default. */
+let OUT = [
+ {id:'spk', name:'Built-in Speakers', g:'󰓃', vol:68, mute:false, def:true, sig:0, ph:0.0},
+ {id:'xm4', name:'WH-1000XM4', g:'󰋋', vol:80, mute:false, def:false, sig:0.72,ph:1.7},
+ {id:'hdmi',name:'HDMI · Dell U2720', g:'󰡁', vol:100,mute:true, def:false, sig:0, ph:2.9},
+];
+let IN = [
+ {id:'bmic', name:'Built-in Mic', g:'󰍬', vol:54, mute:false, def:true, sig:0.16,ph:0.6},
+ {id:'yeti', name:'Blue Yeti USB', g:'󰦉', vol:70, mute:false, def:false, sig:0, ph:2.1},
+ {id:'xm4m', name:'WH-1000XM4 Headset',g:'󰋎', vol:60, mute:true, def:false, sig:0, ph:3.3},
+];
+let master = false, micMode = 'live', pttHeld = false;
+const pkL={v:0}, pkR={v:0};
+
+function outMutedOf(d){ return master || d.mute; }
+function inMutedOf(d){ return d.mute || (d.def && (micMode==='mute' || (micMode==='ptt' && !pttHeld))); }
+
+function fader(dev){
+ const w=document.createElement('div'); w.className='fader';
+ w.innerHTML=`<div class="trk"><div class="fill" style="width:${dev.vol}%"></div></div>`+
+ `<div class="cap" style="left:${dev.vol}%"></div>`;
+ w.onclick=(e)=>{e.stopPropagation();
+ const r=w.getBoundingClientRect();
+ dev.vol=Math.max(0,Math.min(100,Math.round((e.clientX-r.left)/r.width*100)));
+ if(dev.vol>0) dev.mute=false;
+ render(); toast(`${dev.name} → ${dev.vol}%`);
+ };
+ return w;
+}
+function row(dev,kind){
+ const muted = kind==='out'?outMutedOf(dev):inMutedOf(dev);
+ const r=document.createElement('div');
+ r.className='dev'+(dev.def?' active':'')+(muted?' muted':'');
+ r.title=dev.name;
+ const sig=document.createElement('span'); sig.className='sig'; sig.id='sig-'+dev.id;
+ sig.innerHTML='<i></i><i></i><i></i><i></i>';
+ const who=document.createElement('div'); who.className='who';
+ who.innerHTML=`<span class="g">${dev.g}</span><span class="nm">${dev.name}</span><span class="def">DEF</span>`;
+ const pct=document.createElement('span'); pct.className='pct'; pct.textContent=dev.mute?'mute':dev.vol+'%';
+ const mb=document.createElement('button'); mb.className='mute-b'; mb.textContent=dev.mute?'󰝟':'󰕾';
+ mb.title='mute '+dev.name;
+ mb.onclick=(e)=>{e.stopPropagation();dev.mute=!dev.mute;render();toast(`${dev.name} ${dev.mute?'muted':'unmuted'}`);};
+ r.append(sig,who,fader(dev),pct,mb);
+ r.onclick=()=>{ if(dev.def) return;
+ (kind==='out'?OUT:IN).forEach(d=>d.def=false); dev.def=true; render();
+ toast(`default ${kind==='out'?'output':'input'} → ${dev.name}`);
+ };
+ return r;
+}
+function render(){
+ const o=$('outputs'); o.innerHTML=''; OUT.forEach(d=>o.appendChild(row(d,'out')));
+ const i=$('inputs'); i.innerHTML=''; IN.forEach(d=>i.appendChild(row(d,'in')));
+ $('out-cnt').textContent='· '+OUT.length;
+ $('in-cnt').textContent='· '+IN.length;
+ const od=OUT.find(d=>d.def), id=IN.find(d=>d.def);
+ const oM=outMutedOf(od), iM=inMutedOf(id);
+ $('master').className='switch'+(master?' muted':' on');
+ $('state').textContent=oM?'MUTED':'PLAYBACK';
+ $('lamp').className='lamp'+(oM?' red':'');
+ $('face-g').textContent=oM?'󰝟':'󰕾';
+ $('mute-badge').classList.toggle('show',oM);
+ setNeedle('out',oM?0:od.vol,oM); $('v-out').textContent=oM?0:od.vol; $('l-out').textContent=od.name.toUpperCase();
+ setNeedle('in',iM?0:id.vol,iM); $('v-in').textContent=iM?0:id.vol; $('l-in').textContent=id.name.toUpperCase();
+ $('vt-out').textContent=oM?'OUT·MUTE':'OUT VOL'; $('vt-out').className='mode-tag'+(oM?' mut':'');
+ $('vt-in').textContent=iM?'IN·MUTE':'IN VOL'; $('vt-in').className='mode-tag'+(iM?' mut':'');
+ $('vu-src').textContent=od.name.toUpperCase();
+ $('vupair').classList.toggle('mute',oM);
+ // mic controls: one live/muted toggle + one push-to-talk key
+ const tg=$('md-toggle');
+ tg.textContent = micMode==='mute' ? 'MUTED' : 'LIVE';
+ tg.appendChild(Object.assign(document.createElement('span'),{className:'k',textContent:'Super+Shift+A'}));
+ tg.className = 'mode' + (micMode==='mute' ? ' on mute' : micMode==='live' ? ' on' : '');
+ $('md-ptt').className='mode'+(micMode==='ptt'?' on':'');
+ $('ptt-hint').textContent = micMode==='ptt'
+ ? (pttHeld?'▸ transmitting — Space held':'mic muted — hold Space to talk')
+ : micMode==='mute' ? 'mic muted' : '';
+ $('ptt-hint').className='ptt-hint'+(micMode==='ptt'&&pttHeld?' live':'');
+}
+function setNeedle(side,val,muted){
+ const deg=-60+Math.max(0,Math.min(1,val/100))*120;
+ const n=$('n-'+side); n.style.transform=`rotate(${deg}deg)`;
+ n.className='needle'+(muted?' mut':'');
+}
+
+/* live-signal animation — the per-row minis + the stereo VU pair */
+let phase=0;
+function level(dev,muted){
+ if(muted||!dev.sig) return 0;
+ const env=0.5+0.5*Math.abs(Math.sin(phase*1.25+dev.ph));
+ const trans=Math.random()<0.14?Math.random()*0.4:0;
+ return Math.max(0,Math.min(1,dev.sig*env+trans-Math.random()*0.07));
+}
+function paintMini(el,lvl){
+ const b=el.children, lit=Math.round(lvl*4);
+ for(let k=0;k<4;k++){ b[k].className = k<lit ? (k>=3?'clip':k>=2?'hot':'on') : ''; }
+}
+function paintVU(el,lvl,pk){
+ const b=el.children, n=b.length, lit=Math.round(lvl*n);
+ pk.v=Math.max(lit,pk.v-0.35); const p=Math.round(pk.v);
+ for(let k=0;k<n;k++){ let c = k<lit ? (k>=n-2?'clip':k>=n-4?'hot':'on') : '';
+ if(p>0 && k===p-1) c=(c?c+' ':'')+'peak'; b[k].className=c; }
+}
+function buildVU(el,n){ el.innerHTML=''; for(let k=0;k<n;k++) el.appendChild(document.createElement('i')); }
+function tick(){
+ phase+=0.09;
+ OUT.forEach(d=>{const el=$('sig-'+d.id); if(el) paintMini(el, d._l=level(d,outMutedOf(d)));});
+ IN.forEach(d=>{const el=$('sig-'+d.id); if(el) paintMini(el, d._l=level(d,inMutedOf(d)));});
+ const od=OUT.find(d=>d.def), oM=outMutedOf(od);
+ const base=oM?0:level(od,false);
+ paintVU($('vu-l'),Math.min(1,base*(0.92+Math.random()*0.16)),pkL);
+ paintVU($('vu-r'),Math.min(1,base*(0.92+Math.random()*0.16)),pkR);
+}
+
+function masterMute(){ master=!master; render(); toast(master?'ALL OUTPUT MUTED':'output unmuted'); }
+/* two mic buttons: toggle flips live<->muted (and leaves ptt); ptt arms/disarms */
+function micToggle(){ micMode = micMode==='mute' ? 'live' : 'mute'; pttHeld=false; render();
+ toast(micMode==='mute'?'mic muted':'mic live'); }
+function micPtt(){ micMode = micMode==='ptt' ? 'live' : 'ptt'; pttHeld=false; render();
+ toast(micMode==='ptt'?'push-to-talk armed — hold Space':'mic live'); }
+let tT;
+function toast(msg,err){ const t=$('toast'); t.textContent=msg;
+ t.className='toast show'+(err?' err':''); clearTimeout(tT); tT=setTimeout(()=>t.className='toast',2200); }
+addEventListener('keydown',e=>{ if(e.code==='Space'&&micMode==='ptt'&&!e.repeat){e.preventDefault();pttHeld=true;render();}});
+addEventListener('keyup', e=>{ if(e.code==='Space'&&micMode==='ptt'){e.preventDefault();pttHeld=false;render();}});
+
+buildVU($('vu-l'),16); buildVU($('vu-r'),16);
+render();
+if(!reduced) setInterval(tick,70); else tick();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-waybar-redesign-prototype.html b/docs/prototypes/2026-07-03-waybar-redesign-prototype.html
new file mode 100644
index 0000000..3f3e7c1
--- /dev/null
+++ b/docs/prototypes/2026-07-03-waybar-redesign-prototype.html
@@ -0,0 +1,321 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Waybar redesign — dupre instrument console</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:1400px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:88ch}
+.masthead p b{color:var(--silver);font-weight:700}
+
+/* each variation sits on a "desk" — a strip of desktop so the bar reads as a
+ real top bar floating over a window, exactly how it looks on-screen */
+.desk{margin:1.9rem 0 0;border-radius:14px;overflow:hidden;
+ border:1px solid #211e1a;
+ background:
+ linear-gradient(180deg,#0e0d0c 0 62px,transparent 62px),
+ repeating-linear-gradient(135deg,#191613 0 14px,#171512 14px 28px);
+ position:relative}
+.desk-label{display:flex;align-items:baseline;gap:.7rem;padding:.5rem .2rem .1rem}
+.desk-label .n{color:var(--gold);font-size:.82rem;letter-spacing:.08em}
+.desk-label .d{color:var(--dim);font-size:.76rem}
+.desk-window{position:absolute;inset:70px 26px 22px;border:1px solid #262320;border-radius:10px;
+ background:linear-gradient(180deg,#131110,#0e0d0c);opacity:.6}
+.desk-window::before{content:"emacs — instrument-console.el";position:absolute;top:8px;left:14px;
+ color:#3a3630;font-size:.72rem}
+
+/* the bar frame: matches waybar's -54 top strip. two clusters, gold-bordered,
+ floating with a gap between them (modules-center is empty in the real config) */
+.bar{position:relative;z-index:2;display:flex;justify-content:space-between;align-items:flex-start;
+ gap:1rem;padding:10px 12px;height:132px}
+.cluster{display:flex;align-items:center;gap:2px;
+ background:var(--panel);border:1.4px solid var(--gold);border-radius:15px;
+ padding:2px 9px;box-shadow:0 4px 9px rgba(0,0,0,.5)}
+.mod{display:flex;align-items:center;gap:7px;color:var(--silver);
+ padding:7px 9px;border-radius:11px;font-size:14px;cursor:default;white-space:nowrap;position:relative}
+.mod .g{font-size:16px;line-height:1}
+.mod .g.xl{font-size:19px}
+.mod:hover{background:var(--wash)}
+.val{font-variant-numeric:tabular-nums}
+.cream{color:var(--cream)}.gold{color:var(--gold)}.dim{color:var(--dim)}
+.fail{color:var(--fail)}.pass{color:var(--pass)}.steel{color:var(--steel)}
+
+/* workspaces — circular tokens like the real ws-icons */
+.ws{display:flex;gap:5px;padding:0 3px}
+.ws b{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;font-size:13px;
+ color:var(--silver);border:1.4px solid var(--slate);background:#141210}
+.ws b.on{color:var(--panel);background:var(--gold);border-color:var(--gold);font-weight:700;
+ box-shadow:0 0 8px 1px rgba(218,181,61,.4)}
+.ws b.busy{border-color:var(--steel);color:var(--cream)}
+.menu{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;color:var(--gold);
+ font-size:17px;background:linear-gradient(180deg,#211e19,#151210);border:1px solid #33302b}
+.title{color:var(--dim);font-size:13px;max-width:230px;overflow:hidden;text-overflow:ellipsis}
+
+/* collapse arrows — recessed dim wells, per the current design */
+.arrow{color:var(--dim);font-size:12px;background:rgba(0,0,0,.35);
+ box-shadow:inset 0 1px 2px rgba(0,0,0,.7);border-radius:7px;padding:8px 7px}
+
+/* lamp — the panel's signature status dot, glow and all */
+.lamp{width:8px;height:8px;border-radius:50%;background:var(--pass);flex:0 0 auto;
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+
+/* engraved hairline divider between functional groups (echoes .engrave rules) */
+.sep{width:1px;align-self:stretch;margin:6px 3px;
+ background:linear-gradient(180deg,transparent,var(--wash) 22%,var(--wash) 78%,transparent)}
+
+.notes{margin-top:2.4rem;display:flex;gap:2rem;flex-wrap:wrap}
+.note{flex:1 1 300px;min-width:280px}
+.note h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem}
+.note ul{list-style:none}
+.note li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative;color:var(--silver)}
+.note li::before{content:"·";color:var(--gold);position:absolute;left:.25rem}
+.note li b{color:var(--cream);font-weight:700}
+.note li em{color:var(--dim);font-style:normal}
+.rec{border:1px dashed var(--wash);border-radius:10px;padding:.9rem 1.1rem;margin-top:1.1rem;
+ font-size:.83rem;color:var(--silver)}
+.rec b{color:var(--gold)}
+
+/* ============ V1 · FACEPLATE ============ */
+/* machined faceplate: vertical gradient + a 1px top highlight + deeper shadow.
+ otherwise the current layout — lowest-risk, faithful to GTK CSS. */
+.v1 .cluster{background:linear-gradient(180deg,var(--raise),var(--panel));
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.55)}
+.v1 .val.clock{color:var(--cream)}
+
+/* ============ V2 · INSTRUMENT SEGMENTS ============ */
+/* each functional group is its own recessed sub-faceplate with an engraved unit
+ label underneath, lamps on status modules, gold-hi active values */
+.v2 .cluster{background:linear-gradient(180deg,var(--raise),var(--panel));padding:3px 6px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.55)}
+.v2 .seg{display:flex;flex-direction:column;align-items:center;gap:2px;
+ background:var(--well);border:1px solid #232019;border-radius:10px;padding:3px 5px 2px;margin:0 2px}
+.v2 .seg .row{display:flex;align-items:center;gap:6px}
+.v2 .seg .unit{color:var(--steel);font-size:.52rem;letter-spacing:.24em;text-transform:uppercase}
+.v2 .mod{padding:5px 7px}
+.v2 .mod:hover{background:var(--wash)}
+.v2 .menu{background:linear-gradient(180deg,#2a251d,#161310);border-color:var(--gold)}
+/* mini gauge for sysmon — a squat needle echoing the panel meters */
+.gauge{width:26px;height:15px;position:relative;overflow:hidden}
+.gauge .arc{position:absolute;inset:0 0 -26px 0;border:1.5px solid var(--wash);border-radius:50%}
+.gauge .ndl{position:absolute;left:50%;bottom:0;width:1.5px;height:13px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(18deg);border-radius:1px;
+ box-shadow:0 0 4px rgba(255,215,95,.5)}
+.gauge .hub{position:absolute;left:50%;bottom:-2px;width:5px;height:5px;margin-left:-2.5px;
+ border-radius:50%;background:var(--gold)}
+
+/* ============ V3 · FULL CONSOLE ============ */
+/* every module a recessed well with a lamp; console-key toggles with full
+ physical-key gradient + inset; sysmon as twin analog gauges; cream clock */
+.v3 .cluster{background:linear-gradient(180deg,#0d0c0b,#080807);border-width:1.8px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 8px 18px rgba(0,0,0,.6);padding:3px 8px}
+.v3 .mod{background:var(--well);border:1px solid #201d17;border-radius:9px;margin:0 2px;padding:6px 9px}
+.v3 .mod:hover{background:#141210;border-color:var(--slate)}
+/* console-key toggles — the physical key from the panels' .c-btn */
+.v3 .key{background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;
+ border-bottom-color:#0c0b0a;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.v3 .key.engaged{color:var(--gold);border-color:var(--gold)}
+.v3 .key.off{color:var(--fail)}
+.v3 .menu{background:linear-gradient(180deg,#2a251d,#141110);border:1px solid var(--gold);
+ box-shadow:0 0 8px rgba(218,181,61,.25)}
+.v3 .ws b{background:var(--well);border-color:#201d17}
+.v3 .ws b.on{background:var(--gold);border-color:var(--gold)}
+.v3 .val.clock{color:var(--cream);font-weight:700}
+.v3 .tz{color:var(--steel);font-size:.55rem;letter-spacing:.2em}
+.v3 .twin{display:flex;gap:6px}
+.v3 .gauge2{width:22px;height:14px;position:relative;overflow:hidden}
+.v3 .gauge2 .arc{position:absolute;inset:0 0 -22px 0;border:1.5px solid var(--wash);border-radius:50%}
+.v3 .gauge2 .ndl{position:absolute;left:50%;bottom:0;width:1.5px;height:12px;transform-origin:50% 100%;
+ border-radius:1px;background:var(--gold-hi);box-shadow:0 0 4px rgba(255,215,95,.5)}
+.v3 .gauge2 .ndl.warn{background:var(--gold)}
+.v3 .gauge2 .hub{position:absolute;left:50%;bottom:-2px;width:4px;height:4px;margin-left:-2px;
+ border-radius:50%;background:var(--gold)}
+</style>
+</head>
+<body>
+<div class="wrap">
+
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · waybar</div>
+ <h1>Waybar — three ways to spruce it</h1>
+ <p>The bar already runs the dupre palette and a gold border. These three push it toward the
+ <b>instrument-console faceplate</b> language of the net + bluetooth panels — machined
+ gradient plates, engraved unit labels, glowing status lamps, physical console keys, analog
+ gauges — dialing the intensity up from left-touch to full console. Same real module set in
+ each so you're comparing <b>treatment, not content</b>. All three stay inside what GTK3 CSS
+ (waybar's engine) can actually render.</p>
+</header>
+
+<!-- ============ CURRENT (reference) ============ -->
+<div class="desk">
+ <div class="bar" style="padding-top:14px">
+ <div class="cluster">
+ <span class="menu"></span>
+ <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span>
+ <span class="mod"><span class="g">󰕰</span></span>
+ <span class="title">instrument-console.el</span>
+ <span class="mod arrow"></span>
+ </div>
+ <div class="cluster">
+ <span class="mod arrow"></span>
+ <span class="mod"><span class="g gold">󰤨</span></span>
+ <span class="mod"><span class="g xl">󰍬</span></span>
+ <span class="mod"><span class="g xl">󰕾</span> <span class="val">62%</span></span>
+ <span class="mod"><span class="g">󰹇</span></span>
+ <span class="mod"><span class="g">󰃟</span></span>
+ <span class="mod"><span class="g">󰒲</span></span>
+ <span class="mod"><span class="g">󰂯</span></span>
+ <span class="mod"><span class="g">󰻠</span> <span class="val">8%</span></span>
+ <span class="mod"><span class="g">󰔛</span> <span class="val">24:10</span></span>
+ <span class="mod"><span class="g">󰃭</span> <span class="val">Fri Jul 3</span></span>
+ </div>
+ </div>
+ <div class="desk-window"></div>
+</div>
+<div class="desk-label"><span class="n">current</span><span class="d">— flat pills, colour-only states. the baseline these three build on.</span></div>
+
+<!-- ============ V1 · FACEPLATE ============ -->
+<div class="desk v1">
+ <div class="bar" style="padding-top:14px">
+ <div class="cluster">
+ <span class="menu"></span>
+ <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g">󰕰</span></span>
+ <span class="title">instrument-console.el</span>
+ <span class="mod arrow"></span>
+ </div>
+ <div class="cluster">
+ <span class="mod arrow"></span>
+ <span class="mod"><span class="lamp gold"></span><span class="g gold">󰤨</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g xl">󰍬</span></span>
+ <span class="mod"><span class="g xl">󰕾</span> <span class="val cream">62%</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g">󰹇</span></span>
+ <span class="mod"><span class="g">󰃟</span></span>
+ <span class="mod"><span class="g gold">󰒲</span></span>
+ <span class="mod"><span class="lamp"></span><span class="g">󰂯</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g">󰻠</span> <span class="val">8%</span></span>
+ <span class="mod"><span class="g gold">󰔛</span> <span class="val gold">24:10</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g dim">󰃭</span> <span class="val clock">Fri Jul 3</span></span>
+ </div>
+ </div>
+ <div class="desk-window"></div>
+</div>
+<div class="desk-label"><span class="n">variation 1 · faceplate</span><span class="d">— machined gradient + top highlight, engraved hairline dividers, status lamps on net/bt, cream clock. Nearest to today; drop-in GTK CSS.</span></div>
+
+<!-- ============ V2 · INSTRUMENT SEGMENTS ============ -->
+<div class="desk v2">
+ <div class="bar" style="padding-top:12px">
+ <div class="cluster">
+ <span class="menu"></span>
+ <span class="seg"><span class="row ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span><span class="unit">wksp</span></span>
+ <span class="seg"><span class="row"><span class="g">󰕰</span><span class="title" style="max-width:180px">instrument-console.el</span></span><span class="unit">layout · window</span></span>
+ <span class="mod arrow"></span>
+ </div>
+ <div class="cluster">
+ <span class="mod arrow"></span>
+ <span class="seg"><span class="row"><span class="lamp gold"></span><span class="g gold">󰤨</span><span class="gold">CAPTIVE</span></span><span class="unit">net</span></span>
+ <span class="seg"><span class="row"><span class="g xl">󰍬</span><span class="g xl">󰕾</span><span class="val cream">62%</span></span><span class="unit">sound</span></span>
+ <span class="seg"><span class="row"><span class="g">󰹇</span><span class="g">󰃟</span><span class="g gold">󰒲</span></span><span class="unit">toggles</span></span>
+ <span class="seg"><span class="row"><span class="lamp"></span><span class="g">󰂯</span><span class="dim">M650</span></span><span class="unit">bt</span></span>
+ <span class="seg"><span class="row"><span class="gauge"><span class="arc"></span><span class="ndl"></span><span class="hub"></span></span><span class="val">8%</span></span><span class="unit">cpu</span></span>
+ <span class="seg"><span class="row"><span class="g gold">󰔛</span><span class="val gold">24:10</span></span><span class="unit">timer</span></span>
+ <span class="seg"><span class="row"><span class="val cream">Fri Jul 3</span><span class="val">11:23</span></span><span class="unit">clock</span></span>
+ </div>
+ </div>
+ <div class="desk-window"></div>
+</div>
+<div class="desk-label"><span class="n">variation 2 · instrument segments</span><span class="d">— each group a recessed sub-plate with an engraved unit label; a squat needle gauge for cpu. Reads like a row of instruments. Taller; label row costs a few px.</span></div>
+
+<!-- ============ V3 · FULL CONSOLE ============ -->
+<div class="desk v3">
+ <div class="bar" style="padding-top:12px">
+ <div class="cluster">
+ <span class="menu"></span>
+ <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span>
+ <span class="sep"></span>
+ <span class="mod key"><span class="g">󰕰</span></span>
+ <span class="title">instrument-console.el</span>
+ <span class="mod arrow"></span>
+ </div>
+ <div class="cluster">
+ <span class="mod arrow"></span>
+ <span class="mod"><span class="lamp gold"></span><span class="g gold">󰤨</span> <span class="gold">CAPTIVE</span></span>
+ <span class="sep"></span>
+ <span class="mod key"><span class="g xl">󰍬</span></span>
+ <span class="mod"><span class="g xl">󰕾</span> <span class="val cream">62%</span></span>
+ <span class="sep"></span>
+ <span class="mod key engaged"><span class="g">󰹇</span></span>
+ <span class="mod key"><span class="g">󰃟</span></span>
+ <span class="mod key engaged"><span class="g">󰒲</span></span>
+ <span class="mod"><span class="lamp"></span><span class="g">󰂯</span> <span class="dim val">72%</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="twin">
+ <span class="gauge2"><span class="arc"></span><span class="ndl"></span><span class="hub"></span></span>
+ <span class="gauge2"><span class="arc"></span><span class="ndl warn" style="transform:rotate(38deg)"></span><span class="hub"></span></span>
+ </span></span>
+ <span class="mod key"><span class="g gold">󰔛</span> <span class="val gold">24:10</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g dim">󰃭</span> <span class="val clock">Fri Jul 3</span> <span class="val clock">11:23</span><span class="tz">&nbsp;EDT</span></span>
+ </div>
+ </div>
+ <div class="desk-window"></div>
+</div>
+<div class="desk-label"><span class="n">variation 3 · full console</span><span class="d">— every module a recessed well, physical console keys for toggles (gold when engaged, terracotta when off), twin cpu/mem gauges, cream tabular clock with engraved TZ. Furthest from today; closest to the panels.</span></div>
+
+<!-- ============ NOTES ============ -->
+<div class="notes">
+ <div class="note">
+ <h3>What carries over from the panels</h3>
+ <ul>
+ <li><b>Lamps</b> — the glowing status dot lands on net + bt so health reads at a glance, not just by glyph colour <em>(gold = captive/engaged, green = ok, red = fail, dim = off)</em>.</li>
+ <li><b>Machined faceplate</b> — the cluster gets the b-face vertical gradient + 1px top highlight + deeper shadow, so it looks milled rather than printed.</li>
+ <li><b>Engraved dividers</b> — hairline separators group the right cluster into net · sound · toggles · system · clock, echoing the panels' engraved section rules.</li>
+ <li><b>Console keys</b> — the toggles (touchpad, dim, caffeine) borrow .c-btn: gradient fill, inset highlight, gold border when engaged.</li>
+ <li><b>Gauges</b> — sysmon becomes a squat needle (or twin needles for cpu/mem), the same instrument the panels use for throughput and battery.</li>
+ <li><b>Cream + tabular</b> — the clock and live values shift to cream with tabular-nums, matching the panels' readouts.</li>
+ </ul>
+ </div>
+ <div class="note">
+ <h3>GTK3 translation caveats</h3>
+ <ul>
+ <li><b>Dividers</b> need real separator modules or per-module borders — waybar can't inject <em>::before</em> content between modules the way this HTML does.</li>
+ <li><b>Lamps</b> render as a small pango glyph (● with colour + text-shadow glow) prepended in each script, or a tiny bordered box widget — both are GTK-safe.</li>
+ <li><b>Gauges</b> are the real work: GTK CSS can't draw a rotating needle. Options — a Cairo/GTK drawing area in a custom module, or fake it with a unicode gauge glyph that steps by load band. V2's single gauge is cheaper than V3's twin.</li>
+ <li><b>V2's unit labels</b> raise the bar height (the label row). Fine at 54px reserved, but worth eyeballing against the -54 margin strip.</li>
+ <li>Gradients, inset box-shadow, border colour states, tabular-nums — all already proven in the current stylesheet.</li>
+ </ul>
+ </div>
+ <div class="note">
+ <h3>My read</h3>
+ <div class="rec">
+ <b>Variation 1 (faceplate)</b> is the one I'd ship first: it lands ~80% of the instrument-console feel — lamps, milled plates, engraved grouping, cream clock — for pure CSS plus a lamp glyph in the net/bt scripts. No custom drawing, no height risk.
+ <br><br>
+ <b>Variation 3</b> is the aspirational target once a gauge-drawing module exists (it'd also upgrade the sysmon popup). <b>Variation 2</b> is the middle path if you want the unit labels' legibility but not the full recessed-well density. They're not exclusive — 1 can grow into 3.
+ </div>
+ </div>
+</div>
+
+</div>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html b/docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html
new file mode 100644
index 0000000..0b5d6e8
--- /dev/null
+++ b/docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html
@@ -0,0 +1,133 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>PTT waybar indicator — prototype</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --bar:#0d0c0b; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono","JetBrainsMono Nerd Font",monospace;
+}
+*{box-sizing:border-box}
+body{font-family:var(--mono);color:var(--silver);margin:0;padding:2.6rem 2rem 5rem;line-height:1.5;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+h1{font-size:1.05rem;letter-spacing:.02em;color:var(--cream);font-weight:700;margin:0 0 .3rem}
+.sub{color:var(--dim);font-size:.82rem;margin:0 0 2rem;max-width:60ch}
+h2{font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;color:var(--steel);
+ margin:2.2rem 0 .8rem;display:flex;align-items:center;gap:10px}
+h2::after{content:"";height:1px;background:var(--wash);flex:1}
+
+/* a slice of the real waybar: dark rounded bar, right-aligned modules */
+.bar{display:inline-flex;align-items:center;gap:2px;background:var(--bar);
+ border:1px solid #201d17;border-radius:10px;padding:5px 8px;
+ box-shadow:0 2px 10px rgba(0,0,0,.45)}
+.mod{display:flex;align-items:center;gap:7px;padding:4px 9px;border-radius:7px;color:var(--silver);
+ font-size:14px}
+.mod .g{font-size:17px;line-height:1}
+.mod.muted .g{color:var(--fail)}
+.mod.live .g{color:var(--silver)}
+.sep{width:1px;height:16px;background:#26231d;margin:0 2px}
+.neighbor{color:var(--dim);font-size:13px;padding:4px 8px}
+
+/* the PTT tag — outline only, ALWAYS present (so the bar never reflows), and
+ click-to-toggle. Off = barely-visible grey; on = glowing gold; transmitting
+ = brighter gold. Like the gallery 2.4G ghost tag, lit when engaged. */
+.badge{font-size:15px;line-height:1;border-radius:4px;padding:3px 7px;
+ background:transparent;border:1px solid #2a2723;color:#54585c;
+ text-shadow:none;box-shadow:none;cursor:pointer;transition:all .15s ease}
+.badge.txt{font-size:.62rem;letter-spacing:.18em;font-weight:700;padding:2px 7px}
+.badge.on{color:var(--gold);border-color:var(--gold);
+ text-shadow:0 0 6px rgba(218,181,61,.75);
+ box-shadow:0 0 6px rgba(218,181,61,.28),inset 0 0 5px rgba(218,181,61,.15)}
+.badge.talk{color:var(--gold-hi);border-color:var(--gold-hi);
+ text-shadow:0 0 10px rgba(255,215,95,.95);
+ box-shadow:0 0 11px rgba(255,215,95,.5),inset 0 0 6px rgba(255,215,95,.2)}
+.badge.ghost{border-color:var(--wash);color:var(--dim)}
+
+.row{display:flex;align-items:center;gap:22px;flex-wrap:wrap;margin:10px 0}
+.label{width:16rem;color:var(--dim);font-size:.8rem}
+.label b{color:var(--cream);font-weight:700}
+.note{color:var(--dim);font-size:.78rem;margin:.6rem 0 0;max-width:70ch}
+.opt{margin:2.4rem 0 0;padding:14px 16px;border:1px solid #262320;border-radius:10px;
+ background:linear-gradient(180deg,var(--raise),var(--panel));max-width:70ch}
+.opt h3{margin:0 0 .5rem;color:var(--cream);font-size:.86rem;letter-spacing:.04em}
+.opt p{margin:.3rem 0;color:var(--silver);font-size:.82rem}
+.k{color:var(--gold);font-weight:700}
+</style>
+</head>
+<body>
+
+<h1>Push-to-talk — waybar indicator</h1>
+<p class="sub">How the bar reads in each state, so you can tell at a glance whether you're in PTT mode.
+Two elements move together: the <b>mic glyph</b> (live vs muted, the module you already have) and a
+<b>PTT tag</b> — an outline flag that's <b>always present</b> (dim grey when off, so the bar never jumps),
+glows gold when PTT is engaged, and is itself <b>click-to-toggle</b> for PTT mode.</p>
+
+<h2>Not in PTT mode — the tag sits dim (present, barely visible)</h2>
+
+<div class="row">
+ <div class="label"><b>Mic live.</b> Tag dim grey.</div>
+ <div class="bar">
+ <span class="neighbor">󰂯</span><span class="sep"></span>
+ <div class="mod live"><span class="badge">&#xF05CB;</span> <span class="g">󰍬</span></div>
+ <span class="neighbor">85%</span>
+ </div>
+</div>
+
+<div class="row">
+ <div class="label"><b>Mic muted</b> (plain mic-toggle). Tag still dim.</div>
+ <div class="bar">
+ <span class="neighbor">󰂯</span><span class="sep"></span>
+ <div class="mod muted"><span class="badge">&#xF05CB;</span> <span class="g">󰍭</span></div>
+ <span class="neighbor">85%</span>
+ </div>
+</div>
+
+<p class="note">The tag is <b>always there</b> — dim grey when off — so the bar never reflows when PTT flips.
+<b>Click the tag to toggle PTT mode</b> (same as Super+Shift+A). A plain mic-mute leaves the tag dim; only PTT lights it gold.</p>
+
+<h2>In PTT mode — the tag lights gold</h2>
+
+<div class="row">
+ <div class="label"><b>Armed, not talking.</b> Mic muted, tag glows gold.</div>
+ <div class="bar">
+ <span class="neighbor">󰂯</span><span class="sep"></span>
+ <div class="mod muted"><span class="badge on">&#xF05CB;</span> <span class="g">󰍭</span></div>
+ <span class="neighbor">85%</span>
+ </div>
+</div>
+
+<div class="row">
+ <div class="label"><b>Holding the key — transmitting.</b> Mic live, tag brightens.</div>
+ <div class="bar">
+ <span class="neighbor">󰂯</span><span class="sep"></span>
+ <div class="mod live"><span class="badge talk">&#xF05CB;</span> <span class="g">󰍬</span></div>
+ <span class="neighbor">85%</span>
+ </div>
+</div>
+
+<p class="note">Only the letters and outline change colour — the box holds its place, so nothing shifts. While you hold to talk the mic flips live and the tag brightens, so you see you're transmitting, not just armed.</p>
+
+<h2>The four states side by side — the tag never moves</h2>
+<div class="row">
+ <div class="bar">
+ <div class="mod live"><span class="badge">&#xF05CB;</span> <span class="g">󰍬</span></div><span class="sep"></span>
+ <div class="mod muted"><span class="badge">&#xF05CB;</span> <span class="g">󰍭</span></div><span class="sep"></span>
+ <div class="mod muted"><span class="badge on">&#xF05CB;</span> <span class="g">󰍭</span></div><span class="sep"></span>
+ <div class="mod live"><span class="badge talk">&#xF05CB;</span> <span class="g">󰍬</span></div>
+ </div>
+</div>
+<p class="note">left → right: off + live · off + muted · PTT armed · PTT transmitting. The tag holds its position through all four — no jump.</p>
+
+<div class="opt">
+ <h3>Variants to consider</h3>
+ <p>• <span class="k">Tag icon</span> — the account-voice glyph 󰗋 (shown). Alternatives if it doesn't read right: radio-handheld 󰐺, broadcast 󱜠, or plain "PTT"/"TALK" text.</p>
+ <p>• <span class="k">Tag colour</span> — gold (shown, matches the panel's PUSH·TALK lamp). A red tag would read more like "mic hot/muted"; gold reads "mode engaged".</p>
+ <p>• <span class="k">Tag placement</span> — left of the mic glyph (shown) so the flag leads. Could sit right of it instead.</p>
+ <p>• <span class="k">Transmit cue</span> — brighten + glow the tag while holding (shown), or leave the tag steady and let only the mic glyph carry the live/muted change.</p>
+</div>
+
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-07-maint-console-A-two-column.html b/docs/prototypes/2026-07-07-maint-console-A-two-column.html
new file mode 100644
index 0000000..23d0e60
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-A-two-column.html
@@ -0,0 +1,335 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Maintenance console — Approach A · two-column board</title>
+<style>
+:root{
+ --ground:#0a0c0d; --panel:#100f0f; --well:#0a0c0d;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32;
+ --ok:#74932f; --warn:#dab53d; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);line-height:1.4;
+ padding:2.2rem 1.4rem 4rem;
+ background:radial-gradient(1100px 560px at 72% -12%,#141210 0%,transparent 60%),var(--ground);
+ display:flex;flex-direction:column;align-items:center;gap:1.5rem}
+
+/* ---- capsule ---- */
+.capsule{width:1000px;max-width:100%;background:var(--panel);border:1px solid var(--gold);
+ border-radius:16px;padding:18px 20px;box-shadow:0 20px 55px rgba(0,0,0,.6)}
+
+/* ---- header strip ---- */
+.hdr{display:flex;align-items:center;gap:14px;padding-bottom:12px;border-bottom:1px solid var(--wash)}
+.hdr .mark{color:var(--gold);font-weight:700;font-size:1.15rem;letter-spacing:.12em}
+.hdr .mark .host{color:var(--cream)}
+.hdr .verdict{margin-left:auto;display:flex;align-items:center;gap:8px;font-size:.92rem}
+.hdr .verdict .word{color:var(--ok);font-weight:700;letter-spacing:.08em;text-transform:uppercase}
+.subhdr{color:var(--dim);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase;margin-top:8px}
+
+/* ---- lamps ---- */
+.lamp{font-size:.72rem;line-height:1;flex:0 0 auto;position:relative;top:-.5px}
+.lamp.ok{color:var(--ok)}
+.lamp.warn{color:var(--warn)}
+.lamp.fail{color:var(--fail)}
+.lamp.off{color:var(--dim)}
+.lamp.run{color:var(--warn)}
+.lamp.big{font-size:.85rem}
+
+/* ---- board ---- */
+.board{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:16px}
+.col{min-width:0}
+.col-h{display:flex;align-items:baseline;gap:10px;margin-bottom:10px}
+.col-h .tag{color:var(--gold);font-weight:700;font-size:.86rem;letter-spacing:.22em}
+.col-h .sub{color:var(--dim);font-size:.68rem;letter-spacing:.04em}
+
+/* doctor buttons */
+.doctor{display:flex;gap:10px;margin-bottom:14px}
+.btn{font:inherit;cursor:pointer;border-radius:10px;border:1px solid transparent;
+ background:var(--slate);color:var(--cream);padding:.5rem .9rem;font-size:.82rem;letter-spacing:.02em;
+ transition:background .12s,border-color .12s}
+.btn:hover{background:var(--slate-hi)}
+.btn.big{flex:1;text-align:center;padding:.62rem .8rem;font-size:.86rem}
+.btn.big .k{display:block;color:var(--cream);font-weight:700}
+.btn.big .d{display:block;color:var(--silver);font-size:.66rem;letter-spacing:.02em;margin-top:2px;opacity:.85}
+.btn.ghost{background:transparent;border-color:var(--wash);color:var(--silver)}
+.btn.ghost:hover{border-color:var(--slate-hi);background:rgba(66,79,94,.18)}
+.btn.lever{padding:.28rem .66rem;font-size:.74rem;border:1px solid var(--slate);background:rgba(66,79,94,.32);color:var(--cream)}
+.btn.lever:hover{background:var(--slate);border-color:var(--slate-hi)}
+.btn.done{background:var(--slate);color:var(--cream);padding:.5rem 1.4rem}
+
+/* inset group */
+.group{background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:8px 10px 6px;margin-bottom:11px}
+.group-h{color:var(--gold);font-weight:700;font-size:.7rem;letter-spacing:.2em;text-transform:uppercase;
+ display:flex;align-items:center;gap:8px;padding:1px 2px 7px}
+.group-h::after{content:"";height:1px;background:var(--wash);flex:1}
+
+/* metric row */
+.row{display:grid;grid-template-columns:auto 1fr auto auto;align-items:center;gap:9px;
+ padding:4px 4px;border-radius:6px;font-size:.82rem}
+.row:hover{background:rgba(44,47,50,.4)}
+.row .name{color:var(--silver);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.row .cls{color:var(--dim);font-size:.6rem;letter-spacing:.14em;border:1px solid var(--wash);
+ border-radius:4px;padding:0 4px;flex:0 0 auto;text-transform:uppercase}
+.row .val{color:var(--dim);text-align:right;white-space:nowrap;font-size:.78rem}
+.row .val.warn{color:var(--warn)}
+.row .val.fail{color:var(--fail)}
+.row .lev-slot{justify-self:end;min-width:0}
+.wf-link{font:inherit;background:transparent;border:0;cursor:pointer;color:var(--warn);
+ font-size:.72rem;letter-spacing:.02em;white-space:nowrap;padding:.1rem 0}
+.wf-link:hover{color:var(--gold-hi);text-decoration:underline}
+.wf-link.fail{color:var(--fail)}
+.dash{color:var(--wash);font-size:.8rem;padding-right:6px}
+
+/* ---- updates quarantine strip ---- */
+.updates{margin-top:6px;padding-top:15px;border-top:1px solid var(--gold);
+ display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+.updates .u-tag{color:var(--gold);font-weight:700;font-size:.8rem;letter-spacing:.22em}
+.updates .u-body{color:var(--silver);font-size:.82rem;flex:1;min-width:260px}
+.updates .u-body .n{color:var(--cream);font-weight:700}
+.updates .u-body .cve{color:var(--fail);font-weight:700}
+.updates .u-body .pk{color:var(--dim)}
+.updates .btn{white-space:nowrap}
+
+/* ---- running / output-wall capsule ---- */
+.run-cap .hdr .mark .state{color:var(--warn)}
+.wall{margin-top:14px;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:12px 14px;
+ display:flex;flex-direction:column;gap:9px}
+.wall .act{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:10px;font-size:.84rem}
+.wall .act .step{color:var(--silver)}
+.wall .act .dots{color:var(--wash);letter-spacing:.1em;overflow:hidden}
+.wall .act .res{color:var(--dim);font-size:.76rem;white-space:nowrap;text-align:right}
+.wall .act.running .step{color:var(--cream)}
+.wall .act.running .res{color:var(--warn)}
+.wall .act.done .res{color:var(--ok)}
+.wall .act.queued .step{color:var(--dim)}
+.run-foot{display:flex;align-items:center;justify-content:space-between;margin-top:14px}
+.run-foot .prog{color:var(--dim);font-size:.76rem;letter-spacing:.04em}
+.run-foot .prog b{color:var(--cream);font-weight:700}
+
+/* ---- legend ---- */
+.legend{width:1000px;max-width:100%;display:flex;gap:18px;flex-wrap:wrap;
+ font-size:.7rem;color:var(--dim);padding:0 4px}
+.legend .item b{color:var(--silver);font-weight:700}
+.legend .item{white-space:nowrap}
+.legend .swatch{color:var(--gold)}
+
+@media(max-width:1040px){
+ .capsule,.legend{width:100%}
+ .board{grid-template-columns:1fr}
+}
+</style>
+</head>
+<body>
+
+<!-- =================== RESTING BOARD =================== -->
+<section class="capsule">
+ <div class="hdr">
+ <div class="mark">MAINT · <span class="host">ratio</span></div>
+ <div class="verdict">
+ <span class="lamp ok big">&#9679;</span>
+ <span class="word">healthy</span>
+ </div>
+ </div>
+ <div class="subhdr">Approach A — Two-column board</div>
+
+ <div class="board">
+
+ <!-- ============ LEFT / FIX ============ -->
+ <div class="col">
+ <div class="col-h"><span class="tag">FIX</span><span class="sub">actionable — auto &amp; confirm</span></div>
+
+ <div class="doctor">
+ <button class="btn big">
+ <span class="k">Clean up</span><span class="d">runs the Auto set unattended</span>
+ </button>
+ <button class="btn big ghost">
+ <span class="k">Review &amp; fix</span><span class="d">preview the Confirm set</span>
+ </button>
+ </div>
+
+ <!-- Storage -->
+ <div class="group">
+ <div class="group-h">Storage</div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">cache size</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Clean</button></span></div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">btrfs scrub age</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Scrub</button></span></div>
+ <div class="row"><span class="lamp fail">&#9679;</span><span class="name">fstrim.timer</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Enable</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">deep trim (keep-1)</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Deep trim</button></span></div>
+ </div>
+
+ <!-- Snapshots -->
+ <div class="group">
+ <div class="group-h">Snapshots</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">snapper count/retention</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Prune</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">snapshot auto-timer</span><span class="cls">Conf</span><span class="val">active</span></div>
+ </div>
+
+ <!-- Packages -->
+ <div class="group">
+ <div class="group-h">Packages</div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">orphaned packages</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Review</button></span></div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">.pacnew files</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Review</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">keyring freshness</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Refresh</button></span></div>
+ </div>
+
+ <!-- Logs -->
+ <div class="group">
+ <div class="group-h">Logs &amp; coredumps</div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">coredumps</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Clear</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">journald disk usage</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Vacuum</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">app-log cleanup</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Run</button></span></div>
+ </div>
+
+ <!-- systemd -->
+ <div class="group">
+ <div class="group-h">systemd</div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">maintenance timers firing</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Enable</button></span></div>
+ </div>
+
+ <!-- Network confirm -->
+ <div class="group">
+ <div class="group-h">Network</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">firewall active</span><span class="cls">Conf</span><span class="val">active</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">tailscale peers</span><span class="cls">Conf</span><span class="val">4/4</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">fail2ban</span><span class="cls">Conf</span><span class="val">active</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">NTP sync</span><span class="cls">Conf</span><span class="val">synced</span></div>
+ </div>
+
+ <!-- Services confirm -->
+ <div class="group">
+ <div class="group-h">Services</div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">docker reclaimable</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Prune</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">cron</span><span class="cls">Conf</span><span class="val">ok</span></div>
+ </div>
+ </div>
+
+ <!-- ============ RIGHT / WATCH ============ -->
+ <div class="col">
+ <div class="col-h"><span class="tag">WATCH</span><span class="sub">read-only — none, human &amp; forensic</span></div>
+
+ <!-- Hardware -->
+ <div class="group">
+ <div class="group-h">Hardware</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">SMART health</span><span class="cls">None</span><span class="val">PASSED</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">SMART wear</span><span class="cls">None</span><span class="val">0%</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">btrfs device errors</span><span class="cls">None</span><span class="val">0/0</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">CPU/GPU temps</span><span class="cls">None</span><span class="val">61&deg;/54&deg;</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">thermal throttling</span><span class="cls">None</span><span class="val">no</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">kernel/hardware events</span><span class="cls">None</span><span class="val">clean</span></div>
+ </div>
+
+ <!-- System -->
+ <div class="group">
+ <div class="group-h">System &amp; disk</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">is-system-running</span><span class="cls">None</span><span class="val">running</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">disk usage</span><span class="cls">None</span><span class="val">69%</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">btrfs unallocated</span><span class="cls">None</span><span class="val">118 GiB</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">kernel taint flag</span><span class="cls">None</span><span class="val">0</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">pacman file integrity</span><span class="cls">Wflow</span><span class="val">clean</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">reboot required</span><span class="cls">Human</span><span class="val">no</span></div>
+ </div>
+
+ <!-- Forensic / systemd -->
+ <div class="group">
+ <div class="group-h">Forensic (workflow)</div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">failed units</span><span class="cls">Wflow</span><span class="val warn">1</span><span class="lev-slot"><button class="wf-link">&rarr; workflow</button></span></div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">unclean-shutdown rate</span><span class="cls">Wflow</span><span class="val warn">75%</span><span class="lev-slot"><button class="wf-link">&rarr; workflow</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">journal error count</span><span class="cls">Wflow</span><span class="val">12 real</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">memory free / OOM</span><span class="cls">Wflow</span><span class="val">104 GB free</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">rsyncshot backup</span><span class="cls">Wflow</span><span class="val">3h ago</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">DNS / NetworkManager</span><span class="cls">Wflow</span><span class="val">ok</span></div>
+ </div>
+
+ <!-- Power / memory -->
+ <div class="group">
+ <div class="group-h">Memory &amp; power</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">swap / zram</span><span class="cls">None</span><span class="val">16 GiB</span></div>
+ <div class="row"><span class="lamp off">&#9675;</span><span class="name">battery health</span><span class="cls">None</span><span class="val">n/a</span></div>
+ </div>
+
+ <!-- Network posture -->
+ <div class="group">
+ <div class="group-h">Network posture</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">unexpected listeners</span><span class="cls">None</span><span class="val">3</span></div>
+ </div>
+
+ <!-- Services / virt -->
+ <div class="group">
+ <div class="group-h">Services &amp; virt</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">docker stopped containers</span><span class="cls">None</span><span class="val">2</span></div>
+ <div class="row"><span class="lamp off">&#9675;</span><span class="name">libvirt VMs</span><span class="cls">None</span><span class="val">off</span></div>
+ </div>
+ </div>
+
+ </div><!-- /board -->
+
+ <!-- quarantined updates strip -->
+ <div class="updates">
+ <span class="u-tag">UPDATES</span>
+ <span class="u-body"><span class="n">47 pending</span> &middot; <span class="pk">mesa, linux-lts, systemd</span> &middot; <span class="cve">3 CVEs</span></span>
+ <button class="btn">Run workflow</button>
+ </div>
+</section>
+
+<!-- =================== RUNNING / OUTPUT WALL =================== -->
+<section class="capsule run-cap">
+ <div class="hdr">
+ <div class="mark">MAINT · <span class="host">ratio</span> · <span class="state">CLEANING</span></div>
+ <div class="verdict">
+ <span class="lamp run big">&#9680;</span>
+ <span class="word" style="color:var(--warn)">working</span>
+ </div>
+ </div>
+ <div class="subhdr">Doctor has the surface — Auto set streaming</div>
+
+ <div class="wall">
+ <div class="act running">
+ <span class="lamp run">&#9680;</span>
+ <span class="step">cache trim</span>
+ <span class="res">running</span>
+ </div>
+ <div class="act done">
+ <span class="lamp ok">&#9679;</span>
+ <span class="step">journal vacuum</span>
+ <span class="res">done &middot; reclaimed 0.9 GB</span>
+ </div>
+ <div class="act done">
+ <span class="lamp ok">&#9679;</span>
+ <span class="step">coredump clear</span>
+ <span class="res">done &middot; 12 cleared</span>
+ </div>
+ <div class="act queued">
+ <span class="lamp off">&#9675;</span>
+ <span class="step">app-log cleanup</span>
+ <span class="res">queued</span>
+ </div>
+ </div>
+
+ <div class="run-foot">
+ <span class="prog"><b>2</b> of 4 done &middot; 1 running &middot; 1 queued</span>
+ <button class="btn done">Done</button>
+ </div>
+</section>
+
+<!-- =================== AUTOMATION-CLASS LEGEND =================== -->
+<div class="legend">
+ <span class="item"><span class="swatch">&#9679;</span> <b>Auto</b> — doctor "Clean up", fires unattended</span>
+ <span class="item"><span class="swatch">&#9679;</span> <b>Confirm</b> — doctor "Review &amp; fix", preview then one click</span>
+ <span class="item"><span class="swatch">&#9679;</span> <b>Human</b> — panel nudges, you act</span>
+ <span class="item"><span class="swatch">&#9679;</span> <b>Workflow</b> — escalate to the agent health-check</span>
+ <span class="item"><span class="swatch">&#9679;</span> <b>None</b> — diagnostic only, no fix</span>
+</div>
+<div class="legend" style="margin-top:-8px">
+ <span class="item"><span class="lamp ok">&#9679;</span> ok/pass</span>
+ <span class="item"><span class="lamp warn">&#9679;</span> warn</span>
+ <span class="item"><span class="lamp fail">&#9679;</span> fail</span>
+ <span class="item"><span class="lamp run">&#9680;</span> running</span>
+ <span class="item"><span class="lamp off">&#9675;</span> off / n/a</span>
+</div>
+
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html b/docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html
new file mode 100644
index 0000000..ebc3acf
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html
@@ -0,0 +1,382 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>MAINT · ratio — Approach B</title>
+<style>
+ :root{
+ --bg:#0a0c0d; --surface:#100f0f; --gold:#dab53d; --silver:#bfc4d0;
+ --dim:#7c838a; --cream:#f3e7c5; --slate:#424f5e; --slate-h:#54677d;
+ --ok:#74932f; --warn:#dab53d; --fail:#cb6b4d; --na:#7c838a;
+ --inset:#0a0c0d; --inset-bd:#2c2f32;
+ }
+ *{box-sizing:border-box}
+ body{
+ margin:0; padding:34px 16px; background:var(--bg);
+ font-family:"BerkeleyMono Nerd Font", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ color:var(--silver); font-size:13px; line-height:1.45;
+ display:flex; flex-direction:column; align-items:center; gap:20px;
+ }
+ .console{
+ width:1000px; max-width:100%; background:var(--surface);
+ border:1px solid var(--gold); border-radius:16px; padding:18px 20px 22px;
+ }
+ /* header */
+ .hdr{display:flex; align-items:baseline; justify-content:space-between; gap:16px;}
+ .hdr .title{color:var(--cream); font-size:16px; letter-spacing:.5px;}
+ .hdr .title b{color:var(--gold);}
+ .hdr .verdict{font-size:14px; color:var(--ok); font-weight:bold; letter-spacing:.5px;}
+ .sub{color:var(--dim); font-size:12px; margin-top:2px; margin-bottom:14px;}
+
+ /* global action bar */
+ .actionbar{
+ display:flex; align-items:center; gap:10px; flex-wrap:wrap;
+ padding:11px 13px; margin-bottom:16px;
+ background:var(--inset); border:1px solid var(--inset-bd); border-radius:10px;
+ }
+ .actionbar .grp{display:flex; gap:8px;}
+ .actionbar .spacer{flex:1 1 auto;}
+ button{
+ font-family:inherit; font-size:12px; cursor:pointer;
+ background:var(--slate); color:var(--cream); border:1px solid #33404d;
+ border-radius:10px; padding:6px 12px;
+ }
+ button:hover{background:var(--slate-h);}
+ button.ghost{background:transparent; color:var(--silver); border-color:var(--inset-bd); padding:5px 10px;}
+ button.ghost:hover{background:#161a1d; color:var(--cream);}
+ button.tiny{font-size:11px; padding:3px 9px; border-radius:8px;}
+
+ /* updates chip */
+ .updates-chip{
+ display:flex; align-items:center; gap:12px; margin-left:auto;
+ padding:6px 12px; border:1px solid var(--fail); border-radius:999px;
+ background:rgba(203,107,77,.09);
+ }
+ .updates-chip .u-lamp{color:var(--fail);}
+ .updates-chip .u-txt{color:var(--cream); font-size:12px;}
+ .updates-chip .u-txt b{color:var(--warn);}
+
+ /* grid */
+ .grid{
+ display:grid; grid-template-columns:repeat(3, 1fr); gap:12px;
+ }
+ .tile{
+ background:var(--inset); border:1px solid var(--inset-bd); border-radius:10px;
+ padding:11px 12px 12px; display:flex; flex-direction:column; gap:8px;
+ cursor:pointer; transition:border-color .12s;
+ }
+ .tile:hover{border-color:#3c4a58;}
+ .tile.expanded{grid-column:1 / -1; cursor:default; border-color:var(--slate-h);}
+ .tile-top{display:flex; align-items:flex-start; gap:9px;}
+ .rollup{font-size:16px; line-height:1.1; margin-top:1px;}
+ .tile-head{flex:1 1 auto; min-width:0;}
+ .tile-name{color:var(--gold); font-weight:bold; letter-spacing:.3px;}
+ .tile-finding{color:var(--silver); font-size:12px; margin-top:2px;
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
+ .tile-finding.warnt{color:var(--warn);}
+ .tile-finding.failt{color:var(--fail);}
+ .tile-foot{display:flex; align-items:center; gap:8px; justify-content:space-between;}
+ .counts{color:var(--dim); font-size:11px;}
+ .counts .fix{color:var(--silver);}
+ .expander{color:var(--dim); font-size:11px;}
+
+ /* lamps */
+ .lamp{font-size:13px;}
+ .l-ok{color:var(--ok);} .l-warn{color:var(--warn);} .l-fail{color:var(--fail);}
+ .l-na{color:var(--na);} .l-prog{color:var(--warn);}
+
+ /* expanded detail */
+ .detail{border-top:1px solid var(--inset-bd); padding-top:9px; margin-top:2px;}
+ .detail-hint{color:var(--dim); font-size:11px; margin-bottom:7px;}
+ .row{
+ display:grid; grid-template-columns:16px 1fr auto auto;
+ align-items:center; gap:10px; padding:4px 4px; border-radius:6px;
+ }
+ .row:hover{background:#141719;}
+ .row + .row{border-top:1px solid #17191b;}
+ .r-name{color:var(--silver);}
+ .r-val{color:var(--dim); font-size:12px; text-align:right; white-space:nowrap;}
+ .r-lever{justify-self:end; min-width:96px; text-align:right;}
+ .r-lever .none{color:#4d5358;}
+ .r-auto{font-size:10px; color:var(--dim); text-transform:uppercase; letter-spacing:.5px;
+ border:1px solid var(--inset-bd); border-radius:5px; padding:1px 5px; margin-left:6px;}
+
+ /* running capsule */
+ .capsule{
+ width:1000px; max-width:100%; background:var(--surface);
+ border:1px solid var(--gold); border-radius:16px; padding:14px 18px 16px;
+ }
+ .cap-hdr{display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;}
+ .cap-hdr .title{color:var(--cream); font-size:14px; letter-spacing:.5px;}
+ .cap-hdr .title b{color:var(--gold);}
+ .cap-hdr .running{color:var(--warn); font-weight:bold;}
+ .wall{
+ background:var(--inset); border:1px solid var(--inset-bd); border-radius:10px;
+ padding:10px 12px; display:flex; flex-direction:column; gap:6px;
+ }
+ .wall .line{display:flex; align-items:center; gap:10px;}
+ .wall .line .glyph{width:14px; text-align:center;}
+ .wall .line .act{color:var(--silver); min-width:180px;}
+ .wall .line .st{color:var(--dim); font-size:12px;}
+ .wall .line .st b{color:var(--ok);}
+ .wall .line.done .act{color:var(--dim);}
+ .wall .line.queued .act{color:var(--dim);}
+ .cap-foot{display:flex; justify-content:flex-end; margin-top:11px;}
+ .legend{
+ width:1000px; max-width:100%; color:var(--dim); font-size:11px;
+ padding:0 6px; line-height:1.6;
+ }
+ .legend b{color:var(--silver); font-weight:normal;}
+</style>
+</head>
+<body>
+
+<div class="console">
+ <div class="hdr">
+ <div>
+ <div class="title">MAINT · <b>ratio</b></div>
+ </div>
+ <div class="verdict">● healthy</div>
+ </div>
+ <div class="sub">Approach B — Domain-tile grid</div>
+
+ <!-- global actions -->
+ <div class="actionbar">
+ <div class="grp">
+ <button>Clean up</button>
+ <button class="ghost">Review &amp; fix</button>
+ </div>
+ <div class="spacer"></div>
+ <div class="updates-chip">
+ <span class="u-lamp">●</span>
+ <span class="u-txt"><b>47 pending</b> · 3 CVE · mesa,linux-lts</span>
+ <button class="tiny">Run workflow</button>
+ </div>
+ </div>
+
+ <!-- domain grid -->
+ <div class="grid">
+
+ <!-- STORAGE (expanded) -->
+ <div class="tile expanded" id="t-storage">
+ <div class="tile-top">
+ <span class="rollup l-fail">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Storage</div>
+ <div class="tile-finding failt">fstrim off · scrub 34d</div>
+ </div>
+ <div class="tile-foot" style="flex-direction:column; align-items:flex-end; gap:6px;">
+ <button class="tiny">Clean</button>
+ <span class="counts"><span class="fix">4 fix</span> · 6 watch</span>
+ </div>
+ </div>
+ <div class="detail">
+ <div class="detail-hint">10 checks — lever shown where the doctor can act</div>
+
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">cache<span class="r-auto">auto</span></span><span class="r-val">8.8 GB</span><span class="r-lever"><button class="tiny">Clean</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">deep-trim<span class="r-auto">confirm</span></span><span class="r-val">keep-3</span><span class="r-lever"><button class="tiny">Deep trim</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">disk</span><span class="r-val">69%</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">btrfs unalloc</span><span class="r-val">118 GiB</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">btrfs scrub<span class="r-auto">confirm</span></span><span class="r-val">34 d</span><span class="r-lever"><button class="tiny">Scrub</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">btrfs device-err</span><span class="r-val">0 / 0</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">SMART</span><span class="r-val">PASSED</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">SMART wear</span><span class="r-val">0%</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-fail">●</span><span class="r-name">fstrim.timer<span class="r-auto">confirm</span></span><span class="r-val">off</span><span class="r-lever"><button class="tiny">Enable</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">pacman integrity<span class="r-auto">workflow</span></span><span class="r-val">clean</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- SNAPSHOTS -->
+ <div class="tile" onclick="toggle('snap')" id="t-snap">
+ <div class="tile-top">
+ <span class="rollup l-ok">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Snapshots</div>
+ <div class="tile-finding">42 snaps · timer active</div>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">1 fix</span> · 1 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-snap" style="display:none;">
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">snapper count<span class="r-auto">confirm</span></span><span class="r-val">42</span><span class="r-lever"><button class="tiny">Prune</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">snapshot timer<span class="r-auto">confirm</span></span><span class="r-val">active</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- PACKAGES & SECURITY -->
+ <div class="tile" onclick="toggle('pkg')" id="t-pkg">
+ <div class="tile-top">
+ <span class="rollup l-fail">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Packages &amp; Security</div>
+ <div class="tile-finding failt">3 CVE · 47 updates</div>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">3 fix</span> · 4 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-pkg" style="display:none;">
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">orphans<span class="r-auto">confirm</span></span><span class="r-val">13</span><span class="r-lever"><button class="tiny">Review</button></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">.pacnew<span class="r-auto">confirm</span></span><span class="r-val">2</span><span class="r-lever"><button class="tiny">Review</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">keyring<span class="r-auto">confirm</span></span><span class="r-val">12 d</span><span class="r-lever"><button class="tiny">Refresh</button></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">pending updates<span class="r-auto">workflow</span></span><span class="r-val">47</span><span class="r-lever"><button class="tiny">Updates</button></span></div>
+ <div class="row"><span class="lamp l-fail">●</span><span class="r-name">arch-audit<span class="r-auto">workflow</span></span><span class="r-val">3 CVE</span><span class="r-lever"><button class="tiny">Updates</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">AUR staleness<span class="r-auto">workflow</span></span><span class="r-val">5</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">reboot-required<span class="r-auto">human</span></span><span class="r-val">no</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- SYSTEMD & BOOT -->
+ <div class="tile" onclick="toggle('sd')" id="t-sd">
+ <div class="tile-top">
+ <span class="rollup l-warn">●</span>
+ <div class="tile-head">
+ <div class="tile-name">systemd &amp; Boot</div>
+ <div class="tile-finding warnt">1 failed · timers 4/5</div>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">1 fix</span> · 3 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-sd" style="display:none;">
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">is-system-running</span><span class="r-val">running</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">failed units<span class="r-auto">workflow</span></span><span class="r-val">1</span><span class="r-lever"><button class="tiny">Run workflow</button></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">maintenance timers<span class="r-auto">confirm</span></span><span class="r-val">4 / 5</span><span class="r-lever"><button class="tiny">Enable</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">kernel taint</span><span class="r-val">0</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- LOGS & COREDUMPS -->
+ <div class="tile" onclick="toggle('log')" id="t-log">
+ <div class="tile-top">
+ <span class="rollup l-warn">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Logs &amp; Coredumps</div>
+ <div class="tile-finding warnt">18 coredumps / 7d</div>
+ </div>
+ <div class="tile-foot" style="flex-direction:column; align-items:flex-end; gap:6px;">
+ <button class="tiny" onclick="event.stopPropagation()">Clean</button>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">3 fix</span> · 2 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-log" style="display:none;">
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">coredumps<span class="r-auto">auto</span></span><span class="r-val">18 / 7d</span><span class="r-lever"><button class="tiny">Clear</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">journald<span class="r-auto">auto</span></span><span class="r-val">1.2 GB</span><span class="r-lever"><button class="tiny">Vacuum</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">app-log cleanup<span class="r-auto">auto</span></span><span class="r-val">ok</span><span class="r-lever"><button class="tiny">Run</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">journal errors<span class="r-auto">workflow</span></span><span class="r-val">12 real</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">kernel/hw events</span><span class="r-val">clean</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- MEMORY / THERMAL / POWER -->
+ <div class="tile" onclick="toggle('mem')" id="t-mem">
+ <div class="tile-top">
+ <span class="rollup l-warn">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Memory / Thermal / Power</div>
+ <div class="tile-finding warnt">unclean-shutdown 75%</div>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">0 fix</span> · 6 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-mem" style="display:none;">
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">memory<span class="r-auto">workflow</span></span><span class="r-val">104 GB free</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">swap / zram</span><span class="r-val">16 GiB</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">temps</span><span class="r-val">61° / 54°</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">thermal throttle</span><span class="r-val">no</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-na">○</span><span class="r-name">battery</span><span class="r-val">n/a</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">unclean-shutdown<span class="r-auto">workflow</span></span><span class="r-val">75%</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- NETWORK & POSTURE -->
+ <div class="tile" onclick="toggle('net')" id="t-net">
+ <div class="tile-top">
+ <span class="rollup l-ok">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Network &amp; Posture</div>
+ <div class="tile-finding">all posture green</div>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">4 fix</span> · 2 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-net" style="display:none;">
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">DNS / NM<span class="r-auto">workflow</span></span><span class="r-val">ok</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">firewall<span class="r-auto">confirm</span></span><span class="r-val">active</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">listeners</span><span class="r-val">3</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">tailscale<span class="r-auto">confirm</span></span><span class="r-val">4 / 4</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">fail2ban<span class="r-auto">confirm</span></span><span class="r-val">active</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">NTP<span class="r-auto">confirm</span></span><span class="r-val">synced</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- SERVICES / BACKUPS / VIRT -->
+ <div class="tile" onclick="toggle('svc')" id="t-svc">
+ <div class="tile-top">
+ <span class="rollup l-warn">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Services &amp; Backups</div>
+ <div class="tile-finding warnt">docker reclaim 3 GB</div>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">2 fix</span> · 3 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-svc" style="display:none;">
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">rsyncshot<span class="r-auto">workflow</span></span><span class="r-val">3h ago</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">docker reclaim<span class="r-auto">confirm</span></span><span class="r-val">3 GB</span><span class="r-lever"><button class="tiny">Prune</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">docker stopped</span><span class="r-val">2</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-na">○</span><span class="r-name">libvirt</span><span class="r-val">off</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">cron<span class="r-auto">confirm</span></span><span class="r-val">ok</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ </div>
+</div>
+
+<!-- running / output-wall capsule -->
+<div class="capsule">
+ <div class="cap-hdr">
+ <div class="title">MAINT · <b>ratio</b> · <span class="running">CLEANING ◐</span></div>
+ <span style="color:var(--dim); font-size:12px;">4 Auto actions</span>
+ </div>
+ <div class="wall">
+ <div class="line"><span class="glyph l-prog">◐</span><span class="act">cache trim</span><span class="st">running…</span></div>
+ <div class="line done"><span class="glyph l-ok">●</span><span class="act">journal vacuum</span><span class="st">done · reclaimed <b>0.9 GB</b></span></div>
+ <div class="line done"><span class="glyph l-ok">●</span><span class="act">coredump clear</span><span class="st">done · <b>12 cleared</b></span></div>
+ <div class="line queued"><span class="glyph l-na">○</span><span class="act">app-log cleanup</span><span class="st">queued</span></div>
+ </div>
+ <div class="cap-foot"><button>Done</button></div>
+</div>
+
+<div class="legend">
+ Automation — <b>Auto</b> doctor cleans unattended · <b>Confirm</b> doctor previews then you click · <b>Human</b> panel nudges you to act · <b>Workflow</b> escalates to an agent workflow · <b>None</b> diagnostic only.
+</div>
+
+<script>
+ function toggle(id){
+ var t = document.getElementById('t-'+id);
+ var d = document.getElementById('d-'+id);
+ if(!d) return;
+ var open = d.style.display !== 'none';
+ d.style.display = open ? 'none' : 'block';
+ t.classList.toggle('expanded', !open);
+ var ex = t.querySelector('.expander');
+ if(ex) ex.textContent = open ? '▸ detail' : '▾ detail';
+ }
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-07-maint-console-C-severity-feed.html b/docs/prototypes/2026-07-07-maint-console-C-severity-feed.html
new file mode 100644
index 0000000..90182d6
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-C-severity-feed.html
@@ -0,0 +1,410 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>MAINT · ratio — Approach C · Severity feed</title>
+<style>
+ :root{
+ --page:#0a0c0d; --surface:#100f0f; --inset:#0a0c0d;
+ --gold:#dab53d; --silver:#bfc4d0; --dim:#7c838a; --cream:#f3e7c5;
+ --slate:#424f5e; --slate-hi:#54677d; --line:#2c2f32;
+ --ok:#74932f; --warn:#dab53d; --fail:#cb6b4d;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;padding:0}
+ body{
+ background:var(--page);
+ color:var(--silver);
+ font-family:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+ font-size:13px; line-height:1.45;
+ display:flex; flex-direction:column; align-items:center;
+ padding:28px 16px 60px;
+ -webkit-font-smoothing:antialiased;
+ }
+ .console{
+ width:1000px; max-width:100%;
+ background:var(--surface);
+ border:1px solid var(--gold);
+ border-radius:16px;
+ padding:18px;
+ margin-bottom:20px;
+ }
+
+ /* ---- header ---- */
+ .top{display:flex; align-items:baseline; justify-content:space-between; gap:12px}
+ .title{font-weight:700; color:var(--cream); font-size:16px; letter-spacing:.5px}
+ .title .sub-host{color:var(--dim); font-weight:400; font-size:13px}
+ .verdict{font-weight:700; font-size:14px; color:var(--ok)}
+ .subtitle{color:var(--dim); font-size:12px; margin-top:2px; letter-spacing:.3px}
+
+ /* ---- action bar ---- */
+ .actionbar{
+ display:flex; align-items:center; gap:10px; flex-wrap:wrap;
+ margin:14px 0 6px;
+ }
+ .btn{
+ background:var(--slate); color:var(--cream);
+ border:1px solid #2f3a45; border-radius:10px;
+ padding:7px 14px; font-family:inherit; font-size:12px; font-weight:600;
+ cursor:pointer; letter-spacing:.3px;
+ }
+ .btn:hover{background:var(--slate-hi)}
+ .btn.small{padding:4px 10px; font-size:11px; font-weight:500}
+ .btn.wide{padding:7px 18px}
+ .spacer{flex:1 1 auto}
+
+ /* quarantined updates pill */
+ .updates-pill{
+ display:flex; align-items:center; gap:12px;
+ background:var(--inset); border:1px solid var(--line);
+ border-left:3px solid var(--warn);
+ border-radius:10px; padding:6px 8px 6px 12px;
+ }
+ .updates-pill .u-text{font-size:12px; color:var(--silver)}
+ .updates-pill .u-text b{color:var(--cream); font-weight:600}
+ .updates-pill .u-cve{color:var(--fail); font-weight:600}
+ .updates-pill .u-pkgs{color:var(--dim)}
+
+ /* ---- feed ---- */
+ .feed{margin-top:14px}
+ .band-head{
+ display:flex; align-items:center; gap:10px;
+ color:var(--gold); font-weight:700; font-size:12px;
+ letter-spacing:1.2px; text-transform:uppercase;
+ margin:16px 0 6px; padding-bottom:5px;
+ border-bottom:1px solid var(--line);
+ }
+ .band-head .count{color:var(--dim); font-weight:400; letter-spacing:.3px; text-transform:none}
+ .band-head .accent{
+ display:inline-block; width:8px; height:8px; border-radius:2px;
+ }
+ .band-fail .accent{background:var(--fail)}
+ .band-warn .accent{background:var(--warn)}
+ .band-ok .accent{background:var(--ok)}
+
+ .rows{
+ background:var(--inset); border:1px solid var(--line);
+ border-radius:10px; padding:4px 2px;
+ }
+
+ .row{
+ display:flex; align-items:center; gap:10px;
+ padding:6px 12px;
+ border-bottom:1px solid rgba(44,47,50,.5);
+ }
+ .row:last-child{border-bottom:none}
+ .row .lamp{flex:0 0 auto; font-size:13px; width:14px; text-align:center}
+ .row .name{flex:0 0 auto; color:var(--silver); white-space:nowrap}
+ .row .dots{
+ flex:1 1 auto; min-width:16px;
+ border-bottom:1px dotted #3a3f43;
+ transform:translateY(-3px);
+ margin:0 4px;
+ }
+ .row .value{flex:0 0 auto; color:var(--cream); white-space:nowrap; text-align:right}
+ .row .value.warnval{color:var(--warn)}
+ .row .value.failval{color:var(--fail)}
+ .row .chip{
+ flex:0 0 auto; width:64px; text-align:center;
+ font-size:10px; letter-spacing:.4px; text-transform:uppercase;
+ padding:2px 0; border-radius:6px; border:1px solid var(--line);
+ color:var(--dim);
+ }
+ .chip.auto{color:#a9c05a; border-color:#3c4a24}
+ .chip.confirm{color:var(--gold); border-color:#4a4023}
+ .chip.workflow{color:var(--slate-hi); border-color:#33414f}
+ .chip.human{color:var(--cream); border-color:#4a4436}
+ .chip.none{color:var(--dim); border-color:var(--line)}
+ .row .control{flex:0 0 auto; width:118px; text-align:right}
+ .row .control .lever{
+ background:var(--slate); color:var(--cream);
+ border:1px solid #2f3a45; border-radius:8px;
+ padding:3px 10px; font-family:inherit; font-size:11px; cursor:pointer;
+ }
+ .row .control .lever:hover{background:var(--slate-hi)}
+ .row .control .wflink{color:var(--slate-hi); font-size:11px; cursor:pointer}
+ .row .control .wflink:hover{color:var(--cream); text-decoration:underline}
+ .row .control .nolever{color:#4a5054; font-size:11px}
+
+ /* lamps */
+ .l-ok{color:var(--ok)}
+ .l-warn{color:var(--warn)}
+ .l-fail{color:var(--fail)}
+ .l-off{color:var(--dim)}
+ .l-prog{color:var(--warn)}
+
+ /* OK band quieting */
+ .band-ok-summary{
+ display:flex; align-items:center; gap:8px;
+ color:var(--dim); font-size:12px; cursor:pointer;
+ margin:16px 0 6px; padding-bottom:5px;
+ border-bottom:1px solid var(--line);
+ }
+ .band-ok-summary .tri{color:var(--gold)}
+ .band-ok .rows{opacity:.72}
+ .band-ok .row{padding:4px 12px}
+ .band-ok .row .name{color:var(--dim)}
+ .band-ok .row .value{color:var(--silver)}
+
+ /* ---- running capsule ---- */
+ .capsule{
+ width:1000px; max-width:100%;
+ background:var(--surface);
+ border:1px solid var(--gold);
+ border-radius:16px; padding:16px 18px;
+ }
+ .cap-head{
+ display:flex; align-items:center; justify-content:space-between;
+ color:var(--cream); font-weight:700; font-size:13px; letter-spacing:.5px;
+ margin-bottom:10px;
+ }
+ .cap-head .run{color:var(--warn)}
+ .wall{
+ background:var(--inset); border:1px solid var(--line); border-radius:10px;
+ padding:10px 14px; font-size:12px;
+ }
+ .wall .line{display:flex; align-items:center; gap:10px; padding:3px 0}
+ .wall .line .lamp{width:14px; text-align:center}
+ .wall .line .act{color:var(--silver)}
+ .wall .line .act .done{color:var(--dim)}
+ .wall .line .res{color:var(--ok); margin-left:auto}
+ .wall .line.queued .act{color:var(--dim)}
+ .cap-foot{display:flex; justify-content:flex-end; margin-top:12px}
+
+ .legend{
+ width:1000px; max-width:100%;
+ color:var(--dim); font-size:11px; line-height:1.6;
+ margin-top:16px; padding:0 4px;
+ }
+ .legend b{color:var(--silver); font-weight:600}
+</style>
+</head>
+<body>
+
+<div class="console">
+ <div class="top">
+ <div>
+ <div class="title">MAINT <span class="sub-host">· ratio</span></div>
+ <div class="subtitle">Approach C — Severity feed</div>
+ </div>
+ <div class="verdict">● healthy</div>
+ </div>
+
+ <!-- action bar -->
+ <div class="actionbar">
+ <button class="btn wide">Clean up <span style="color:var(--dim);font-weight:400">· 4 Auto</span></button>
+ <button class="btn wide">Review &amp; fix <span style="color:var(--dim);font-weight:400">· 15 Confirm</span></button>
+ <div class="spacer"></div>
+ <div class="updates-pill">
+ <span class="u-text"><b>47 pending</b> · <span class="u-cve">3 CVE</span> · <span class="u-pkgs">mesa, linux-lts</span></span>
+ <button class="btn small">Run workflow</button>
+ </div>
+ </div>
+
+ <div class="feed">
+
+ <!-- ===================== FAIL ===================== -->
+ <div class="band-head band-fail"><span class="accent"></span>Fail <span class="count">— act now · 2</span></div>
+ <div class="rows">
+ <div class="row">
+ <span class="lamp l-fail">●</span>
+ <span class="name">fstrim.timer</span>
+ <span class="dots"></span>
+ <span class="value failval">off</span>
+ <span class="chip confirm">Confirm</span>
+ <span class="control"><button class="lever">Enable</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-fail">●</span>
+ <span class="name">arch-audit</span>
+ <span class="dots"></span>
+ <span class="value failval">3 CVE</span>
+ <span class="chip workflow">Workflow</span>
+ <span class="control"><span class="wflink">→ workflow</span></span>
+ </div>
+ </div>
+
+ <!-- ===================== WARN ===================== -->
+ <div class="band-head band-warn"><span class="accent"></span>Warn <span class="count">— review · 13</span></div>
+ <div class="rows">
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">cache</span>
+ <span class="dots"></span>
+ <span class="value warnval">8.8&nbsp;GB</span>
+ <span class="chip auto">Auto</span>
+ <span class="control"><button class="lever">Clean</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">btrfs scrub</span>
+ <span class="dots"></span>
+ <span class="value warnval">34d ago</span>
+ <span class="chip confirm">Confirm</span>
+ <span class="control"><button class="lever">Scrub</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">orphans</span>
+ <span class="dots"></span>
+ <span class="value warnval">13</span>
+ <span class="chip confirm">Confirm</span>
+ <span class="control"><button class="lever">Review</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">.pacnew files</span>
+ <span class="dots"></span>
+ <span class="value warnval">2</span>
+ <span class="chip confirm">Confirm</span>
+ <span class="control"><button class="lever">Review</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">pending updates</span>
+ <span class="dots"></span>
+ <span class="value warnval">47</span>
+ <span class="chip workflow">Workflow</span>
+ <span class="control"><span class="wflink">→ workflow</span></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">failed units</span>
+ <span class="dots"></span>
+ <span class="value warnval">1</span>
+ <span class="chip workflow">Workflow</span>
+ <span class="control"><span class="wflink">→ workflow</span></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">maintenance timers</span>
+ <span class="dots"></span>
+ <span class="value warnval">4/5</span>
+ <span class="chip confirm">Confirm</span>
+ <span class="control"><button class="lever">Enable</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">coredumps</span>
+ <span class="dots"></span>
+ <span class="value warnval">18 / 7d</span>
+ <span class="chip auto">Auto</span>
+ <span class="control"><button class="lever">Clear</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">unclean-shutdown</span>
+ <span class="dots"></span>
+ <span class="value warnval">75%</span>
+ <span class="chip workflow">Workflow</span>
+ <span class="control"><span class="wflink">→ workflow</span></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">docker reclaim</span>
+ <span class="dots"></span>
+ <span class="value warnval">3&nbsp;GB</span>
+ <span class="chip confirm">Confirm</span>
+ <span class="control"><button class="lever">Prune</button></span>
+ </div>
+ </div>
+
+ <!-- ===================== OK (quieted) ===================== -->
+ <div class="band-ok" id="okband">
+ <div class="band-ok-summary" onclick="var b=document.getElementById('okrows');b.style.display=b.style.display==='none'?'block':'none';this.querySelector('.tri').textContent=b.style.display==='none'?'▸':'▾';">
+ <span class="tri">▾</span>
+ <span style="color:var(--ok)">●</span>
+ <span>29 OK — clean, no action</span>
+ <span style="flex:1"></span>
+ <span style="color:#565c60">collapse</span>
+ </div>
+ <div class="rows" id="okrows">
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">deep-trim keep-3</span><span class="dots"></span><span class="value">available</span><span class="chip confirm">Confirm</span><span class="control"><button class="lever">Deep trim</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">disk usage</span><span class="dots"></span><span class="value">69%</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">btrfs unalloc</span><span class="dots"></span><span class="value">118&nbsp;GiB</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">btrfs device-err</span><span class="dots"></span><span class="value">0 / 0</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">SMART status</span><span class="dots"></span><span class="value">PASSED</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">SMART wear</span><span class="dots"></span><span class="value">0%</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">pacman integrity</span><span class="dots"></span><span class="value">clean</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">snapper count</span><span class="dots"></span><span class="value">42</span><span class="chip confirm">Confirm</span><span class="control"><button class="lever">Prune</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">snapshot timer</span><span class="dots"></span><span class="value">active</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">keyring</span><span class="dots"></span><span class="value">12d</span><span class="chip confirm">Confirm</span><span class="control"><button class="lever">Refresh</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">AUR staleness</span><span class="dots"></span><span class="value">5</span><span class="chip workflow">Workflow</span><span class="control"><span class="wflink">→ workflow</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">reboot-required</span><span class="dots"></span><span class="value">no</span><span class="chip human">Human</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">is-system-running</span><span class="dots"></span><span class="value">running</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">kernel taint</span><span class="dots"></span><span class="value">0</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">journald size</span><span class="dots"></span><span class="value">1.2&nbsp;GB</span><span class="chip auto">Auto</span><span class="control"><button class="lever">Vacuum</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">app-log cleanup</span><span class="dots"></span><span class="value">ok</span><span class="chip auto">Auto</span><span class="control"><button class="lever">Run</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">journal errors</span><span class="dots"></span><span class="value">12 real</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">kernel/hw events</span><span class="dots"></span><span class="value">clean</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">memory</span><span class="dots"></span><span class="value">104&nbsp;GB free</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">swap/zram</span><span class="dots"></span><span class="value">16&nbsp;GiB</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">temps</span><span class="dots"></span><span class="value">61° / 54°</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">thermal throttle</span><span class="dots"></span><span class="value">no</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">DNS / NM</span><span class="dots"></span><span class="value">ok</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">firewall</span><span class="dots"></span><span class="value">active</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">listeners</span><span class="dots"></span><span class="value">3</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">tailscale</span><span class="dots"></span><span class="value">4/4</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">fail2ban</span><span class="dots"></span><span class="value">active</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">NTP</span><span class="dots"></span><span class="value">synced</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">rsyncshot</span><span class="dots"></span><span class="value">3h ago</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">docker stopped</span><span class="dots"></span><span class="value">2</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">cron</span><span class="dots"></span><span class="value">ok</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- ===================== OFF / N-A ===================== -->
+ <div class="band-ok" style="margin-top:6px">
+ <div class="rows" style="opacity:.55">
+ <div class="row"><span class="lamp l-off">○</span><span class="name" style="color:var(--dim)">battery</span><span class="dots"></span><span class="value" style="color:var(--dim)">n/a</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-off">○</span><span class="name" style="color:var(--dim)">libvirt</span><span class="dots"></span><span class="value" style="color:var(--dim)">off</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ </div>
+ </div>
+
+ </div>
+</div>
+
+<!-- =============== RUNNING / OUTPUT-WALL capsule =============== -->
+<div class="capsule">
+ <div class="cap-head">
+ <span>MAINT · ratio · CLEANING <span class="run">◐</span></span>
+ <span style="color:var(--dim); font-weight:400; font-size:12px">4 Auto actions</span>
+ </div>
+ <div class="wall">
+ <div class="line">
+ <span class="lamp l-prog">◐</span>
+ <span class="act">cache trim <span style="color:var(--dim)">running…</span></span>
+ </div>
+ <div class="line">
+ <span class="lamp l-ok">●</span>
+ <span class="act"><span class="done">journal vacuum — done</span></span>
+ <span class="res">reclaimed 0.9&nbsp;GB</span>
+ </div>
+ <div class="line">
+ <span class="lamp l-ok">●</span>
+ <span class="act"><span class="done">coredump clear — done</span></span>
+ <span class="res">12 cleared</span>
+ </div>
+ <div class="line queued">
+ <span class="lamp l-off">○</span>
+ <span class="act">app-log cleanup <span style="color:#565c60">queued</span></span>
+ </div>
+ </div>
+ <div class="cap-foot">
+ <button class="btn">Done</button>
+ </div>
+</div>
+
+<div class="legend">
+ <b>Automation classes:</b>
+ <b>Auto</b> — doctor "Clean up" runs unattended ·
+ <b>Confirm</b> — doctor "Review &amp; fix" preview then click ·
+ <b>Human</b> — you act ·
+ <b>Workflow</b> — escalate to an agent workflow ·
+ <b>None</b> — diagnostic only.
+ &nbsp;&nbsp;<b>Lamps:</b> ● ok / warn / fail · ◐ in-progress · ○ off-or-n/a. ZFS rows omitted (btrfs host).
+</div>
+
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-07-maint-console-D-doctor-first.html b/docs/prototypes/2026-07-07-maint-console-D-doctor-first.html
new file mode 100644
index 0000000..7f2a86e
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-D-doctor-first.html
@@ -0,0 +1,364 @@
+<!-- Approach D — Doctor-first maintenance console mockup (ratio) -->
+<title>MAINT · ratio — Approach D Doctor-first</title>
+<style>
+ :root{
+ --bg:#0a0c0d; --surface:#100f0f; --gold:#dab53d; --silver:#bfc4d0;
+ --dim:#7c838a; --cream:#f3e7c5; --slate:#424f5e; --slate-h:#54677d;
+ --inset:#0a0c0d; --line:#2c2f32;
+ --olive:#74932f; --amber:#dab53d; --terra:#cb6b4d;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;padding:0}
+ body{
+ background:var(--bg);
+ color:var(--silver);
+ font-family:"BerkeleyMono Nerd Font", ui-monospace, monospace;
+ font-size:13px; line-height:1.45;
+ display:flex; justify-content:center;
+ padding:28px 16px 60px;
+ -webkit-font-smoothing:antialiased;
+ }
+ .console{
+ width:1000px; max-width:100%;
+ background:var(--surface);
+ border:1px solid var(--gold);
+ border-radius:16px;
+ padding:18px;
+ }
+
+ /* ---- header ---- */
+ .head{display:flex; align-items:baseline; justify-content:space-between; gap:12px}
+ .head .title{color:var(--cream); font-size:15px; letter-spacing:.06em; font-weight:700}
+ .head .title .sub{display:block; color:var(--dim); font-size:11px; font-weight:400; letter-spacing:.02em; margin-top:2px}
+ .verdict{font-size:14px; font-weight:700; letter-spacing:.03em}
+ .verdict .lamp{font-size:13px}
+
+ .rule{height:1px; background:var(--line); border:0; margin:14px 0}
+
+ /* ---- lamps ---- */
+ .lamp{font-style:normal}
+ .ok{color:var(--olive)}
+ .warn{color:var(--amber)}
+ .fail{color:var(--terra)}
+ .na{color:var(--dim)}
+ .run{color:var(--amber)}
+
+ /* ---- generic inset ---- */
+ .inset{background:var(--inset); border:1px solid var(--line); border-radius:10px; padding:14px}
+ .sec-h{color:var(--gold); font-weight:700; letter-spacing:.05em; font-size:12px; text-transform:uppercase; margin:0 0 10px}
+
+ /* ---- buttons ---- */
+ button{font-family:inherit; cursor:pointer}
+ .btn{
+ background:var(--slate); color:var(--cream); border:1px solid #33414d;
+ border-radius:10px; padding:9px 16px; font-size:12px; letter-spacing:.03em;
+ transition:background .12s;
+ }
+ .btn:hover{background:var(--slate-h)}
+ .btn-lever{padding:4px 10px; font-size:11px; border-radius:8px}
+
+ /* ================= HERO ================= */
+ .hero{
+ background:linear-gradient(180deg,#141312,#0d0c0c);
+ border:1px solid var(--line); border-radius:14px;
+ padding:22px 22px 24px; text-align:center;
+ }
+ .hero .stethoscope{font-size:26px; color:var(--gold); letter-spacing:.3em; margin-bottom:6px}
+ .hero .stat-line{color:var(--silver); font-size:13px; letter-spacing:.04em}
+ .hero .stat-line b{color:var(--cream); font-weight:700}
+ .hero .stat-line .fail{font-weight:700}
+
+ /* summary lamp strip */
+ .strip{display:flex; flex-wrap:wrap; gap:3px; justify-content:center; margin:14px auto 4px; max-width:720px}
+ .strip .dot{font-size:11px; line-height:1}
+
+ .dbig{display:flex; gap:16px; justify-content:center; margin-top:20px; flex-wrap:wrap}
+ .doc-btn{
+ background:var(--slate); color:var(--cream);
+ border:1px solid #33414d; border-radius:12px;
+ padding:16px 26px; min-width:280px; text-align:center;
+ transition:background .12s;
+ }
+ .doc-btn:hover{background:var(--slate-h)}
+ .doc-btn .big{display:block; font-size:17px; font-weight:700; letter-spacing:.08em; color:var(--cream)}
+ .doc-btn .sub{display:block; font-size:11px; color:#cdd7cf; margin-top:6px; letter-spacing:.02em; opacity:.85}
+ .doc-btn.primary{border-color:var(--gold); box-shadow:0 0 0 1px rgba(218,181,61,.25) inset}
+ .doc-btn.primary .big{color:var(--cream)}
+
+ .hero-foot{color:var(--dim); font-size:11px; margin-top:18px; letter-spacing:.02em}
+
+ /* ================= DETAILS DISCLOSURE ================= */
+ .disc{margin-top:16px}
+ .disc-head{
+ display:flex; align-items:center; gap:8px;
+ color:var(--gold); font-weight:700; font-size:12px; letter-spacing:.05em;
+ cursor:pointer; user-select:none; padding:4px 2px;
+ }
+ .disc-head .caret{transition:transform .12s}
+ .disc-body{margin-top:12px}
+
+ .domain{margin-bottom:14px}
+ .domain-h{color:var(--dim); font-size:10px; letter-spacing:.14em; text-transform:uppercase; margin:0 0 6px; border-bottom:1px solid var(--line); padding-bottom:4px}
+ table.metrics{width:100%; border-collapse:collapse}
+ table.metrics td{padding:3px 8px; vertical-align:middle; white-space:nowrap}
+ table.metrics tr:hover{background:rgba(66,79,94,.14)}
+ td.mlamp{width:16px; text-align:center}
+ td.mname{color:var(--silver)}
+ td.mval{color:var(--dim); text-align:right; width:150px}
+ td.mlever{width:110px; text-align:right}
+ .auto-tag{font-size:9px; letter-spacing:.08em; color:var(--dim); text-transform:uppercase; padding-right:6px}
+
+ /* ================= UPDATES QUARANTINE ================= */
+ .updates{margin-top:16px; border:1px dashed #4a4535; background:#12100c}
+ .updates .sec-h{color:var(--amber)}
+ .updates-row{display:flex; align-items:center; justify-content:space-between; gap:14px; flex-wrap:wrap}
+ .updates-info b{color:var(--cream)}
+ .updates-info .cve{color:var(--terra); font-weight:700}
+ .updates-info .pkgs{color:var(--dim)}
+ .updates-note{color:var(--dim); font-size:10px; margin-top:8px; letter-spacing:.02em}
+
+ /* ================= OUTPUT WALL ================= */
+ .wall{
+ margin-top:18px;
+ background:#0b0a09; border:1px solid var(--gold); border-radius:14px;
+ padding:18px 20px;
+ }
+ .wall-h{display:flex; align-items:center; justify-content:space-between; margin-bottom:4px}
+ .wall-h .t{color:var(--gold); font-weight:700; letter-spacing:.06em; font-size:13px}
+ .wall-h .meta{color:var(--dim); font-size:11px}
+ .wall-sub{color:var(--dim); font-size:11px; margin-bottom:12px}
+ .prog{height:6px; background:#1a1712; border:1px solid var(--line); border-radius:4px; overflow:hidden; margin-bottom:14px}
+ .prog i{display:block; height:100%; width:62%; background:linear-gradient(90deg,#74932f,#dab53d)}
+ .stream{font-size:12.5px; line-height:1.9}
+ .srow{display:flex; align-items:baseline; gap:10px}
+ .srow .l{width:14px; text-align:center; flex:0 0 auto}
+ .srow .task{color:var(--silver); flex:0 0 200px}
+ .srow .status{color:var(--dim)}
+ .srow.done .task{color:var(--silver)}
+ .srow.done .status{color:var(--olive)}
+ .srow.run .task{color:var(--cream)}
+ .srow.run .status{color:var(--amber)}
+ .srow.queued .task{color:var(--dim)}
+ .srow.queued .status{color:var(--dim)}
+ .srow .result{color:var(--cream); opacity:.85}
+ .wall-foot{display:flex; justify-content:space-between; align-items:center; margin-top:16px}
+ .wall-foot .summary{color:var(--dim); font-size:11px}
+ .wall-foot .summary b{color:var(--olive)}
+
+ .stagelabel{color:var(--dim); font-size:10px; letter-spacing:.18em; text-transform:uppercase; margin:26px 0 8px; text-align:center}
+</style>
+
+<div class="console">
+
+ <!-- ================= HEADER ================= -->
+ <div class="head">
+ <div class="title">MAINT · ratio
+ <span class="sub">Approach D — Doctor-first</span>
+ </div>
+ <div class="verdict ok"><span class="lamp">●</span> healthy</div>
+ </div>
+ <hr class="rule">
+
+ <div class="stagelabel">— resting state · the doctor is what you see first —</div>
+
+ <!-- ================= HERO (resting) ================= -->
+ <div class="hero">
+ <div class="stethoscope">✚ ✚ ✚</div>
+ <div class="stat-line">
+ <b>ratio</b> · <span class="fail">1 fail</span> · <span class="warn">8 warnings</span> · <span class="ok">34 ok</span>
+ </div>
+
+ <!-- summary lamp strip: one tiny lamp per metric -->
+ <div class="strip" title="one lamp per metric, colored by state">
+ <!-- 44 dots; mostly olive, some amber, one terracotta -->
+ <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot fail">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot warn">●</span><span class="dot warn">●</span><span class="dot ok">●</span>
+ <span class="dot warn">●</span><span class="dot fail">●</span><span class="dot ok">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot warn">●</span>
+ <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot warn">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot ok">●</span><span class="dot na">○</span><span class="dot warn">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot na">○</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span>
+ </div>
+
+ <!-- big doctor actions -->
+ <div class="dbig">
+ <button class="doc-btn primary" onclick="document.getElementById('wall').scrollIntoView({behavior:'smooth'})">
+ <span class="big">CLEAN UP</span>
+ <span class="sub">4 safe tasks · reclaims ~2 GB</span>
+ </button>
+ <button class="doc-btn">
+ <span class="big">REVIEW &amp; FIX</span>
+ <span class="sub">9 items need a look</span>
+ </button>
+ </div>
+
+ <div class="hero-foot">
+ "Should I run the doctor?" — press Clean up for the 4 unattended tasks, or Review &amp; fix to preview the 9 confirm items.
+ </div>
+ </div>
+
+ <!-- ================= DETAILS DISCLOSURE (shown expanded statically) ================= -->
+ <div class="disc inset">
+ <div class="disc-head" onclick="var b=document.getElementById('discbody'); var c=this.querySelector('.caret'); if(b.style.display==='none'){b.style.display='block'; c.textContent='▾';} else {b.style.display='none'; c.textContent='▸';}">
+ <span class="caret">▾</span> 44 metrics · the fine print
+ </div>
+ <div class="disc-body" id="discbody">
+
+ <!-- STORAGE / FILESYSTEM -->
+ <div class="domain">
+ <div class="domain-h">Storage &amp; filesystem</div>
+ <table class="metrics">
+ <tr><td class="mlamp warn">●</td><td class="mname">cache</td><td class="mval">8.8 GB</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Clean</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">deep-trim keep-3</td><td class="mval">ready</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Deep trim</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">disk</td><td class="mval">69%</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">btrfs unalloc</td><td class="mval">118 GiB</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">btrfs scrub</td><td class="mval">34 d ago</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Scrub</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">btrfs device-err</td><td class="mval">0 / 0</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">SMART</td><td class="mval">PASSED</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">SMART wear</td><td class="mval">0%</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp fail">●</td><td class="mname">fstrim.timer</td><td class="mval">off</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Enable</button></td></tr>
+ </table>
+ </div>
+
+ <!-- PACKAGES -->
+ <div class="domain">
+ <div class="domain-h">Packages &amp; snapshots</div>
+ <table class="metrics">
+ <tr><td class="mlamp ok">●</td><td class="mname">pacman integrity</td><td class="mval">clean</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">snapper count</td><td class="mval">42</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Prune</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">snapshot timer</td><td class="mval">active</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">orphans</td><td class="mval">13</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Review</button></td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">.pacnew</td><td class="mval">2</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Review</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">keyring</td><td class="mval">12 d</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Refresh</button></td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">pending updates</td><td class="mval">47</td><td class="mlever"><span class="auto-tag">workflow</span>updates</td></tr>
+ <tr><td class="mlamp fail">●</td><td class="mname">arch-audit</td><td class="mval">3 CVE</td><td class="mlever"><span class="auto-tag">workflow</span>updates</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">AUR staleness</td><td class="mval">5</td><td class="mlever"><span class="auto-tag">workflow</span>updates</td></tr>
+ </table>
+ </div>
+
+ <!-- SYSTEM STATE -->
+ <div class="domain">
+ <div class="domain-h">System state</div>
+ <table class="metrics">
+ <tr><td class="mlamp ok">●</td><td class="mname">reboot-required</td><td class="mval">no</td><td class="mlever"><span class="auto-tag">human</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">is-system-running</td><td class="mval">running</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">failed units</td><td class="mval">1</td><td class="mlever"><span class="auto-tag">workflow</span><button class="btn btn-lever">Run</button></td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">maintenance timers</td><td class="mval">4 / 5</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Enable</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">kernel taint</td><td class="mval">0</td><td class="mlever">—</td></tr>
+ </table>
+ </div>
+
+ <!-- LOGS -->
+ <div class="domain">
+ <div class="domain-h">Logs &amp; coredumps</div>
+ <table class="metrics">
+ <tr><td class="mlamp warn">●</td><td class="mname">coredumps</td><td class="mval">18 / 7 d</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Clear</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">journald</td><td class="mval">1.2 GB</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Vacuum</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">app-log cleanup</td><td class="mval">ok</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Run</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">journal errors</td><td class="mval">12 real</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">kernel/hw events</td><td class="mval">clean</td><td class="mlever">—</td></tr>
+ </table>
+ </div>
+
+ <!-- RESOURCES -->
+ <div class="domain">
+ <div class="domain-h">Resources &amp; thermals</div>
+ <table class="metrics">
+ <tr><td class="mlamp ok">●</td><td class="mname">memory</td><td class="mval">104 GB free</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">swap / zram</td><td class="mval">16 GiB</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">temps</td><td class="mval">61° / 54°</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">thermal throttle</td><td class="mval">no</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp na">○</td><td class="mname na">battery</td><td class="mval">n/a</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">unclean-shutdown</td><td class="mval">75%</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr>
+ </table>
+ </div>
+
+ <!-- NETWORK / SECURITY -->
+ <div class="domain">
+ <div class="domain-h">Network &amp; security</div>
+ <table class="metrics">
+ <tr><td class="mlamp ok">●</td><td class="mname">DNS / NM</td><td class="mval">ok</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">firewall</td><td class="mval">active</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">listeners</td><td class="mval">3</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">tailscale</td><td class="mval">4 / 4</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">fail2ban</td><td class="mval">active</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">NTP</td><td class="mval">synced</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr>
+ </table>
+ </div>
+
+ <!-- SERVICES / BACKUP -->
+ <div class="domain">
+ <div class="domain-h">Backup &amp; services</div>
+ <table class="metrics">
+ <tr><td class="mlamp ok">●</td><td class="mname">rsyncshot</td><td class="mval">3 h ago</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">docker reclaim</td><td class="mval">3 GB</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Prune</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">docker stopped</td><td class="mval">2</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp na">○</td><td class="mname na">libvirt</td><td class="mval">off</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">cron</td><td class="mval">ok</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr>
+ </table>
+ </div>
+
+ </div>
+ </div>
+
+ <!-- ================= UPDATES QUARANTINE ================= -->
+ <div class="updates inset">
+ <div class="sec-h">Updates — quarantined from the doctor</div>
+ <div class="updates-row">
+ <div class="updates-info">
+ <b>47 pending</b> · <span class="cve">3 CVE</span> · <span class="pkgs">mesa, linux-lts, systemd</span>
+ </div>
+ <button class="btn">Run workflow</button>
+ </div>
+ <div class="updates-note">Updates run through the workflow, never the doctor. Clean up will not touch these.</div>
+ </div>
+
+ <div class="stagelabel">— running state · press CLEAN UP and the hero becomes the output wall —</div>
+
+ <!-- ================= OUTPUT WALL (the star) ================= -->
+ <div class="wall" id="wall">
+ <div class="wall-h">
+ <span class="t">✚ DOCTOR · CLEAN UP</span>
+ <span class="meta">4 tasks · unattended · started 14:22:07</span>
+ </div>
+ <div class="wall-sub">Running the 4 safe (auto) tasks. Confirm items and updates are untouched.</div>
+ <div class="prog"><i></i></div>
+
+ <div class="stream">
+ <div class="srow run">
+ <span class="l run">◐</span>
+ <span class="task">cache trim</span>
+ <span class="status">running… 8.8 GB → clearing</span>
+ </div>
+ <div class="srow done">
+ <span class="l ok">●</span>
+ <span class="task">journald vacuum</span>
+ <span class="status">done · <span class="result">reclaimed 0.9 GB</span></span>
+ </div>
+ <div class="srow done">
+ <span class="l ok">●</span>
+ <span class="task">coredump clear</span>
+ <span class="status">done · <span class="result">12 cleared</span></span>
+ </div>
+ <div class="srow queued">
+ <span class="l na">○</span>
+ <span class="task">app-log cleanup</span>
+ <span class="status">queued</span>
+ </div>
+ </div>
+
+ <div class="wall-foot">
+ <span class="summary">3 of 4 complete · <b>reclaimed 0.9 GB</b> so far · ~1.1 GB pending</span>
+ <button class="btn">Done</button>
+ </div>
+ </div>
+
+</div>
diff --git a/docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html b/docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html
new file mode 100644
index 0000000..52ecfad
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html
@@ -0,0 +1,475 @@
+<meta charset="utf-8">
+<title>MAINT · ratio — Approach E · Instrument Dashboard</title>
+<style>
+ :root{
+ --page:#0a0c0d; --surface:#100f0f; --inset:#0a0c0d;
+ --gold:#dab53d; --silver:#bfc4d0; --dim:#7c838a; --cream:#f3e7c5;
+ --slate:#424f5e; --slate-hi:#54677d; --line:#2c2f32;
+ --olive:#74932f; --amber:#dab53d; --terra:#cb6b4d;
+ --font:"BerkeleyMono Nerd Font", ui-monospace, monospace;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;background:var(--page);}
+ body{
+ font-family:var(--font); color:var(--silver);
+ display:flex; justify-content:center; padding:26px 14px 60px;
+ -webkit-font-smoothing:antialiased;
+ }
+ .console{
+ width:1050px; max-width:100%;
+ background:var(--surface);
+ border:1px solid var(--gold); border-radius:16px;
+ padding:18px;
+ }
+
+ /* ---- header ---- */
+ .head{display:flex; align-items:baseline; justify-content:space-between; gap:16px;}
+ .head .title{font-size:19px; color:var(--cream); letter-spacing:.5px;}
+ .head .title b{color:var(--gold); font-weight:700;}
+ .verdict{font-size:15px; color:var(--olive); font-weight:700; white-space:nowrap;}
+ .sub{color:var(--dim); font-size:12px; margin-top:2px; letter-spacing:.4px;}
+
+ /* ---- top action bar ---- */
+ .actionbar{
+ display:flex; align-items:center; gap:10px;
+ margin:14px 0 16px; padding:10px 12px;
+ background:var(--inset); border:1px solid var(--line); border-radius:10px;
+ }
+ .actionbar .lbl{color:var(--dim); font-size:11.5px; letter-spacing:.5px; text-transform:uppercase; margin-right:2px;}
+ .btn{
+ font-family:var(--font); cursor:pointer;
+ background:var(--slate); color:var(--cream);
+ border:none; border-radius:10px; padding:7px 14px; font-size:13px;
+ }
+ .btn:hover{background:var(--slate-hi);}
+ .btn.lever{padding:3px 9px; font-size:11px; border-radius:8px;}
+ .actionbar .spacer{flex:1;}
+ .legend{color:var(--dim); font-size:11px; line-height:1.5;}
+ .legend b{color:var(--silver); font-weight:400;}
+
+ /* ---- grid ---- */
+ .cluster{display:grid; grid-template-columns:repeat(12,1fr); gap:10px;}
+ .card{
+ background:var(--inset); border:1px solid var(--line); border-radius:10px;
+ padding:11px 12px 12px; position:relative; min-height:96px;
+ display:flex; flex-direction:column;
+ }
+ .card .lever{position:absolute; top:9px; right:9px;}
+ .card .cap{
+ color:var(--dim); font-size:10.5px; letter-spacing:.6px;
+ text-transform:uppercase; margin-bottom:8px; padding-right:56px;
+ }
+ .card .big{color:var(--cream); font-size:23px; line-height:1; font-weight:700;}
+ .card .unit{color:var(--dim); font-size:12px; font-weight:400;}
+ .card .note{color:var(--dim); font-size:11px; margin-top:6px;}
+
+ /* domain band label */
+ .band{
+ grid-column:1/-1; color:var(--gold); font-weight:700; font-size:12px;
+ letter-spacing:1.2px; text-transform:uppercase; margin:6px 2px 0;
+ display:flex; align-items:center; gap:10px;
+ }
+ .band::after{content:""; flex:1; height:1px; background:var(--line);}
+
+ /* span helpers */
+ .s3{grid-column:span 3;} .s4{grid-column:span 4;}
+ .s5{grid-column:span 5;} .s6{grid-column:span 6;}
+ .s7{grid-column:span 7;} .s8{grid-column:span 8;}
+ .s9{grid-column:span 9;} .s12{grid-column:span 12;}
+
+ /* ---- horizontal capacity meter ---- */
+ .meter{margin-top:auto;}
+ .meter .track{
+ position:relative; height:14px; background:#07090a;
+ border:1px solid var(--line); border-radius:7px; overflow:hidden;
+ }
+ .meter .fill{height:100%; border-radius:6px 0 0 6px;}
+ .fill.ok{background:linear-gradient(90deg,#5c7626,#74932f);}
+ .fill.warn{background:linear-gradient(90deg,#a98a2c,#dab53d);}
+ .fill.fail{background:linear-gradient(90deg,#a8543b,#cb6b4d);}
+ .meter .tick{position:absolute; top:-2px; bottom:-2px; width:2px; background:var(--gold);}
+ .meter .zone{position:absolute; top:0; bottom:0; opacity:.16;}
+ .zone.amber{background:var(--amber);} .zone.terra{background:var(--terra);}
+ .meter .scale{display:flex; justify-content:space-between; color:var(--dim); font-size:9.5px; margin-top:4px;}
+
+ /* ---- lamps ---- */
+ .lamp{display:inline-block; width:1em; text-align:center;}
+ .l-ok{color:var(--olive);} .l-warn{color:var(--amber);}
+ .l-fail{color:var(--terra);} .l-off{color:var(--dim);} .l-prog{color:var(--amber);}
+
+ /* ---- lamp board ---- */
+ .lampboard{
+ grid-column:1/-1; background:var(--inset); border:1px solid var(--line);
+ border-radius:10px; padding:12px 14px;
+ display:grid; grid-template-columns:repeat(4,1fr); gap:7px 18px;
+ }
+ .lampboard .row{display:flex; align-items:center; gap:8px; font-size:12px;}
+ .lampboard .row .nm{color:var(--silver);}
+ .lampboard .row .vv{color:var(--dim); margin-left:auto; font-size:11px;}
+
+ /* ---- counter tile ---- */
+ .tile .row{display:flex; align-items:center; gap:8px;}
+ .tile .big{font-size:26px;}
+
+ /* ---- updates quarantine ---- */
+ .updates{
+ grid-column:1/-1; border:1px solid var(--amber);
+ background:linear-gradient(180deg,#141210,#0a0c0d);
+ border-radius:10px; padding:13px 16px;
+ display:flex; align-items:center; gap:26px; flex-wrap:wrap;
+ }
+ .updates .q{
+ color:var(--amber); font-size:10.5px; letter-spacing:1.5px;
+ text-transform:uppercase; border:1px solid var(--amber);
+ padding:3px 8px; border-radius:6px; align-self:flex-start;
+ }
+ .updates .metric{display:flex; flex-direction:column; gap:2px;}
+ .updates .metric .n{font-size:30px; color:var(--cream); font-weight:700; line-height:1;}
+ .updates .metric .n.cve{color:var(--terra);}
+ .updates .metric .k{color:var(--dim); font-size:10.5px; text-transform:uppercase; letter-spacing:.6px;}
+ .updates .spacer{flex:1;}
+
+ /* ---- svg arc/dial labels ---- */
+ .gaugewrap{display:flex; gap:14px; margin-top:2px;}
+ .gauge{display:flex; flex-direction:column; align-items:center; gap:3px;}
+ .gauge .rd{color:var(--cream); font-size:13px; font-weight:700;}
+ .gauge .lb{color:var(--dim); font-size:10px;}
+ .dialwrap{display:flex; justify-content:center; margin-top:2px;}
+
+ /* ---- output wall ---- */
+ .wall{
+ margin-top:16px; background:var(--inset);
+ border:1px solid var(--line); border-radius:10px; padding:13px 15px;
+ }
+ .wall .wh{
+ display:flex; align-items:center; justify-content:space-between;
+ color:var(--cream); font-size:13px; letter-spacing:.5px; margin-bottom:10px;
+ }
+ .wall .wh .l{color:var(--gold);}
+ .wall .stream{font-size:12.5px; line-height:1.85;}
+ .wall .stream .ln{display:flex; gap:9px; align-items:baseline;}
+ .wall .stream .msg{color:var(--silver);}
+ .wall .stream .meta{color:var(--dim);}
+ .wall .stream .queued .msg{color:var(--dim);}
+ .wall .wfoot{margin-top:11px; display:flex; justify-content:flex-end;}
+
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
+ .spin{animation:pulse 1.1s ease-in-out infinite;}
+</style>
+
+<div class="console">
+
+ <!-- HEADER -->
+ <div class="head">
+ <div>
+ <div class="title">MAINT · <b>ratio</b></div>
+ <div class="sub">Approach E — Instrument dashboard</div>
+ </div>
+ <div class="verdict"><span class="lamp l-ok">●</span> healthy</div>
+ </div>
+
+ <!-- ACTION BAR -->
+ <div class="actionbar">
+ <span class="lbl">Doctor</span>
+ <button class="btn">Clean up</button>
+ <button class="btn">Review &amp; fix</button>
+ <span class="spacer"></span>
+ <span class="legend">
+ <b>Auto</b> unattended · <b>Confirm</b> preview+click · <b>Human</b> you act · <b>Workflow</b> escalate · <b>None</b> diagnostic
+ </span>
+ </div>
+
+ <!-- CLUSTER -->
+ <div class="cluster">
+
+ <!-- STORAGE & FILESYSTEM -->
+ <div class="band">Storage &amp; Filesystem</div>
+
+ <div class="card s4">
+ <div class="cap">Disk usage</div>
+ <div class="big">69<span class="unit">%</span></div>
+ <div class="note">root · btrfs</div>
+ <div class="meter">
+ <div class="track">
+ <div class="zone amber" style="left:80%;right:10%"></div>
+ <div class="zone terra" style="left:90%;right:0"></div>
+ <div class="fill ok" style="width:69%"></div>
+ </div>
+ <div class="scale"><span>0</span><span>80</span><span>90</span><span>100%</span></div>
+ </div>
+ </div>
+
+ <div class="card s4">
+ <button class="btn lever">Clean</button>
+ <div class="cap">Package cache <span class="lamp l-warn">●</span></div>
+ <div class="big">8.8<span class="unit"> GB</span></div>
+ <div class="note">Auto · threshold ~10 GB</div>
+ <div class="meter">
+ <div class="track">
+ <div class="tick" style="left:88%"></div>
+ <div class="fill warn" style="width:88%"></div>
+ </div>
+ <div class="scale"><span>0</span><span>threshold 10 GB</span></div>
+ </div>
+ </div>
+
+ <div class="card s4">
+ <button class="btn lever">Deep trim</button>
+ <div class="cap">Deep-trim keep-3 <span class="lamp l-ok">●</span></div>
+ <div class="big">keep 3</div>
+ <div class="note">Confirm · retain 3 versions</div>
+ <div class="meter">
+ <div class="track"><div class="fill ok" style="width:100%"></div></div>
+ <div class="scale"><span>staged &amp; ready</span></div>
+ </div>
+ </div>
+
+ <div class="card s3">
+ <button class="btn lever">Scrub</button>
+ <div class="cap">btrfs scrub</div>
+ <div class="dialwrap">
+ <svg width="98" height="98" viewBox="0 0 98 98">
+ <circle cx="49" cy="49" r="40" fill="none" stroke="#1a1d1f" stroke-width="8"/>
+ <!-- 34d of ~30d cadence => over => amber, ~0.62 of ring -->
+ <circle cx="49" cy="49" r="40" fill="none" stroke="#dab53d" stroke-width="8"
+ stroke-linecap="round" stroke-dasharray="251.3" stroke-dashoffset="95"
+ transform="rotate(-90 49 49)"/>
+ <text x="49" y="46" text-anchor="middle" fill="#f3e7c5" font-family="monospace" font-size="20" font-weight="700">34</text>
+ <text x="49" y="63" text-anchor="middle" fill="#7c838a" font-family="monospace" font-size="10">days</text>
+ </svg>
+ </div>
+ <div class="note" style="text-align:center"><span class="lamp l-warn">●</span> past 30d cadence</div>
+ </div>
+
+ <div class="card s3">
+ <div class="cap">btrfs unalloc <span class="lamp l-ok">●</span></div>
+ <div class="big">118<span class="unit"> GiB</span></div>
+ <div class="note">unallocated headroom</div>
+ <div class="meter">
+ <div class="track"><div class="fill ok" style="width:46%"></div></div>
+ <div class="scale"><span>ample slack</span></div>
+ </div>
+ </div>
+
+ <div class="card s3">
+ <div class="cap">btrfs device-err <span class="lamp l-ok">●</span></div>
+ <div class="big">0 / 0</div>
+ <div class="note">read / write errors</div>
+ </div>
+
+ <div class="card s3">
+ <button class="btn lever">Enable</button>
+ <div class="cap">fstrim.timer <span class="lamp l-fail">●</span></div>
+ <div class="big" style="color:var(--terra)">OFF</div>
+ <div class="note">Confirm · SSD discard disabled</div>
+ </div>
+
+ <!-- THERMAL & POWER -->
+ <div class="band">Thermal &amp; Power</div>
+
+ <div class="card s5">
+ <div class="cap">Temperatures <span class="lamp l-ok">●</span></div>
+ <div class="gaugewrap">
+ <div class="gauge">
+ <svg width="112" height="64" viewBox="0 0 112 64">
+ <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/>
+ <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/>
+ <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/>
+ <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/>
+ <!-- needle @ 61C (~61% of arc) -->
+ <line x1="56" y1="60" x2="86" y2="30" stroke="#f3e7c5" stroke-width="2.5"/>
+ <circle cx="56" cy="60" r="4" fill="#dab53d"/>
+ </svg>
+ <div class="rd">61°C</div><div class="lb">CPU package</div>
+ </div>
+ <div class="gauge">
+ <svg width="112" height="64" viewBox="0 0 112 64">
+ <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/>
+ <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/>
+ <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/>
+ <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/>
+ <!-- needle @ 54C (~54%) -->
+ <line x1="56" y1="60" x2="79" y2="34" stroke="#f3e7c5" stroke-width="2.5"/>
+ <circle cx="56" cy="60" r="4" fill="#dab53d"/>
+ </svg>
+ <div class="rd">54°C</div><div class="lb">NVMe</div>
+ </div>
+ </div>
+ </div>
+
+ <div class="card s3">
+ <div class="cap">Thermal throttle <span class="lamp l-ok">●</span></div>
+ <div class="big">NO</div>
+ <div class="note">no throttle events</div>
+ </div>
+
+ <div class="card s2 s3" style="grid-column:span 2;">
+ <div class="cap">Battery <span class="lamp l-off">○</span></div>
+ <div class="big" style="color:var(--dim)">n/a</div>
+ <div class="note">desktop</div>
+ </div>
+
+ <div class="card s2 s3" style="grid-column:span 2;">
+ <div class="cap">Swap / zram <span class="lamp l-ok">●</span></div>
+ <div class="big">16<span class="unit"> GiB</span></div>
+ <div class="note">zram active</div>
+ </div>
+
+ <!-- MEMORY -->
+ <div class="card s6">
+ <div class="cap">Memory free <span class="lamp l-ok">●</span></div>
+ <div class="big">104<span class="unit"> GB free</span></div>
+ <div class="note">of 125 GB total · Workflow</div>
+ <div class="meter">
+ <div class="track"><div class="fill ok" style="width:83%"></div></div>
+ <div class="scale"><span>0</span><span>free 104 / 125 GB</span></div>
+ </div>
+ </div>
+
+ <div class="card s6">
+ <div class="cap">Unclean-shutdown ratio <span class="lamp l-warn">●</span></div>
+ <div class="big" style="color:var(--amber)">75<span class="unit">%</span></div>
+ <div class="note">clean boots · Workflow — investigate</div>
+ <div class="meter">
+ <div class="track">
+ <div class="zone amber" style="left:0;right:0"></div>
+ <div class="fill warn" style="width:75%"></div>
+ </div>
+ <div class="scale"><span>0</span><span>clean-boot fraction</span><span>100%</span></div>
+ </div>
+ </div>
+
+ <!-- UPDATES QUARANTINE -->
+ <div class="band">Updates — quarantined</div>
+ <div class="updates">
+ <div class="q">Updates</div>
+ <div class="metric"><span class="n">47</span><span class="k">pending</span></div>
+ <div class="metric"><span class="n cve">3</span><span class="k">CVE · arch-audit</span></div>
+ <div class="metric"><span class="n">5</span><span class="k">AUR stale</span></div>
+ <div class="spacer"></div>
+ <div class="note" style="max-width:230px; margin:0;">
+ <span class="lamp l-fail">●</span> Security advisories present.
+ Applying updates escalates to the update workflow — not an unattended clean.
+ </div>
+ <button class="btn">Open updates</button>
+ </div>
+
+ <!-- PACKAGES & JOURNAL COUNTERS -->
+ <div class="band">Packages · Logs · Counters</div>
+
+ <div class="card s3 tile">
+ <button class="btn lever">Review</button>
+ <div class="cap">Orphans</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">13</span></div>
+ <div class="note">Confirm · unowned pkgs</div>
+ </div>
+
+ <div class="card s3 tile">
+ <button class="btn lever">Review</button>
+ <div class="cap">.pacnew files</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">2</span></div>
+ <div class="note">Confirm · config merges</div>
+ </div>
+
+ <div class="card s3 tile">
+ <button class="btn lever">Clear</button>
+ <div class="cap">Coredumps 7d</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">18</span></div>
+ <div class="note">Auto · clearable</div>
+ </div>
+
+ <div class="card s3 tile">
+ <button class="btn lever">Run workflow</button>
+ <div class="cap">Failed units</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">1</span></div>
+ <div class="note">Workflow · systemd</div>
+ </div>
+
+ <div class="card s3">
+ <button class="btn lever">Prune</button>
+ <div class="cap">Snapper snapshots <span class="lamp l-ok">●</span></div>
+ <div class="big">42</div>
+ <div class="note">Confirm · retention ok</div>
+ <div class="meter">
+ <div class="track"><div class="fill ok" style="width:52%"></div></div>
+ <div class="scale"><span>within budget</span></div>
+ </div>
+ </div>
+
+ <div class="card s3">
+ <button class="btn lever">Refresh</button>
+ <div class="cap">Keyring age</div>
+ <div class="dialwrap">
+ <svg width="90" height="90" viewBox="0 0 90 90">
+ <circle cx="45" cy="45" r="36" fill="none" stroke="#1a1d1f" stroke-width="7"/>
+ <circle cx="45" cy="45" r="36" fill="none" stroke="#74932f" stroke-width="7"
+ stroke-linecap="round" stroke-dasharray="226.2" stroke-dashoffset="176"
+ transform="rotate(-90 45 45)"/>
+ <text x="45" y="43" text-anchor="middle" fill="#f3e7c5" font-family="monospace" font-size="18" font-weight="700">12</text>
+ <text x="45" y="59" text-anchor="middle" fill="#7c838a" font-family="monospace" font-size="9">days</text>
+ </svg>
+ </div>
+ <div class="note" style="text-align:center"><span class="lamp l-ok">●</span> Confirm · fresh</div>
+ </div>
+
+ <div class="card s3 tile">
+ <div class="cap">Journal errors</div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="big">12</span></div>
+ <div class="note">Workflow · real errors</div>
+ </div>
+
+ <div class="card s3 tile">
+ <div class="cap">Listeners</div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="big">3</span></div>
+ <div class="note">None · open sockets</div>
+ </div>
+
+ <!-- LAMP BOARD -->
+ <div class="band">Status board — binary &amp; diagnostic</div>
+ <div class="lampboard">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">SMART</span><span class="vv">PASSED</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">SMART wear</span><span class="vv">0%</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">pacman integrity</span><span class="vv">clean</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">snapshot timer</span><span class="vv">active</span></div>
+
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">is-system-running</span><span class="vv">running</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">reboot-required</span><span class="vv">no</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">kernel taint</span><span class="vv">0</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">kernel/hw events</span><span class="vv">clean</span></div>
+
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">firewall</span><span class="vv">active</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">fail2ban</span><span class="vv">active</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">tailscale</span><span class="vv">4/4</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">NTP</span><span class="vv">synced</span></div>
+
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">DNS / NM</span><span class="vv">ok</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">rsyncshot</span><span class="vv">3h ago</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">cron</span><span class="vv">ok</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">app-log cleanup</span><span class="vv">ok</span></div>
+
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">journald size</span><span class="vv">1.2 GB</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">docker stopped</span><span class="vv">2</span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="nm">maintenance timers</span><span class="vv">4/5</span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="nm">docker reclaim</span><span class="vv">3 GB · Prune</span></div>
+
+ <div class="row"><span class="lamp l-off">○</span><span class="nm">libvirt</span><span class="vv">off</span></div>
+ </div>
+
+ </div><!-- /cluster -->
+
+ <!-- RUNNING / OUTPUT WALL -->
+ <div class="wall">
+ <div class="wh">
+ <span>MAINT · <span class="l">ratio</span> · CLEANING <span class="lamp l-prog spin">◐</span></span>
+ <span class="meta" style="font-size:11px">4 Auto actions</span>
+ </div>
+ <div class="stream">
+ <div class="ln"><span class="lamp l-prog spin">◐</span><span class="msg">cache trim</span><span class="meta">running…</span></div>
+ <div class="ln"><span class="lamp l-ok">●</span><span class="msg">journal vacuum</span><span class="meta">done · reclaimed 0.9 GB</span></div>
+ <div class="ln"><span class="lamp l-ok">●</span><span class="msg">coredump clear</span><span class="meta">done · 12 cleared</span></div>
+ <div class="ln queued"><span class="lamp l-off">○</span><span class="msg">app-log cleanup</span><span class="meta">queued</span></div>
+ </div>
+ <div class="wfoot"><button class="btn">Done</button></div>
+ </div>
+
+</div>
diff --git a/docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html b/docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html
new file mode 100644
index 0000000..b818006
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html
@@ -0,0 +1,443 @@
+<meta charset="utf-8">
+<title>MAINT · ratio — Approach E2 · Grouped Instrument Dashboard</title>
+<style>
+ :root{
+ --page:#0a0c0d; --surface:#100f0f; --inset:#0a0c0d;
+ --gold:#dab53d; --silver:#bfc4d0; --dim:#7c838a; --cream:#f3e7c5;
+ --slate:#424f5e; --slate-hi:#54677d; --line:#2c2f32;
+ --olive:#74932f; --amber:#dab53d; --terra:#cb6b4d;
+ --font:"BerkeleyMono Nerd Font", ui-monospace, monospace;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;background:var(--page);}
+ body{
+ font-family:var(--font); color:var(--silver);
+ display:flex; justify-content:center; padding:26px 14px 60px;
+ -webkit-font-smoothing:antialiased;
+ }
+ .console{ width:1050px; max-width:100%; background:var(--surface);
+ border:1px solid var(--gold); border-radius:16px; padding:18px; }
+
+ /* header */
+ .head{display:flex; align-items:baseline; justify-content:space-between; gap:16px;}
+ .head .title{font-size:19px; color:var(--cream); letter-spacing:.5px;}
+ .head .title b{color:var(--gold); font-weight:700;}
+ .verdict{font-size:15px; color:var(--olive); font-weight:700; white-space:nowrap;}
+ .sub{color:var(--dim); font-size:12px; margin-top:2px; letter-spacing:.4px;}
+
+ /* action bar */
+ .actionbar{display:flex; align-items:center; gap:10px; margin:14px 0 14px;
+ padding:10px 12px; background:var(--inset); border:1px solid var(--line); border-radius:10px;}
+ .actionbar .lbl{color:var(--dim); font-size:11.5px; letter-spacing:.5px; text-transform:uppercase; margin-right:2px;}
+ .btn{font-family:var(--font); cursor:pointer; background:var(--slate); color:var(--cream);
+ border:none; border-radius:10px; padding:7px 14px; font-size:13px;}
+ .btn:hover{background:var(--slate-hi);}
+ .btn.lever{padding:3px 9px; font-size:11px; border-radius:8px;}
+ .btn.mini{padding:2px 8px; font-size:10.5px; border-radius:7px;}
+ .actionbar .spacer{flex:1;}
+ .legend{color:var(--dim); font-size:11px; line-height:1.5;}
+ .legend b{color:var(--silver); font-weight:400;}
+
+ /* updates quarantine */
+ .updates{border:1px solid var(--amber);
+ background:linear-gradient(180deg,#141210,#0a0c0d);
+ border-radius:10px; padding:12px 16px; margin-bottom:14px;
+ display:flex; align-items:center; gap:26px; flex-wrap:wrap;}
+ .updates .q{color:var(--amber); font-size:10.5px; letter-spacing:1.5px; text-transform:uppercase;
+ border:1px solid var(--amber); padding:3px 8px; border-radius:6px; align-self:flex-start;}
+ .updates .metric{display:flex; flex-direction:column; gap:2px;}
+ .updates .metric .n{font-size:28px; color:var(--cream); font-weight:700; line-height:1;}
+ .updates .metric .n.cve{color:var(--terra);}
+ .updates .metric .k{color:var(--dim); font-size:10.5px; text-transform:uppercase; letter-spacing:.6px;}
+ .updates .spacer{flex:1;}
+
+ /* ---- category panel ---- */
+ .panel{border:1px solid var(--line); border-left:3px solid var(--line);
+ border-radius:11px; margin-bottom:10px; overflow:hidden; background:#0d0c0c;}
+ .panel.attn{border-left-color:var(--amber);}
+ .panel.crit{border-left-color:var(--terra);}
+ .panel.clear{border-left-color:var(--olive); opacity:.85;}
+ .phead{display:flex; align-items:center; gap:11px; padding:9px 14px; cursor:pointer;}
+ .phead:hover{background:#141313;}
+ .phead .nm{color:var(--gold); font-weight:700; letter-spacing:.9px; text-transform:uppercase; font-size:12.5px;}
+ .phead .cnt{color:var(--dim); font-size:11px; margin-left:auto;}
+ .phead .chev{color:var(--dim); font-size:12px; width:1em; text-align:center;}
+ .phead .attnword{color:var(--amber); font-size:11px;}
+ .phead .attnword.crit{color:var(--terra);}
+ .phead .okword{color:var(--olive); font-size:11px;}
+ .pbody{padding:2px 14px 13px;}
+
+ .heroes{display:grid; grid-template-columns:repeat(12,1fr); gap:10px; margin-bottom:11px;}
+ .card{background:var(--inset); border:1px solid var(--line); border-radius:10px;
+ padding:10px 12px 11px; position:relative; min-height:88px; display:flex; flex-direction:column;}
+ .card .lever{position:absolute; top:8px; right:8px;}
+ .card .cap{color:var(--dim); font-size:10.5px; letter-spacing:.6px; text-transform:uppercase;
+ margin-bottom:7px; padding-right:52px;}
+ .card .big{color:var(--cream); font-size:22px; line-height:1; font-weight:700;}
+ .card .unit{color:var(--dim); font-size:12px; font-weight:400;}
+ .card .note{color:var(--dim); font-size:11px; margin-top:6px;}
+ .s2{grid-column:span 2;} .s3{grid-column:span 3;} .s4{grid-column:span 4;}
+ .s5{grid-column:span 5;} .s6{grid-column:span 6;}
+
+ /* meter */
+ .meter{margin-top:auto;}
+ .meter .track{position:relative; height:13px; background:#07090a; border:1px solid var(--line);
+ border-radius:7px; overflow:hidden;}
+ .meter .fill{height:100%; border-radius:6px 0 0 6px;}
+ .fill.ok{background:linear-gradient(90deg,#5c7626,#74932f);}
+ .fill.warn{background:linear-gradient(90deg,#a98a2c,#dab53d);}
+ .fill.fail{background:linear-gradient(90deg,#a8543b,#cb6b4d);}
+ .meter .tick{position:absolute; top:-2px; bottom:-2px; width:2px; background:var(--gold);}
+ .meter .zone{position:absolute; top:0; bottom:0; opacity:.16;}
+ .zone.amber{background:var(--amber);} .zone.terra{background:var(--terra);}
+ .meter .scale{display:flex; justify-content:space-between; color:var(--dim); font-size:9.5px; margin-top:4px;}
+
+ /* lamps */
+ .lamp{display:inline-block; width:1em; text-align:center;}
+ .l-ok{color:var(--olive);} .l-warn{color:var(--amber);}
+ .l-fail{color:var(--terra);} .l-off{color:var(--dim);} .l-prog{color:var(--amber);}
+
+ .tile .row{display:flex; align-items:center; gap:8px;}
+ .tile .big{font-size:25px;}
+
+ .gaugewrap{display:flex; gap:14px; margin-top:2px;}
+ .gauge{display:flex; flex-direction:column; align-items:center; gap:3px;}
+ .gauge .rd{color:var(--cream); font-size:13px; font-weight:700;}
+ .gauge .lb{color:var(--dim); font-size:10px;}
+ .dialwrap{display:flex; justify-content:center; margin-top:2px;}
+
+ /* compact strip (the boring/green/binary metrics) */
+ .strip{display:grid; grid-template-columns:repeat(3,1fr); gap:5px 22px;
+ padding-top:9px; border-top:1px solid var(--line);}
+ .strip .row{display:flex; align-items:center; gap:8px; font-size:12px; min-height:22px;}
+ .strip .row .nm2{color:var(--silver);}
+ .strip .row .vv{color:var(--dim); margin-left:auto; font-size:11px;}
+ .strip .row .lever{margin-left:auto;}
+ .strip .row .lever + .vv{margin-left:8px;}
+
+ /* output wall */
+ .wall{margin-top:16px; background:var(--inset); border:1px solid var(--line);
+ border-radius:10px; padding:13px 15px;}
+ .wall .wh{display:flex; align-items:center; justify-content:space-between;
+ color:var(--cream); font-size:13px; letter-spacing:.5px; margin-bottom:10px;}
+ .wall .wh .l{color:var(--gold);}
+ .wall .stream{font-size:12.5px; line-height:1.85;}
+ .wall .stream .ln{display:flex; gap:9px; align-items:baseline;}
+ .wall .stream .msg{color:var(--silver);}
+ .wall .stream .meta{color:var(--dim);}
+ .wall .stream .queued .msg{color:var(--dim);}
+ .wall .wfoot{margin-top:11px; display:flex; justify-content:flex-end;}
+
+ .footnote{color:var(--dim); font-size:11px; margin-top:14px; line-height:1.6;}
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
+ .spin{animation:pulse 1.1s ease-in-out infinite;}
+</style>
+
+<div class="console">
+
+ <!-- HEADER -->
+ <div class="head">
+ <div>
+ <div class="title">MAINT · <b>ratio</b></div>
+ <div class="sub">Approach E2 — Grouped instrument dashboard (categories collapse when clean)</div>
+ </div>
+ <div class="verdict"><span class="lamp l-ok">●</span> healthy</div>
+ </div>
+
+ <!-- ACTION BAR -->
+ <div class="actionbar">
+ <span class="lbl">Doctor</span>
+ <button class="btn">Clean up</button>
+ <button class="btn">Review &amp; fix</button>
+ <span class="spacer"></span>
+ <span class="legend">
+ <b>Auto</b> unattended · <b>Confirm</b> preview+click · <b>Human</b> you act · <b>Workflow</b> escalate · <b>None</b> diagnostic
+ </span>
+ </div>
+
+ <!-- UPDATES (pulled up: the 3 CVEs are the one real fail) -->
+ <div class="updates">
+ <div class="q">Updates</div>
+ <div class="metric"><span class="n">47</span><span class="k">pending</span></div>
+ <div class="metric"><span class="n cve">3</span><span class="k">CVE · arch-audit</span></div>
+ <div class="metric"><span class="n">5</span><span class="k">AUR stale</span></div>
+ <div class="spacer"></div>
+ <div class="note" style="max-width:250px; margin:0; color:var(--dim); font-size:11px;">
+ <span class="lamp l-fail">●</span> Security advisories present. Updates run through the workflow — never an unattended clean.
+ </div>
+ <button class="btn">Run workflow</button>
+ </div>
+
+ <!-- ============ CATEGORY PANELS (severity-ordered) ============ -->
+
+ <!-- STORAGE (has a fail: fstrim) -->
+ <div class="panel crit">
+ <div class="phead">
+ <span class="chev">▾</span><span class="lamp l-fail">●</span>
+ <span class="nm">Storage &amp; Filesystem</span>
+ <span class="cnt"><span class="attnword crit">3 need attention</span> · 6 ok</span>
+ </div>
+ <div class="pbody">
+ <div class="heroes">
+ <div class="card s3">
+ <div class="cap">Disk usage</div>
+ <div class="big">69<span class="unit">%</span></div>
+ <div class="note">root · btrfs</div>
+ <div class="meter"><div class="track">
+ <div class="zone amber" style="left:80%;right:10%"></div>
+ <div class="zone terra" style="left:90%;right:0"></div>
+ <div class="fill ok" style="width:69%"></div></div>
+ <div class="scale"><span>0</span><span>80</span><span>90</span><span>100</span></div>
+ </div>
+ </div>
+ <div class="card s3">
+ <button class="btn lever">Clean</button>
+ <div class="cap">Cache <span class="lamp l-warn">●</span></div>
+ <div class="big">8.8<span class="unit"> GB</span></div>
+ <div class="note">Auto · thr ~10 GB</div>
+ <div class="meter"><div class="track"><div class="tick" style="left:88%"></div>
+ <div class="fill warn" style="width:88%"></div></div>
+ <div class="scale"><span>0</span><span>10 GB</span></div>
+ </div>
+ </div>
+ <div class="card s3">
+ <button class="btn lever">Scrub</button>
+ <div class="cap">btrfs scrub</div>
+ <div class="dialwrap">
+ <svg width="86" height="86" viewBox="0 0 98 98">
+ <circle cx="49" cy="49" r="40" fill="none" stroke="#1a1d1f" stroke-width="8"/>
+ <circle cx="49" cy="49" r="40" fill="none" stroke="#dab53d" stroke-width="8"
+ stroke-linecap="round" stroke-dasharray="251.3" stroke-dashoffset="95" transform="rotate(-90 49 49)"/>
+ <text x="49" y="46" text-anchor="middle" fill="#f3e7c5" font-family="monospace" font-size="20" font-weight="700">34</text>
+ <text x="49" y="63" text-anchor="middle" fill="#7c838a" font-family="monospace" font-size="10">days</text>
+ </svg>
+ </div>
+ <div class="note" style="text-align:center"><span class="lamp l-warn">●</span> past 30d</div>
+ </div>
+ <div class="card s3">
+ <button class="btn lever">Enable</button>
+ <div class="cap">fstrim.timer <span class="lamp l-fail">●</span></div>
+ <div class="big" style="color:var(--terra)">OFF</div>
+ <div class="note">Confirm · SSD discard off</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">SMART</span><span class="vv">PASSED</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">SMART wear</span><span class="vv">0%</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">device errors</span><span class="vv">0 / 0</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">btrfs unalloc</span><span class="vv">118 GiB</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">pacman integrity</span><span class="vv">clean</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">deep-trim</span><button class="btn mini lever" style="position:static">Deep trim</button><span class="vv">keep 3</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- PACKAGES & SECURITY -->
+ <div class="panel attn">
+ <div class="phead">
+ <span class="chev">▾</span><span class="lamp l-warn">●</span>
+ <span class="nm">Packages &amp; Security</span>
+ <span class="cnt"><span class="attnword">2 need attention</span> · 2 ok · updates above</span>
+ </div>
+ <div class="pbody">
+ <div class="heroes">
+ <div class="card s3 tile">
+ <button class="btn lever">Review</button>
+ <div class="cap">Orphans</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">13</span></div>
+ <div class="note">Confirm · unowned pkgs</div>
+ </div>
+ <div class="card s3 tile">
+ <button class="btn lever">Review</button>
+ <div class="cap">.pacnew files</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">2</span></div>
+ <div class="note">Confirm · config merges</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">keyring age</span><button class="btn mini lever" style="position:static">Refresh</button><span class="vv">12d</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">reboot required</span><span class="vv">no</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- SYSTEMD & BOOT -->
+ <div class="panel attn">
+ <div class="phead">
+ <span class="chev">▾</span><span class="lamp l-warn">●</span>
+ <span class="nm">systemd &amp; Boot</span>
+ <span class="cnt"><span class="attnword">2 need attention</span> · 2 ok</span>
+ </div>
+ <div class="pbody">
+ <div class="heroes">
+ <div class="card s3 tile">
+ <button class="btn lever">Run workflow</button>
+ <div class="cap">Failed units</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">1</span></div>
+ <div class="note">Workflow · systemd</div>
+ </div>
+ <div class="card s3 tile">
+ <button class="btn lever">Enable</button>
+ <div class="cap">Maintenance timers</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">4/5</span></div>
+ <div class="note">Confirm · one not firing</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">is-system-running</span><span class="vv">running</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">kernel taint</span><span class="vv">0</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- LOGS & COREDUMPS -->
+ <div class="panel attn">
+ <div class="phead">
+ <span class="chev">▾</span><span class="lamp l-warn">●</span>
+ <span class="nm">Logs &amp; Coredumps</span>
+ <span class="cnt"><span class="attnword">1 needs attention</span> · 4 ok</span>
+ </div>
+ <div class="pbody">
+ <div class="heroes">
+ <div class="card s3 tile">
+ <button class="btn lever">Clear</button>
+ <div class="cap">Coredumps 7d</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">18</span></div>
+ <div class="note">Auto · clearable</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">journald size</span><button class="btn mini lever" style="position:static">Vacuum</button><span class="vv">1.2 GB</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">app-log cleanup</span><button class="btn mini lever" style="position:static">Run</button><span class="vv">ok</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">journal errors</span><span class="vv">12 real</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">kernel/hw events</span><span class="vv">clean</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- MEMORY / THERMAL / POWER -->
+ <div class="panel attn">
+ <div class="phead">
+ <span class="chev">▾</span><span class="lamp l-warn">●</span>
+ <span class="nm">Memory · Thermal · Power</span>
+ <span class="cnt"><span class="attnword">1 needs attention</span> · 4 ok</span>
+ </div>
+ <div class="pbody">
+ <div class="heroes">
+ <div class="card s5">
+ <div class="cap">Temperatures <span class="lamp l-ok">●</span></div>
+ <div class="gaugewrap">
+ <div class="gauge">
+ <svg width="104" height="60" viewBox="0 0 112 64">
+ <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/>
+ <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/>
+ <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/>
+ <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/>
+ <line x1="56" y1="60" x2="86" y2="30" stroke="#f3e7c5" stroke-width="2.5"/>
+ <circle cx="56" cy="60" r="4" fill="#dab53d"/>
+ </svg>
+ <div class="rd">61°C</div><div class="lb">CPU</div>
+ </div>
+ <div class="gauge">
+ <svg width="104" height="60" viewBox="0 0 112 64">
+ <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/>
+ <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/>
+ <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/>
+ <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/>
+ <line x1="56" y1="60" x2="79" y2="34" stroke="#f3e7c5" stroke-width="2.5"/>
+ <circle cx="56" cy="60" r="4" fill="#dab53d"/>
+ </svg>
+ <div class="rd">54°C</div><div class="lb">NVMe</div>
+ </div>
+ </div>
+ </div>
+ <div class="card s4">
+ <div class="cap">Memory free <span class="lamp l-ok">●</span></div>
+ <div class="big">104<span class="unit"> GB</span></div>
+ <div class="note">of 125 GB · Workflow</div>
+ <div class="meter"><div class="track"><div class="fill ok" style="width:83%"></div></div>
+ <div class="scale"><span>0</span><span>free 104 / 125</span></div></div>
+ </div>
+ <div class="card s3">
+ <div class="cap">Unclean boot <span class="lamp l-warn">●</span></div>
+ <div class="big" style="color:var(--amber)">75<span class="unit">%</span></div>
+ <div class="note">Workflow · investigate</div>
+ <div class="meter"><div class="track"><div class="zone amber" style="left:0;right:0"></div>
+ <div class="fill warn" style="width:75%"></div></div>
+ <div class="scale"><span>0</span><span>100</span></div></div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">swap / zram</span><span class="vv">16 GiB</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">thermal throttle</span><span class="vv">no</span></div>
+ <div class="row"><span class="lamp l-off">○</span><span class="nm2">battery</span><span class="vv">n/a · desktop</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- SERVICES & BACKUPS -->
+ <div class="panel attn">
+ <div class="phead">
+ <span class="chev">▾</span><span class="lamp l-warn">●</span>
+ <span class="nm">Services &amp; Backups</span>
+ <span class="cnt"><span class="attnword">1 needs attention</span> · 3 ok</span>
+ </div>
+ <div class="pbody">
+ <div class="heroes">
+ <div class="card s3 tile">
+ <button class="btn lever">Prune</button>
+ <div class="cap">Docker reclaim</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">3<span class="unit"> GB</span></span></div>
+ <div class="note">Confirm · reclaimable</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">rsyncshot backup</span><span class="vv">3h ago</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">docker stopped</span><span class="vv">2</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">cron</span><span class="vv">ok</span></div>
+ <div class="row"><span class="lamp l-off">○</span><span class="nm2">libvirt VMs</span><span class="vv">off</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- ===== COLLAPSED (all-green) categories — the space win ===== -->
+
+ <div class="panel clear">
+ <div class="phead">
+ <span class="chev">▸</span><span class="lamp l-ok">●</span>
+ <span class="nm">Snapshots</span>
+ <span class="cnt"><span class="okword">all clear · 2 ✓</span></span>
+ </div>
+ </div>
+
+ <div class="panel clear">
+ <div class="phead">
+ <span class="chev">▸</span><span class="lamp l-ok">●</span>
+ <span class="nm">Network &amp; Posture</span>
+ <span class="cnt"><span class="okword">all clear · 6 ✓</span></span>
+ </div>
+ </div>
+
+ <div class="footnote">
+ Collapsed panels are all-green — one header line each (chevron ▸ to expand). On a fully-healthy day every
+ panel collapses to this and the console is ~4 header rows plus the updates strip. The board's height tracks
+ how much needs you, not how many metrics exist. Verdict lamp (top-right) reads the worst <i>diagnostic</i>
+ state only — the fstrim fail and the 3 CVEs are actionable/updates, so the box still reads "healthy."
+ </div>
+
+ <!-- RUNNING / OUTPUT WALL -->
+ <div class="wall">
+ <div class="wh">
+ <span>MAINT · <span class="l">ratio</span> · CLEANING <span class="lamp l-prog spin">◐</span></span>
+ <span class="meta" style="font-size:11px">4 Auto actions</span>
+ </div>
+ <div class="stream">
+ <div class="ln"><span class="lamp l-prog spin">◐</span><span class="msg">cache trim</span><span class="meta">running…</span></div>
+ <div class="ln"><span class="lamp l-ok">●</span><span class="msg">journal vacuum</span><span class="meta">done · reclaimed 0.9 GB</span></div>
+ <div class="ln"><span class="lamp l-ok">●</span><span class="msg">coredump clear</span><span class="meta">done · 12 cleared</span></div>
+ <div class="ln queued"><span class="lamp l-off">○</span><span class="msg">app-log cleanup</span><span class="meta">queued</span></div>
+ </div>
+ <div class="wfoot"><button class="btn">Done</button></div>
+ </div>
+
+</div>
diff --git a/docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html b/docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html
new file mode 100644
index 0000000..fbfb03d
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html
@@ -0,0 +1,488 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>MAINT · ratio — Approach E3 · Hi-fi drill-down console</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --amber:#dab53d; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);line-height:1.45;
+ padding:2rem 1.4rem 5rem;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground);
+ display:flex;justify-content:center}
+.console{width:1080px;max-width:100%}
+
+/* ---- shared primitives (from the widget gallery) ---- */
+.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass);
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 7px 1px rgba(203,107,77,.6)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite}
+@keyframes pulse{50%{opacity:.25}}
+
+.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:6px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.key:hover{color:var(--gold);border-color:var(--gold)}
+.key:active{transform:translateY(1px)}
+.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.key.sm{font-size:10.5px;padding:4px 9px;border-radius:7px}
+
+.badge{font-size:.62rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px}
+.badge.red{background:var(--fail);color:var(--cream)}
+.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+
+.bar{width:100%;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative}
+.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px}
+.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)}
+
+.ring{width:58px;height:58px;border-radius:50%;
+ background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0);
+ display:grid;place-items:center;position:relative;flex:none}
+.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1}
+.ring small{position:relative;color:var(--dim);font-size:8.5px}
+
+.readout{color:var(--cream);font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em}
+.readout small{color:var(--dim);font-size:11px;font-weight:400}
+
+/* engraved section label */
+.engrave{color:var(--steel);font-size:.66rem;letter-spacing:.28em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px;flex:1}
+.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.7rem}
+
+/* lamp row (list item) */
+.lrow{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;font-size:12.5px}
+.lrow .who{color:var(--silver)} .lrow .who b{color:var(--cream)}
+.lrow .what{margin-left:auto;color:var(--dim);font-size:11px;display:flex;align-items:center;gap:8px}
+
+/* faceplate card */
+.plate{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)}
+
+/* ---- masthead ---- */
+.mast{padding:15px 18px;margin-bottom:14px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
+.mast .brand{display:flex;flex-direction:column;gap:2px}
+.mast .brand .ey{color:var(--steel);font-size:.64rem;letter-spacing:.28em;text-transform:uppercase}
+.mast .brand .ti{color:var(--cream);font-size:19px;letter-spacing:.5px}
+.mast .brand .ti b{color:var(--gold)}
+.mast .verdict{display:flex;align-items:center;gap:8px;color:var(--pass);font-size:14px;font-weight:700}
+.mast .spacer{flex:1}
+.mast .doctor{display:flex;align-items:center;gap:8px}
+.mast .doctor .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.22em;text-transform:uppercase;margin-right:2px}
+
+/* updates readout inline in masthead */
+.updbar{display:flex;align-items:center;gap:16px;padding:9px 18px;margin-bottom:16px;
+ border:1px solid var(--fail);border-radius:11px;
+ background:linear-gradient(180deg,#181210,#0a0c0d)}
+.updbar .q{color:var(--fail);font-size:.6rem;letter-spacing:.2em;text-transform:uppercase;
+ border:1px solid var(--fail);border-radius:5px;padding:2px 7px}
+.updbar .rd{display:flex;flex-direction:column;line-height:1.05}
+.updbar .rd .n{color:var(--cream);font-size:20px;font-weight:700;font-variant-numeric:tabular-nums}
+.updbar .rd .n.cve{color:var(--fail)}
+.updbar .rd .k{color:var(--dim);font-size:.6rem;letter-spacing:.12em;text-transform:uppercase}
+.updbar .spacer{flex:1}
+.updbar .msg{color:var(--dim);font-size:11px;max-width:300px}
+
+/* ---- OVERVIEW: station grid ---- */
+.ovhead{margin:0 4px 9px;display:flex;align-items:center;gap:10px}
+.overview{display:grid;grid-template-columns:repeat(4,1fr);gap:11px;margin-bottom:22px}
+.station{padding:12px 13px;cursor:pointer;display:flex;flex-direction:column;gap:9px;
+ transition:border-color .12s,transform .06s;border-left:3px solid var(--wash)}
+.station:hover{border-color:#3a352c;transform:translateY(-1px)}
+.station.attn{border-left-color:var(--amber)}
+.station.crit{border-left-color:var(--fail)}
+.station.clear{border-left-color:var(--pass)}
+.station .top{display:flex;align-items:center;gap:9px}
+.station .top .nm{color:var(--cream);font-size:12.5px;font-weight:700;letter-spacing:.02em}
+.station .top .chev{margin-left:auto;color:var(--dim);font-size:11px}
+.station .st{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)}
+.station .st .att{color:var(--amber)} .station .st .att.crit{color:var(--fail)}
+.station .st .okc{color:var(--pass)}
+.station .spark{margin-top:1px}
+
+/* ---- CATEGORY drill-down sections ---- */
+.cat{margin-bottom:11px;overflow:hidden}
+.cathead{display:flex;align-items:center;gap:12px;padding:11px 16px;cursor:pointer;user-select:none}
+.cathead:hover{background:#161412}
+.cathead .chev{color:var(--gold);font-size:12px;width:1em;text-align:center;transition:transform .15s}
+.cat.open .cathead .chev{transform:rotate(90deg)}
+.cathead .nm{color:var(--gold);font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:12.5px}
+.cathead .cnt{margin-left:auto;color:var(--dim);font-size:11px;display:flex;gap:10px;align-items:center}
+.cathead .cnt .att{color:var(--amber)} .cathead .cnt .att.crit{color:var(--fail)}
+.catbody{display:none;padding:2px 16px 15px}
+.cat.open .catbody{display:block}
+
+.heroes{display:grid;grid-template-columns:repeat(12,1fr);gap:11px;margin-bottom:12px}
+.inst{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:11px 12px;
+ display:flex;flex-direction:column;gap:8px;position:relative;min-height:84px}
+.inst .cap{color:var(--steel);font-size:.62rem;letter-spacing:.16em;text-transform:uppercase;
+ display:flex;align-items:center;gap:7px}
+.inst .cap .key{position:absolute;top:9px;right:9px}
+.inst .val{display:flex;align-items:baseline;gap:8px;margin-top:auto}
+.inst.s3{grid-column:span 3} .inst.s4{grid-column:span 4} .inst.s5{grid-column:span 5} .inst.s6{grid-column:span 6}
+.inst .sub{color:var(--dim);font-size:10.5px}
+.gaugepair{display:flex;gap:16px;align-items:center;justify-content:center;margin-top:auto}
+.gaugepair .g{display:flex;flex-direction:column;align-items:center;gap:3px}
+.gaugepair .g .lb{color:var(--steel);font-size:9px;letter-spacing:.1em}
+
+.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:6px 12px}
+
+/* ---- output well ---- */
+.owell{margin-top:18px;padding:13px 15px}
+.owell .oh{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px;
+ color:var(--cream);font-size:13px}
+.owell .oh .l{color:var(--gold)}
+.owell .ostep{display:flex;gap:9px;align-items:flex-start;padding:3px 0;font-size:12.5px}
+.owell .ostep .lamp{margin-top:3px;width:8px;height:8px}
+.owell .ostep b{color:var(--cream);font-weight:700}
+.owell .ostep .ev{color:var(--steel);font-size:11px}
+.owell .queued b{color:var(--dim)}
+.owell .ofoot{margin-top:11px;display:flex;justify-content:flex-end}
+
+.hint{color:var(--dim);font-size:11px;margin:0 4px 14px;display:flex;align-items:center;gap:8px}
+.controls-row{display:flex;gap:8px;margin:0 2px 12px}
+.footnote{color:var(--dim);font-size:11px;margin-top:16px;line-height:1.6;padding:0 4px}
+.footnote i{color:var(--steel);font-style:normal}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div class="console">
+
+ <!-- ===== MASTHEAD ===== -->
+ <div class="plate mast">
+ <div class="brand">
+ <div class="ey">archsetup · maintenance console</div>
+ <div class="ti">MAINT · <b>ratio</b></div>
+ </div>
+ <div class="verdict"><span class="lamp"></span> HEALTHY</div>
+ <div class="spacer"></div>
+ <div class="doctor">
+ <span class="lbl">Doctor</span>
+ <button class="key" onclick="runDoctor()">CLEAN UP</button>
+ <button class="key">REVIEW &amp; FIX</button>
+ </div>
+ </div>
+
+ <!-- ===== UPDATES (quarantined, top billing for the 3 CVEs) ===== -->
+ <div class="updbar">
+ <span class="q">Updates</span>
+ <div class="rd"><span class="n">47</span><span class="k">pending</span></div>
+ <div class="rd"><span class="n cve">3</span><span class="k">CVE</span></div>
+ <div class="rd"><span class="n">5</span><span class="k">AUR stale</span></div>
+ <span class="msg"><span class="lamp red" style="width:8px;height:8px"></span> Security advisories present — updates run through the workflow, never the doctor.</span>
+ <span class="spacer"></span>
+ <button class="key red">RUN WORKFLOW</button>
+ </div>
+
+ <!-- ===== OVERVIEW: all categories at a glance ===== -->
+ <div class="ovhead"><span class="engrave">Overview<span class="cnt">· 8 systems</span></span></div>
+ <div class="overview" id="overview"></div>
+
+ <!-- ===== DRILL-DOWN CATEGORY SECTIONS ===== -->
+ <div class="controls-row">
+ <button class="key sm" onclick="allCats(true)">EXPAND ALL</button>
+ <button class="key sm" onclick="allCats(false)">COLLAPSE ALL</button>
+ <span class="hint" style="margin:0 0 0 6px">click any category header — or its overview tile — to drill in</span>
+ </div>
+
+ <!-- STORAGE -->
+ <div class="cat plate open" id="cat-storage">
+ <div class="cathead" onclick="toggleCat('storage')">
+ <span class="chev">▸</span><span class="lamp red"></span>
+ <span class="nm">Storage &amp; Filesystem</span>
+ <span class="cnt"><span class="att crit">3 attention</span> · 6 ok</span>
+ </div>
+ <div class="catbody">
+ <div class="heroes">
+ <div class="inst s3">
+ <div class="cap">Disk usage</div>
+ <div class="val"><span class="readout">69<small>%</small></span></div>
+ <div class="bar"><span style="width:69%"></span></div>
+ <div class="sub">root · btrfs · thr 80 / 90</div>
+ </div>
+ <div class="inst s3">
+ <button class="key sm">CLEAN</button>
+ <div class="cap"><span class="lamp gold"></span> Package cache</div>
+ <div class="val"><span class="readout">8.8<small> GB</small></span></div>
+ <div class="bar warn"><span style="width:88%"></span><span class="tk" style="left:100%"></span></div>
+ <div class="sub">Auto · threshold ~10 GB</div>
+ </div>
+ <div class="inst s3">
+ <button class="key sm">SCRUB</button>
+ <div class="cap"><span class="lamp gold"></span> btrfs scrub</div>
+ <div style="display:flex;justify-content:center;margin-top:auto">
+ <span class="ring" style="--p:62;--rc:var(--amber)"><b>34</b><small>days</small></span>
+ </div>
+ <div class="sub" style="text-align:center">past 30d cadence</div>
+ </div>
+ <div class="inst s3">
+ <button class="key sm red">ENABLE</button>
+ <div class="cap"><span class="lamp red"></span> fstrim.timer</div>
+ <div class="val"><span class="readout" style="color:var(--fail)">OFF</span></div>
+ <div class="sub">Confirm · SSD discard disabled</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">SMART</span><span class="what">PASSED</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">SMART wear</span><span class="what">0%</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">device errors</span><span class="what">0 / 0</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">btrfs unalloc</span><span class="what">118 GiB</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">pacman integrity</span><span class="what">clean</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">deep-trim</span><span class="what"><button class="key sm">DEEP TRIM</button> keep 3</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- PACKAGES -->
+ <div class="cat plate open" id="cat-packages">
+ <div class="cathead" onclick="toggleCat('packages')">
+ <span class="chev">▸</span><span class="lamp gold"></span>
+ <span class="nm">Packages &amp; Security</span>
+ <span class="cnt"><span class="att">2 attention</span> · 2 ok · updates above</span>
+ </div>
+ <div class="catbody">
+ <div class="heroes">
+ <div class="inst s3">
+ <button class="key sm">REVIEW</button>
+ <div class="cap"><span class="lamp gold"></span> Orphans</div>
+ <div class="val"><span class="readout">13</span></div>
+ <div class="sub">Confirm · unowned pkgs</div>
+ </div>
+ <div class="inst s3">
+ <button class="key sm">REVIEW</button>
+ <div class="cap"><span class="lamp gold"></span> .pacnew files</div>
+ <div class="val"><span class="readout">2</span></div>
+ <div class="sub">Confirm · config merges</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">keyring age</span><span class="what"><button class="key sm">REFRESH</button> 12d</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">reboot required</span><span class="what">no</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- SYSTEMD -->
+ <div class="cat plate open" id="cat-systemd">
+ <div class="cathead" onclick="toggleCat('systemd')">
+ <span class="chev">▸</span><span class="lamp gold"></span>
+ <span class="nm">systemd &amp; Boot</span>
+ <span class="cnt"><span class="att">2 attention</span> · 2 ok</span>
+ </div>
+ <div class="catbody">
+ <div class="heroes">
+ <div class="inst s3">
+ <button class="key sm">WORKFLOW</button>
+ <div class="cap"><span class="lamp gold"></span> Failed units</div>
+ <div class="val"><span class="readout">1</span></div>
+ <div class="sub">Workflow · systemd</div>
+ </div>
+ <div class="inst s3">
+ <button class="key sm">ENABLE</button>
+ <div class="cap"><span class="lamp gold"></span> Maint timers</div>
+ <div class="val"><span class="readout">4<small>/5</small></span></div>
+ <div class="sub">Confirm · one not firing</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">is-system-running</span><span class="what">running</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">kernel taint</span><span class="what">0</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- LOGS -->
+ <div class="cat plate open" id="cat-logs">
+ <div class="cathead" onclick="toggleCat('logs')">
+ <span class="chev">▸</span><span class="lamp gold"></span>
+ <span class="nm">Logs &amp; Coredumps</span>
+ <span class="cnt"><span class="att">1 attention</span> · 4 ok</span>
+ </div>
+ <div class="catbody">
+ <div class="heroes">
+ <div class="inst s3">
+ <button class="key sm">CLEAR</button>
+ <div class="cap"><span class="lamp gold"></span> Coredumps 7d</div>
+ <div class="val"><span class="readout">18</span></div>
+ <div class="sub">Auto · clearable</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">journald size</span><span class="what"><button class="key sm">VACUUM</button> 1.2 GB</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">app-log cleanup</span><span class="what"><button class="key sm">RUN</button> ok</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">journal errors</span><span class="what">12 real</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">kernel/hw events</span><span class="what">clean</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- MEMORY/THERMAL/POWER -->
+ <div class="cat plate open" id="cat-power">
+ <div class="cathead" onclick="toggleCat('power')">
+ <span class="chev">▸</span><span class="lamp gold"></span>
+ <span class="nm">Memory · Thermal · Power</span>
+ <span class="cnt"><span class="att">1 attention</span> · 4 ok</span>
+ </div>
+ <div class="catbody">
+ <div class="heroes">
+ <div class="inst s5">
+ <div class="cap"><span class="lamp"></span> Temperatures</div>
+ <div class="gaugepair">
+ <div class="g"><span class="ring" style="--p:61;--rc:var(--pass)"><b>61°</b></span><span class="lb">CPU</span></div>
+ <div class="g"><span class="ring" style="--p:54;--rc:var(--pass)"><b>54°</b></span><span class="lb">NVMe</span></div>
+ </div>
+ </div>
+ <div class="inst s4">
+ <div class="cap"><span class="lamp"></span> Memory free</div>
+ <div class="val"><span class="readout">104<small> GB</small></span></div>
+ <div class="bar"><span style="width:83%"></span></div>
+ <div class="sub">of 125 GB · 0 OOM</div>
+ </div>
+ <div class="inst s3">
+ <div class="cap"><span class="lamp gold"></span> Unclean boot</div>
+ <div class="val"><span class="readout" style="color:var(--amber)">75<small>%</small></span></div>
+ <div class="bar warn"><span style="width:75%"></span></div>
+ <div class="sub">Workflow · investigate</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">swap / zram</span><span class="what">16 GiB</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">thermal throttle</span><span class="what">no</span></div>
+ <div class="lrow"><span class="lamp off"></span><span class="who">battery</span><span class="what">n/a · desktop</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- SERVICES -->
+ <div class="cat plate open" id="cat-services">
+ <div class="cathead" onclick="toggleCat('services')">
+ <span class="chev">▸</span><span class="lamp gold"></span>
+ <span class="nm">Services &amp; Backups</span>
+ <span class="cnt"><span class="att">1 attention</span> · 3 ok</span>
+ </div>
+ <div class="catbody">
+ <div class="heroes">
+ <div class="inst s3">
+ <button class="key sm">PRUNE</button>
+ <div class="cap"><span class="lamp gold"></span> Docker reclaim</div>
+ <div class="val"><span class="readout">3<small> GB</small></span></div>
+ <div class="sub">Confirm · reclaimable</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot backup</span><span class="what">3h ago</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">docker stopped</span><span class="what">2</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">cron</span><span class="what">ok</span></div>
+ <div class="lrow"><span class="lamp off"></span><span class="who">libvirt VMs</span><span class="what">off</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- SNAPSHOTS (collapsed by default — all green) -->
+ <div class="cat plate" id="cat-snapshots">
+ <div class="cathead" onclick="toggleCat('snapshots')">
+ <span class="chev">▸</span><span class="lamp"></span>
+ <span class="nm">Snapshots</span>
+ <span class="cnt"><span class="okc" style="color:var(--pass)">all clear · 2 ✓</span></span>
+ </div>
+ <div class="catbody">
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">snapper count</span><span class="what"><button class="key sm">PRUNE</button> 42 · retention ok</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">snapshot timer</span><span class="what">active</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- NETWORK (collapsed by default — all green) -->
+ <div class="cat plate" id="cat-network">
+ <div class="cathead" onclick="toggleCat('network')">
+ <span class="chev">▸</span><span class="lamp"></span>
+ <span class="nm">Network &amp; Posture</span>
+ <span class="cnt"><span class="okc" style="color:var(--pass)">all clear · 6 ✓</span></span>
+ </div>
+ <div class="catbody">
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">firewall</span><span class="what">active</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">unexpected listeners</span><span class="what">3</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">tailscale</span><span class="what">4/4</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">fail2ban</span><span class="what">active</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">NTP sync</span><span class="what">synced</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">DNS / NM</span><span class="what">ok</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- ===== OUTPUT WELL (doctor running) ===== -->
+ <div class="plate owell" id="owell" style="display:none">
+ <div class="oh"><span>MAINT · <span class="l">ratio</span> · CLEANING <span class="lamp busy"></span></span>
+ <span style="color:var(--dim);font-size:11px">4 Auto actions</span></div>
+ <div class="ostep"><span class="lamp busy"></span><span><b>cache trim</b> <span class="ev">running…</span></span></div>
+ <div class="ostep"><span class="lamp"></span><span><b>journal vacuum</b> <span class="ev">done · reclaimed 0.9 GB</span></span></div>
+ <div class="ostep"><span class="lamp"></span><span><b>coredump clear</b> <span class="ev">done · 12 cleared</span></span></div>
+ <div class="ostep queued"><span class="lamp off"></span><span><b>app-log cleanup</b> <span class="ev">queued</span></span></div>
+ <div class="ofoot"><button class="key" onclick="document.getElementById('owell').style.display='none'">DONE</button></div>
+ </div>
+
+ <div class="footnote">
+ Two levels: the <i>Overview</i> grid is every system at a glance — click a tile to jump to and open its section.
+ Each <i>category</i> below is a drill-down you expand or collapse (click the header, or use Expand/Collapse all).
+ All-green categories (Snapshots, Network) start collapsed — on a fully-healthy day every section collapses and the
+ console is the masthead + overview. Verdict lamp reads worst <i>diagnostic</i> state only — the fstrim fail and the
+ 3 CVEs are actionable/updates, so the box still reads HEALTHY.
+ </div>
+
+</div>
+
+<script>
+const CATS = [
+ {id:'storage', name:'Storage & FS', cls:'crit', att:'3', ok:'6', crit:true},
+ {id:'packages', name:'Packages & Sec', cls:'attn', att:'2', ok:'2'},
+ {id:'systemd', name:'systemd & Boot', cls:'attn', att:'2', ok:'2'},
+ {id:'logs', name:'Logs & Cores', cls:'attn', att:'1', ok:'4'},
+ {id:'power', name:'Mem·Therm·Power', cls:'attn', att:'1', ok:'4'},
+ {id:'services', name:'Services & Backup', cls:'attn', att:'1', ok:'3'},
+ {id:'snapshots',name:'Snapshots', cls:'clear', att:'0', ok:'2'},
+ {id:'network', name:'Network & Posture',cls:'clear', att:'0', ok:'6'},
+];
+const lampFor = c => c.cls==='crit'?'red':c.cls==='attn'?'gold':'';
+// build overview station tiles
+const ov = document.getElementById('overview');
+CATS.forEach(c=>{
+ const el=document.createElement('div');
+ el.className='plate station '+c.cls;
+ const att = c.att!=='0'
+ ? `<span class="att ${c.crit?'crit':''}">${c.att} attention</span> · <span class="okc">${c.ok} ok</span>`
+ : `<span class="okc">all clear · ${c.ok} ✓</span>`;
+ el.innerHTML =
+ `<div class="top"><span class="lamp ${lampFor(c)}"></span><span class="nm">${c.name}</span><span class="chev">▸</span></div>
+ <div class="st">${att}</div>`;
+ el.onclick=()=>openCat(c.id);
+ ov.appendChild(el);
+});
+function toggleCat(id){ document.getElementById('cat-'+id).classList.toggle('open'); }
+function openCat(id){
+ const el=document.getElementById('cat-'+id);
+ el.classList.add('open');
+ el.scrollIntoView({behavior:'smooth',block:'center'});
+}
+function allCats(open){ CATS.forEach(c=>{
+ document.getElementById('cat-'+c.id).classList.toggle('open',open); }); }
+function runDoctor(){ document.getElementById('owell').style.display='block';
+ document.getElementById('owell').scrollIntoView({behavior:'smooth',block:'center'}); }
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-07-maint-console-E4-interactive.html b/docs/prototypes/2026-07-07-maint-console-E4-interactive.html
new file mode 100644
index 0000000..c678a8d
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-E4-interactive.html
@@ -0,0 +1,546 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>MAINT · ratio — Approach E4 · Interactive view-swap console</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --amber:#dab53d; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);line-height:1.45;
+ padding:2rem 1.4rem 5rem;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground);
+ display:flex;justify-content:center}
+.console{width:1060px;max-width:100%}
+
+/* ---- primitives (widget-gallery kit) ---- */
+.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass);
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 7px 1px rgba(203,107,77,.6)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite}
+@keyframes pulse{50%{opacity:.25}}
+
+.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:6px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4);
+ white-space:nowrap}
+.key:hover{color:var(--gold);border-color:var(--gold)}
+.key:active{transform:translateY(1px)}
+.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.key.armed{background:rgba(203,107,77,.15);border-color:var(--fail);color:var(--fail)}
+.key.sm{font-size:10.5px;padding:4px 9px;border-radius:7px}
+.key.done{opacity:.45;pointer-events:none}
+
+.bar{width:100%;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative}
+.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px;
+ transition:width .5s ease}
+.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+.bar.okc>span{background:linear-gradient(90deg,#5c7626,var(--pass))}
+.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)}
+
+.ring{width:58px;height:58px;border-radius:50%;
+ background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0);
+ display:grid;place-items:center;position:relative;flex:none}
+.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1}
+.ring small{position:relative;color:var(--dim);font-size:8.5px}
+
+.readout{color:var(--cream);font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em}
+.readout small{color:var(--dim);font-size:11px;font-weight:400}
+
+.engrave{color:var(--steel);font-size:.66rem;letter-spacing:.28em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px;flex:1}
+.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.7rem}
+
+.lrow{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;font-size:12.5px}
+.lrow .who{color:var(--silver)} .lrow .who b{color:var(--cream)}
+.lrow .what{margin-left:auto;color:var(--dim);font-size:11px;display:flex;align-items:center;gap:8px}
+
+.plate{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)}
+
+/* masthead */
+.mast{padding:15px 18px;margin-bottom:14px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+.mast .brand{display:flex;flex-direction:column;gap:2px}
+.mast .brand .ey{color:var(--steel);font-size:.64rem;letter-spacing:.28em;text-transform:uppercase}
+.mast .brand .ti{color:var(--cream);font-size:19px;letter-spacing:.5px}
+.mast .brand .ti b{color:var(--gold)}
+.mast .verdict{display:flex;align-items:center;gap:8px;color:var(--pass);font-size:14px;font-weight:700}
+.mast .spacer{flex:1}
+.mast .doctor{display:flex;align-items:center;gap:8px}
+.mast .doctor .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.22em;text-transform:uppercase;margin-right:2px}
+
+/* updates */
+.updbar{display:flex;align-items:center;gap:16px;padding:9px 18px;margin-bottom:16px;
+ border:1px solid var(--fail);border-radius:11px;
+ background:linear-gradient(180deg,#181210,#0a0c0d)}
+.updbar .q{color:var(--fail);font-size:.6rem;letter-spacing:.2em;text-transform:uppercase;
+ border:1px solid var(--fail);border-radius:5px;padding:2px 7px}
+.updbar .rd{display:flex;flex-direction:column;line-height:1.05}
+.updbar .rd .n{color:var(--cream);font-size:20px;font-weight:700;font-variant-numeric:tabular-nums}
+.updbar .rd .n.cve{color:var(--fail)}
+.updbar .rd .k{color:var(--dim);font-size:.6rem;letter-spacing:.12em;text-transform:uppercase}
+.updbar .spacer{flex:1}
+.updbar .msg{color:var(--dim);font-size:11px;max-width:300px}
+
+/* overview stations */
+.overview{display:grid;grid-template-columns:repeat(4,1fr);gap:11px}
+.station{padding:12px 13px;cursor:pointer;display:flex;flex-direction:column;gap:9px;
+ transition:border-color .12s,transform .06s;border-left:3px solid var(--wash)}
+.station:hover{border-color:#3a352c;transform:translateY(-1px)}
+.station.attn{border-left-color:var(--amber)}
+.station.crit{border-left-color:var(--fail)}
+.station.clear{border-left-color:var(--pass)}
+.station .top{display:flex;align-items:center;gap:9px}
+.station .top .nm{color:var(--cream);font-size:12.5px;font-weight:700}
+.station .top .chev{margin-left:auto;color:var(--dim);font-size:11px}
+.station .st{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)}
+.station .st .att{color:var(--amber)} .station .st .att.crit{color:var(--fail)}
+.station .st .okc{color:var(--pass)}
+.ovhead{margin:0 4px 9px;display:flex;align-items:center;gap:10px}
+
+/* category detail view */
+.catplate{padding:0 0 15px}
+.cathead{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid #201d17;margin-bottom:13px}
+.cathead .nm{color:var(--gold);font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:13px}
+.cathead .cnt{color:var(--dim);font-size:11px;display:flex;gap:10px;align-items:center}
+.cathead .cnt .att{color:var(--amber)} .cathead .cnt .att.crit{color:var(--fail)}
+.cathead .cnt .okc{color:var(--pass)}
+.cathead .navs{margin-left:auto;display:flex;gap:7px}
+.catbody{padding:0 16px}
+.heroes{display:grid;grid-template-columns:repeat(12,1fr);gap:11px;margin-bottom:12px}
+.inst{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:11px 12px;
+ display:flex;flex-direction:column;gap:8px;position:relative;min-height:86px}
+.inst .cap{color:var(--steel);font-size:.62rem;letter-spacing:.16em;text-transform:uppercase;
+ display:flex;align-items:center;gap:7px;padding-right:70px}
+.inst .lever{position:absolute;top:9px;right:9px}
+.inst .val{display:flex;align-items:baseline;gap:8px;margin-top:auto}
+.inst.s3{grid-column:span 3} .inst.s4{grid-column:span 4} .inst.s5{grid-column:span 5} .inst.s6{grid-column:span 6}
+.inst .sub{color:var(--dim);font-size:10.5px}
+.gaugepair{display:flex;gap:16px;align-items:center;justify-content:center;margin-top:auto}
+.gaugepair .g{display:flex;flex-direction:column;align-items:center;gap:3px}
+.gaugepair .g .lb{color:var(--steel);font-size:9px;letter-spacing:.1em}
+.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:6px 12px}
+
+/* doctor / review views */
+.owell{padding:13px 16px}
+.owell .oh{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px;
+ color:var(--cream);font-size:13px}
+.owell .oh .l{color:var(--gold)}
+.ostep{display:flex;gap:9px;align-items:flex-start;padding:4px 0;font-size:12.5px}
+.ostep .lamp{margin-top:3px;width:8px;height:8px}
+.ostep b{color:var(--cream);font-weight:700}
+.ostep .ev{color:var(--steel);font-size:11px;display:block}
+.ostep.queued b{color:var(--dim)}
+.ofoot{margin-top:12px;display:flex;justify-content:flex-end;gap:8px}
+
+.rvrow{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;background:#141210;
+ font-size:12.5px;margin-bottom:6px}
+.rvrow .who{color:var(--cream);font-weight:700}
+.rvrow .why{color:var(--dim);font-size:11px}
+.rvrow .keys{margin-left:auto;display:flex;gap:7px;align-items:center}
+.rvrow .res{color:var(--pass);font-size:11px}
+.rvnote{color:var(--dim);font-size:11px;margin:10px 2px 0;line-height:1.5}
+
+/* toast */
+#toast{position:fixed;bottom:26px;right:26px;display:flex;flex-direction:column;gap:8px;z-index:9}
+.toastw{font-size:11.5px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 11px;
+ box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .18s ease}
+.toastw.err{background:#5e3a2e}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+
+.crumb{color:var(--dim);font-size:11px;margin:0 4px 9px;display:flex;gap:6px;align-items:center}
+.crumb a{color:var(--gold);cursor:pointer;text-decoration:none}
+.footnote{color:var(--dim);font-size:11px;margin-top:16px;line-height:1.6;padding:0 4px}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div class="console">
+
+ <!-- persistent masthead -->
+ <div class="plate mast">
+ <div class="brand">
+ <div class="ey">archsetup · maintenance console · E4 interactive</div>
+ <div class="ti">MAINT · <b>ratio</b></div>
+ </div>
+ <div class="verdict" id="verdict"><span class="lamp"></span> HEALTHY</div>
+ <div class="spacer"></div>
+ <div class="doctor">
+ <button class="key" id="homekey" onclick="go('home')">HOME</button>
+ <span class="lbl" style="margin-left:8px">Doctor</span>
+ <button class="key" onclick="go('doctor')">CLEAN UP</button>
+ <button class="key" onclick="go('review')">REVIEW &amp; FIX</button>
+ </div>
+ </div>
+
+ <!-- swapped view -->
+ <div id="view"></div>
+
+ <div class="footnote">
+ Fully interactive: views replace each other (no inline collapse). HOME = overview of all systems;
+ click a station to drill in; PREV/NEXT walk the categories; CLEAN UP streams the output well and
+ actually updates the metrics; REVIEW &amp; FIX lists every Confirm item — destructive ones arm on
+ first press, fire on second. Workflow items always escalate (toast) — the console never runs them.
+ </div>
+</div>
+<div id="toast"></div>
+
+<script>
+/* ================= state ================= */
+const S = {
+ cache:{v:8.8, st:'warn'}, scrub:{v:34, st:'warn'}, fstrim:{v:'OFF', st:'fail'},
+ disk:{v:69, st:'ok'}, smart:{v:'PASSED', st:'ok'}, wear:{v:'0%', st:'ok'},
+ deverr:{v:'0 / 0', st:'ok'}, unalloc:{v:'118 GiB', st:'ok'}, integ:{v:'clean', st:'ok'},
+ deeptrim:{v:'keep 3', st:'ok'},
+ orphans:{v:13, st:'warn'}, pacnew:{v:2, st:'warn'}, keyring:{v:12, st:'ok'}, reboot:{v:'no', st:'ok'},
+ failed:{v:1, st:'warn'}, timers:{v:'4/5', st:'warn'}, sysrun:{v:'running', st:'ok'}, taint:{v:0, st:'ok'},
+ cores:{v:18, st:'warn'}, journald:{v:1.2, st:'ok'}, applog:{v:'ok', st:'ok'},
+ jerr:{v:'12 real', st:'ok'}, khw:{v:'clean', st:'ok'},
+ temps:{v:'61/54', st:'ok'}, mem:{v:104, st:'ok'}, unclean:{v:75, st:'warn'},
+ zram:{v:'16 GiB', st:'ok'}, throttle:{v:'no', st:'ok'}, battery:{v:'n/a', st:'off'},
+ docker:{v:3, st:'warn'}, rsync:{v:'3h ago', st:'ok'}, dstopped:{v:2, st:'ok'},
+ cron:{v:'ok', st:'ok'}, libvirt:{v:'off', st:'off'},
+ snapper:{v:42, st:'ok'}, stimer:{v:'active', st:'ok'},
+ fw:{v:'active', st:'ok'}, listen:{v:3, st:'ok'}, ts:{v:'4/4', st:'ok'},
+ f2b:{v:'active', st:'ok'}, ntp:{v:'synced', st:'ok'}, dns:{v:'ok', st:'ok'},
+};
+
+const CATS = [
+ {id:'storage', name:'Storage & Filesystem', ids:['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']},
+ {id:'packages', name:'Packages & Security', ids:['orphans','pacnew','keyring','reboot']},
+ {id:'systemd', name:'systemd & Boot', ids:['failed','timers','sysrun','taint']},
+ {id:'logs', name:'Logs & Coredumps', ids:['cores','journald','applog','jerr','khw']},
+ {id:'power', name:'Memory · Thermal · Power', ids:['temps','mem','unclean','zram','throttle','battery']},
+ {id:'services', name:'Services & Backups', ids:['docker','rsync','dstopped','cron','libvirt']},
+ {id:'snapshots',name:'Snapshots', ids:['snapper','stimer']},
+ {id:'network', name:'Network & Posture', ids:['fw','listen','ts','f2b','ntp','dns']},
+];
+
+let view = 'home';
+let armed = null; // id of armed destructive key
+let doctorTimer = null;
+
+/* ================= helpers ================= */
+const $ = id => document.getElementById(id);
+function counts(cat){
+ let attn=0, ok=0, crit=false;
+ cat.ids.forEach(i=>{ const st=S[i].st;
+ if(st==='warn'){attn++} else if(st==='fail'){attn++;crit=true} else ok++; });
+ return {attn, ok, crit};
+}
+function lampCls(st){ return st==='ok'?'':st==='warn'?'gold':st==='fail'?'red':'off'; }
+function catLamp(cat){ const c=counts(cat); return c.crit?'red':c.attn?'gold':''; }
+function toast(msg, err){
+ const t=document.createElement('div'); t.className='toastw'+(err?' err':''); t.textContent=msg;
+ $('toast').appendChild(t); setTimeout(()=>t.remove(), 2600);
+}
+function esc(id){ toast('escalates to the system-health-check workflow — not run from the console', true); }
+
+/* generic lever runner: busy → mutate → re-render */
+function fire(act){
+ const a = ACTIONS[act];
+ if(a.arm && armed!==act){ armed=act; render(); toast(a.armMsg||'press again to confirm', true); return; }
+ armed=null;
+ toast(a.busy||'running…');
+ setTimeout(()=>{ a.run(); toast(a.doneMsg); render(); }, a.ms||800);
+}
+
+const ACTIONS = {
+ clean_cache:{ busy:'paccache -r / -ruk0 …', ms:1000, doneMsg:'cache trim — reclaimed 8.4 GB',
+ run(){ S.cache={v:0.4, st:'ok'}; } },
+ scrub:{ busy:'btrfs scrub started on / …', ms:1400, doneMsg:'scrub running — age resets on completion',
+ run(){ S.scrub={v:0, st:'ok'}; } },
+ fstrim:{ busy:'systemctl enable --now fstrim.timer …', ms:900, doneMsg:'fstrim.timer enabled — weekly TRIM active',
+ run(){ S.fstrim={v:'ON', st:'ok'}; S.timers={v:'5/5', st:'ok'}; } },
+ deeptrim:{ arm:true, armMsg:'keep only 1 version of every pkg — press DEEP TRIM again', busy:'paccache -rk1 …', ms:1100,
+ doneMsg:'deep trim — kept 1 version, reclaimed 3.1 GB more', run(){ S.deeptrim={v:'keep 1', st:'ok'}; if(S.cache.st==='ok') S.cache={v:0.2,st:'ok'}; } },
+ orphans:{ arm:true, armMsg:'remove 13 orphaned packages — press again', busy:'pacman -Rns (13 pkgs) …', ms:1200,
+ doneMsg:'orphans removed — 13 pkgs', run(){ S.orphans={v:0, st:'ok'}; } },
+ pacnew:{ busy:'diffing 2 .pacnew files …', ms:900, doneMsg:'.pacnew resolved — mirrorlist deleted, locale.gen merged',
+ run(){ S.pacnew={v:0, st:'ok'}; } },
+ keyring:{ busy:'pacman -Sy archlinux-keyring …', ms:900, doneMsg:'keyring refreshed',
+ run(){ S.keyring={v:0, st:'ok'}; } },
+ timers:{ busy:'systemctl enable --now (1 timer) …', ms:800, doneMsg:'all 5 maintenance timers firing',
+ run(){ S.timers={v:'5/5', st:'ok'}; S.fstrim.st==='fail' && (S.fstrim={v:'ON',st:'ok'}); } },
+ cores:{ busy:'coredumpctl clean (keep 3d) …', ms:800, doneMsg:'coredumps cleared — 18 removed',
+ run(){ S.cores={v:0, st:'ok'}; } },
+ journald:{ busy:'journalctl --vacuum-size=300M …', ms:800, doneMsg:'journal vacuumed — reclaimed 0.9 GB',
+ run(){ S.journald={v:0.3, st:'ok'}; } },
+ applog:{ busy:'log-cleanup …', ms:600, doneMsg:'app logs — nothing older than 7d', run(){} },
+ docker:{ arm:true, armMsg:'docker prune tier 1+2 — press again', busy:'docker container/image prune …', ms:1200,
+ doneMsg:'docker pruned — reclaimed 2.8 GB', run(){ S.docker={v:0.2, st:'ok'}; } },
+ snapper:{ arm:true, armMsg:'prune stale manual snapshots — press again', busy:'snapper cleanup …', ms:900,
+ doneMsg:'snapper — retention applied', run(){ S.snapper={v:31, st:'ok'}; } },
+};
+
+/* ================= view renderers ================= */
+function lever(act, label, cls){
+ const armedNow = armed===act;
+ return `<button class="key sm ${cls||''} ${armedNow?'armed':''}" onclick="event.stopPropagation();fire('${act}')">${armedNow?label+'?':label}</button>`;
+}
+function wkey(label){ return `<button class="key sm" onclick="event.stopPropagation();esc()">${label||'WORKFLOW'}</button>`; }
+function lrow(id, name, extra){
+ const m=S[id];
+ return `<div class="lrow"><span class="lamp ${lampCls(m.st)}"></span><span class="who">${name}</span>
+ <span class="what">${extra||''}${m.v}</span></div>`;
+}
+
+function renderHome(){
+ const stations = CATS.map(c=>{
+ const k=counts(c);
+ const st = k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>`
+ : `<span class="okc">all clear · ${k.ok} ✓</span>`;
+ return `<div class="plate station ${k.crit?'crit':k.attn?'attn':'clear'}" onclick="go('cat:${c.id}')">
+ <div class="top"><span class="lamp ${catLamp(c)}"></span><span class="nm">${c.name}</span><span class="chev">▸</span></div>
+ <div class="st">${st}</div></div>`;
+ }).join('');
+ return `
+ <div class="updbar">
+ <span class="q">Updates</span>
+ <div class="rd"><span class="n">47</span><span class="k">pending</span></div>
+ <div class="rd"><span class="n cve">3</span><span class="k">CVE</span></div>
+ <div class="rd"><span class="n">5</span><span class="k">AUR stale</span></div>
+ <span class="msg"><span class="lamp red" style="width:8px;height:8px"></span> Security advisories present — updates run through the workflow, never the doctor.</span>
+ <span class="spacer"></span>
+ <button class="key red" onclick="esc()">RUN WORKFLOW</button>
+ </div>
+ <div class="ovhead"><span class="engrave">Overview<span class="cnt">· ${CATS.length} systems</span></span></div>
+ <div class="overview">${stations}</div>`;
+}
+
+function heroBar(id, cap, unit, pct, opts){
+ const m=S[id]; const o=opts||{};
+ return `<div class="inst s3">
+ ${o.lever||''}
+ <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div>
+ <div class="val"><span class="readout">${m.v}<small> ${unit||''}</small></span></div>
+ <div class="bar ${m.st==='warn'?'warn':m.st==='ok'?'okc':''}">${o.tick?'<span class="tk" style="left:'+o.tick+'%"></span>':''}<span style="width:${pct}%"></span></div>
+ <div class="sub">${o.sub||''}</div></div>`;
+}
+function heroCount(id, cap, opts){
+ const m=S[id]; const o=opts||{};
+ return `<div class="inst s3">
+ ${o.lever||''}
+ <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div>
+ <div class="val"><span class="readout" ${m.st==='fail'?'style="color:var(--fail)"':m.st==='warn'&&o.tint?'style="color:var(--amber)"':''}>${m.v}${o.unit?'<small>'+o.unit+'</small>':''}</span></div>
+ <div class="sub">${o.sub||''}</div></div>`;
+}
+function heroRing(id, cap, pct, color, small, opts){
+ const m=S[id]; const o=opts||{};
+ return `<div class="inst s3">
+ ${o.lever||''}
+ <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div>
+ <div style="display:flex;justify-content:center;margin-top:auto">
+ <span class="ring" style="--p:${pct};--rc:${color}"><b>${m.v}</b><small>${small}</small></span></div>
+ <div class="sub" style="text-align:center">${o.sub||''}</div></div>`;
+}
+
+const CATVIEW = {
+ storage(){ return {
+ heroes:
+ heroBar('disk','Disk usage','%',S.disk.v,{sub:'root · btrfs · thr 80 / 90'})+
+ heroBar('cache','Package cache','GB',Math.min(100,S.cache.v/10*100),{lever:`<span class="lever">${lever('clean_cache','CLEAN')}</span>`,tick:100,sub:'Auto · threshold ~10 GB'})+
+ heroRing('scrub','btrfs scrub',Math.min(100,S.scrub.v/55*100),S.scrub.st==='ok'?'var(--pass)':'var(--amber)','days',{lever:`<span class="lever">${lever('scrub','SCRUB')}</span>`,sub:S.scrub.st==='ok'?'scrub fresh':'past 30d cadence'})+
+ heroCount('fstrim','fstrim.timer',{lever:`<span class="lever">${lever('fstrim','ENABLE',S.fstrim.st==='fail'?'red':'')}</span>`,sub:'Confirm · weekly SSD TRIM'}),
+ strip:
+ lrow('smart','SMART')+lrow('wear','SMART wear')+lrow('deverr','device errors')+
+ lrow('unalloc','btrfs unalloc')+lrow('integ','pacman integrity')+
+ lrow('deeptrim','deep-trim', lever('deeptrim','DEEP TRIM')+' ')
+ };},
+ packages(){ return {
+ heroes:
+ heroCount('orphans','Orphans',{lever:`<span class="lever">${lever('orphans','REMOVE')}</span>`,sub:'Confirm · unowned pkgs',tint:1})+
+ heroCount('pacnew','.pacnew files',{lever:`<span class="lever">${lever('pacnew','REVIEW')}</span>`,sub:'Confirm · config merges',tint:1}),
+ strip:
+ lrow('keyring','keyring age (d)', lever('keyring','REFRESH')+' ')+
+ lrow('reboot','reboot required')
+ };},
+ systemd(){ return {
+ heroes:
+ heroCount('failed','Failed units',{lever:`<span class="lever">${wkey()}</span>`,sub:'Workflow · investigate why',tint:1})+
+ heroCount('timers','Maint timers',{lever:`<span class="lever">${lever('timers','ENABLE')}</span>`,sub:'Confirm · paccache/scrub/fstrim/…',tint:1}),
+ strip: lrow('sysrun','is-system-running')+lrow('taint','kernel taint')
+ };},
+ logs(){ return {
+ heroes:
+ heroCount('cores','Coredumps 7d',{lever:`<span class="lever">${lever('cores','CLEAR')}</span>`,sub:'Auto · keeps last 3 days',tint:1}),
+ strip:
+ lrow('journald','journald size (GB)', lever('journald','VACUUM')+' ')+
+ lrow('applog','app-log cleanup', lever('applog','RUN')+' ')+
+ lrow('jerr','journal errors')+lrow('khw','kernel/hw events')
+ };},
+ power(){ return {
+ heroes:
+ `<div class="inst s5"><div class="cap"><span class="lamp"></span> Temperatures</div>
+ <div class="gaugepair">
+ <div class="g"><span class="ring" style="--p:61;--rc:var(--pass)"><b>61°</b></span><span class="lb">CPU</span></div>
+ <div class="g"><span class="ring" style="--p:54;--rc:var(--pass)"><b>54°</b></span><span class="lb">NVMe</span></div>
+ </div></div>`+
+ `<div class="inst s4"><div class="cap"><span class="lamp"></span> Memory free</div>
+ <div class="val"><span class="readout">104<small> GB</small></span></div>
+ <div class="bar okc"><span style="width:83%"></span></div><div class="sub">of 125 GB · 0 OOM</div></div>`+
+ `<div class="inst s3"><span class="lever">${wkey()}</span><div class="cap"><span class="lamp gold"></span> Unclean boot</div>
+ <div class="val"><span class="readout" style="color:var(--amber)">75<small>%</small></span></div>
+ <div class="bar warn"><span style="width:75%"></span></div><div class="sub">Workflow · investigate</div></div>`,
+ strip: lrow('zram','swap / zram')+lrow('throttle','thermal throttle')+lrow('battery','battery')
+ };},
+ services(){ return {
+ heroes:
+ heroCount('docker','Docker reclaim',{lever:`<span class="lever">${lever('docker','PRUNE')}</span>`,unit:' GB',sub:'Confirm · tiers 1+2',tint:1}),
+ strip:
+ lrow('rsync','rsyncshot backup')+lrow('dstopped','docker stopped')+
+ lrow('cron','cron')+lrow('libvirt','libvirt VMs')
+ };},
+ snapshots(){ return {
+ heroes:'',
+ strip:
+ lrow('snapper','snapper count', lever('snapper','PRUNE')+' ')+
+ lrow('stimer','snapshot timer')
+ };},
+ network(){ return {
+ heroes:'',
+ strip:
+ lrow('fw','firewall')+lrow('listen','unexpected listeners')+lrow('ts','tailscale')+
+ lrow('f2b','fail2ban')+lrow('ntp','NTP sync')+lrow('dns','DNS / NM')
+ };},
+};
+
+function renderCat(cid){
+ const idx = CATS.findIndex(c=>c.id===cid);
+ const cat = CATS[idx];
+ const k = counts(cat);
+ const prev = CATS[(idx+CATS.length-1)%CATS.length].id;
+ const next = CATS[(idx+1)%CATS.length].id;
+ const body = CATVIEW[cid]();
+ const cnt = k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>`
+ : `<span class="okc">all clear · ${k.ok} ✓</span>`;
+ return `
+ <div class="crumb"><a onclick="go('home')">overview</a> ▸ ${cat.name.toLowerCase()}</div>
+ <div class="plate catplate">
+ <div class="cathead">
+ <span class="lamp ${catLamp(cat)}"></span>
+ <span class="nm">${cat.name}</span>
+ <span class="cnt">${cnt}</span>
+ <span class="navs">
+ <button class="key sm" onclick="go('cat:${prev}')">◂ PREV</button>
+ <button class="key sm" onclick="go('home')">HOME</button>
+ <button class="key sm" onclick="go('cat:${next}')">NEXT ▸</button>
+ </span>
+ </div>
+ <div class="catbody">
+ ${body.heroes?`<div class="heroes">${body.heroes}</div>`:''}
+ <div class="strip">${body.strip}</div>
+ </div>
+ </div>`;
+}
+
+/* ---- doctor (clean up) ---- */
+let docSteps = [];
+function renderDoctor(){
+ const rows = docSteps.map(s=>{
+ const lamp = s.st==='run'?'busy':s.st==='done'?'':'off';
+ return `<div class="ostep ${s.st==='wait'?'queued':''}"><span class="lamp ${lamp}"></span>
+ <span><b>${s.name}</b> <span class="ev">${s.ev}</span></span></div>`;
+ }).join('');
+ const running = docSteps.some(s=>s.st!=='done');
+ return `
+ <div class="crumb"><a onclick="go('home')">overview</a> ▸ doctor · clean up</div>
+ <div class="plate owell">
+ <div class="oh"><span>MAINT · <span class="l">ratio</span> · CLEANING ${running?'<span class="lamp busy"></span>':'<span class="lamp"></span>'}</span>
+ <span style="color:var(--dim);font-size:11px">${docSteps.length} Auto actions</span></div>
+ ${rows}
+ <div class="ofoot"><button class="key ${running?'done':''}" onclick="go('home')">DONE</button></div>
+ </div>`;
+}
+function startDoctor(){
+ clearTimeout(doctorTimer);
+ docSteps = [
+ {name:'cache trim', act:'clean_cache', st:'wait', ev:'queued',
+ res:()=> S.cache.st==='warn' ? (S.cache={v:0.4,st:'ok'}, 'done · reclaimed 8.4 GB') : 'done · nothing to reclaim'},
+ {name:'journal vacuum',act:'journald', st:'wait', ev:'queued',
+ res:()=> S.journald.v>0.5 ? (S.journald={v:0.3,st:'ok'}, 'done · reclaimed 0.9 GB') : 'done · already tight'},
+ {name:'coredump clear',act:'cores', st:'wait', ev:'queued',
+ res:()=> S.cores.v>0 ? (()=>{const n=S.cores.v; S.cores={v:0,st:'ok'}; return 'done · '+n+' cleared';})() : 'done · none to clear'},
+ {name:'app-log cleanup',act:'applog', st:'wait', ev:'queued',
+ res:()=> 'done · nothing older than 7d'},
+ ];
+ let i = 0;
+ function step(){
+ if(i>0){ docSteps[i-1].st='done'; docSteps[i-1].ev=docSteps[i-1].res(); }
+ if(i<docSteps.length){ docSteps[i].st='run'; docSteps[i].ev='running…'; i++;
+ if(view==='doctor') $('view').innerHTML=renderDoctor();
+ doctorTimer=setTimeout(step, 1000);
+ } else {
+ if(view==='doctor') $('view').innerHTML=renderDoctor();
+ toast('clean up complete');
+ }
+ }
+ step();
+}
+
+/* ---- review & fix ---- */
+const REVIEW = [
+ {act:'fstrim', who:'fstrim.timer', why:'weekly SSD TRIM disabled', id:'fstrim', okv:()=>S.fstrim.st==='ok'},
+ {act:'scrub', who:'btrfs scrub', why:'34d since last scrub (30d cadence)', id:'scrub', okv:()=>S.scrub.st==='ok'},
+ {act:'orphans', who:'orphans', why:'13 unowned packages', id:'orphans', okv:()=>S.orphans.st==='ok'},
+ {act:'pacnew', who:'.pacnew files', why:'2 configs awaiting merge', id:'pacnew', okv:()=>S.pacnew.st==='ok'},
+ {act:'timers', who:'maint timers', why:'1 of 5 not firing', id:'timers', okv:()=>S.timers.st==='ok'},
+ {act:'keyring', who:'keyring', why:'refresh before next update', id:'keyring', okv:()=>S.keyring.st==='ok'},
+ {act:'docker', who:'docker reclaim', why:'3 GB reclaimable (tiers 1+2)', id:'docker', okv:()=>S.docker.st==='ok'},
+ {act:'snapper', who:'snapper', why:'retention within budget — optional', id:'snapper', okv:()=>S.snapper.v<42},
+ {act:'deeptrim',who:'deep-trim', why:'keep 1 version — frees most, less downgrade headroom', id:'deeptrim', okv:()=>S.deeptrim.v==='keep 1'},
+];
+function renderReview(){
+ const rows = REVIEW.map(r=>{
+ const done = r.okv();
+ return `<div class="rvrow">
+ <span class="lamp ${done?'':lampCls(S[r.id].st)}"></span>
+ <span class="who">${r.who}</span><span class="why">${r.why}</span>
+ <span class="keys">${done?'<span class="res">done ✓</span>':lever(r.act, ACTIONS[r.act].arm?'FIX':'FIX')}</span>
+ </div>`;
+ }).join('');
+ return `
+ <div class="crumb"><a onclick="go('home')">overview</a> ▸ doctor · review &amp; fix</div>
+ <div class="plate owell">
+ <div class="oh"><span>MAINT · <span class="l">ratio</span> · REVIEW &amp; FIX</span>
+ <span style="color:var(--dim);font-size:11px">${REVIEW.filter(r=>!r.okv()).length} Confirm items</span></div>
+ ${rows}
+ <div class="rvnote">Destructive fixes (orphan removal, deep-trim, prunes) arm on first press and fire on the
+ second. Workflow items — failed units, unclean boots, updates, CVEs — are deliberately absent here: they
+ escalate to the health-check workflow.</div>
+ <div class="ofoot"><button class="key" onclick="go('home')">DONE</button></div>
+ </div>`;
+}
+
+/* ================= router ================= */
+function go(v){
+ armed = null;
+ view = v;
+ if(v==='doctor') startDoctor();
+ render();
+ window.scrollTo({top:0, behavior:'smooth'});
+}
+function render(){
+ $('homekey').classList.toggle('on', view==='home');
+ const el = $('view');
+ if(view==='home') el.innerHTML = renderHome();
+ else if(view==='doctor') el.innerHTML = renderDoctor();
+ else if(view==='review') el.innerHTML = renderReview();
+ else if(view.startsWith('cat:')) el.innerHTML = renderCat(view.slice(4));
+}
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html b/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html
new file mode 100644
index 0000000..95ec4c9
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html
@@ -0,0 +1,1696 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>MAINT · ratio — E5 · Selector + dense subpanel</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --amber:#dab53d; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);line-height:1.45;
+ padding:1.6rem 1.2rem 4rem;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground);
+ display:flex;justify-content:center;align-items:flex-start}
+
+.capsule{width:960px;max-width:100%;background:var(--panel);color:var(--silver);
+ border:1.6px solid var(--gold);border-radius:16px;padding:17px 19px;
+ box-shadow:0 18px 50px rgba(0,0,0,.55);position:relative}
+
+/* ---- primitives ---- */
+.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass);
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 7px 1px rgba(203,107,77,.6)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite;
+ box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+@keyframes pulse{50%{opacity:.25}}
+
+.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:6px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4);
+ white-space:nowrap}
+.key:hover{color:var(--gold);border-color:var(--gold)}
+.key:active{transform:translateY(1px)}
+.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.key.armed{background:rgba(203,107,77,.15);border-color:var(--fail);color:var(--fail)}
+.key.dis{opacity:.3;cursor:not-allowed}
+.key.dis:hover{color:var(--silver);border-color:#33302b}
+.key.sm{font-size:10px;padding:3px 8px;border-radius:7px}
+
+.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;white-space:nowrap}
+.badge.red{background:var(--fail);color:var(--cream)}
+.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+.badge.pass{background:var(--pass);color:var(--cream)}
+
+.bar{width:100%;height:11px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative}
+.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px;transition:width .5s ease}
+.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+.bar.okc>span{background:linear-gradient(90deg,#5c7626,var(--pass))}
+.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)}
+
+.ring{width:54px;height:54px;border-radius:50%;
+ background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0);
+ display:grid;place-items:center;position:relative;flex:none}
+.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1}
+.ring small{position:relative;color:var(--dim);font-size:8px}
+
+.readout{color:var(--cream);font-size:21px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em;line-height:1}
+.readout small{color:var(--dim);font-size:11px;font-weight:400}
+
+.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.26em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px}
+.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.68rem}
+
+.lrow{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:7px;background:#141210;font-size:12px}
+.lrow .who{color:var(--silver)}
+.lrow .what{margin-left:auto;color:var(--dim);font-size:10.5px;display:flex;align-items:center;gap:7px}
+
+.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:16px}
+.ladder i{width:5px;background:var(--wash);border-radius:1px}
+.ladder i:nth-child(1){height:5px}.ladder i:nth-child(2){height:9px}
+.ladder i:nth-child(3){height:12px}.ladder i:nth-child(4){height:16px}
+.ladder i.on{background:var(--pass)}
+.ladder.bad i.on{background:var(--fail)}
+
+/* faceplate */
+.face{display:flex;align-items:center;gap:11px;padding:11px 13px;margin-bottom:11px;
+ background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:11px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}
+.face .word{color:var(--cream);font-size:15.5px;font-weight:700;letter-spacing:.22em}
+.face .spacer{flex:1}
+.face .unit{color:var(--steel);font-size:.72rem;letter-spacing:.1em}
+.face .xbtn{width:24px;height:24px;border-radius:50%;border:1px solid #33302b;background:transparent;
+ color:var(--dim);cursor:pointer;font-size:12px;line-height:1;display:grid;place-items:center;font-family:var(--mono)}
+.face .xbtn:hover{color:var(--fail);border-color:var(--fail)}
+
+.ctlrow{display:flex;align-items:center;gap:8px;margin-bottom:11px;padding:0 2px}
+.ctlrow .lbl{color:var(--steel);font-size:.6rem;letter-spacing:.22em;text-transform:uppercase;margin-right:3px}
+.ctlrow .spacer{flex:1}
+
+.updbar{display:flex;align-items:center;gap:13px;padding:7px 12px;margin-bottom:12px;
+ border:1px solid var(--wash);border-radius:9px;background:linear-gradient(180deg,#141310,#0a0c0d);font-size:11.5px}
+.updbar .q{color:var(--dim);font-size:.58rem;letter-spacing:.2em;text-transform:uppercase;
+ border:1px solid var(--wash);border-radius:5px;padding:1px 6px}
+.updbar.ok{border-color:var(--pass)} .updbar.ok .q{color:var(--pass);border-color:var(--pass)}
+.updbar.warn{border-color:var(--amber)} .updbar.warn .q{color:var(--amber);border-color:var(--amber)}
+.updbar.crit{border-color:var(--fail)} .updbar.crit .q{color:var(--fail);border-color:var(--fail)}
+.updbar b{color:var(--cream);font-variant-numeric:tabular-nums}
+.updbar .cve{color:var(--fail);font-weight:700}
+.updbar .spacer{flex:1}
+.updbar .dimtx{color:var(--dim);font-size:10.5px}
+
+/* selector */
+.selector{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin-bottom:13px}
+.selbtn{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;border-radius:9px;
+ background:linear-gradient(180deg,#191715,#131110);border:1px solid #262320;border-left:3px solid var(--wash);
+ transition:border-color .1s}
+.selbtn:hover{border-color:#3a352c}
+.selbtn .nm{color:var(--silver);font-size:11.5px;font-weight:700;letter-spacing:.02em}
+.selbtn .ct{margin-left:auto;font-size:10px;color:var(--dim);font-variant-numeric:tabular-nums;white-space:nowrap}
+.selbtn .ct .a{color:var(--amber)} .selbtn .ct .a.crit{color:var(--fail)} .selbtn .ct .k{color:var(--pass)}
+.selbtn.attn{border-left-color:var(--amber)}
+.selbtn.crit{border-left-color:var(--fail)}
+.selbtn.clear{border-left-color:var(--pass)}
+.selbtn.loading{border-left-color:var(--amber)}
+.selbtn.sel{background:linear-gradient(180deg,#4a5768,var(--slate));border-color:var(--gold);border-left-width:3px}
+.selbtn.sel .nm{color:var(--gold)}
+.selbtn.sel .ct, .selbtn.sel .ct .a, .selbtn.sel .ct .k{color:var(--cream)}
+
+/* subpanel */
+.sub{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:13px 14px;min-height:330px}
+.subhead{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+.subhead .nm{color:var(--gold);font-weight:700;letter-spacing:.16em;text-transform:uppercase;font-size:12.5px}
+.subhead .cnt{color:var(--dim);font-size:11px;margin-left:auto;display:flex;gap:9px;align-items:center}
+.subhead .cnt .att{color:var(--amber)} .subhead .cnt .att.crit{color:var(--fail)} .subhead .cnt .okc{color:var(--pass)}
+
+.wgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-bottom:10px}
+.cell{background:#100f0e;border:1px solid #1f1c18;border-radius:9px;padding:9px 10px;
+ display:flex;flex-direction:column;gap:7px;position:relative;min-height:76px}
+.cell .cap{color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;
+ display:flex;align-items:center;gap:6px;padding-right:56px}
+.cell .lever{position:absolute;top:7px;right:7px}
+.cell .val{display:flex;align-items:baseline;gap:7px;margin-top:auto}
+.cell .sub2{color:var(--dim);font-size:10px}
+.cell .center{display:flex;justify-content:center;margin-top:auto}
+.gpair{display:flex;gap:14px;align-items:center;justify-content:center;margin-top:auto}
+.gpair .g{display:flex;flex-direction:column;align-items:center;gap:2px}
+.gpair .g .lb{color:var(--steel);font-size:8.5px;letter-spacing:.1em}
+
+.striph{margin:9px 0 7px}
+.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:5px 9px}
+.strip.two{grid-template-columns:repeat(2,1fr)}
+
+/* segmented selector (gallery widget 06) */
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden;width:max-content}
+.seg button{font:inherit;font-size:10px;color:var(--silver);background:#191715;border:0;
+ border-right:1px solid #33302b;padding:5px 9px;cursor:pointer;letter-spacing:.06em}
+.seg button:last-child{border-right:0}
+.seg button:hover{color:var(--gold)}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+
+/* evidence column headers */
+.evh{color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;margin-bottom:4px}
+.colh{display:flex;align-items:center;gap:8px;min-width:0}
+.colh .engrave{flex:1;min-width:0}
+
+/* ===== rotary band selector (amplifier input-selector idiom) ===== */
+.deck{display:flex;align-items:center;gap:22px;margin:13px 4px 11px;padding:9px 14px;
+ background:linear-gradient(180deg,#161412,#100f0e);border:1px solid #201d17;border-radius:10px}
+.knob{width:46px;height:46px;border-radius:50%;position:relative;cursor:pointer;flex:none;
+ background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c;
+ box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
+.knob:hover{border-color:var(--gold)}
+.knob .ind{position:absolute;left:50%;top:4px;width:2px;height:14px;background:var(--gold-hi);
+ margin-left:-1px;transform-origin:50% 19px;border-radius:1px;
+ box-shadow:0 0 5px rgba(255,215,95,.6);transition:transform .25s cubic-bezier(.3,1.3,.5,1)}
+.bands{display:flex;gap:26px;align-items:flex-end}
+.band{display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer;user-select:none}
+.band .lamp{width:7px;height:7px}
+.band .bl{color:var(--steel);font-size:.6rem;letter-spacing:.18em;text-transform:uppercase;white-space:nowrap}
+.band:hover .bl{color:var(--silver)}
+.band.on .bl{color:var(--cream)}
+.band .ul{height:2px;width:100%;border-radius:1px;background:transparent;transition:background .15s}
+.band.on .ul{background:var(--gold);box-shadow:0 0 6px rgba(218,181,61,.5)}
+
+/* journal digest rows — capped at ~3.5 rows; the half row signals scrollability */
+/* rows are fixed 30px so the 3.5-row cap is exact: 3×30 + 3×4 gap + 15 half = 117 */
+.jlist{display:flex;flex-direction:column;gap:4px;max-height:117px;overflow-y:auto;padding-right:4px}
+.jlist .jrow{height:30px;min-height:30px;padding:2px 8px;flex:none}
+
+/* dark scrollbars, consistent with the panel family (slate thumb on dark track) */
+.capsule ::-webkit-scrollbar{width:8px;height:8px}
+.capsule ::-webkit-scrollbar-track{background:#0a0c0d;border-radius:4px}
+.capsule ::-webkit-scrollbar-thumb{background:#2c2f32;border-radius:4px}
+.capsule ::-webkit-scrollbar-thumb:hover{background:#54677d}
+.capsule *{scrollbar-width:thin;scrollbar-color:#2c2f32 #0a0c0d}
+.jrow{gap:8px}
+.jrow .jmsg{color:var(--dim);font-size:10.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
+.jrow .what{margin-left:0;flex:none}
+.jrow.known .who{color:var(--dim)}
+
+.skel{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;
+ min-height:260px;color:var(--dim);font-size:11px;letter-spacing:.24em;text-transform:uppercase}
+
+/* doctor / review */
+.ostep{display:flex;gap:9px;align-items:flex-start;padding:4px 0;font-size:12.5px}
+.ostep .lamp{margin-top:3px;width:8px;height:8px}
+.ostep b{color:var(--cream);font-weight:700}
+.ostep .ev{color:var(--steel);font-size:11px;display:block}
+.ostep .ev.evfail{color:var(--fail)}
+.ostep.queued b{color:var(--dim)}
+.ofoot{margin-top:12px;display:flex;justify-content:flex-end;gap:8px}
+/* review list: fixed 32px rows + 5px gap (37 stride); cap = 8.5 rows so the
+ panel holds its shape and the half row cues the scroll */
+.rvlist{max-height:312px;overflow-y:auto;padding-right:4px}
+.rvrow{display:flex;align-items:center;gap:10px;padding:2px 10px;border-radius:8px;background:#141210;
+ font-size:12px;margin-bottom:5px;height:32px;min-height:32px}
+.rvrow .who{color:var(--cream);font-weight:700}
+.rvrow .why{color:var(--dim);font-size:10.5px}
+.rvrow .why.errtx{color:var(--fail)}
+.rvrow .keys{margin-left:auto;display:flex;gap:7px;align-items:center}
+.rvrow .res{color:var(--pass);font-size:11px}
+.rvnote{color:var(--dim);font-size:10.5px;margin:9px 2px 0;line-height:1.5}
+
+.errline{color:var(--fail);font-size:10px;line-height:1.35;margin-top:3px}
+
+/* results wall (output-well idiom from the net/bt panels) */
+.rwall{margin-top:12px;background:var(--well);border:1px solid #201d17;border-radius:11px;padding:10px 14px 12px}
+.rwall .rhead{display:flex;align-items:center;gap:10px;margin-bottom:4px}
+.rwall .rhead .keys{margin-left:auto;display:flex;gap:6px}
+/* rows fixed 30px so the 3.5-entry cap is exact: 3.5 × 30 = 105 */
+.rwall .rbody{max-height:105px;overflow-y:auto;padding-right:4px}
+.rwall .rstep{display:flex;gap:9px;align-items:center;height:30px;min-height:30px;font-size:12px}
+.rwall .rstep .tm{color:var(--dim);font-size:10px;font-variant-numeric:tabular-nums;flex:none;
+ display:flex;flex-direction:column;line-height:1.2}
+.rwall .rstep .tm .dt{font-size:8.5px;color:#5d636a;letter-spacing:.04em}
+.rwall .rstep .lamp{width:8px;height:8px}
+.rwall .rstep .rtxt{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.rwall .rstep b{color:var(--cream);font-weight:700}
+.rwall .rstep .ev{color:var(--steel);font-size:11px}
+.rwall .rstep .ev.evfail{color:var(--fail)}
+
+#toast{position:absolute;bottom:16px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:9;
+ align-items:flex-end;pointer-events:none;max-width:70%}
+.toastw{font-size:11.5px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 11px;
+ box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .18s ease}
+.toastw.err{background:#5e3a2e}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+
+#closed{display:none;color:var(--dim);font-size:12px;border:1px dashed var(--wash);
+ border-radius:10px;padding:14px 20px;cursor:pointer}
+#closed:hover{color:var(--gold);border-color:var(--gold)}
+
+/* page-level prototype chrome (NOT part of the panel) */
+.pagectl{max-width:960px;margin:0 0 12px;display:flex;align-items:center;gap:8px;
+ color:var(--dim);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase}
+.footnote{color:var(--dim);font-size:10.5px;margin-top:14px;line-height:1.55;max-width:960px}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div>
+
+<!-- ===== prototype controls (page level, not panel UI) ===== -->
+<div class="pagectl">
+ prototype controls —
+ <button class="key sm" id="k-fail" onclick="toggleFail()" title="make every action fail so the error UX is visible">SIM FAIL</button>
+ <button class="key sm" id="k-bad" onclick="toggleBad()" title="load the degraded-machine snapshot: every metric in its warn/fail state">SIM BAD DAY</button>
+ <button class="key sm" id="k-zfs" onclick="toggleZfs()" title="show the velox/ZFS storage capability set instead of btrfs">SIM ZFS</button>
+ <button class="key sm" onclick="reloadPanel()" title="replay the open-and-hydrate sequence">SIM RELOAD</button>
+</div>
+
+<div class="capsule" id="capsule">
+
+ <!-- faceplate -->
+ <div class="face">
+ <span class="lamp" id="facelamp"></span>
+ <span class="word">MAINTENANCE</span>
+ <span class="badge red" id="cvebadge">3 CVE</span>
+ <span class="badge ghost" id="attbadge">9 ATTN</span>
+ <span class="badge red" id="errbadge" style="display:none">0 FAILED</span>
+ <span class="spacer"></span>
+ <span class="unit">MNT·01</span>
+ <button class="xbtn" title="Close (Esc)" onclick="closePanel()">✕</button>
+ </div>
+
+ <!-- doctor control row -->
+ <div class="ctlrow">
+ <span class="lbl">Doctor</span>
+ <button class="key" id="k-clean" onclick="go('doctor')">CLEAN UP</button>
+ <button class="key" id="k-review" onclick="go('review')">REVIEW &amp; FIX</button>
+ <span class="spacer"></span>
+ <span class="lbl" style="margin-right:0" id="lastscan">last scan 07:41 · 44 checks · 0.9s</span>
+ </div>
+
+ <!-- updates quarantine -->
+ <div class="updbar" id="updbar">
+ <span class="q">Updates</span>
+ <span><b id="upd-p">47</b> pending</span>
+ <span class="cve" id="upd-c">3 CVE</span>
+ <span><b id="upd-a">5</b> AUR stale</span>
+ <span><b id="upd-f">0</b> firmware</span>
+ <span class="dimtx">mesa · linux-lts · systemd — guard-checked before any apply</span>
+ <span class="spacer"></span>
+ <span id="upd-rb"></span>
+ <button class="key sm" id="k-update" onclick="fire('sysupdate')">UPDATE</button>
+ <button class="key sm" id="k-topgrade" onclick="fire('topgrade')">TOPGRADE</button>
+ </div>
+
+ <!-- persistent selector -->
+ <div class="selector" id="selector"></div>
+
+ <!-- swapped subpanel -->
+ <div class="sub" id="sub"></div>
+
+ <!-- results wall: appears on first action; toggle / copy / close -->
+ <div class="rwall" id="rwall" style="display:none">
+ <div class="rhead">
+ <span class="engrave" style="flex:1">Results<span class="cnt" id="rcount">· 0</span></span>
+ <span class="keys">
+ <button class="key sm" id="rtoggle" onclick="toggleWall()">HIDE</button>
+ <button class="key sm" onclick="copyWall()">COPY</button>
+ </span>
+ </div>
+ <div class="rbody" id="rbody"></div>
+ </div>
+
+ <div id="toast"></div>
+</div>
+
+<div id="closed" onclick="openPanel()">MNT·01 closed — click to reopen (waybar: click the maint tag)</div>
+
+<div class="footnote">
+ Prototype notes (not part of the panel): selector stays put — the gold/slate tile is the selection; the
+ subpanel below swaps. ✕ or Esc closes (chip reopens; reopening replays the hydrate sequence). CLEAN UP
+ streams into the subpanel and mutates real state; REVIEW &amp; FIX lists every Confirm lever, destructive
+ ones arm-then-fire. Metrics without a determinate lever are read-only telemetry (agent/workflow assistance
+ is vLater). SIM FAIL makes every action fail (red lamp, error line, RETRY key, FAILED badge); SIM BAD DAY
+ loads a degraded-machine snapshot so every metric shows its warn/fail face, doctor or not; SIM RELOAD
+ replays the open: tiles pulse amber per tier (local reads land first, process probes next, package scans
+ last) and settle individually — cached values would fill the board instantly in the real build, with lamps
+ confirming freshness as probes return.
+</div>
+</div>
+
+<script>
+/* ================= state snapshots ================= */
+function GOOD(){ return {
+ cache:{v:8.8, st:'warn'}, scrub:{v:34, st:'warn'}, fstrim:{v:'OFF', st:'fail'},
+ disk:{v:69, st:'ok'}, smart:{v:'PASSED', st:'ok'}, wear:{v:0, st:'ok'},
+ deverr:{v:'0 / 0', st:'ok'}, unalloc:{v:'118', st:'ok'}, integ:{v:'clean', st:'ok'},
+ deeptrim:{v:'keep 3', st:'ok'}, smartlast:'passed 2026-07-01', devrows:[],
+ dtop:[['~/videos','620 GB'],['~/music','240 GB'],['~/pictures','180 GB'],['/var/lib/docker','38 GB'],['~/.cache','22 GB']],
+ orphans:{v:13, st:'warn'}, pacnew:{v:2, st:'warn'}, keyring:{v:12, st:'ok'}, reboot:{v:'NO', st:'ok'},
+ orphlist:[['rust','1.2 GB'],['electron28','210 MB'],['python-build','84 MB'],['go-tools','61 MB'],
+ ['libplacebo','44 MB'],['ocaml','38 MB'],['ruby-rake','21 MB'],['libfoo','12 MB'],['perl-clone','9 MB'],
+ ['xcb-util-old','7 MB'],['libdazzle','6 MB'],['gtest','5 MB'],['orc-old','4 MB']],
+ pacnews:[{f:'/etc/pacman.d/mirrorlist.pacnew', safe:true, why:'reflector-managed'},
+ {f:'/etc/locale.gen.pacnew', safe:false, why:'needs merge'}],
+ cves:[['openssh','CVE-2026-2892','high'],['mesa','CVE-2026-1104','medium'],['libxml2','CVE-2026-0517','low']],
+ aurnames:'zoom · spotify · ttf-berkeley-mono +2', fwnames:'',
+ failed:{v:1, st:'warn'}, timers:{v:'4/5', st:'warn'}, sysrun:{v:'running', st:'ok'}, taint:{v:0, st:'ok'},
+ funits:[{u:'systemd-oomd', since:'09:12', info:'exit 217'}],
+ cores:{v:18, st:'warn'}, journald:{v:1.2, st:'ok'}, applog:{v:'ok', st:'ok'},
+ jerr:{v:12, st:'ok'}, khw:{v:'clean', st:'ok'},
+ coregroups:[['chrome',12,'13:02'],['hyprland',3,'06-07'],['hypridle',2,'11:40'],['dunst',1,'10:05']],
+ khwev:[],
+ jgroups:[
+ {id:'bluetoothd', msg:'Hands-Free Voice gateway connect failed', n:26, first:'07:02', last:'13:58', unit:'bluetooth.service'},
+ {id:'pixman', msg:'_pixman_log_error: BadDrawable', n:140, first:'07:02', last:'13:59', unit:null},
+ {id:'xkbcomp', msg:'Errors from xkbcomp are not fatal', n:8, first:'07:02', last:'12:10', unit:null},
+ {id:'insync', msg:'QSocketNotifier: invalid socket', n:6, first:'08:15', last:'13:20', unit:null},
+ {id:'gammastep', msg:'Wayland connection experienced a fatal error', n:3, first:'07:02', last:'07:03', unit:'gammastep.service'},
+ {id:'hypridle', msg:'Failed to inhibit idle', n:2, first:'09:41', last:'11:12', unit:'hypridle.service'},
+ {id:'dunst', msg:'gdk_pixbuf assertion failure', n:1, first:'10:05', last:'10:05', unit:'dunst.service'},
+ ],
+ temps:{v:'61/54', st:'ok'}, mem:{v:104, st:'ok'}, unclean:{v:75, st:'warn'},
+ zram:{v:'16 GiB', st:'ok'}, throttle:{v:'NO', st:'ok'}, battery:{v:'n/a', st:'off'},
+ cpumode:{v:'power', st:'ok'}, batt:{v:94, st:'ok'}, blimit:{v:'none', st:'warn'},
+ memtop:[['chrome','4.2 GB'],['emacs','1.9 GB'],['insync','0.9 GB'],['waybar','0.3 GB'],['mpd','0.2 GB']],
+ boots:[['07-07 07:01','clean'],['07-06 08:12','unclean'],['07-05 09:45','unclean'],['07-04 07:58','clean'],['07-03 08:30','unclean']],
+ pevents:[],
+ docker:{v:3, st:'warn'}, rsync:{v:'3h', st:'ok'}, dstopped:{v:2, st:'ok'},
+ cron:{v:'ok', st:'ok'}, libvirt:{v:'off', st:'off'},
+ ddf:[['images','14.2 GB','1.8 GB'],['containers','0.4 GB','0.1 GB'],['volumes','6.8 GB','0.3 GB'],['build cache','0.8 GB','0.8 GB']],
+ ctrs:[{name:'winvm', when:'2d ago', code:0},{name:'deepsat-pg', when:'6h ago', code:0}],
+ snapper:{v:42, st:'ok'}, stimer:{v:'active', st:'ok'},
+ snaptypes:{timeline:31, single:8, prepost:3, oldsingle:'2026-06-12'},
+ oldest:{v:8, st:'ok'}, retention:{v:'H6 D7 W2 M2 Y0', st:'ok'},
+ fw:{v:'active', st:'ok'}, listen:{v:0, st:'ok'}, ts:{v:'4/4', st:'ok'}, tsdown:[],
+ lsn:[
+ {proc:'sshd', port:22, bind:'0.0.0.0', unit:'sshd'},
+ {proc:'mpd', port:6600, bind:'127.0.0.1', unit:'mpd'},
+ {proc:'tailscaled', port:41641, bind:'0.0.0.0', unit:'tailscaled'},
+ ],
+ f2b:{v:0, st:'ok'}, ntp:{v:'synced', st:'ok'}, dns:{v:'ok', st:'ok'},
+ tgage:{v:9, st:'warn'},
+ upd:{p:47, c:3, a:5, f:0},
+ zcap:{v:62, st:'ok'}, zfrag:{v:31, st:'ok'}, ztrim:{v:'OFF', st:'fail'},
+ zhealth:{v:'ONLINE', st:'ok'}, zerr:{v:'0 / 0 / 0', st:'ok'},
+};}
+function BAD(){ return {
+ cache:{v:17, st:'warn'}, scrub:{v:74, st:'fail'}, fstrim:{v:'OFF', st:'fail'},
+ disk:{v:92, st:'fail'}, smart:{v:'FAILING', st:'fail'}, wear:{v:91, st:'warn'},
+ deverr:{v:'14 / 2', st:'fail'}, unalloc:{v:'9', st:'warn'}, integ:{v:'3 modified', st:'warn'},
+ deeptrim:{v:'never run', st:'warn'}, smartlast:'failed 2026-07-06 — reallocated sectors',
+ devrows:[['nvme0n1','12 read · 2 write · 3 corruption'],['nvme1n1','2 read · 0 write · 1 corruption']],
+ dtop:[['~/videos','1.4 TB'],['/var/lib/docker','160 GB'],['~/music','240 GB'],['~/.cache','96 GB'],['~/downloads','88 GB']],
+ orphans:{v:51, st:'warn'}, pacnew:{v:7, st:'warn'}, keyring:{v:94, st:'warn'}, reboot:{v:'YES', st:'warn'},
+ orphlist:[['rust','1.2 GB'],['cuda-11','2.8 GB'],['electron28','210 MB'],['electron25','195 MB'],
+ ['python-build','84 MB'],['go-tools','61 MB'],['libplacebo','44 MB'],['ocaml','38 MB'],
+ ['texlive-extra','1.1 GB'],['ruby-rake','21 MB'],['libfoo','12 MB'],['perl-clone','9 MB']],
+ pacnews:[{f:'/etc/pacman.d/mirrorlist.pacnew', safe:true, why:'reflector-managed'},
+ {f:'/etc/sudoers.pacnew', safe:false, why:'needs merge — review carefully'},
+ {f:'/etc/ssh/sshd_config.pacnew', safe:false, why:'needs merge'},
+ {f:'/etc/locale.gen.pacnew', safe:false, why:'needs merge'}],
+ cves:[['openssh','CVE-2026-2892','high'],['sudo','CVE-2026-3011','high'],['mesa','CVE-2026-1104','medium'],
+ ['curl','CVE-2026-2440','medium'],['libxml2','CVE-2026-0517','low']],
+ aurnames:'zoom · spotify · ttf-berkeley-mono · paru-git +8', fwnames:'UEFI 3.07→3.09 · NVMe fw',
+ failed:{v:4, st:'fail'}, timers:{v:'2/5', st:'fail'}, sysrun:{v:'degraded', st:'fail'}, taint:{v:'W', st:'warn'},
+ funits:[{u:'systemd-oomd', since:'09:12', info:'exit 217'},
+ {u:'fail2ban', since:'07:44', info:'exit 1 — bad jail'},
+ {u:'cronie', since:'07:02', info:'exit 203'},
+ {u:'smartd', since:'07:02', info:'exit 1'}],
+ cores:{v:112, st:'fail'}, journald:{v:3.8, st:'warn'}, applog:{v:'stale', st:'warn'},
+ jerr:{v:214, st:'warn'}, khw:{v:'MCE + I/O', st:'fail'},
+ coregroups:[['chrome',61,'13:41'],['hyprland',23,'12:15'],['electron',17,'11:03'],['dunst',11,'13:10']],
+ khwev:[['12:44','MCE: CPU 3 bank 5 — corrected'],['11:02','nvme0n1: I/O error, sector 88121'],['10:58','thermal: package throttle 91°C']],
+ jgroups:[
+ {id:'bluetoothd', msg:'Hands-Free Voice gateway connect failed', n:31, first:'07:02', last:'13:58', unit:'bluetooth.service'},
+ {id:'pixman', msg:'_pixman_log_error: BadDrawable', n:180, first:'07:02', last:'13:59', unit:null},
+ {id:'xkbcomp', msg:'Errors from xkbcomp are not fatal', n:9, first:'07:02', last:'12:10', unit:null},
+ {id:'systemd-oomd', msg:'Failed to acquire memory pressure info', n:88, first:'07:05', last:'13:55', unit:'systemd-oomd.service'},
+ {id:'wpa_supplicant', msg:'bgscan simple: Failed to enable signal monitoring', n:61, first:'07:02', last:'13:59', unit:'wpa_supplicant.service'},
+ {id:'hyprland', msg:'GLES2 framebuffer incomplete', n:31, first:'08:11', last:'13:40', unit:null},
+ {id:'rsyncshot', msg:'rsync error: partial transfer (code 23)', n:23, first:'02:30', last:'13:30', unit:'rsyncshot.service'},
+ {id:'dunst', msg:'gdk_pixbuf assertion failure', n:11, first:'07:20', last:'13:10', unit:'dunst.service'},
+ ],
+ temps:{v:'91/78', st:'fail'}, mem:{v:6, st:'warn'}, unclean:{v:82, st:'warn'},
+ zram:{v:'none', st:'warn'}, throttle:{v:'YES', st:'fail'}, battery:{v:'n/a', st:'off'},
+ cpumode:{v:'perf', st:'ok'}, batt:{v:71, st:'warn'}, blimit:{v:'none', st:'warn'},
+ memtop:[['chrome','38.4 GB'],['java (deepsat)','22.1 GB'],['emacs','2.1 GB'],['Hyprland','1.9 GB'],['insync','1.4 GB'],['dockerd','1.1 GB']],
+ boots:[['07-07 07:05','unclean'],['07-06 21:12','unclean'],['07-06 08:12','unclean'],['07-05 09:45','clean'],['07-04 07:58','unclean']],
+ pevents:[['13:41','OOM killed chrome (38 GB)'],['12:20','thermal throttle 91°C · 340 s'],['09:15','OOM killed java (22 GB)']],
+ docker:{v:22, st:'warn'}, rsync:{v:'52h', st:'fail'}, dstopped:{v:5, st:'warn'},
+ cron:{v:'down', st:'fail'}, libvirt:{v:'off', st:'off'},
+ ddf:[['images','38.1 GB','18.2 GB'],['containers','1.2 GB','0.4 GB'],['volumes','9.8 GB','2.1 GB'],['build cache','1.3 GB','1.3 GB']],
+ ctrs:[{name:'winvm', when:'2d ago', code:0},{name:'deepsat-pg', when:'6h ago', code:0},
+ {name:'jellyfin', when:'2h ago', code:137},{name:'pihole', when:'9h ago', code:1},
+ {name:'registry', when:'3d ago', code:0}],
+ snapper:{v:214, st:'warn'}, stimer:{v:'inactive', st:'fail'},
+ snaptypes:{timeline:62, single:140, prepost:12, oldsingle:'2026-01-03'},
+ oldest:{v:194, st:'fail'}, retention:{v:'H6 D7 W2 M10 Y2', st:'warn'},
+ fw:{v:'inactive', st:'fail'}, listen:{v:9, st:'warn'}, ts:{v:'1/4', st:'fail'},
+ tsdown:['truenas','worker','cjennings'],
+ lsn:[
+ {proc:'sshd', port:22, bind:'0.0.0.0', unit:'sshd'},
+ {proc:'mpd', port:6600, bind:'127.0.0.1', unit:'mpd'},
+ {proc:'tailscaled', port:41641, bind:'0.0.0.0', unit:'tailscaled'},
+ {proc:'python', port:8000, bind:'0.0.0.0', unit:null},
+ {proc:'ncat', port:4444, bind:'0.0.0.0', unit:null},
+ {proc:'smbd', port:445, bind:'0.0.0.0', unit:'smb'},
+ {proc:'node', port:3000, bind:'127.0.0.1', unit:null},
+ {proc:'postgres', port:5432, bind:'127.0.0.1', unit:'postgresql'},
+ {proc:'cupsd', port:631, bind:'127.0.0.1', unit:'cups'},
+ ],
+ f2b:{v:'down', st:'fail'}, ntp:{v:'unsynced', st:'warn'}, dns:{v:'FAIL', st:'fail'},
+ tgage:{v:34, st:'fail'},
+ upd:{p:214, c:9, a:12, f:2},
+ zcap:{v:91, st:'fail'}, zfrag:{v:68, st:'warn'}, ztrim:{v:'OFF', st:'fail'},
+ zhealth:{v:'DEGRADED', st:'fail'}, zerr:{v:'3 / 0 / 1', st:'fail'},
+};}
+let S = GOOD();
+
+const CATS = [
+ {id:'storage', name:'STORAGE', ids:['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']},
+ {id:'packages', name:'PACKAGES', ids:['orphans','pacnew','keyring','reboot','tgage']},
+ {id:'systemd', name:'SYSTEMD', ids:['failed','timers','sysrun','taint']},
+ {id:'logs', name:'LOGS', ids:['cores','journald','applog','jerr','khw']},
+ {id:'power', name:'MEM·PWR', ids:['temps','mem','unclean','zram','throttle','battery']},
+ {id:'services', name:'SERVICES', ids:['docker','rsync','dstopped','cron','libvirt']},
+ {id:'snapshots',name:'SNAPSHOTS', ids:['snapper','stimer','oldest','retention']},
+ {id:'network', name:'NETWORK', ids:['fw','listen','ts','f2b','ntp','dns']},
+];
+/* diagnostic (no lever here) set — drives the faceplate verdict lamp */
+const DIAG = ['disk','smart','wear','deverr','unalloc','integ','sysrun','taint','jerr','khw',
+ 'temps','mem','zram','throttle','battery','listen','dstopped','libvirt','oldest','retention',
+ 'failed','unclean','rsync','dns','zcap','zfrag','zhealth','zerr','batt'];
+
+let view = 'cat:storage';
+let armed = null;
+let doctorTimer = null;
+let failMode = false;
+let badMode = false;
+const ERR = {};
+const loaded = new Set(); // categories whose probes have landed
+let loadTimers = [];
+
+/* ================= helpers ================= */
+const $ = id => document.getElementById(id);
+const BTRFS_IDS = ['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim'];
+const ZFS_IDS = ['zcap','cache','scrub','ztrim','smart','wear','zhealth','zerr','zfrag','integ','deeptrim'];
+const PWR_RATIO = ['temps','mem','unclean','zram','throttle','battery','cpumode'];
+const PWR_VELOX = ['temps','mem','unclean','zram','throttle','batt','blimit','cpumode'];
+function catIds(cat){
+ if(cat.id==='storage') return zfsMode?ZFS_IDS:BTRFS_IDS;
+ if(cat.id==='power') return zfsMode?PWR_VELOX:PWR_RATIO;
+ return cat.ids;
+}
+/* fixable/watch split per category — sets expectations before a single cell is read */
+function fixwatch(cid){
+ switch(cid){
+ case 'storage': return zfsMode?[7,4]:[8,2];
+ case 'packages': return [5,0];
+ case 'systemd': return [2,2];
+ case 'logs': return [4,1];
+ case 'power': return zfsMode?[3,5]:[2,5];
+ case 'services': return [4,1];
+ case 'snapshots':return [3,1];
+ case 'network': return [6,0];
+ }
+}
+function counts(cat){
+ let attn=0, ok=0, crit=false;
+ catIds(cat).forEach(i=>{ const st=S[i].st;
+ if(st==='warn'){attn++} else if(st==='fail'){attn++;crit=true} else ok++; });
+ return {attn, ok, crit};
+}
+function lampCls(st){ return st==='ok'?'':st==='warn'?'gold':st==='fail'?'red':'off'; }
+function toast(msg, err){
+ const t=document.createElement('div'); t.className='toastw'+(err?' err':''); t.textContent=msg;
+ $('toast').appendChild(t); setTimeout(()=>t.remove(), 2600);
+}
+
+/* ---- prototype toggles ---- */
+function toggleFail(){
+ failMode = !failMode;
+ const k=$('k-fail'); k.classList.toggle('red', failMode);
+ k.textContent = failMode ? 'SIM FAIL ON' : 'SIM FAIL';
+ toast(failMode ? 'failure simulation ON — every action will fail' : 'failure simulation off', failMode);
+}
+function toggleBad(){
+ badMode = !badMode;
+ const k=$('k-bad'); k.classList.toggle('red', badMode);
+ k.textContent = badMode ? 'BAD DAY ON' : 'SIM BAD DAY';
+ S = badMode ? BAD() : GOOD();
+ Object.keys(ERR).forEach(k2=>delete ERR[k2]);
+ render();
+ toast(badMode ? 'degraded snapshot loaded — every metric at its warn/fail face' : 'healthy snapshot restored', badMode);
+}
+/* ---- journal noise list: shipped defaults + user marks (two layers) ---- */
+const NOISE = [
+ {id:'bluetoothd', pat:'Hands-Free Voice gateway connect failed', date:'2026-02-27', shipped:true, disabled:false},
+ {id:'pixman', pat:'_pixman_log_error: BadDrawable', date:'2026-02-27', shipped:true, disabled:false},
+ {id:'xkbcomp', pat:'Errors from xkbcomp are not fatal', date:'2026-03-24', shipped:true, disabled:false},
+];
+const nkey = g => g.id+'|'+(g.pat||g.msg);
+function noiseFor(g){ return NOISE.find(n=>!n.disabled && n.id===g.id && n.pat===g.msg); }
+function jSignal(){ return S.jgroups.filter(g=>!noiseFor(g)).sort((a,b)=>b.n-a.n); }
+function jKnown(){ return S.jgroups.filter(g=>noiseFor(g)); }
+function recalcJerr(){
+ const sum = jSignal().reduce((s,g)=>s+g.n,0);
+ S.jerr = {v:sum, st: sum===0?'ok': sum<=25?'ok':'warn'};
+}
+function markKnown(key){
+ const g = S.jgroups.find(x=>nkey(x)===key); if(!g) return;
+ if(armed!=='mark:'+key){ armed='mark:'+key; render();
+ toast('mark known: '+g.id+' · "'+g.msg.slice(0,40)+'…" — press again', true); return; }
+ armed=null;
+ NOISE.push({id:g.id, pat:g.msg, date:today(), shipped:false, disabled:false});
+ resAdd('mark known', g.id+' · '+g.msg.slice(0,50)+' ('+g.n+' this boot)', 'done');
+ render();
+}
+function unmarkKnown(key){
+ const g = S.jgroups.find(x=>nkey(x)===key); if(!g) return;
+ const n = noiseFor(g); if(!n) return;
+ if(n.shipped) n.disabled = true;
+ else NOISE.splice(NOISE.indexOf(n),1);
+ resAdd('unmark', g.id+' — back in signal'+(n.shipped?' (shipped default disabled)':''), 'done');
+ render();
+}
+function clearMarks(){
+ if(armed!=='clearmarks'){ armed='clearmarks'; render();
+ toast('clear all user marks + re-enable shipped defaults — press again', true); return; }
+ armed=null;
+ const removed = NOISE.filter(n=>!n.shipped).length;
+ for(let i=NOISE.length-1;i>=0;i--){ if(!NOISE[i].shipped) NOISE.splice(i,1); else NOISE[i].disabled=false; }
+ resAdd('clear marks', removed+' user mark(s) removed · shipped defaults restored', 'done');
+ render();
+}
+
+/* ---- listeners: expected-list curation + guarded per-socket remedies ---- */
+const LEXPECT = [
+ {proc:'sshd', port:22, date:'2026-02-27', shipped:true, disabled:false},
+ {proc:'mpd', port:6600, date:'2026-02-27', shipped:true, disabled:false},
+ {proc:'tailscaled', port:41641, date:'2026-02-27', shipped:true, disabled:false},
+];
+const lkey = r => r.proc+'|'+r.port;
+const isPub = b => b==='0.0.0.0'||b==='::';
+function lexpFor(r){ return LEXPECT.find(p=>!p.disabled && p.proc===r.proc && p.port===r.port); }
+function lsnSignal(){ return S.lsn.filter(r=>!lexpFor(r)); }
+function recalcListen(){
+ const sig = lsnSignal();
+ const pub = sig.filter(r=>isPub(r.bind)).length;
+ S.listen = {v:sig.length, st: sig.length===0?'ok': pub?'fail':'warn'};
+}
+function markExpected(key){
+ const r = S.lsn.find(x=>lkey(x)===key); if(!r) return;
+ if(armed!=='lexp:'+key){ armed='lexp:'+key; render();
+ toast('mark expected: '+r.proc+' :'+r.port+' — press again', true); return; }
+ armed=null;
+ LEXPECT.push({proc:r.proc, port:r.port, date:today(), shipped:false, disabled:false});
+ resAdd('mark expected', r.proc+' :'+r.port+' ('+r.bind+')', 'done');
+ render();
+}
+function unmarkExpected(key){
+ const p = LEXPECT.find(x=>!x.disabled && x.proc+'|'+x.port===key); if(!p) return;
+ if(p.shipped) p.disabled = true; else LEXPECT.splice(LEXPECT.indexOf(p),1);
+ resAdd('unmark expected', p.proc+' :'+p.port+' — back in signal'+(p.shipped?' (shipped default disabled)':''), 'done');
+ render();
+}
+function clearLsn(){
+ if(armed!=='clearlsn'){ armed='clearlsn'; render();
+ toast('clear user expected-marks + re-enable shipped — press again', true); return; }
+ armed=null;
+ const removed = LEXPECT.filter(p=>!p.shipped).length;
+ for(let i=LEXPECT.length-1;i>=0;i--){ if(!LEXPECT[i].shipped) LEXPECT.splice(i,1); else LEXPECT[i].disabled=false; }
+ resAdd('clear expected', removed+' user mark(s) removed · shipped defaults restored', 'done');
+ render();
+}
+function lsnFix(key){
+ const r = S.lsn.find(x=>lkey(x)===key); if(!r) return;
+ const verb = r.unit ? 'systemctl stop '+r.unit : 'SIGTERM '+r.proc;
+ if(armed!=='lsnfix:'+key){ armed='lsnfix:'+key; render();
+ toast((r.unit?'STOP ':'KILL ')+r.proc+' :'+r.port+' — '+verb+' · press again', true); return; }
+ armed=null;
+ const e = resAdd(r.unit?'stop unit':'kill listener', verb+' …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail', r.unit?'systemctl: unit busy — stop refused':r.proc+' respawned — supervised? check its unit'); }
+ else { S.lsn.splice(S.lsn.indexOf(r),1);
+ resDone(e,'done', r.proc+' stopped — port '+r.port+' closed'); }
+ render();
+ }, 800);
+}
+
+/* ---- rotary sub-section selector state ---- */
+const SUBSEC = {packages:'orphans', logs:'signal', services:'containers'};
+const DECKDEFS = {};
+function setSubsec(cat,id){ SUBSEC[cat]=id; armed=null; render(); }
+function cycleSubsec(cat){
+ const defs = DECKDEFS[cat]; if(!defs||!defs.length) return;
+ const i = defs.findIndex(d=>d.id===SUBSEC[cat]);
+ setSubsec(cat, defs[(i+1)%defs.length].id);
+}
+function deck(cat, defs){
+ DECKDEFS[cat] = defs;
+ const cur = SUBSEC[cat];
+ const i = Math.max(0, defs.findIndex(d=>d.id===cur));
+ const ang = defs.length>1 ? -50 + i*(100/(defs.length-1)) : 0;
+ const bands = defs.map(d=>`<span class="band ${d.id===cur?'on':''}" onclick="setSubsec('${cat}','${d.id}')">
+ <span class="lamp ${d.lamp||''}"></span>
+ <span class="bl">${d.label}${d.count!==undefined&&d.count!==''?' · '+d.count:''}</span>
+ <span class="ul"></span></span>`).join('');
+ return `<div class="deck">
+ <span class="knob" onclick="cycleSubsec('${cat}')" title="cycle sections">
+ <span class="ind" style="transform:rotate(${ang}deg)"></span></span>
+ <span class="bands">${bands}</span></div>`;
+}
+
+/* ---- orphans: KEEP curation (the rust lesson) + per-package REMOVE ---- */
+const OKEEP = []; // user layer: packages marked intentional
+const okept = name => OKEEP.some(k=>k.name===name);
+function orphKeep(name){
+ if(okept(name)) return;
+ OKEEP.push({name, date:today()});
+ S.orphans.v = Math.max(0, S.orphans.v-1);
+ if(S.orphans.v===0) S.orphans.st='ok';
+ resAdd('keep', name+' marked intentional — excluded from removal', 'done');
+ render();
+}
+function orphUnkeep(name){
+ const k = OKEEP.find(x=>x.name===name); if(!k) return;
+ OKEEP.splice(OKEEP.indexOf(k),1);
+ S.orphans.v++; S.orphans.st='warn';
+ resAdd('unkeep', name+' — back in the orphan set', 'done');
+ render();
+}
+function orphRemove(name){
+ const row = S.orphlist.find(r=>r[0]===name); if(!row) return;
+ if(armed!=='orem:'+name){ armed='orem:'+name; render();
+ toast('pacman -Rns '+name+' ('+row[1]+') — press again', true); return; }
+ armed=null;
+ const e = resAdd('orphan remove', 'pacman -Rns '+name+' …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail','pacman: breaking dependency — '+name+' required by another package'); }
+ else { S.orphlist.splice(S.orphlist.indexOf(row),1);
+ S.orphans.v = Math.max(0, S.orphans.v-1);
+ if(S.orphans.v===0) S.orphans.st='ok';
+ resDone(e,'done',name+' removed — '+row[1]+' freed'); }
+ render();
+ }, 900);
+}
+
+/* ---- pacnew: per-file DELETE (safe) / MERGE (terminal delegation) ---- */
+function pacnewAct(file){
+ const p = S.pacnews.find(x=>x.f===file); if(!p) return;
+ const e = resAdd(p.safe?'pacnew delete':'pacnew merge',
+ p.safe ? 'rm '+file+' — '+p.why+' …' : 'opening diff in terminal — '+file+' …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail', p.safe?'rm: permission denied':'diff tool not found'); }
+ else { S.pacnews.splice(S.pacnews.indexOf(p),1);
+ S.pacnew.v = Math.max(0, S.pacnew.v-1);
+ if(S.pacnew.v===0) S.pacnew.st='ok';
+ resDone(e,'done', p.safe ? file+' deleted — current config is authoritative'
+ : file+' merged in terminal · pacnew deleted'); }
+ render();
+ }, 700);
+}
+
+/* ---- failed units: per-unit roster remedies ---- */
+function funitAct(u, reset){
+ const r = S.funits.find(x=>x.u===u); if(!r) return;
+ const e = resAdd(reset?'reset-failed':'unit restart',
+ (reset?'systemctl reset-failed ':'systemctl restart ')+u+' …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail', u+': start request repeated too quickly'); }
+ else { S.funits.splice(S.funits.indexOf(r),1);
+ S.failed.v = Math.max(0, S.failed.v-1);
+ if(S.failed.v===0){ S.failed.st='ok'; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; }
+ resDone(e,'done', u+(reset?' cleared from failed list':' restarted — running')); }
+ render();
+ }, 800);
+}
+
+/* ---- containers: expected-list curation + per-container START ---- */
+const CEXPECT = [
+ {name:'winvm', date:'2026-02-27', shipped:true, disabled:false},
+ {name:'deepsat-pg', date:'2026-06-20', shipped:false, disabled:false},
+];
+const cexp = name => CEXPECT.some(p=>!p.disabled && p.name===name);
+function recalcCtrs(){
+ const unexp = S.ctrs.filter(c=>!cexp(c.name)).length;
+ S.dstopped = {v:S.ctrs.length, st: unexp?'warn':'ok'};
+}
+function ctrStartOne(name){
+ const c = S.ctrs.find(x=>x.name===name); if(!c) return;
+ const e = resAdd('container start', 'docker start '+name+' …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail', name+' exits immediately (code 1) — see docker logs'); }
+ else { S.ctrs.splice(S.ctrs.indexOf(c),1);
+ resDone(e,'done', name+' running'); }
+ render();
+ }, 800);
+}
+function ctrMark(name){
+ if(armed!=='cexp:'+name){ armed='cexp:'+name; render();
+ toast('mark expected: '+name+' stays stopped by design — press again', true); return; }
+ armed=null;
+ CEXPECT.push({name, date:today(), shipped:false, disabled:false});
+ resAdd('mark expected', name+' — expected-stopped (on-demand)', 'done');
+ render();
+}
+function ctrUnmark(name){
+ const p = CEXPECT.find(x=>!x.disabled && x.name===name); if(!p) return;
+ if(p.shipped) p.disabled=true; else CEXPECT.splice(CEXPECT.indexOf(p),1);
+ resAdd('unmark expected', name+' — back in signal', 'done');
+ render();
+}
+
+/* ---- snapshots: stale manual (single) snapshot delete ---- */
+function snapDelStale(){
+ if(armed!=='snapstale'){ armed='snapstale'; render();
+ toast('delete '+(S.snaptypes.single-2)+' stale manual snapshots (keep newest 2) — press again', true); return; }
+ armed=null;
+ const n = S.snaptypes.single-2;
+ const e = resAdd('stale snapshots', 'snapper delete ('+n+' singles) …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail','snapper: config busy — snapshot in progress'); }
+ else { S.snaptypes.single=2; S.snaptypes.oldsingle='recent';
+ S.snapper.v = S.snaptypes.timeline+S.snaptypes.single+S.snaptypes.prepost;
+ if(S.snapper.v<100) S.snapper.st='ok';
+ resDone(e,'done', n+' manual snapshots deleted — space freed on next cleanup'); }
+ render();
+ }, 900);
+}
+
+/* kill process: arm-to-fire SIGTERM on a top-memory item; session-critical names protected */
+const PROTECTED = ['systemd','Hyprland','maintenance-panel'];
+function killProc(name){
+ const row = S.memtop.find(r=>r[0]===name); if(!row) return;
+ if(armed!=='kill:'+name){ armed='kill:'+name; render();
+ toast('KILL '+name+' ('+row[1]+') — SIGTERM · press again', true); return; }
+ armed=null;
+ const e = resAdd('kill process', 'SIGTERM '+name+' ('+row[1]+') …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail',name+' ignored SIGTERM — state D (uninterruptible IO)'); }
+ else {
+ S.memtop.splice(S.memtop.indexOf(row),1);
+ const gb = parseFloat(row[1])||0;
+ S.mem.v = Math.min(125, Math.round((S.mem.v+gb)*10)/10);
+ if(S.mem.v>16 && S.mem.st!=='ok') S.mem={v:S.mem.v, st:'ok'};
+ resDone(e,'done',name+' exited cleanly · '+row[1]+' freed');
+ }
+ render();
+ }, 700);
+}
+
+/* cpu mode: free selector — set the active EPP mode to anything */
+const MODE_LABELS = {perf:'performance', bal:'balance_performance', power:'power'};
+function setMode(m){
+ if(S.cpumode.v===m) return;
+ const e = resAdd('cpu mode', 'writing EPP hint: '+MODE_LABELS[m]+' …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail','sysfs: energy_performance_preference write refused'); }
+ else { S.cpumode.v=m; resDone(e,'done','EPP → '+MODE_LABELS[m]+' (all cores)'); }
+ render();
+ }, 500);
+}
+
+let zfsMode = false;
+function toggleZfs(){
+ zfsMode = !zfsMode;
+ const k=$('k-zfs'); k.classList.toggle('on', zfsMode);
+ k.textContent = zfsMode ? 'ZFS ON' : 'SIM ZFS';
+ Object.keys(ERR).forEach(k2=>{ if(ACT2CAT[k2]==='storage') delete ERR[k2]; });
+ render();
+ toast(zfsMode ? 'storage shows the velox/ZFS capability set' : 'storage shows the ratio/btrfs capability set');
+}
+function reloadPanel(){ openPanel(); }
+
+/* ---- actions ---- */
+/* ---- results wall ---- */
+const RES = [];
+let wallHidden = false;
+const ANAMES = { clean_cache:'cache trim', scrub:'btrfs scrub', fstrim:'fstrim enable',
+ deeptrim:'deep trim', orphans:'orphan removal', pacnew:'pacnew merge', keyring:'keyring refresh',
+ timers:'timer enable', cores:'coredump clear', journald:'journal vacuum', applog:'app-log cleanup',
+ docker:'docker prune', snapper:'snapper cleanup', unit_restart:'unit restart',
+ f2b_fix:'fail2ban restart', cron_fix:'cronie start', ntp_fix:'chrony restart',
+ ts_fix:'tailscaled restart', stimer_fix:'snapshot timers', zram_fix:'zram enable',
+ netdoc:'net doctor', balance:'btrfs balance', reinstall:'pkg reinstall', rsyncnow:'backup run',
+ smarttest:'smart self-test', retention:'retention repair', reboot:'reboot',
+ sysupdate:'system update', topgrade:'topgrade', ztrim_fix:'zpool autotrim',
+ openjournal:'open journal', bat_limit:'charge limit',
+ ctr_start:'container start', fw_fix:'firewall enable' };
+function now(){ return new Date().toTimeString().slice(0,8); }
+function today(){ const d=new Date();
+ return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); }
+function resAdd(name, ev, st){
+ const e = {d:today(), t:now(), name, ev, st:st||'run'};
+ RES.push(e); renderWall(); return e;
+}
+function resDone(e, st, ev){ e.st=st; e.ev=ev; e.d=today(); e.t=now(); renderWall(); }
+function renderWall(){
+ const w = $('rwall');
+ if(!RES.length){ w.style.display='none'; return; }
+ w.style.display='block';
+ $('rcount').textContent = '· '+RES.length;
+ $('rtoggle').textContent = wallHidden ? 'SHOW' : 'HIDE';
+ const b = $('rbody');
+ b.style.display = wallHidden ? 'none' : 'block';
+ b.innerHTML = RES.map(e=>{
+ const lamp = e.st==='run'?'busy':e.st==='fail'?'red':'';
+ return `<div class="rstep"><span class="tm"><span class="dt">${e.d}</span>${e.t}</span><span class="lamp ${lamp}"></span>
+ <span class="rtxt" title="${e.name} — ${e.ev}"><b>${e.name}</b> <span class="ev ${e.st==='fail'?'evfail':''}">${e.ev}</span></span></div>`;
+ }).join('');
+ b.scrollTop = b.scrollHeight;
+}
+function toggleWall(){ wallHidden=!wallHidden; renderWall(); }
+function copyWall(){
+ const txt = RES.map(e=>`${e.d} ${e.t} ${e.st.toUpperCase().padEnd(4)} ${e.name} — ${e.ev}`).join('\n');
+ (navigator.clipboard ? navigator.clipboard.writeText(txt) : Promise.reject())
+ .then(()=>toast('copied '+RES.length+' result lines'))
+ .catch(()=>toast('clipboard unavailable', true));
+}
+
+function fire(act){
+ const a = ACTIONS[act];
+ // guard-armed updates: first press runs the guard check and arms; second press overrides
+ if(a.guardArm && armed!==act){
+ armed = act;
+ const e = resAdd(ANAMES[act]||act, a.busy||'checking guard…');
+ setTimeout(()=>{
+ resDone(e,'fail','guard: mesa in update set — press again to run anyway, or apply from a TTY');
+ render();
+ }, 600);
+ render();
+ return;
+ }
+ if(a.arm && armed!==act && !ERR[act]){ armed=act; render(); toast(a.armMsg||'press again to confirm', true); return; }
+ const overridden = a.guardArm && armed===act;
+ armed=null;
+ const e = resAdd(ANAMES[act]||act, (overridden && a.busy2) ? a.busy2 : (a.busy||'running…'));
+ setTimeout(()=>{
+ if(failMode){ ERR[act]=a.err||'exited 1'; resDone(e,'fail',ERR[act]); }
+ else { delete ERR[act]; a.run(); resDone(e,'done',a.doneMsg);
+ if(a.guardArm && S.reboot.v==='YES')
+ resAdd('reboot offer','kernel/mesa updated — REBOOT key armed on the updates strip','done');
+ }
+ render();
+ }, a.ms||800);
+}
+const ACTIONS = {
+ clean_cache:{ busy:'paccache -r / -ruk0 …', ms:1000, doneMsg:'cache trim — reclaimed 8.4 GB',
+ err:'paccache: /var/cache/pacman/pkg — permission denied (polkit dismissed)',
+ run(){ S.cache={v:0.4, st:'ok'}; } },
+ scrub:{ busy:'btrfs scrub started on / …', ms:1400, doneMsg:'scrub running — age resets on completion',
+ err:'btrfs: scrub already running on / — see dmesg',
+ run(){ S.scrub={v:0, st:'ok'}; } },
+ fstrim:{ busy:'systemctl enable --now fstrim.timer …', ms:900, doneMsg:'fstrim.timer enabled — weekly TRIM active',
+ err:'systemctl: unit fstrim.timer is masked',
+ run(){ S.fstrim={v:'ON', st:'ok'}; if(S.timers.st==='warn') S.timers={v:'5/5', st:'ok'}; } },
+ deeptrim:{ arm:true, armMsg:'keep only 1 version of every pkg — press again', busy:'paccache -rk1 …', ms:1100,
+ doneMsg:'deep trim — kept 1 version, reclaimed 3.1 GB more',
+ err:'paccache -rk1: exited 1 — cache dir busy (pacman lock held)',
+ run(){ S.deeptrim={v:'keep 1', st:'ok'}; if(S.cache.st==='ok') S.cache={v:0.2,st:'ok'}; } },
+ orphans:{ arm:true, armMsg:'remove all unkept orphans — press again', busy:'pacman -Rns (unkept set) …', ms:1200,
+ doneMsg:'orphans removed — kept packages skipped',
+ err:'pacman -Rns: breaking dependency — rust required by cargo-audit',
+ run(){ S.orphlist = S.orphlist.filter(r=>okept(r[0])); S.orphans={v:0, st:'ok'}; } },
+ pacnew:{ busy:'diffing .pacnew files …', ms:900, doneMsg:'.pacnew resolved — mirrorlist deleted, locale.gen merged',
+ err:'merge aborted — locale.gen.pacnew conflicts, needs manual merge',
+ run(){ S.pacnew={v:0, st:'ok'}; } },
+ keyring:{ busy:'pacman -Sy archlinux-keyring …', ms:900, doneMsg:'keyring refreshed',
+ err:'pacman -Sy: mirror timeout — no network?',
+ run(){ S.keyring={v:0, st:'ok'}; } },
+ timers:{ busy:'systemctl enable --now …', ms:800, doneMsg:'all 5 maintenance timers firing',
+ err:'systemctl: failed to enable — unit file not found',
+ run(){ S.timers={v:'5/5', st:'ok'}; if(S.fstrim.st==='fail') S.fstrim={v:'ON',st:'ok'}; } },
+ cores:{ busy:'coredumpctl clean (keep 3d) …', ms:800, doneMsg:'coredumps cleared',
+ err:'coredumpctl: permission denied — /var/lib/systemd/coredump',
+ run(){ S.cores={v:0, st:'ok'}; S.coregroups=[]; } },
+ journald:{ busy:'journalctl --vacuum-size=300M …', ms:800, doneMsg:'journal vacuumed — reclaimed 0.9 GB',
+ err:'journalctl: vacuum failed — rotation in progress, retry',
+ run(){ S.journald={v:0.3, st:'ok'}; } },
+ applog:{ busy:'log-cleanup …', ms:600, doneMsg:'app logs — nothing older than 7d',
+ err:'log-cleanup: exited 1 — ~/.local/var/log not writable',
+ run(){ S.applog={v:'ok', st:'ok'}; } },
+ docker:{ arm:true, armMsg:'docker prune tier 1+2 — press again', busy:'docker container/image prune …', ms:1200,
+ doneMsg:'docker pruned — reclaimed 2.8 GB',
+ err:'docker: cannot connect to daemon — is docker.service running?',
+ run(){ S.docker={v:0.2, st:'ok'}; S.ddf = S.ddf.map(r=>[r[0], r[1], '0 GB']); } },
+ snapper:{ arm:true, armMsg:'prune stale manual snapshots — press again', busy:'snapper cleanup …', ms:900,
+ doneMsg:'snapper — retention applied',
+ err:'snapper: config busy — snapshot in progress',
+ run(){ S.snapper={v:Math.min(S.snapper.v,31), st:'ok'}; } },
+ /* --- group A: service restarts / enables --- */
+ unit_restart:{ busy:'systemctl restart (all failed units) …', ms:900, doneMsg:'units restarted — running',
+ err:'systemd-oomd: start request repeated too quickly',
+ run(){ S.funits=[]; S.failed={v:0, st:'ok'}; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } },
+ f2b_fix:{ busy:'systemctl restart fail2ban …', ms:800, doneMsg:'fail2ban running — sshd jail active',
+ err:'fail2ban: failed to start — bad jail config',
+ run(){ S.f2b={v:0, st:'ok'}; } },
+ cron_fix:{ busy:'systemctl start cronie …', ms:700, doneMsg:'cronie running — entries live',
+ err:'cronie: unit masked',
+ run(){ S.cron={v:'ok', st:'ok'}; } },
+ ntp_fix:{ busy:'systemctl restart chronyd + makestep …', ms:800, doneMsg:'clock stepped — synchronized',
+ err:'chronyd: no reachable sources',
+ run(){ S.ntp={v:'synced', st:'ok'}; } },
+ ts_fix:{ busy:'systemctl restart tailscaled …', ms:900, doneMsg:'tailscaled up — peers reconnecting',
+ err:'tailscaled: bind: address already in use',
+ run(){ S.ts={v:'4/4', st:'ok'}; S.tsdown=[]; } },
+ stimer_fix:{ busy:'systemctl enable --now snapper timers …', ms:700, doneMsg:'snapshot timers firing',
+ err:'systemctl: unit not found',
+ run(){ S.stimer={v:'active', st:'ok'}; } },
+ zram_fix:{ busy:'re-applying zram-generator config …', ms:800, doneMsg:'zram active — 16 GiB zstd',
+ err:'zram-generator: module not loaded',
+ run(){ S.zram={v:'16 GiB', st:'ok'}; } },
+ netdoc:{ busy:'opening NET·01 …', ms:400, doneMsg:'delegated to the net panel doctor (NET·01)',
+ err:'net panel not running',
+ run(){} },
+ /* --- group B: deterministic maintenance ops --- */
+ balance:{ arm:true, armMsg:'btrfs balance is IO-heavy and slow — press again', busy:'btrfs balance -dusage=50 …', ms:1500,
+ doneMsg:'balance complete — unallocated recovered',
+ err:'balance cancelled — ENOSPC during relocation',
+ run(){ S.unalloc={v:'118', st:'ok'}; } },
+ reinstall:{ busy:'pacman -S (owning packages) …', ms:1100, doneMsg:'modified files restored — integrity clean',
+ err:'pacman: failed to commit transaction',
+ run(){ S.integ={v:'clean', st:'ok'}; } },
+ rsyncnow:{ busy:'rsyncshot manual run …', ms:1400, doneMsg:'backup complete — synced to truenas',
+ err:'rsync exit 23 — partial transfer (permissions)',
+ run(){ S.rsync={v:'now', st:'ok'}; } },
+ smarttest:{ busy:'smartctl -t short (both NVMe) …', ms:900, doneMsg:'short self-test started — results in ~2 min',
+ err:'smartctl: device busy',
+ run(){ S.smartlast='passed just now (short test)'; } },
+ retention:{ busy:'writing sane retention (M2 Y0) + cleanup …', ms:1000, doneMsg:'retention repaired — old chain pruned',
+ err:'snapperd: config locked',
+ run(){ S.retention={v:'H6 D7 W2 M2 Y0', st:'ok'}; S.oldest={v:8, st:'ok'}; S.snapper={v:Math.min(S.snapper.v,42), st:'ok'}; } },
+ /* --- group D: disruptive but determinate --- */
+ reboot:{ arm:true, armMsg:'reboot the machine — press again', busy:'rebooting … (simulated)', ms:1200,
+ doneMsg:'rebooted — new kernel running, taint cleared',
+ err:'logind: inhibitor lock held (backup in progress)',
+ run(){ S.reboot={v:'NO', st:'ok'}; S.taint={v:0, st:'ok'}; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } },
+ ctr_start:{ busy:'docker start (all unexpected exited) …', ms:1000,
+ doneMsg:'containers started — running again',
+ err:'docker: container exits immediately (code 1) — see docker logs',
+ run(){ S.ctrs = S.ctrs.filter(c=>cexp(c.name)); } },
+ fw_fix:{ busy:'ufw enable …', ms:800,
+ doneMsg:'firewall active — mosh rule loaded',
+ err:'ufw: iptables lock held — retry',
+ run(){ S.fw={v:'active', st:'ok'}; } },
+ bat_limit:{ busy:'writing charge_control_end_threshold=80 …', ms:700,
+ doneMsg:'charge limit set — 80% (battery longevity)',
+ err:'sysfs: permission denied',
+ run(){ S.blimit={v:'80%', st:'ok'}; } },
+ openjournal:{ busy:'spawning terminal: journalctl -p err -b …', ms:400,
+ doneMsg:'opened in terminal — journalctl -p err -b',
+ err:'no terminal available',
+ run(){} },
+ /* --- zfs (velox capability set) --- */
+ ztrim_fix:{ busy:'zpool set autotrim=on + zpool trim …', ms:1000,
+ doneMsg:'autotrim enabled — initial trim running',
+ err:'zpool: pool is busy (resilver in progress)',
+ run(){ S.ztrim={v:'ON', st:'ok'}; } },
+ /* --- updates (Confirm; guard arms instead of blocking — user decides) --- */
+ sysupdate:{ guardArm:true, busy:'checking update set against the live-update guard …',
+ busy2:'pacman -Syu … (guard overridden)', ms:1400,
+ doneMsg:'system updated — 47 packages · reboot recommended',
+ err:'pacman: failed to commit transaction (conflicting files)',
+ run(){ S.upd.p=0; S.upd.c=0; S.reboot={v:'YES', st:'warn'}; } },
+ topgrade:{ guardArm:true, busy:'guard check, then topgrade (--disable git) …',
+ busy2:'topgrade full run … (guard overridden)', ms:1800,
+ doneMsg:'topgrade complete — system + ecosystems · reboot recommended',
+ err:'topgrade: system step failed — pacman lock held',
+ run(){ S.upd.p=0; S.upd.c=0; S.upd.a=0; S.tgage={v:0, st:'ok'}; S.reboot={v:'YES', st:'warn'}; } },
+};
+
+/* ================= widget builders ================= */
+function lever(act, label, cls){
+ const a = armed===act;
+ if(ERR[act]) return `<button class="key sm red" onclick="event.stopPropagation();fire('${act}')">RETRY</button>`;
+ return `<button class="key sm ${cls||''} ${a?'armed':''}" onclick="event.stopPropagation();fire('${act}')">${a?label+'?':label}</button>`;
+}
+function errline(act){ return act && ERR[act] ? `<div class="errline">✗ ${ERR[act]}</div>` : ''; }
+function lampFor(id, act){ return (act && ERR[act]) ? 'red' : lampCls(S[id].st); }
+function lrow(id, name, extra, valOverride, act){
+ return `<div class="lrow"><span class="lamp ${lampFor(id,act)}"></span><span class="who">${name}</span>
+ <span class="what">${extra||''}${(act&&ERR[act])?'✗ failed':(valOverride!==undefined?valOverride:S[id].v)}</span></div>`;
+}
+function barCls(st){ return st==='ok'?'okc':'warn'; }
+function cellBar(id,cap,unit,pct,o){ o=o||{}; const m=S[id];
+ return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''}
+ <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div>
+ <div class="val"><span class="readout">${m.v}<small> ${unit||''}</small></span></div>
+ <div class="bar ${barCls(m.st)}">${o.tick?`<span class="tk" style="left:${o.tick}%"></span>`:''}<span style="width:${Math.min(100,pct)}%"></span></div>
+ <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; }
+function cellCount(id,cap,o){ o=o||{}; const m=S[id];
+ const col = m.st==='fail'?'style="color:var(--fail)"':(m.st==='warn'?'style="color:var(--amber)"':'');
+ return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''}
+ <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div>
+ <div class="val"><span class="readout" ${col}>${m.v}${o.unit?`<small>${o.unit}</small>`:''}</span>${o.badge||''}</div>
+ <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; }
+function cellRing(id,cap,pct,small,o){ o=o||{}; const m=S[id];
+ const rc = (o.act&&ERR[o.act])?'var(--fail)':m.st==='ok'?'var(--pass)':m.st==='fail'?'var(--fail)':'var(--amber)';
+ return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''}
+ <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div>
+ <div class="center"><span class="ring" style="--p:${Math.min(100,pct)};--rc:${rc}"><b>${m.v}</b><small>${small}</small></span></div>
+ <div class="sub2" style="text-align:center">${o.sub||''}</div>${errline(o.act)}</div>`; }
+function cellBadge(id,cap,o){ o=o||{}; const m=S[id];
+ const cls = m.st==='ok'?'pass':m.st==='fail'?'red':m.st==='warn'?'':'ghost';
+ return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''}
+ <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div>
+ <div class="val"><span class="badge ${cls}">${String(m.v).toUpperCase()}</span></div>
+ <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; }
+
+/* dynamic rosters */
+function timerRoster(){
+ const st = S.timers.st;
+ const rows = [
+ ['paccache.timer','weekly', true],
+ ['btrfs-scrub@-.timer','monthly', st!=='fail'],
+ ['fstrim.timer','weekly', S.fstrim.st==='ok'],
+ ['reflector.timer','weekly', st!=='fail'],
+ ['snapper-cleanup.timer','daily', true],
+ ];
+ return rows.map(([n,c,ok])=>`<div class="lrow"><span class="lamp ${ok?'':'red'}"></span>
+ <span class="who">${n}</span><span class="what">${ok?c+' · ok':'disabled'}</span></div>`).join('');
+}
+function backupRoster(){
+ if(S.rsync.st==='ok') return `
+ <div class="lrow"><span class="lamp"></span><span class="who">hourly</span><span class="what">3h ago · ok</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">daily 02:30</span><span class="what">11h ago · ok</span></div>`;
+ return `
+ <div class="lrow"><span class="lamp red"></span><span class="who">hourly</span><span class="what">52h ago · stale</span></div>
+ <div class="lrow"><span class="lamp red"></span><span class="who">daily 02:30</span><span class="what">rsync exit 23 · failed</span></div>`;
+}
+function peerRoster(){
+ const down = S.tsdown||[];
+ return ['truenas','velox','worker','cjennings'].map(p=>{
+ const d = down.includes(p);
+ return `<div class="lrow"><span class="lamp ${d?'red':''}"></span><span class="who">${p}</span>
+ <span class="what">${d?'offline':'online'}</span></div>`;}).join('');
+}
+
+/* ================= subpanel templates ================= */
+const SUB = {
+storage(){
+ if(zfsMode) return `
+ <div class="wgrid">
+ ${cellBar('zcap','zpool capacity','%',S.zcap.v,{lever:`<button class="key sm" onclick="event.stopPropagation();go('reclaim')">RECLAIM</button>`,sub:'CoW slows > 80 · best-fit > 90'})}
+ ${cellBar('cache','Pkg cache','GB',S.cache.v/10*100,{lever:lever('clean_cache','CLEAN'),act:'clean_cache',tick:100,sub:'Auto · thr ~10 GB'})}
+ ${cellRing('scrub','zpool scrub',S.scrub.v/60*100,'days',{lever:lever('scrub','SCRUB'),act:'scrub',sub:S.scrub.st==='ok'?'fresh':'warn 35d · crit 60d'})}
+ ${cellCount('ztrim','zpool autotrim',{lever:S.ztrim.st!=='ok'?lever('ztrim_fix','ENABLE',S.ztrim.st==='fail'?'red':''):'',act:'ztrim_fix',sub:'the fstrim counterpart on ZFS'})}
+ </div>
+ <div class="wgrid">
+ ${cellBadge('smart','SMART',{lever:lever('smarttest','SELF-TEST'),act:'smarttest',sub:'NVMe'})}
+ ${cellRing('wear','NVMe wear',Math.max(2,S.wear.v),'%',{sub:S.wear.st==='ok'?'0%':'high wear — plan replacement'})}
+ ${cellBadge('zhealth','Pool health',{sub:'zpool status -x'})}
+ ${cellCount('zerr','Pool errors',{sub:'read / write / cksum — no reset lever; investigate'})}
+ </div>
+ <div class="engrave striph">filesystem<span class="cnt">· 3</span></div>
+ <div class="strip">
+ ${lrow('zfrag','fragmentation', '', S.zfrag.v+'% — no defrag exists; keep < 80% full')}
+ ${lrow('integ','pacman file integrity', S.integ.st!=='ok'?lever('reinstall','REINSTALL')+' ':'', undefined, 'reinstall')}
+ ${lrow('deeptrim','deep-trim (keep 1)', lever('deeptrim','DEEP TRIM')+' ', undefined, 'deeptrim')}
+ </div>`;
+ return `
+ <div class="wgrid">
+ ${cellBar('disk','Disk usage','%',S.disk.v,{lever:`<button class="key sm" onclick="event.stopPropagation();go('reclaim')">RECLAIM</button>`,sub:'root · btrfs · thr 80/90'})}
+ ${cellBar('cache','Pkg cache','GB',S.cache.v/10*100,{lever:lever('clean_cache','CLEAN'),act:'clean_cache',tick:100,sub:'Auto · thr ~10 GB'})}
+ ${cellRing('scrub','btrfs scrub',S.scrub.v/55*100,'days',{lever:lever('scrub','SCRUB'),act:'scrub',sub:S.scrub.st==='ok'?'fresh':'past 30d cadence'})}
+ ${cellCount('fstrim','fstrim.timer',{lever:lever('fstrim','ENABLE',S.fstrim.st==='fail'?'red':''),act:'fstrim',sub:'Confirm · weekly TRIM'})}
+ </div>
+ <div class="wgrid">
+ ${cellBadge('smart','SMART',{lever:lever('smarttest','SELF-TEST'),act:'smarttest',sub:'last test: '+S.smartlast})}
+ ${cellRing('wear','NVMe wear',Math.max(2,S.wear.v),'%',{sub:S.wear.st==='ok'?'0% both drives':'high wear — plan replacement'})}
+ ${cellCount('deverr','Device errors',{sub:S.devrows.length?'per-device below · reset stays manual':'read / write · RAID1 · watch only'})}
+ ${cellCount('unalloc','btrfs unallocated',{lever:S.unalloc.st!=='ok'?lever('balance','BALANCE'):'',act:'balance',unit:' GiB',sub:'chunk headroom — raw space not yet carved into chunks'})}
+ </div>
+ ${S.devrows.length?`
+ <div class="engrave striph">device errors — which drive<span class="cnt">· cross-check SMART</span></div>
+ <div class="jlist">${S.devrows.map(r=>`<div class="lrow jrow"><span class="lamp red"></span>
+ <span class="who"><b>${r[0]}</b></span><span class="what">${r[1]}</span></div>`).join('')}</div>`:''}
+ <div class="engrave striph">disk — top consumers<span class="cnt">· evidence only</span></div>
+ <div class="jlist">${S.dtop.map(r=>`<div class="lrow jrow"><span class="who">${r[0]}</span>
+ <span class="what">${r[1]}</span></div>`).join('')}</div>
+ <div class="engrave striph">filesystem<span class="cnt">· 2</span></div>
+ <div class="strip two">
+ ${lrow('integ','pacman file integrity', S.integ.st!=='ok'?lever('reinstall','REINSTALL')+' ':'', undefined, 'reinstall')}
+ ${lrow('deeptrim','deep-trim (keep 1)', lever('deeptrim','DEEP TRIM')+' ', undefined, 'deeptrim')}
+ </div>`; },
+
+packages(){
+ const kept = OKEEP.map(k=>k.name);
+ const orows = S.orphlist.filter(r=>!kept.includes(r[0])).map(r=>{
+ const a = armed==='orem:'+r[0];
+ return `<div class="lrow jrow"><span class="lamp gold"></span>
+ <span class="who"><b>${r[0]}</b></span><span class="jmsg">${r[1]}</span>
+ <span class="what">
+ <button class="key sm" onclick="event.stopPropagation();orphKeep('${r[0]}')">KEEP</button>
+ <button class="key sm ${a?'armed':''}" onclick="event.stopPropagation();orphRemove('${r[0]}')">${a?'REMOVE?':'REMOVE'}</button>
+ </span></div>`;
+ }).join('');
+ const krows = OKEEP.map(k=>`<div class="lrow jrow known"><span class="lamp off"></span>
+ <span class="who">${k.name}</span><span class="what">kept ${k.date}
+ <button class="key sm" onclick="event.stopPropagation();orphUnkeep('${k.name}')">UNKEEP</button></span></div>`).join('');
+ const prows = S.pacnews.map(p=>`<div class="lrow jrow"><span class="lamp ${p.safe?'':'gold'}"></span>
+ <span class="who"><b>${p.f.split('/').pop()}</b></span><span class="jmsg">${p.f} · ${p.why}</span>
+ <span class="what"><button class="key sm" onclick="event.stopPropagation();pacnewAct('${p.f}')">${p.safe?'DELETE':'MERGE'}</button></span></div>`).join('');
+ const crows = S.upd.c ? S.cves.map(c=>`<div class="lrow jrow">
+ <span class="lamp ${c[2]==='high'?'red':c[2]==='medium'?'gold':''}"></span>
+ <span class="who"><b>${c[0]}</b></span><span class="jmsg">${c[1]}</span>
+ <span class="what" style="color:${c[2]==='high'?'var(--fail)':'var(--dim)'}">${c[2]} · fix: UPDATE</span></div>`).join('') : '';
+ return `
+ <div class="wgrid">
+ ${cellCount('orphans','Orphans',{lever:lever('orphans','REMOVE ALL'),act:'orphans',sub:'digest below · kept pkgs skipped'})}
+ ${cellCount('pacnew','.pacnew',{lever:lever('pacnew','REVIEW'),act:'pacnew',sub:'per-file below'})}
+ ${cellRing('keyring','Keyring age',S.keyring.v/30*100,'days',{lever:lever('keyring','REFRESH'),act:'keyring',sub:'refresh < 30d'})}
+ ${cellBadge('reboot','Reboot req.',{lever:S.reboot.v==='YES'?lever('reboot','REBOOT','red'):'',act:'reboot',sub:'kernel == modules'})}
+ </div>
+ <div class="wgrid">
+ ${cellRing('tgage','Topgrade age',S.tgage.v/14*100,'days',{lever:lever('topgrade','TOPGRADE'),act:'topgrade',sub:'ecosystems · guard-aware'})}
+ </div>
+ ${deck('packages',[
+ {id:'orphans', label:'ORPHANS', count:S.orphans.v, lamp:S.orphans.v?'gold':''},
+ {id:'pacnew', label:'PACNEW', count:S.pacnews.length, lamp:S.pacnews.length?'gold':''},
+ {id:'advis', label:'ADVISORIES', count:S.upd.c, lamp:S.upd.c?'red':''},
+ ])}
+ ${SUBSEC.packages==='orphans'?`
+ <div class="engrave striph">orphans — top by size<span class="cnt">· KEEP = intentional (the rust lesson)</span></div>
+ <div class="jlist">${orows || '<div class="rvnote">no unkept orphans.</div>'}</div>
+ ${OKEEP.length?`<div class="evh" style="margin-top:8px">kept · ${OKEEP.length}</div><div class="jlist">${krows}</div>`:''}`:''}
+ ${SUBSEC.packages==='pacnew'?`
+ <div class="engrave striph">pacnew files<span class="cnt">· safe-delete vs needs-merge</span></div>
+ <div class="jlist">${prows || '<div class="rvnote">none pending ✓</div>'}</div>`:''}
+ ${SUBSEC.packages==='advis'?`
+ <div class="engrave striph">security advisories<span class="cnt">· arch-audit</span></div>
+ <div class="jlist">${crows || '<div class="rvnote">clean ✓</div>'}</div>`:''}
+ <div class="engrave striph">update surface<span class="cnt">· informational</span></div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp ${S.upd.c?'red':''}"></span><span class="who">arch-audit</span><span class="what">${S.upd.c?S.upd.c+' CVE — named above':'clean'}</span></div>
+ <div class="lrow"><span class="lamp gold"></span><span class="who">pending updates</span><span class="what">${S.upd.p}</span></div>
+ <div class="lrow"><span class="lamp ${S.upd.a>8?'gold':''}"></span><span class="who">AUR / foreign</span><span class="what">${S.upd.a?S.upd.a+' — '+S.aurnames:'fresh'}</span></div>
+ ${S.upd.f?`<div class="lrow"><span class="lamp gold"></span><span class="who">firmware</span><span class="what">${S.upd.f} — ${S.fwnames}</span></div>`:''}
+ </div>`; },
+
+systemd(){
+ const frows = S.funits.map(f=>`<div class="lrow jrow"><span class="lamp red"></span>
+ <span class="who"><b>${f.u}</b></span><span class="jmsg">since ${f.since} · ${f.info} · journalctl -u ${f.u}</span>
+ <span class="what">
+ <button class="key sm" onclick="event.stopPropagation();funitAct('${f.u}')">RESTART</button>
+ <button class="key sm" onclick="event.stopPropagation();funitAct('${f.u}',true)">RESET</button>
+ </span></div>`).join('');
+ return `
+ <div class="wgrid">
+ ${cellCount('failed','Failed units',{lever:S.failed.v>1?lever('unit_restart','RESTART ALL'):'',act:'unit_restart',sub:S.failed.v?'roster below · per-unit keys':'none failed'})}
+ ${cellCount('timers','Maint timers',{lever:lever('timers','ENABLE'),act:'timers',sub:'Confirm · enable missing'})}
+ ${cellBadge('sysrun','is-system-running',{sub:S.sysrun.st==='ok'?'whole-system verdict · watch':'degraded — '+S.failed.v+' failed unit'+(S.failed.v===1?'':'s')+' below'})}
+ ${cellCount('taint','Kernel taint',{sub:S.taint.st==='ok'?'0 = untainted · watch':'W = kernel warned once — clears on reboot'})}
+ </div>
+ ${S.funits.length?`<div class="engrave striph">failed units<span class="cnt">· ${S.funits.length}</span></div>
+ <div class="jlist">${frows}</div>`:''}
+ <div class="engrave striph">timer roster<span class="cnt">· 5</span></div>
+ <div class="strip">${timerRoster()}</div>`; },
+
+logs(){
+ const sig = jSignal(), kn = jKnown();
+ const sigRows = sig.slice(0,10).map(g=>{
+ const key = nkey(g);
+ const armedNow = armed==='mark:'+key;
+ return `<div class="lrow jrow"><span class="lamp gold"></span>
+ <span class="who"><b>${g.id}</b></span><span class="jmsg">${g.msg}</span>
+ <span class="what">×${g.n} · ${g.first}–${g.last}${g.unit?' · journalctl -u '+g.unit.replace('.service',''):''}
+ <button class="key sm ${armedNow?'armed':''}" onclick="event.stopPropagation();markKnown('${key}')">${armedNow?'MARK?':'MARK KNOWN'}</button></span></div>`;
+ }).join('');
+ const knRows = kn.map(g=>{
+ const key = nkey(g); const n = noiseFor(g);
+ return `<div class="lrow jrow known"><span class="lamp off"></span>
+ <span class="who">${g.id}</span><span class="jmsg">${g.msg}</span>
+ <span class="what">×${g.n} · ${n.shipped?'shipped default':'marked '+n.date}
+ <button class="key sm" onclick="event.stopPropagation();unmarkKnown('${key}')">UNMARK</button></span></div>`;
+ }).join('');
+ const cgrows = S.coregroups.map(g=>`<div class="lrow jrow"><span class="lamp gold"></span>
+ <span class="who"><b>${g[0]}</b></span><span class="jmsg">coredumpctl info ${g[0]}</span>
+ <span class="what">×${g[1]} · last ${g[2]}</span></div>`).join('');
+ const khrows = S.khwev.map(e=>`<div class="lrow jrow"><span class="who" style="color:var(--dim)">${e[0]}</span>
+ <span class="jmsg" style="color:var(--fail)">${e[1]}</span></div>`).join('');
+ return `
+ <div class="wgrid">
+ ${cellCount('cores','Coredumps 7d',{lever:lever('cores','CLEAR'),act:'cores',sub:S.coregroups.length?'by binary below · keep 3d':'Auto · keep 3d'})}
+ ${cellBar('journald','journald','GB',S.journald.v/2*100,{lever:lever('journald','VACUUM'),act:'journald',sub:'Auto · cap 2 GB'})}
+ ${cellCount('jerr','Journal errors',{sub:'this boot · noise-filtered'})}
+ ${cellBadge('khw','Kernel / HW',{sub:S.khw.st==='ok'?'MCE · USB · I/O · thermal — watch':'events below — hardware, watch only'})}
+ </div>
+ ${deck('logs',[
+ {id:'signal', label:'SIGNAL', count:S.jerr.v, lamp:S.jerr.st==='ok'?(S.jerr.v?'gold':''):'gold'},
+ {id:'noise', label:'KNOWN NOISE', count:kn.length, lamp:'off'},
+ {id:'cores', label:'COREDUMPS', count:S.cores.v, lamp:lampCls(S.cores.st)},
+ {id:'hw', label:'KERNEL/HW', count:S.khwev.length||'', lamp:lampCls(S.khw.st)},
+ ])}
+ ${SUBSEC.logs==='signal'?`
+ <div class="striph" style="display:flex;align-items:center;gap:10px">
+ <span class="engrave">journal — signal<span class="cnt">· ${sig.length} group${sig.length===1?'':'s'} · ${S.jerr.v} entries</span></span>
+ ${lever('openjournal','OPEN JOURNAL')}
+ </div>
+ <div class="jlist">${sigRows || '<div class="rvnote">no unexplained errors this boot ✓</div>'}</div>`:''}
+ ${SUBSEC.logs==='noise'?`
+ <div class="striph" style="display:flex;align-items:center;gap:10px">
+ <span class="engrave">known noise<span class="cnt">· ${kn.length} pattern${kn.length===1?'':'s'}</span></span>
+ <button class="key sm ${armed==='clearmarks'?'armed':''}" onclick="event.stopPropagation();clearMarks()">${armed==='clearmarks'?'CLEAR?':'CLEAR MARKS'}</button>
+ </div>
+ <div class="jlist">${knRows || '<div class="rvnote">no known-noise patterns match this boot.</div>'}</div>`:''}
+ ${SUBSEC.logs==='cores'?`
+ <div class="engrave striph">coredumps — by binary<span class="cnt">· ${S.cores.v} total · coredumpctl info</span></div>
+ <div class="jlist">${cgrows || '<div class="rvnote">none ✓</div>'}</div>
+ <div class="evh" style="margin-top:8px">rotation</div>
+ <div class="jlist">${lrow('applog','app-log cleanup', lever('applog','RUN')+' ', S.applog.v==='ok'?'7d rotation':'stale >7d', 'applog')}</div>`:''}
+ ${SUBSEC.logs==='hw'?`
+ <div class="engrave striph">kernel / hw events<span class="cnt">· hardware — watch only</span></div>
+ <div class="jlist">${khrows || '<div class="rvnote">clean — no MCE, I/O, USB, or thermal events ✓</div>'}</div>`:''}`; },
+
+power(){
+ const [tc,tg] = String(S.temps.v).split('/');
+ const trc = S.temps.st==='ok'?'var(--pass)':S.temps.st==='fail'?'var(--fail)':'var(--amber)';
+ const seg = ['perf','bal','power'].map(m=>
+ `<button class="${S.cpumode.v===m?'on':''}" onclick="event.stopPropagation();setMode('${m}')">${m.toUpperCase()}</button>`).join('');
+ const modeCell = `
+ <div class="cell"><div class="cap"><span class="lamp ${lampCls(S.cpumode.st)}"></span> CPU mode</div>
+ <div class="val"><span class="seg">${seg}</span></div>
+ <div class="sub2">EPP hint · ${zfsMode?'intel_pstate':'amd-pstate'} active</div></div>`;
+ const veloxCells = zfsMode ? `
+ ${cellRing('batt','Battery health',S.batt.v,'%',{sub:S.batt.st==='ok'?'of design · 187 cycles · hardware — watch only':'degraded · plan replacement — watch only'})}
+ ${cellCount('blimit','Charge limit',{lever:S.blimit.st!=='ok'?lever('bat_limit','SET 80%'):'',act:'bat_limit',sub:'longevity cap · sysfs threshold'})}` : '';
+ const evRows = (rows,kind) => rows.length
+ ? rows.map(r=>{
+ if(kind==='boot') return `<div class="lrow jrow"><span class="lamp ${r[1]==='clean'?'':'red'}"></span><span class="who">${r[0]}</span><span class="what">${r[1]}</span></div>`;
+ if(kind==='ev') return `<div class="lrow jrow"><span class="who" style="color:var(--dim)">${r[0]}</span><span class="jmsg" style="color:var(--fail)">${r[1]}</span></div>`;
+ // top-memory rows: KILL (arm-to-fire) — disabled on session-critical names
+ const prot = PROTECTED.includes(r[0]);
+ const armedNow = armed==='kill:'+r[0];
+ const key = prot
+ ? `<button class="key sm dis" title="session-critical — protected">KILL</button>`
+ : `<button class="key sm ${armedNow?'armed':''}" onclick="event.stopPropagation();killProc('${r[0]}')">${armedNow?'KILL?':'KILL'}</button>`;
+ return `<div class="lrow jrow"><span class="who">${r[0]}</span><span class="what">${r[1]} ${key}</span></div>`;
+ }).join('')
+ : `<div class="lrow jrow"><span class="what" style="margin-left:0">none this boot ✓</span></div>`;
+ return `
+ <div class="wgrid">
+ <div class="cell"><div class="cap"><span class="lamp ${lampCls(S.temps.st)}"></span> Temperatures</div>
+ <div class="gpair">
+ <div class="g"><span class="ring" style="--p:${tc};--rc:${trc}"><b>${tc}°</b></span><span class="lb">CPU</span></div>
+ <div class="g"><span class="ring" style="--p:${tg};--rc:${trc}"><b>${tg}°</b></span><span class="lb">NVME</span></div>
+ </div>
+ <div class="sub2" style="text-align:center">hardware — watch only</div></div>
+ ${cellBar('mem','Memory free','GB',S.mem.v/125*100,{sub:'of 125 GB · evidence below'})}
+ ${cellBar('unclean','Unclean boots','%',S.unclean.v,{sub:'evidence below — watch only'})}
+ ${cellCount('zram','Swap / zram',{lever:S.zram.st!=='ok'?lever('zram_fix','ENABLE'):'',act:'zram_fix',sub:S.zram.st==='ok'?'zstd · active':'no swap configured'})}
+ </div>
+ <div class="wgrid">
+ ${modeCell}
+ ${veloxCells}
+ ${!zfsMode?`<div class="cell"><div class="cap"><span class="lamp off"></span> Battery</div>
+ <div class="val"><span class="readout" style="color:var(--dim)">n/a</span></div>
+ <div class="sub2">desktop — no battery</div></div>`:''}
+ ${cellBadge('throttle','Thermal throttle',{sub:S.throttle.st==='ok'?'no events · hardware — watch only':'active — cooling issue · watch only'})}
+ </div>
+ <div class="strip striph" style="align-items:center">
+ <span class="engrave">top memory</span>
+ <span class="engrave" style="grid-column:span 2">evidence — watch only</span>
+ </div>
+ <div class="strip">
+ <div><div class="evh">kill: sigterm · arm-to-fire</div><div class="jlist">${evRows(S.memtop,'mem')}</div></div>
+ <div><div class="evh">recent boots</div><div class="jlist">${evRows(S.boots,'boot')}</div></div>
+ <div><div class="evh">throttle / oom</div><div class="jlist">${evRows(S.pevents,'ev')}</div></div>
+ </div>`; },
+
+services(){
+ const unexp = S.ctrs.filter(c=>!cexp(c.name));
+ const expd = S.ctrs.filter(c=>cexp(c.name));
+ const urows = unexp.map(c=>{
+ const aM = armed==='cexp:'+c.name;
+ return `<div class="lrow jrow"><span class="lamp gold"></span>
+ <span class="who"><b>${c.name}</b></span><span class="jmsg">exited ${c.when} · code ${c.code}${c.code?' · docker logs '+c.name:''}</span>
+ <span class="what">
+ <button class="key sm ${aM?'armed':''}" onclick="event.stopPropagation();ctrMark('${c.name}')">${aM?'MARK?':'MARK EXPECTED'}</button>
+ <button class="key sm" onclick="event.stopPropagation();ctrStartOne('${c.name}')">START</button>
+ </span></div>`;
+ }).join('');
+ const erows = expd.map(c=>{
+ const p = CEXPECT.find(x=>!x.disabled&&x.name===c.name)||{};
+ return `<div class="lrow jrow known"><span class="lamp off"></span>
+ <span class="who">${c.name}</span><span class="jmsg">exited ${c.when}</span>
+ <span class="what">${p.shipped?'shipped default':'marked '+(p.date||'')}
+ <button class="key sm" onclick="event.stopPropagation();ctrUnmark('${c.name}')">UNMARK</button></span></div>`;
+ }).join('');
+ const dfrows = S.ddf.map(r=>`<div class="lrow jrow"><span class="who">${r[0]}</span>
+ <span class="what">${r[1]} · reclaimable ${r[2]}</span></div>`).join('');
+ const cronRows = S.cron.st==='ok' ? `
+ <div class="lrow"><span class="lamp"></span><span class="who">log-cleanup (12:00)</span><span class="what">present</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot hourly</span><span class="what">present</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot daily 02:30</span><span class="what">present</span></div>` : `
+ <div class="lrow"><span class="lamp red"></span><span class="who">log-cleanup (12:00)</span><span class="what">not running — cronie down</span></div>
+ <div class="lrow"><span class="lamp red"></span><span class="who">rsyncshot hourly</span><span class="what">not running — cronie down</span></div>
+ <div class="lrow"><span class="lamp red"></span><span class="who">rsyncshot daily 02:30</span><span class="what">not running — cronie down</span></div>`;
+ return `
+ <div class="wgrid">
+ ${cellCount('docker','Docker reclaim',{lever:lever('docker','PRUNE'),act:'docker',unit:' GB',sub:'breakdown below · tiers 1+2'})}
+ ${cellCount('rsync','Last backup',{lever:lever('rsyncnow','RUN NOW'),act:'rsyncnow',unit:' ago',sub:'rsyncshot → truenas'})}
+ ${cellCount('dstopped','Stopped containers',{lever:unexp.length>1?lever('ctr_start','START ALL'):'',act:'ctr_start',sub:unexp.length?unexp.length+' unexpected · '+expd.length+' expected':'all expected'})}
+ ${cellBadge('cron','cron',{lever:S.cron.st!=='ok'?lever('cron_fix','START'):'',act:'cron_fix',sub:'entry roster below'})}
+ </div>
+ ${deck('services',[
+ {id:'containers', label:'CONTAINERS', count:unexp.length?unexp.length+'!':expd.length, lamp:unexp.length?'gold':''},
+ {id:'docker', label:'DOCKER DISK', count:S.docker.v+' GB', lamp:lampCls(S.docker.st)},
+ {id:'cron', label:'CRON & BACKUPS', count:'', lamp:(S.cron.st!=='ok'||S.rsync.st!=='ok')?'red':''},
+ ])}
+ ${SUBSEC.services==='containers'?`
+ ${unexp.length?`<div class="engrave striph">containers — signal<span class="cnt">· ${unexp.length} unexpected</span></div>
+ <div class="jlist">${urows}</div>`:''}
+ <div class="engrave striph">containers — expected<span class="cnt">· ${expd.length}</span></div>
+ <div class="jlist">${erows || '<div class="rvnote">no expected-stopped containers present.</div>'}</div>`:''}
+ ${SUBSEC.services==='docker'?`
+ <div class="engrave striph">docker disk<span class="cnt">· system df · reclaimable by type</span></div>
+ <div class="jlist">${dfrows}</div>
+ <div class="evh" style="margin-top:8px">virt</div>
+ <div class="jlist">${lrow('libvirt','libvirt VMs','', 'shut off · expected')}</div>`:''}
+ ${SUBSEC.services==='cron'?`
+ <div class="engrave striph">cron entries — expected<span class="cnt">· 3</span></div>
+ <div class="jlist">${cronRows}</div>
+ <div class="evh" style="margin-top:8px">backups</div>
+ <div class="jlist">${backupRoster()}</div>`:''}`; },
+
+snapshots(){
+ const t = S.snaptypes;
+ const staleN = Math.max(0, t.single-2);
+ const aS = armed==='snapstale';
+ return `
+ <div class="wgrid">
+ ${cellCount('snapper','Snapper count',{lever:lever('snapper','PRUNE'),act:'snapper',sub:S.snapper.st==='ok'?'by type below':'pile-up — see singles below'})}
+ ${cellBadge('stimer','Auto-timer',{lever:S.stimer.st!=='ok'?lever('stimer_fix','ENABLE'):'',act:'stimer_fix',sub:'timeline + cleanup'})}
+ ${cellCount('oldest','Oldest',{unit:' days',sub:S.oldest.st==='ok'?'within budget':'chain hoards space'})}
+ <div class="cell">${S.retention.st!=='ok'?`<span class="lever">${lever('retention','FIX')}</span>`:''}
+ <div class="cap"><span class="lamp ${lampFor('retention','retention')}"></span> Retention</div>
+ <div class="val" style="font-size:12px;color:${S.retention.st==='ok'?'var(--cream)':'var(--amber)'}">${S.retention.v}</div>
+ <div class="sub2">${S.retention.st==='ok'?'monthly=2 (the /home lesson)':'monthly=10 — silently hoards space'}</div>${errline('retention')}</div>
+ </div>
+ <div class="engrave striph">by type<span class="cnt">· singles escape timeline cleanup</span></div>
+ <div class="jlist">
+ <div class="lrow jrow"><span class="lamp"></span><span class="who">timeline</span><span class="what">${t.timeline} · auto-pruned</span></div>
+ <div class="lrow jrow"><span class="lamp ${staleN>3?'gold':''}"></span><span class="who"><b>single (manual)</b></span>
+ <span class="jmsg">oldest ${t.oldsingle}</span>
+ <span class="what">${t.single}${staleN>3?` <button class="key sm ${aS?'armed':''}" onclick="event.stopPropagation();snapDelStale()">${aS?'DELETE?':'DELETE STALE'}</button>`:''}</span></div>
+ <div class="lrow jrow"><span class="lamp"></span><span class="who">pre / post</span><span class="what">${t.prepost} · paired with pacman ops</span></div>
+ </div>`; },
+
+network(){
+ const n = parseInt(S.ts.v)||0;
+ const bars = [1,2,3,4].map(i=>`<i class="${i<=n?'on':''}"></i>`).join('');
+ const sig = lsnSignal();
+ const exp = LEXPECT.filter(p=>!p.disabled);
+ const pubN = sig.filter(r=>isPub(r.bind)).length;
+ const sigRows = sig.map(r=>{
+ const key = lkey(r);
+ const aFix = armed==='lsnfix:'+key, aMark = armed==='lexp:'+key;
+ const bindTag = isPub(r.bind)
+ ? `<span class="badge red" style="font-size:.55rem">PUBLIC</span>`
+ : `<span style="color:var(--dim);font-size:10px">loopback</span>`;
+ return `<div class="lrow jrow"><span class="lamp ${isPub(r.bind)?'red':'gold'}"></span>
+ <span class="who"><b>${r.proc}</b></span>
+ <span class="jmsg">:${r.port} · ${r.bind}${r.unit?' · '+r.unit+'.service':''}</span>
+ <span class="what">${bindTag}
+ <button class="key sm ${aMark?'armed':''}" onclick="event.stopPropagation();markExpected('${key}')">${aMark?'MARK?':'MARK EXPECTED'}</button>
+ <button class="key sm ${aFix?'armed':''}" onclick="event.stopPropagation();lsnFix('${key}')">${aFix?(r.unit?'STOP?':'KILL?'):(r.unit?'STOP':'KILL')}</button>
+ </span></div>`;
+ }).join('');
+ const expRows = exp.map(p=>{
+ const key = p.proc+'|'+p.port;
+ return `<div class="lrow jrow known"><span class="lamp off"></span>
+ <span class="who">${p.proc}</span><span class="jmsg">:${p.port}</span>
+ <span class="what">${p.shipped?'shipped default':'marked '+p.date}
+ <button class="key sm" onclick="event.stopPropagation();unmarkExpected('${key}')">UNMARK</button></span></div>`;
+ }).join('');
+ const fwNote = S.fw.st!=='ok' && pubN
+ ? ` <span style="color:var(--fail);letter-spacing:0;text-transform:none">· ufw down — ${pubN} public bind${pubN===1?'':'s'} exposed</span>` : '';
+ return `
+ <div class="wgrid">
+ ${cellBadge('fw','Firewall',{lever:S.fw.st!=='ok'?lever('fw_fix','ENABLE',S.fw.st==='fail'?'red':''):'',act:'fw_fix',sub:'ufw · mosh rule'})}
+ ${cellCount('listen','Unexpected listeners',{sub:S.listen.st==='ok'?'all matched · '+exp.length+' expected':S.listen.v+' unmatched · '+pubN+' public-bind'})}
+ <div class="cell">${S.ts.st!=='ok'?`<span class="lever">${lever('ts_fix','RESTART')}</span>`:''}
+ <div class="cap"><span class="lamp ${lampFor('ts','ts_fix')}"></span> Tailscale</div>
+ <div class="val"><span class="ladder ${S.ts.st==='fail'?'bad':''}">${bars}</span>
+ <span class="readout" style="font-size:15px">${S.ts.v}</span></div>
+ <div class="sub2">peers online</div>${errline('ts_fix')}</div>
+ ${cellCount('f2b','fail2ban',{lever:S.f2b.st!=='ok'?lever('f2b_fix','RESTART'):'',act:'f2b_fix',sub:S.f2b.st==='ok'?'sshd jail · '+S.f2b.v+' bans 7d':'service down'})}
+ </div>
+ <div class="striph" style="display:flex;align-items:center;gap:10px">
+ <span class="engrave">listeners — signal<span class="cnt">· ${sig.length} unexpected</span>${fwNote}</span>
+ </div>
+ <div class="jlist">${sigRows || '<div class="rvnote">every listener matches the expected list.</div>'}</div>
+ <div class="striph" style="display:flex;align-items:center;gap:10px">
+ <span class="engrave">expected<span class="cnt">· ${exp.length} pattern${exp.length===1?'':'s'}</span></span>
+ <button class="key sm ${armed==='clearlsn'?'armed':''}" onclick="event.stopPropagation();clearLsn()">${armed==='clearlsn'?'CLEAR?':'CLEAR MARKS'}</button>
+ </div>
+ <div class="jlist">${expRows}</div>
+ <div class="engrave striph">peers<span class="cnt">· 4</span></div>
+ <div class="strip">${peerRoster()}</div>
+ <div class="engrave striph">resolution<span class="cnt">· 2</span></div>
+ <div class="strip two">
+ ${lrow('ntp','NTP (chrony)', S.ntp.st!=='ok'?lever('ntp_fix','RESTART')+' ':'', S.ntp.st==='ok'?'synced · +2 ms':undefined, 'ntp_fix')}
+ ${lrow('dns','DNS / NetworkManager', S.dns.st!=='ok'?lever('netdoc','NET DOCTOR')+' ':'', undefined, 'netdoc')}
+ </div>`; },
+};
+
+/* ---- doctor: streams into the results wall; the metrics subpanel stays put ---- */
+let doctorRunning = false;
+function startDoctor(mode){
+ if(doctorRunning){ toast('doctor already running'); return; }
+ const title = mode==='reclaim' ? 'reclaim space' : 'clean up';
+ const steps = [
+ {name:'cache trim', act:'clean_cache',
+ res:()=> S.cache.st==='warn' ? (S.cache={v:0.4,st:'ok'}, 'reclaimed 8.4 GB') : 'nothing to reclaim'},
+ {name:'journal vacuum', act:'journald',
+ res:()=> S.journald.v>0.5 ? (S.journald={v:0.3,st:'ok'}, 'reclaimed 0.9 GB') : 'already tight'},
+ {name:'coredump clear', act:'cores',
+ res:()=> S.cores.v>0 ? (()=>{const n=S.cores.v; S.cores={v:0,st:'ok'}; return n+' cleared';})() : 'none to clear'},
+ {name:'app-log cleanup', act:'applog',
+ res:()=> (S.applog={v:'ok',st:'ok'}, 'nothing older than 7d')},
+ ];
+ if(mode==='reclaim'){
+ steps.push(
+ {name:'docker prune (tier 1)', act:'docker',
+ res:()=> S.docker.st==='warn' ? (S.docker={v:0.2,st:'ok'}, 'reclaimed 2.8 GB') : 'nothing to prune'},
+ {name:'snapper cleanup', act:'snapper',
+ res:()=> (S.snapper={v:Math.min(S.snapper.v,31),st:'ok'}, 'retention applied')},
+ {name:'disk re-read', act:'applog',
+ res:()=> { S.disk={v:Math.max(60,S.disk.v-11), st:S.disk.v-11>90?'fail':S.disk.v-11>80?'warn':'ok'}; return 'now '+S.disk.v+'%'; }},
+ );
+ }
+ doctorRunning = true;
+ resAdd('doctor · '+title, 'started · '+steps.length+' actions', 'done');
+ render();
+ let i=0, entry=null, failed=0;
+ function step(){
+ if(entry){
+ const p = steps[i-1];
+ if(failMode){ ERR[p.act]=ACTIONS[p.act].err; failed++; resDone(entry,'fail',ERR[p.act]); }
+ else { delete ERR[p.act]; resDone(entry,'done',p.res()); }
+ render();
+ }
+ if(i<steps.length){
+ entry = resAdd(steps[i].name, 'running…'); i++;
+ doctorTimer = setTimeout(step, 1000);
+ } else {
+ doctorRunning = false;
+ resAdd('doctor · '+title,
+ `${steps.length-failed} done${failed?' · '+failed+' failed':''}`,
+ failed?'fail':'done');
+ render();
+ }
+ }
+ step();
+}
+
+/* ---- review & fix ---- */
+const REVIEW = [
+ {act:'fstrim', who:'fstrim.timer', why:'weekly SSD TRIM disabled', id:'fstrim', ok:()=>zfsMode||S.fstrim.st==='ok'},
+ {act:'ztrim_fix', who:'zpool autotrim', why:'TRIM disabled on the pool', id:'ztrim', ok:()=>!zfsMode||S.ztrim.st==='ok'},
+ {act:'scrub', who:'scrub', why:'past the scrub cadence', id:'scrub', ok:()=>S.scrub.st==='ok'},
+ {act:'orphans', who:'orphans', why:'unowned packages present', id:'orphans', ok:()=>S.orphans.st==='ok'},
+ {act:'pacnew', who:'.pacnew files', why:'configs awaiting merge', id:'pacnew', ok:()=>S.pacnew.st==='ok'},
+ {act:'timers', who:'maint timers', why:'timers not firing', id:'timers', ok:()=>S.timers.st==='ok'},
+ {act:'keyring', who:'keyring', why:'refresh before next update', id:'keyring', ok:()=>S.keyring.st==='ok'},
+ {act:'docker', who:'docker reclaim', why:'space reclaimable (tiers 1+2)', id:'docker', ok:()=>S.docker.st==='ok'},
+ {act:'snapper', who:'snapper', why:'retention pass', id:'snapper', ok:()=>S.snapper.st==='ok'&&S.snapper.v<42},
+ {act:'deeptrim',who:'deep-trim', why:'keep 1 version — max reclaim, less downgrade headroom', id:'deeptrim', ok:()=>S.deeptrim.v==='keep 1'},
+ {act:'unit_restart', who:'failed units', why:'restart the failed unit(s)', id:'failed', ok:()=>S.failed.st==='ok'},
+ {act:'tgage', who:'topgrade', why:'ecosystem updates stale', id:'tgage', ok:()=>S.tgage.st==='ok', useAct:'topgrade'},
+ {act:'f2b_fix', who:'fail2ban', why:'service down', id:'f2b', ok:()=>S.f2b.st==='ok'},
+ {act:'cron_fix',who:'cronie', why:'service down', id:'cron', ok:()=>S.cron.st==='ok'},
+ {act:'ntp_fix', who:'NTP (chrony)', why:'clock unsynchronized', id:'ntp', ok:()=>S.ntp.st==='ok'},
+ {act:'ts_fix', who:'tailscaled', why:'peers unreachable', id:'ts', ok:()=>S.ts.st==='ok'},
+ {act:'stimer_fix', who:'snapshot timer', why:'auto-snapshots not firing', id:'stimer', ok:()=>S.stimer.st==='ok'},
+ {act:'zram_fix',who:'zram', why:'no swap configured', id:'zram', ok:()=>S.zram.st==='ok'},
+ {act:'balance', who:'btrfs balance', why:'unallocated headroom low', id:'unalloc', ok:()=>zfsMode||S.unalloc.st==='ok'},
+ {act:'reinstall', who:'file integrity', why:'modified package files', id:'integ', ok:()=>S.integ.st==='ok'},
+ {act:'retention', who:'snapshot retention', why:'monthly limit hoards space', id:'retention', ok:()=>S.retention.st==='ok'},
+ {act:'rsyncnow', who:'backup', why:'stale or failed — run now', id:'rsync', ok:()=>S.rsync.st==='ok'},
+ {act:'reboot', who:'reboot', why:'running kernel != installed', id:'reboot', ok:()=>S.reboot.v!=='YES'},
+ {act:'bat_limit', who:'charge limit', why:'no charge cap — battery longevity', id:'blimit', ok:()=>!zfsMode||S.blimit.st==='ok'},
+ {act:'ctr_start', who:'stopped containers', why:'unexpected exited containers', id:'dstopped', ok:()=>S.dstopped.st==='ok'},
+ {act:'fw_fix', who:'firewall', why:'ufw inactive — host exposed', id:'fw', ok:()=>S.fw.st==='ok'},
+];
+function renderReview(){
+ const items = REVIEW.filter(r=>!r.ok()||ERR[r.useAct||r.act]);
+ const rows = items.map(r=>{
+ const act = r.useAct||r.act;
+ const done = r.ok();
+ const failed = !!ERR[act];
+ return `<div class="rvrow">
+ <span class="lamp ${failed?'red':done?'':lampCls(S[r.id].st)}"></span>
+ <span class="who">${r.who}</span>
+ <span class="why ${failed?'errtx':''}">${failed?'✗ '+ERR[act]:r.why}</span>
+ <span class="keys">${done?'<span class="res">done ✓</span>':lever(act,'FIX')}</span></div>`;
+ }).join('');
+ return `
+ <div class="subhead"><span class="lamp gold"></span><span class="nm">Doctor · Review &amp; fix</span>
+ <span class="cnt">${items.length} Confirm items</span></div>
+ <div class="rvlist">${rows||'<div class="rvnote">nothing needs review — all Confirm items clear.</div>'}</div>
+ <div class="rvnote">Destructive fixes arm on first press, fire on the second. Metrics without a determinate
+ lever — failed units, unclean boots, updates, CVEs — stay on their subpanels as read-only telemetry.</div>
+ <div class="ofoot"><button class="key" onclick="go('cat:storage')">DONE</button></div>`;
+}
+
+/* ================= loading / hydration ================= */
+/* tiers mirror real probe cost: t0 = local file/daemon reads, t1 = process probes
+ (smartctl, docker df, snapper, du), t2 = package scans (cached in real build) */
+const TIERS = [
+ {ms:220, cats:['systemd','power','network','snapshots']},
+ {ms:650, cats:['storage','logs','services']},
+ {ms:1150, cats:['packages']},
+];
+function startLoad(){
+ loadTimers.forEach(clearTimeout); loadTimers=[];
+ loaded.clear();
+ $('lastscan').textContent = 'scanning…';
+ render();
+ TIERS.forEach(t=>{
+ loadTimers.push(setTimeout(()=>{
+ t.cats.forEach(c=>loaded.add(c));
+ if(loaded.size===CATS.length) $('lastscan').textContent='last scan just now · 44 checks · 1.2s';
+ render();
+ }, t.ms));
+ });
+}
+
+/* ================= render ================= */
+const ACT2CAT = { clean_cache:'storage', scrub:'storage', fstrim:'storage', deeptrim:'storage',
+ balance:'storage', reinstall:'storage', smarttest:'storage', ztrim_fix:'storage',
+ orphans:'packages', pacnew:'packages', keyring:'packages', reboot:'packages', topgrade:'packages',
+ timers:'systemd', unit_restart:'systemd',
+ cores:'logs', journald:'logs', applog:'logs', openjournal:'logs',
+ zram_fix:'power', bat_limit:'power',
+ docker:'services', cron_fix:'services', rsyncnow:'services', ctr_start:'services', fw_fix:'network',
+ snapper:'snapshots', stimer_fix:'snapshots', retention:'snapshots',
+ f2b_fix:'network', ntp_fix:'network', ts_fix:'network', netdoc:'network' };
+function catErr(cid){ return Object.keys(ERR).some(a=>ACT2CAT[a]===cid); }
+
+function renderSelector(){
+ $('selector').innerHTML = CATS.map(c=>{
+ const sel = view==='cat:'+c.id;
+ if(!loaded.has(c.id)){
+ return `<div class="selbtn loading ${sel?'sel':''}" onclick="go('cat:${c.id}')">
+ <span class="lamp busy"></span><span class="nm">${c.name}</span><span class="ct">···</span></div>`;
+ }
+ const k = counts(c);
+ const err = catErr(c.id);
+ const ct = err ? `<span class="a crit">✗ fail</span> · <span class="k">${k.ok}✓</span>`
+ : k.attn ? `<span class="a ${k.crit?'crit':''}">${k.attn}!</span> · <span class="k">${k.ok}✓</span>`
+ : `<span class="k">${k.ok} ✓</span>`;
+ return `<div class="selbtn ${(k.crit||err)?'crit':k.attn?'attn':'clear'} ${sel?'sel':''}" onclick="go('cat:${c.id}')">
+ <span class="lamp ${(k.crit||err)?'red':k.attn?'gold':''}"></span>
+ <span class="nm">${c.name}</span><span class="ct">${ct}</span></div>`;
+ }).join('');
+}
+function renderSub(){
+ const el = $('sub');
+ if(view==='review'){ el.innerHTML = renderReview(); return; }
+ const cid = view.slice(4);
+ if(!loaded.has(cid)){
+ const cat = CATS.find(c=>c.id===cid);
+ el.innerHTML = `<div class="subhead"><span class="lamp busy"></span><span class="nm">${cat.name}</span>
+ <span class="cnt">gathering…</span></div>
+ <div class="skel"><span class="lamp busy" style="width:14px;height:14px"></span>probing</div>`;
+ return;
+ }
+ const cat = CATS.find(c=>c.id===cid);
+ const k = counts(cat);
+ const [nfix,nwatch] = fixwatch(cid);
+ const cnt = (k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>`
+ : `<span class="okc">all clear · ${k.ok} ✓</span>`)
+ + ` · ${nfix} fixable · ${nwatch} watch`;
+ el.innerHTML = `<div class="subhead">
+ <span class="lamp ${k.crit?'red':k.attn?'gold':''}"></span>
+ <span class="nm">${cat.name}</span><span class="cnt">${cnt}</span></div>` + SUB[cid]();
+}
+function render(){
+ recalcJerr(); recalcListen(); recalcCtrs();
+ renderSelector(); renderSub();
+ // faceplate verdict = worst diagnostic state
+ let worst='ok';
+ DIAG.forEach(d=>{ const st=S[d].st;
+ if(st==='fail') worst='fail'; else if(st==='warn'&&worst!=='fail') worst='warn'; });
+ const fl=$('facelamp');
+ fl.className = 'lamp ' + (loaded.size<CATS.length?'busy':worst==='fail'?'red':worst==='warn'?'gold':'');
+ // badges
+ let attn=0; CATS.forEach(c=>{ if(loaded.has(c.id)){ attn+=counts(c).attn; } });
+ $('attbadge').textContent = loaded.size<CATS.length ? 'SCANNING' : (attn ? attn+' ATTN' : 'ALL CLEAR');
+ const errN = Object.keys(ERR).length;
+ $('errbadge').style.display = errN ? '' : 'none';
+ $('errbadge').textContent = errN + ' FAILED';
+ // updates strip: green when nothing, amber for a few, red for CVEs / a lot
+ const u = S.upd;
+ const ucls = (u.c>0 || u.p>100) ? 'crit' : (u.p>0 || u.a>0 || u.f>0) ? 'warn' : 'ok';
+ $('updbar').className = 'updbar ' + ucls;
+ // CVE badge: red and visible only when there are CVEs to show
+ $('cvebadge').style.display = S.upd.c ? '' : 'none';
+ $('cvebadge').textContent = S.upd.c + ' CVE';
+ $('upd-p').textContent = S.upd.p; $('upd-c').textContent = S.upd.c + ' CVE';
+ $('upd-c').classList.toggle('cve', S.upd.c>0);
+ $('upd-c').style.color = S.upd.c ? '' : 'var(--dim)';
+ $('upd-a').textContent = S.upd.a; $('upd-f').textContent = S.upd.f;
+ $('k-clean').classList.toggle('on', doctorRunning);
+ $('k-review').classList.toggle('on', view==='review');
+ // guard-armed update keys: red + '?' while awaiting the override press
+ $('k-update').classList.toggle('armed', armed==='sysupdate');
+ $('k-update').textContent = armed==='sysupdate' ? 'UPDATE?' : 'UPDATE';
+ $('k-topgrade').classList.toggle('armed', armed==='topgrade');
+ $('k-topgrade').textContent = armed==='topgrade' ? 'TOPGRADE?' : 'TOPGRADE';
+ // reboot offer key appears on the updates strip once an update lands
+ $('upd-rb').innerHTML = S.reboot.v==='YES' ? lever('reboot','REBOOT','red') : '';
+}
+function go(v){
+ armed=null;
+ if(v==='doctor'||v==='reclaim'){ startDoctor(v==='reclaim'?'reclaim':undefined); return; }
+ view=v; render();
+}
+function closePanel(){ $('capsule').style.display='none'; $('closed').style.display='block'; }
+function openPanel(){ $('capsule').style.display='block'; $('closed').style.display='none'; startLoad(); }
+document.addEventListener('keydown', e=>{ if(e.key==='Escape') closePanel(); });
+startLoad();
+
+/* live refresh sim: the thermal/memory group re-reads every 3 s while its
+ subpanel is visible (panel-open gated, like the audio panel's meters) */
+setInterval(()=>{
+ if($('capsule').style.display==='none') return;
+ if(view!=='cat:power' || !loaded.has('power')) return;
+ const j = (v,lo,hi)=>Math.max(lo,Math.min(hi, v + (Math.random()<0.5?-1:1)));
+ let [tc,tg] = String(S.temps.v).split('/').map(Number);
+ if(badMode){ tc=j(tc,88,94); tg=j(tg,74,80); } else { tc=j(tc,57,65); tg=j(tg,50,58); }
+ S.temps.v = tc+'/'+tg;
+ S.mem.v = Math.round((S.mem.v + (Math.random()-0.5)*0.6)*10)/10;
+ render();
+}, 3000);
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/README.org b/docs/prototypes/README.org
new file mode 100644
index 0000000..651636a
--- /dev/null
+++ b/docs/prototypes/README.org
@@ -0,0 +1,92 @@
+#+TITLE: Panel & Waybar Design Prototypes
+#+AUTHOR: Craig Jennings
+
+Self-contained HTML/CSS design prototypes for the instrument-console panel
+family and the waybar redesign. Each opens standalone in a browser (no external
+assets). These are the normative visual references the specs in [[file:../specs/][docs/specs/]]
+point at.
+
+* Prototypes
+
+- [[file:2026-07-03-instrument-console-panels-prototype.html][2026-07-03-instrument-console-panels-prototype.html]] — the net + bluetooth
+ pair; the approved faceplate design that shipped. Normative reference for
+ [[file:../specs/2026-07-03-instrument-console-panels-spec.org][the instrument-console spec]].
+- [[file:2026-07-03-net-panel-rescan-prototype.html][2026-07-03-net-panel-rescan-prototype.html]] — the manual rescan/scan ⟳
+ affordance for the NETWORKS/NEARBY headers (busy-style throbber + list fade).
+- [[file:2026-07-03-sound-panel-prototype.html][2026-07-03-sound-panel-prototype.html]] — the audio/pulsemixer console; layout
+ reference for [[file:../specs/2026-07-03-audio-panel-spec.org][the audio-panel spec]].
+- [[file:panel-widget-gallery.html][panel-widget-gallery.html]] — the shared instrument-console
+ widget kit (lamps, engraved sections, console keys, needle gauges). The
+ living catalogue: every widget here is the visual + behavioral spec for
+ its reusable-component ports. All widgets build from [[file:widgets.js][widgets.js]] (below);
+ the gallery page is the reference consumer.
+- [[file:2026-07-03-waybar-redesign-prototype.html][2026-07-03-waybar-redesign-prototype.html]] — three directions for sprucing up
+ waybar in the dupre instrument-console aesthetic (future work).
+
+* Widget library ([[file:widgets.js][widgets.js]])
+
+The whole kit lives in =widgets.js= — a classic script (no modules, no build
+step) exposing the =GW= namespace with one builder per gallery card. Load it
+with =<script src="widgets.js"></script>= and call builders directly.
+
+** Builder contract
+
+=GW.name(host, opts)= → handle ={el, get, set, ...}=. =host= is an empty
+element the widget renders into. =opts.onChange(value, text)= fires on every
+state change, including the initial paint; =text= is the widget's canonical
+readout string. (Exception: live meters whose original init was silent — they
+paint on the first =set=/=push=.)
+
+** Styling
+
+Widget-internal CSS ships inside =widgets.js= (the =GW_CSS= block, injected
+once as =<style id="gw-css">= at load), so widgets are fully styled on any
+page. The design tokens (=--gold=, =--glow-hi=, the amber family, =--mono=,
+=--pulse-rate=, …) must be defined on =:root= by the consumer — the gallery's
+generated =:root= block is the reference; =tokens.json= is the source.
+
+** Tick contract (live meters)
+
+The page owns the clock and the signal source; builders own rendering state.
+Live meters expose value-driven handles that repaint synchronously and fire
+=onChange=:
+
+- =set(level)= for level meters (peak-hold logic lives in the builder)
+- =push(v)= / =set(samples, current)= for history meters (the builder owns
+ the ring buffer)
+- =set(samples, amp)= for sampled traces (samples normalized)
+
+Widget-owned animation (the R17 scope trace, R31 radar sweep, R44 servo
+chase, R52 pseudo-PC, R53 day clock, R10 TIME page) runs inside the builder,
+gated on =prefers-reduced-motion=.
+
+** Style options
+
+Widgets with named style axes take them as constructor opts backed by a
+=STYLES= table on the builder — e.g. =GW.slideToggle(host, {onStyle:'green',
+thumb:'brass'})= with the axes enumerated in =GW.slideToggle.STYLES=, and
+=handle.setStyle(axis, name)= to restyle a live instance. The gallery's
+option chips are demo rigs driving =setStyle=.
+
+** Verification
+
+=tests/gallery-probes/= drives the full kit headlessly over CDP:
+=probe.mjs= (every card renders + responds, size toggle, drags at zoom) and
+=probe-fams.mjs= (screen-family retinting). Run with =node= against the
+gallery file URL; both must pass before a gallery or widgets.js change lands.
+
+* Design tokens (single source, three targets)
+
+[[file:tokens.json][tokens.json]] is the source of truth for the design tokens (palette, amber
+family, glows, pulse rate). [[file:gen_tokens.py][gen_tokens.py]] regenerates all three targets
+(=python3 gen_tokens.py=):
+
+- the =:root= block inside the gallery HTML (web CSS custom properties)
+- [[file:tokens-waybar.css][tokens-waybar.css]] — GTK =@define-color= declarations for the waybar panels
+- [[file:gallery-tokens.el][gallery-tokens.el]] — an elisp alist for svg.el renderers
+
+[[file:gallery-widget.el][gallery-widget.el]] is the Emacs renderer (proof widget: the needle gauge,
+gallery card 10) — it reads gallery-tokens.el and emits the widget as SVG via
+svg.el, so the same look renders inside Emacs. Tests:
+=tests/gallery-tokens/= (generator, unittest) and =tests/gallery-widgets/=
+(renderer, ERT), both in =make test-unit=.
diff --git a/docs/prototypes/gallery-tokens.el b/docs/prototypes/gallery-tokens.el
new file mode 100644
index 0000000..1fb6e52
--- /dev/null
+++ b/docs/prototypes/gallery-tokens.el
@@ -0,0 +1,41 @@
+;;; gallery-tokens.el --- generated from tokens.json -*- lexical-binding: t; -*-
+;;; Commentary:
+;; Generated by gen_tokens.py — do not edit by hand; edit tokens.json.
+;;; Code:
+(defconst gallery-tokens
+ '((ground . "#151311")
+ (panel . "#100f0f")
+ (well . "#0a0c0d")
+ (raise . "#1a1917")
+ (silver . "#bfc4d0")
+ (cream . "#f3e7c5")
+ (steel . "#969385")
+ (dim . "#7c838a")
+ (slate . "#424f5e")
+ (slate-hi . "#54677d")
+ (wash . "#2c2f32")
+ (pass . "#74932f")
+ (fail . "#cb6b4d")
+ (phos . "#7fe0a0")
+ (phos-dim . "#1c3626")
+ (vfd . "#63e6c8")
+ (sevgrn . "#57d357")
+ (sevred . "#e2543f")
+ (sevoff . "#1a1613")
+ (jewel-r . "#ff5b45")
+ (jewel-a . "#ffb43a")
+ (jewel-g . "#6fce33")
+ (gold . "#e2a038")
+ (gold-hi . "#ffbe54")
+ (amber-grad-top . "#f2c76a")
+ (amber-grad-mid . "#d29638")
+ (amber-grad-bot . "#8f671f")
+ (amber-edge . "#7d5c16")
+ (amber-warn . "#f0b552")
+ (glow-hi . "#ffbe54")
+ (glow-lo . "#e2a038")
+ (mono . "\"BerkeleyMono Nerd Font\",\"Berkeley Mono\",monospace")
+ (pulse-rate . "1s"))
+ "Design tokens for the panel widget gallery.")
+(provide 'gallery-tokens)
+;;; gallery-tokens.el ends here
diff --git a/docs/prototypes/gallery-widget.el b/docs/prototypes/gallery-widget.el
new file mode 100644
index 0000000..1c96f45
--- /dev/null
+++ b/docs/prototypes/gallery-widget.el
@@ -0,0 +1,166 @@
+;;; gallery-widget.el --- svg.el renderers for the panel widget gallery -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Proof-of-concept Emacs target for the panel widget gallery. The web
+;; gallery (panel-widget-gallery.html) is the visual spec; tokens.json is
+;; the shared source of truth, and gen_tokens.py emits gallery-tokens.el
+;; (the `gallery-tokens' alist this file reads). Same tokens, same geometry,
+;; different renderer — that's the reuse model: shared values and shared
+;; SVG shapes, per-target code.
+;;
+;; The first widget is the needle gauge (gallery card 10): a semicircular
+;; arc in the wash color, three steel ticks at -60/0/+60 degrees, an amber
+;; needle with a soft glow (a real feGaussianBlur underlay, which librsvg —
+;; Emacs's own SVG renderer — rasterizes), a gold hub, and a value readout.
+;;
+;; Usage:
+;; (gallery-widget-needle-gauge 72) ; => svg dom object
+;; (gallery-widget-svg-string (gallery-widget-needle-gauge 72))
+;; (gallery-widget-write-svg (gallery-widget-needle-gauge 72) "/tmp/g.svg")
+;; In a live Emacs buffer:
+;; (insert-image (svg-image (gallery-widget-needle-gauge 72)))
+
+;;; Code:
+
+(require 'svg)
+(require 'dom)
+
+(load (expand-file-name "gallery-tokens.el"
+ (file-name-directory (or load-file-name buffer-file-name)))
+ nil t)
+
+(defun gallery-widget-token (name)
+ "Return the color/value string for token NAME from `gallery-tokens'.
+Signal an error for an unknown NAME rather than silently returning nil —
+a typo'd token must fail loudly, not render black."
+ (or (cdr (assq name gallery-tokens))
+ (error "Unknown gallery token: %s" name)))
+
+;;; --- needle gauge (gallery card 10) ---
+
+(defconst gallery-widget--gauge-min-deg -60.0
+ "Needle angle at value 0, degrees from vertical (negative = left).")
+(defconst gallery-widget--gauge-max-deg 60.0
+ "Needle angle at value 100.")
+
+(defun gallery-widget--needle-angle (value)
+ "Map VALUE (0-100, clamped) onto the gauge's needle angle in degrees."
+ (unless (numberp value)
+ (error "Gauge value must be a number, got %S" value))
+ (let ((v (min 100.0 (max 0.0 (float value)))))
+ (+ gallery-widget--gauge-min-deg
+ (* (/ v 100.0)
+ (- gallery-widget--gauge-max-deg gallery-widget--gauge-min-deg)))))
+
+(defun gallery-widget--polar (cx cy radius angle-deg)
+ "Point at RADIUS from (CX . CY) at ANGLE-DEG from vertical, as (X . Y).
+Positive angles swing right, matching the gauge's needle travel."
+ (let ((rad (* float-pi (/ angle-deg 180.0))))
+ (cons (+ cx (* radius (sin rad)))
+ (- cy (* radius (cos rad))))))
+
+(defun gallery-widget--fmt (n)
+ "Format coordinate N with two decimals for stable, readable SVG output."
+ (format "%.2f" n))
+
+(defun gallery-widget--node (svg tag &rest attrs)
+ "Append a TAG element with ATTRS plist to SVG and return it."
+ (let ((node (dom-node tag
+ (cl-loop for (k v) on attrs by #'cddr
+ collect (cons (intern (substring (symbol-name k) 1))
+ v)))))
+ (svg--append svg node)
+ node))
+
+(defun gallery-widget-needle-gauge (value)
+ "Render the gallery's needle gauge at VALUE (0-100) as an svg.el object.
+Geometry mirrors the web card: 96px-wide semicircular dial, pivot at its
+bottom center, 40px needle sweeping -60..+60 degrees, readout below."
+ (let* ((w 96) (h 64) (cx 48.0) (cy 48.0)
+ (angle (gallery-widget--needle-angle value))
+ (tip (gallery-widget--polar cx cy 40.0 angle))
+ (svg (svg-create w h :viewBox (format "0 0 %d %d" w h))))
+ ;; glow filter: the SVG equivalent of the web card's box-shadow bloom
+ (svg--append
+ svg
+ (dom-node 'defs nil
+ (dom-node 'filter
+ '((id . "gw-glow") (x . "-75%") (y . "-75%")
+ (width . "250%") (height . "250%"))
+ (dom-node 'feGaussianBlur
+ '((in . "SourceGraphic")
+ (stdDeviation . "2"))))))
+ ;; arc: 2px wash stroke, centerline radius 47 (96px circle, 2px border)
+ (gallery-widget--node svg 'path
+ :d "M 1 48 A 47 47 0 0 1 95 48"
+ :fill "none"
+ :stroke (gallery-widget-token 'wash)
+ :stroke-width "2")
+ ;; ticks: 8px steel radials at -60 / 0 / +60
+ (dolist (deg '(-60.0 0.0 60.0))
+ (let ((outer (gallery-widget--polar cx cy 47.0 deg))
+ (inner (gallery-widget--polar cx cy 39.0 deg)))
+ (gallery-widget--node svg 'line
+ :class "tick"
+ :x1 (gallery-widget--fmt (car inner))
+ :y1 (gallery-widget--fmt (cdr inner))
+ :x2 (gallery-widget--fmt (car outer))
+ :y2 (gallery-widget--fmt (cdr outer))
+ :stroke (gallery-widget-token 'steel)
+ :stroke-width "1.5")))
+ ;; needle glow underlay (blurred, translucent), then the crisp needle
+ (gallery-widget--node svg 'line
+ :class "needle-glow"
+ :x1 (gallery-widget--fmt cx)
+ :y1 (gallery-widget--fmt cy)
+ :x2 (gallery-widget--fmt (car tip))
+ :y2 (gallery-widget--fmt (cdr tip))
+ :stroke (gallery-widget-token 'gold-hi)
+ :stroke-width "4"
+ :stroke-linecap "round"
+ :opacity "0.55"
+ :filter "url(#gw-glow)")
+ (gallery-widget--node svg 'line
+ :class "needle"
+ :x1 (gallery-widget--fmt cx)
+ :y1 (gallery-widget--fmt cy)
+ :x2 (gallery-widget--fmt (car tip))
+ :y2 (gallery-widget--fmt (cdr tip))
+ :stroke (gallery-widget-token 'gold-hi)
+ :stroke-width "2"
+ :stroke-linecap "round")
+ ;; hub: half-dome on the dial's bottom edge (the web card clips the
+ ;; lower half of its 8px circle; here the dome is drawn directly)
+ (gallery-widget--node svg 'path
+ :class "hub"
+ :d "M 44 48 A 4 4 0 0 1 52 48 Z"
+ :fill (gallery-widget-token 'gold))
+ ;; value readout, matching the web card's cream bold figure
+ (svg-text svg (format "%d%%" (round value))
+ :x 48 :y 62
+ :fill (gallery-widget-token 'cream)
+ ;; the token's CSS font stack uses double quotes, which would
+ ;; break the XML attribute — SVG accepts single-quoted names
+ :font-family (replace-regexp-in-string
+ "\"" "'" (gallery-widget-token 'mono))
+ :font-size "12"
+ :font-weight "700"
+ :text-anchor "middle")
+ svg))
+
+;;; --- output helpers ---
+
+(defun gallery-widget-svg-string (svg)
+ "Return SVG (an svg.el dom object) serialized as an XML string."
+ (with-temp-buffer
+ (svg-print svg)
+ (buffer-string)))
+
+(defun gallery-widget-write-svg (svg file)
+ "Write SVG to FILE and return FILE."
+ (with-temp-file file
+ (svg-print svg))
+ file)
+
+(provide 'gallery-widget)
+;;; gallery-widget.el ends here
diff --git a/docs/prototypes/gen_tokens.py b/docs/prototypes/gen_tokens.py
new file mode 100644
index 0000000..9f011e9
--- /dev/null
+++ b/docs/prototypes/gen_tokens.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+"""Single-source design-token generator for the panel widget gallery.
+
+tokens.json is the neutral source of truth for the gallery's design tokens
+(palette, the amber family, glows, font, timing). This script reads it and
+emits three target representations so the same look and feel travels across
+every place the widgets get built:
+
+ web CSS :root { --gold:#e2a038; --glow-hi:255,190,84; ... }
+ waybar GTK @define-color gold #e2a038; (GTK has no custom properties)
+ Emacs (defconst gallery-tokens '((gold . "#e2a038") ...)) for svg.el
+
+The three differ on purpose, which is the whole reason a generator earns its
+keep instead of hand-maintaining three copies:
+
+ - CSS custom props are hyphenated (--glow-hi) and store glow colors as bare
+ "r,g,b" triples so rgba(var(--glow-hi),.5) resolves with a variable alpha.
+ - GTK CSS has no custom properties; it uses @define-color with underscore
+ names and resolves glows to their source hex (GTK does alpha(@color,a)).
+ - Elisp uses a hyphenated-symbol alist of hex strings, which svg.el /
+ librsvg consume directly (a bare triple is not a color there).
+
+The amber hue is defined once, in tokens.json. Both its solid form (--gold)
+and its glow form (--glow-hi, the rgb triple) are derived here, so retuning
+the amber is a one-line edit to the source plus a regenerate.
+
+Usage:
+ python3 gen_tokens.py # regenerate all three targets in place
+
+The web CSS is written into panel-widget-gallery.html between the
+`/* @tokens:start */` and `/* @tokens:end */` markers; the waybar and elisp
+targets are written to tokens-waybar.css and gallery-tokens.el beside it.
+"""
+
+import json
+import os
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOKENS_START = "/* @tokens:start */"
+TOKENS_END = "/* @tokens:end */"
+
+# The elisp target's filename must match its (provide 'gallery-tokens) so
+# `require` resolves it from a load-path.
+DEFAULT_ELISP_NAME = "gallery-tokens.el"
+
+# sections whose values are plain colors (searched by resolve_color, and the
+# ones that become @define-color / alist color entries)
+COLOR_SECTIONS = ("palette", "amber")
+
+
+def hex_to_triple(h):
+ """'#ffbe54' -> '255,190,84'. Accepts 3- or 6-digit hex, '#' optional."""
+ h = h.lstrip("#")
+ if len(h) == 3:
+ h = "".join(c * 2 for c in h)
+ if len(h) != 6:
+ raise ValueError(f"expected 3- or 6-digit hex, got {h!r}")
+ try:
+ r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
+ except ValueError:
+ raise ValueError(f"non-hex digits in {h!r}")
+ return f"{r},{g},{b}"
+
+
+def resolve_color(tokens, key):
+ """Return the hex for a color named in palette or amber; KeyError if absent."""
+ for section in COLOR_SECTIONS:
+ if key in tokens.get(section, {}):
+ return tokens[section][key]
+ raise KeyError(key)
+
+
+def _el_str(v):
+ """Quote a value as an elisp string literal, escaping backslashes/quotes."""
+ return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
+
+
+def emit_web_css(tokens):
+ """The inner :root block: hyphenated --vars, glows as rgb triples."""
+ lines = []
+ for section in COLOR_SECTIONS:
+ for k, v in tokens.get(section, {}).items():
+ lines.append(f"--{k}:{v};")
+ for name, source in tokens.get("glow", {}).items():
+ lines.append(f"--{name}:{hex_to_triple(resolve_color(tokens, source))};")
+ for k, v in tokens.get("font", {}).items():
+ lines.append(f"--{k}:{v};")
+ for k, v in tokens.get("timing", {}).items():
+ lines.append(f"--{k}:{v};")
+ return "\n".join(" " + line for line in lines)
+
+
+def emit_waybar_gtk(tokens):
+ """GTK @define-color declarations; underscore names, glows resolved to hex."""
+ def gtk(name):
+ return name.replace("-", "_")
+
+ lines = [
+ "/* generated from tokens.json by gen_tokens.py — do not edit by hand.",
+ " GTK CSS has no custom properties; reference these as @name, and use",
+ " alpha(@name, 0.5) where the web build uses rgba(var(--name),.5). */",
+ ]
+ for section in COLOR_SECTIONS:
+ for k, v in tokens.get(section, {}).items():
+ lines.append(f"@define-color {gtk(k)} {v};")
+ for name, source in tokens.get("glow", {}).items():
+ lines.append(f"@define-color {gtk(name)} {resolve_color(tokens, source)};")
+ return "\n".join(lines)
+
+
+def emit_elisp(tokens):
+ """An alist of (name . hex) plus timing, for the future svg.el renderer."""
+ pairs = []
+ for section in COLOR_SECTIONS:
+ for k, v in tokens.get(section, {}).items():
+ pairs.append(f"({k} . {_el_str(v)})")
+ for name, source in tokens.get("glow", {}).items():
+ pairs.append(f"({name} . {_el_str(resolve_color(tokens, source))})")
+ for k, v in tokens.get("font", {}).items():
+ pairs.append(f"({k} . {_el_str(v)})")
+ for k, v in tokens.get("timing", {}).items():
+ pairs.append(f"({k} . {_el_str(v)})")
+ body = "\n ".join(pairs)
+ return (
+ ";;; gallery-tokens.el --- generated from tokens.json -*- lexical-binding: t; -*-\n"
+ ";;; Commentary:\n"
+ ";; Generated by gen_tokens.py — do not edit by hand; edit tokens.json.\n"
+ ";;; Code:\n"
+ "(defconst gallery-tokens\n '(" + body + ")\n"
+ ' "Design tokens for the panel widget gallery.")\n'
+ "(provide 'gallery-tokens)\n"
+ ";;; gallery-tokens.el ends here\n"
+ )
+
+
+def replace_between_markers(text, start, end, block):
+ """Replace the text between the start and end marker lines with block.
+
+ The marker lines themselves survive; only their interior is swapped.
+ Idempotent: re-running with the same block is a no-op. Raises ValueError
+ if either marker is missing or out of order.
+ """
+ si = text.find(start)
+ ei = text.find(end)
+ if si == -1 or ei == -1 or ei < si:
+ raise ValueError("token markers not found (or out of order)")
+ start_line_end = text.find("\n", si)
+ if start_line_end == -1:
+ start_line_end = si + len(start)
+ end_line_start = text.rfind("\n", 0, ei) + 1
+ return text[:start_line_end + 1] + block + "\n" + text[end_line_start:]
+
+
+def main(tokens_path=None, html_path=None, waybar_path=None, elisp_path=None):
+ """Regenerate all three targets from tokens.json."""
+ tokens_path = tokens_path or os.path.join(HERE, "tokens.json")
+ html_path = html_path or os.path.join(HERE, "panel-widget-gallery.html")
+ waybar_path = waybar_path or os.path.join(HERE, "tokens-waybar.css")
+ elisp_path = elisp_path or os.path.join(HERE, DEFAULT_ELISP_NAME)
+
+ with open(tokens_path) as f:
+ tokens = json.load(f)
+
+ note = (" /* generated from tokens.json by gen_tokens.py — "
+ "edit tokens.json, then run: python3 gen_tokens.py */")
+ block = note + "\n" + emit_web_css(tokens)
+
+ with open(html_path) as f:
+ html = f.read()
+ html = replace_between_markers(html, TOKENS_START, TOKENS_END, block)
+ with open(html_path, "w") as f:
+ f.write(html)
+
+ with open(waybar_path, "w") as f:
+ f.write(emit_waybar_gtk(tokens) + "\n")
+ with open(elisp_path, "w") as f:
+ f.write(emit_elisp(tokens))
+
+ return {"html": html_path, "waybar": waybar_path, "elisp": elisp_path}
+
+
+if __name__ == "__main__": # pragma: no cover
+ out = main()
+ print("regenerated:", ", ".join(f"{k}={v}" for k, v in out.items()))
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html
new file mode 100644
index 0000000..9740c46
--- /dev/null
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -0,0 +1,1616 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Panel widget gallery — dupre instrument console (interactive)</title>
+<style>
+:root{
+/* @tokens:start */
+ /* generated from tokens.json by gen_tokens.py — edit tokens.json, then run: python3 gen_tokens.py */
+ --ground:#151311;
+ --panel:#100f0f;
+ --well:#0a0c0d;
+ --raise:#1a1917;
+ --silver:#bfc4d0;
+ --cream:#f3e7c5;
+ --steel:#969385;
+ --dim:#7c838a;
+ --slate:#424f5e;
+ --slate-hi:#54677d;
+ --wash:#2c2f32;
+ --pass:#74932f;
+ --fail:#cb6b4d;
+ --phos:#7fe0a0;
+ --phos-dim:#1c3626;
+ --vfd:#63e6c8;
+ --sevgrn:#57d357;
+ --sevred:#e2543f;
+ --sevoff:#1a1613;
+ --jewel-r:#ff5b45;
+ --jewel-a:#ffb43a;
+ --jewel-g:#6fce33;
+ --gold:#e2a038;
+ --gold-hi:#ffbe54;
+ --amber-grad-top:#f2c76a;
+ --amber-grad-mid:#d29638;
+ --amber-grad-bot:#8f671f;
+ --amber-edge:#7d5c16;
+ --amber-warn:#f0b552;
+ --glow-hi:255,190,84;
+ --glow-lo:226,160,56;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+ --pulse-rate:1s;
+/* @tokens:end */
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground);font-size:130%}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:1320px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:86ch}
+.masthead p b{color:var(--silver)}
+.szbar{display:flex;align-items:center;gap:8px;margin-top:.8rem}
+.szbar .lab{color:var(--steel);font-size:.68rem;letter-spacing:.22em;text-transform:uppercase;margin-right:4px}
+
+/* masthead row: description left, index panel right */
+.mastrow{display:flex;gap:28px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap}
+.toc{position:relative;flex:0 0 auto;min-width:220px;padding:14px 20px 13px;border-radius:12px;
+ background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.4)}
+.toc .tt{color:var(--steel);font-size:.66rem;letter-spacing:.26em;text-transform:uppercase;
+ margin-bottom:7px;display:flex;align-items:center;gap:10px}
+.toc .tt::after{content:"";height:1px;background:var(--wash);flex:1;min-width:24px}
+.toc a{display:block;color:var(--dim);text-decoration:none;font-size:.8rem;letter-spacing:.04em;padding:3.5px 0}
+.toc a::before{content:"▸ ";color:var(--steel);font-size:.72em}
+.toc a:hover{color:var(--gold-hi)}
+.toc .scr{position:absolute;width:5px;height:5px;border-radius:50%;
+ background:radial-gradient(circle at 35% 30%,#524d43,#161310);box-shadow:0 .5px 0 rgba(255,255,255,.08)}
+html{scroll-behavior:smooth}
+/* size toggle scales the widget stage only; card text stays at its own (slightly raised) size */
+body[data-size="2"] .stagew{zoom:1.7}
+body[data-size="3"] .stagew{zoom:2.4}
+/* intrinsically tiny widgets get an extra bump so they do not float in an empty stage */
+.stagew.boost{zoom:1.5}
+body[data-size="2"] .stagew.boost{zoom:2.55}
+body[data-size="3"] .stagew.boost{zoom:3.6}
+body[data-size="2"] .grid{grid-template-columns:repeat(auto-fill,minmax(380px,1fr))}
+body[data-size="3"] .grid{grid-template-columns:repeat(auto-fill,minmax(520px,1fr))}
+body[data-size="2"] .wrap,body[data-size="3"] .wrap{max-width:none}
+
+.secnote{color:var(--dim);font-size:.74rem;margin-top:.5rem;max-width:86ch}
+.palgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(176px,1fr));gap:10px;margin-top:1rem}
+.swatch{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:9px;
+ padding:9px;display:flex;flex-direction:column;gap:6px}
+.swatch .sw{height:44px;border-radius:6px;border:1px solid rgba(255,255,255,.08)}
+.swatch .sw.txt{display:flex;align-items:center;justify-content:center;color:var(--steel);font-size:.62rem;
+ background:var(--well);padding:0 6px;text-align:center;overflow:hidden}
+.swatch .swn{color:var(--cream);font-size:.8rem}
+.swatch .swv{color:var(--dim);font-size:.72rem;line-height:1.35}
+.palhead{grid-column:1/-1;color:var(--steel);font-size:.72rem;letter-spacing:.22em;text-transform:uppercase;
+ margin-top:.8rem;display:flex;align-items:center;gap:12px}
+.palhead::after{content:"";height:1px;background:var(--wash);flex:1}
+h2{color:var(--steel);font-size:.74rem;letter-spacing:.24em;text-transform:uppercase;
+ margin:2.2rem 0 .2rem;display:flex;align-items:center;gap:12px}
+h2::after{content:"";height:1px;background:var(--wash);flex:1}
+
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));gap:14px;margin-top:1rem}
+.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px;
+ padding:13px 14px 12px;display:flex;flex-direction:column;gap:9px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)}
+.wname{color:var(--cream);font-size:.95rem;font-weight:700;display:flex;align-items:center;gap:8px}
+.wname .no{color:var(--panel);background:var(--gold);border-radius:4px;font-size:.7rem;padding:0 5px;font-weight:400}
+/* validation lamp: off = not done, amber = in progress, green = done; click cycles, state persists */
+.vlamp{margin-left:auto;flex:0 0 auto;width:10px;height:10px;border-radius:50%;cursor:pointer;
+ background:#221f1b;border:1px solid #33302b}
+.vlamp[data-v="amber"],.vtally .vdot[data-v="amber"],#vaudit .vdot[data-v="amber"]{background:var(--gold);border-color:#7d5c16;box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)}
+.vlamp[data-v="green"],.vtally .vdot[data-v="green"],#vaudit .vdot[data-v="green"]{background:var(--pass);border-color:#4a5c22;box-shadow:0 0 6px 1px rgba(116,147,47,.6)}
+/* audit stepper pill: docks bottom-right while stepping through one state's cards */
+#vaudit{position:fixed;right:18px;bottom:16px;z-index:60;display:flex;align-items:center;gap:7px;
+ padding:8px 13px;border-radius:20px;background:var(--raise);border:1px solid var(--wash);
+ color:var(--cream);font-size:.78rem;letter-spacing:.05em;cursor:pointer;user-select:none;
+ box-shadow:0 4px 14px rgba(0,0,0,.5)}
+#vaudit .vdot{flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:#221f1b;border:1px solid #33302b}
+#vaudit .vx{margin-left:6px;color:var(--steel);padding:0 2px}
+#vaudit .vx:hover{color:var(--fail)}
+/* validation tally in the index: .vdot mirrors the lamp look but must NOT match
+ .vlamp — the counter counts .vlamp nodes */
+.vtally{margin-top:2px}
+.vtally .vrow{display:flex;align-items:center;gap:7px;color:var(--dim);font-size:.75rem;
+ letter-spacing:.04em;padding:2.5px 0}
+.vtally .vrow[data-v]{cursor:pointer}
+.vtally .vrow[data-v]:hover{color:var(--gold-hi)}
+.vtally .vdot{flex:0 0 auto;width:8px;height:8px;border-radius:50%;
+ background:#221f1b;border:1px solid #33302b}
+.vtally .vn{margin-left:auto;color:var(--cream);font-variant-numeric:tabular-nums}
+.vtally .vtot{border-top:1px solid var(--wash);margin-top:3px;padding-top:4.5px;color:var(--steel)}
+.stagew{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:14px 12px;
+ min-height:78px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap}
+.wrd{color:var(--gold-hi);font-size:.8rem;letter-spacing:.06em;font-variant-numeric:tabular-nums;
+ min-height:.95rem;display:flex;align-items:center;gap:6px;
+ border-top:1px solid var(--wash);padding-top:9px;margin-top:2px}
+.wrd::before{content:"▸";color:var(--steel);font-size:.7rem}
+.opts{display:flex;flex-direction:column;gap:5px}
+.opts:empty{display:none}
+.wnote{color:var(--dim);font-size:.85rem;line-height:1.45;
+ border-top:1px solid var(--wash);padding-top:9px;margin-top:2px}
+.wnote b{color:var(--steel);font-weight:400}
+
+/* per-card spec sheet (collapsible) */
+.winfo{border-top:1px dashed var(--wash);padding-top:6px;margin-top:2px}
+.winfo summary{color:var(--steel);font-size:.74rem;letter-spacing:.18em;text-transform:uppercase;
+ cursor:pointer;list-style:none;user-select:none}
+.winfo summary::-webkit-details-marker{display:none}
+.winfo summary::before{content:"▸ "}
+.winfo[open] summary::before{content:"▾ "}
+.igrid{display:grid;grid-template-columns:max-content 1fr;gap:4px 10px;margin-top:7px}
+.igrid .ik{color:var(--steel);font-size:.72rem;letter-spacing:.08em;text-transform:uppercase;padding-top:1px}
+.igrid .iv{color:var(--dim);font-size:.82rem;line-height:1.45}
+.igrid .iv b{color:var(--silver);font-weight:400}
+.igrid .iv a{color:var(--gold);text-decoration:none;border-bottom:1px dotted var(--gold)}
+.igrid .iv a:hover{color:var(--gold-hi);border-color:var(--gold-hi)}
+.card a.xref{color:var(--gold);text-decoration:none;border-bottom:1px dotted var(--gold)}
+.card a.xref:hover{color:var(--gold-hi);border-color:var(--gold-hi)}
+.card:target{border-color:var(--gold);box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 0 0 1px var(--gold),0 6px 14px rgba(0,0,0,.4)}
+
+/* option chips: each group is an encircled capsule (label + choices); groups flow with spacing */
+.famchips{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:1px}
+.fgroup{display:inline-flex;align-items:center;gap:6px;padding:3px 9px 3px 8px;
+ border:1px solid #2e2b26;border-radius:10px;background:rgba(255,255,255,.015)}
+.famchips .lab{color:var(--steel);font-size:.6rem;letter-spacing:.14em;text-transform:uppercase;margin-right:2px}
+.famchips .fc{width:13px;height:13px;border-radius:50%;cursor:pointer;
+ border:1px solid rgba(255,255,255,.22);box-shadow:0 1px 3px rgba(0,0,0,.5)}
+.famchips .fc.on{outline:2px solid var(--silver);outline-offset:1px}
+
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body data-size="2">
+<div class="wrap">
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · live kit</div>
+ <h1>Widget gallery — the instrument-console kit, fully driveable</h1>
+ <div class="mastrow">
+ <div>
+ <p>Every control + display idiom in the dupre faceplate language, merged into one live console — the base kit,
+ the candidate batch, and the growing reference batch (R: modeled on period hardware), all rendering from
+ the same tokens the net / bt / sound panels use.
+ <b>Controls</b> take input; <b>meters &amp; gauges</b> show a live analog value; <b>indicators &amp;
+ readouts</b> show state or a number. Every widget is operable: drag the faders, knobs and gauges; click the
+ switches, keys and lamps. Each card carries a live readout that tracks what you do. The trace displays run
+ a live signal; the pointer types drive from your drag.</p>
+ <div class="szbar"><span class="lab">widget size</span>
+ <button class="key" data-sz="1">S</button>
+ <button class="key on" data-sz="2">M</button>
+ <button class="key" data-sz="3">L</button>
+ </div>
+ </div>
+ <nav class="toc">
+ <span class="scr" style="top:6px;left:6px"></span><span class="scr" style="top:6px;right:6px"></span>
+ <span class="scr" style="bottom:6px;left:6px"></span><span class="scr" style="bottom:6px;right:6px"></span>
+ <div class="tt">Index</div>
+ <a href="#sec-controls">Controls</a>
+ <a href="#sec-meters">Meters &amp; gauges</a>
+ <a href="#sec-indicators">Indicators &amp; readouts</a>
+ <a href="#sec-palette">Palette</a>
+ <div class="tt" style="margin-top:11px">Validation</div>
+ <div class="vtally" id="vtally"></div>
+ </nav>
+ </div>
+</header>
+
+<h2 id="sec-controls">Controls — take input</h2>
+<div class="grid" id="controls"></div>
+
+<h2 id="sec-meters">Meters &amp; gauges — live analog value</h2>
+<div class="grid" id="meters"></div>
+
+<h2 id="sec-indicators">Indicators &amp; readouts — state or number</h2>
+<div class="grid" id="indicators"></div>
+
+<h2 id="sec-palette">Palette — the instrument colors</h2>
+<p class="secnote">Every named color the instruments are built from — markers, hands, faces, lamps, phosphors,
+ materials — with where it appears. Page chrome (background, body text) is deliberately absent.</p>
+<div class="palgrid" id="palette"></div>
+
+</div>
+<script src="widgets.js"></script>
+<script>
+const $ = id => document.getElementById(id);
+const {svgEl,polar,dragX,dragY,dragDelta,seg7,buildBars,VUDB,vuDb,SCREEN_FAMS}=GW;
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+document.querySelectorAll('.szbar .key').forEach(b=>b.addEventListener('click',()=>{
+ document.body.dataset.size=b.dataset.sz;
+ localStorage.setItem('gv-size',b.dataset.sz);
+ document.querySelectorAll('.szbar .key').forEach(k=>k.classList.toggle('on',k===b));
+}));
+(function(){const s=localStorage.getItem('gv-size');
+ if(!s||!/^[123]$/.test(s))return;
+ document.body.dataset.size=s;
+ document.querySelectorAll('.szbar .key').forEach(k=>k.classList.toggle('on',k.dataset.sz===s));})();
+/* boost the intrinsically tiny widgets (audit: content under 10% of the stage) */
+const BOOST=['01','07','08','12','13','N14','18'];
+
+/* ---- palette: the named instrument colors, grouped by role. No codes shown — names and uses. ---- */
+const PALETTE=[
+ ['Materials',[
+ ['Brass','#b8944a','comfort-meter bezel and knurl'],
+ ['Copper','#ad7a43','knife-switch blades, jaws and hinge'],
+ ['Chrome silver','var(--silver)','needles, ball lever, fader caps, chrome handles'],
+ ['Machined steel','var(--steel)','engraved captions, scale furniture, printed panel lines'],
+ ['Aluminum','#c9c4b8','timer hub, lever shafts, counter bezels'],
+ ['Rubber & bakelite','#2c2824','knob bodies, rocker pad, stomp switch'],
+ ]],
+ ['Faces & inks',[
+ ['Cream dial','var(--cream)','tick marks, scale numerals, dial faces, flag windows'],
+ ['Chart paper','#efe9da','day-date disc, circular chart, comfort face, telegraph dial'],
+ ['Face ink','#14110e','numerals and printing on the paper faces'],
+ ['CRT face green','#b9d8c0','round-scope face tint'],
+ ['Sky slate','var(--slate-hi)','attitude-indicator sky'],
+ ['Earth brown','#5c4630','attitude-indicator ground'],
+ ['Instrument black','#17140f','bezels, plates and housings'],
+ ]],
+ ['Lamps, LEDs & jewels',[
+ ['Lit amber','var(--gold-hi)','lit keys, sweep line, fixed indexes, blinkenlight lamps'],
+ ['Panel amber','var(--gold)','key gradients, bearing rings, seated pins, heading bug'],
+ ['Warning amber','var(--amber-warn)','warn cells, tripped collars, gear-transit lamps'],
+ ['Run green','var(--pass)','run lamps, gear greens, monitor bars, LIVE lamps'],
+ ['Fail terracotta','var(--fail)','muted keys, fault cells, TRIP button'],
+ ['LED green','var(--sevgrn)','seven-segment digits, battery cells'],
+ ['LED red','var(--sevred)','red seven-segment variant'],
+ ['Matrix red','#ff4a30','dot-matrix LEDs'],
+ ['Jewel red','var(--jewel-r)','pilot-lamp lens'],
+ ['Jewel amber','var(--jewel-a)','pilot-lamp lens, four-way corner lamps'],
+ ['Jewel green','var(--jewel-g)','pilot-lamp lens'],
+ ['Neon orange','#ff9a4c','nixie digits, dekatron glow, red screen family'],
+ ['Flip-disc yellow','#e3d44f','flip-dot faces'],
+ ['PDP magenta','#3a1420','blinkenlights panel skin'],
+ ]],
+ ['Screens & phosphors',[
+ ['Phosphor green','var(--phos)','oscilloscope traces, green screen family'],
+ ['Phosphor dim','var(--phos-dim)','phosphor afterglow and dark screen grounds'],
+ ['VFD cyan','var(--vfd)','marquee, filter-bank arrows, vfd screen family'],
+ ['Graticule green','#3d5c46','CRT graticule rulings'],
+ ['LCD white','#f2f4f2','waveform-editor ink, white screen family'],
+ ['P4 blue-white','#cfe4ff','blue screen family'],
+ ]],
+ ['Needles & controls',[
+ ['Needle red','#c23a28','comfort-meter needles, red hands'],
+ ['Control red','#e0523a','stop knobs, telegraph pointer, palm buttons, OFF marks'],
+ ['Pointer yellow','#e8cf4a','day-date read hand'],
+ ]],
+];
+(function buildPalette(){
+ const host=$('palette'); if(!host)return;
+ for(const [group,colors] of PALETTE){
+ const h=document.createElement('div'); h.className='palhead'; h.textContent=group;
+ host.appendChild(h);
+ for(const [name,val,used] of colors){
+ const tile=document.createElement('div'); tile.className='swatch';
+ tile.innerHTML=`<div class="sw" style="background:${val}"></div>`+
+ `<div class="swn">${name}</div><div class="swv">${used}</div>`;
+ host.appendChild(tile);}}
+})();
+
+/* ---- screen-color families: period-authentic alternates for the screen widgets ----
+ green = P1 CRT phosphor (radar/scopes; hi reuses --phos, graticule keeps the kit's scope green)
+ amber = P3 phosphor / backlit amber (the gold family the kit already speaks)
+ red = neon orange-red, the nixie's color pulled out as a family
+ blue = P4 blue-white (early TV / precision scopes)
+ vfd = blue-green vacuum-fluorescent (the marquee's --vfd pulled out as a family)
+ white = monochrome LCD/CRT white
+ Exploration data, deliberately NOT in tokens.json yet — families get tokenized once picked,
+ the same way amber earned its tokens. Values keyed by the CSS custom property they drive. */
+/* SCREEN_FAMS lives in widgets.js (GW.SCREEN_FAMS) */
+function screenChips(no,el,fams,def){
+ const cardEl=el.closest('.card'); if(!cardEl)return;
+ const row=document.createElement('div'); row.className='famchips';
+ const g=document.createElement('span'); g.className='fgroup'; row.appendChild(g);
+ const lab=document.createElement('span'); lab.className='lab'; lab.textContent='screen'; g.appendChild(lab);
+ fams.forEach(f=>{const b=document.createElement('span');
+ b.className='fc'+(f===def?' on':''); b.style.background=SCREEN_FAMS[f]['--scr-hi']; b.title=f;
+ b.addEventListener('click',()=>{
+ for(const [k,v] of Object.entries(SCREEN_FAMS[f])) el.style.setProperty(k,v);
+ g.querySelectorAll('.fc').forEach(x=>x.classList.toggle('on',x===b));
+ setRd(no,'screen '+f);});
+ g.appendChild(b);});
+ cardEl.querySelector('.opts').appendChild(row);
+}
+/* ---- per-card spec sheets ----
+ input how you drive it, incl. the model: click widgets port to every target (Emacs click-regions
+ included); drag widgets have no Emacs drag equivalent and need a click/key idiom there;
+ display widgets take no input in real use (gallery interaction is demo only).
+ period present only when the component is clearly not timeless — use it to keep a console
+ period-consistent (swap a modern part for its historical counterpart or vice versa). */
+const INFO={
+'01':{input:'Click to flip. Click-only, ports everywhere incl. Emacs.',
+ solves:'One persistent on/off state, visible at a glance without reading text.',
+ use:'Common in every modern UI. Shines as a master control: power, mute, enable.',
+ limits:'Two states only. Ambiguous if the on-direction is not marked.',
+ origin:'Slide switches (transistor radios, Walkman hold) → the iPod hold switch with its orange reveal → the iOS toggle (2007). This card wears the hardware skin of that lineage.',difficulty:'Intuitive.',
+ prefer:'The state persists and gets glanced at more than changed. For period consoles use R03 or N01 instead.',
+ period:'The pill rendering is modern (iPod/iOS era); the slide-switch mechanism underneath is 1950s+.',
+ ref:'reference/slide-toggle.jpg'},
+'02':{input:'Click one key to engage it; the others release. Click-only.',
+ solves:'One-of-few mode selection where the active mode must glow.',
+ use:'Common on consoles. Shines for 2-4 modes switched often.',
+ limits:'Wide per option; past ~5 keys a selector reads better.',
+ origin:'Broadcast and studio consoles.',difficulty:'Intuitive.',
+ prefer:'Each mode deserves a lit, labeled, pressable identity.'},
+'03':{input:'Drag along the track. Drag-only — needs a click/key idiom for Emacs.',
+ solves:'Continuous level with position as the value readout.',
+ use:'Common in audio and settings. Shines when relative position matters.',
+ limits:'Precision bounded by track length; needs horizontal room.',
+ origin:'Mixing desks.',difficulty:'Intuitive.',
+ prefer:'The value is a level the eye should compare across channels.'},
+'04':{input:'Drag along the track. Drag-only — needs a click/key idiom for Emacs.',
+ solves:'Same as the horizontal fader in a column footprint.',
+ use:'Common. Shines in channel strips where many sit side by side.',
+ limits:'Needs vertical room; thumb occludes the scale mid-drag.',
+ origin:'Mixing desks.',difficulty:'Intuitive.',
+ prefer:'Levels stack in parallel columns (mixer, EQ bank).'},
+'05':{input:'Drag vertically to rotate. Drag-only — Emacs wants +/- keys or click-step.',
+ solves:'Continuous control in the smallest footprint of any analog input.',
+ use:'Common everywhere. Shines for gain, tune, adjust anywhere space is tight.',
+ limits:'Drag direction is a learned convention; fine setting needs a readout.',
+ origin:'Radio and audio gear.',difficulty:'Easy — the vertical-drag convention must be learned.',
+ prefer:'Space is tight and the exact value matters less than the gesture.'},
+'06':{input:'Click a segment. Click-only, ports everywhere.',
+ solves:'One-of-N selection with every option visible at once.',
+ use:'Common. Shines for 3-6 named, equally likely options.',
+ limits:'Options must fit the bar; long labels break it.',
+ origin:'Test equipment range switches, modern segmented controls.',difficulty:'Intuitive.',
+ prefer:'All options should stay readable without opening anything.'},
+'07':{input:'Click to toggle. Click-only.',
+ solves:'A lightweight boolean that reads as a tag, not a switch.',
+ use:'Common in filters and option sets. Shines in rows of independent flags.',
+ limits:'Low visual weight — poor for states that must shout.',
+ origin:'Modern web UI, filter chips.',difficulty:'Intuitive.',
+ prefer:'Many independent booleans sit together and density wins.'},
+'08':{input:'Two clicks: arm, then fire; disarms on timeout. Click-only.',
+ solves:'Destructive actions need friction sized to their consequence.',
+ use:'Specialty. Shines guarding resets, wipes, irreversible sends.',
+ limits:'Friction annoys if the action is actually routine.',
+ origin:'Missile and launch panels, industrial interlocks.',difficulty:'Easy.',
+ prefer:'A mis-click would cost real work — make intent two-step.'},
+'09':{input:'Click a lamp to toggle it. Click-only.',
+ solves:'A bank of independent booleans with lit state per item.',
+ use:'Common. Shines for channel enables, feature flags.',
+ limits:'Unlabeled lamps need a legend; color alone excludes colorblind users.',
+ origin:'Console channel strips.',difficulty:'Intuitive.',
+ prefer:'The set is small and each member needs its own light.'},
+'24':{input:'Click to step to the next position. Click-only.',
+ solves:'One-of-N selection with a physical pointer you can read across the room.',
+ use:'Common on period gear. Shines for mode/range knobs.',
+ limits:'Stepping is sequential — jumping to a far position takes several clicks.',
+ origin:'Rotary switches on radios and instruments.',difficulty:'Intuitive.',
+ prefer:'The selection should read like hardware, not a menu.'},
+'25':{input:'Click a numeral, a mark, or between marks for the units; focused, arrows step one unit. Click + keys.',
+ solves:'Reading a value against a long calibrated scale.',
+ use:'Specialty. Shines where the scale itself carries meaning.',
+ limits:'Scale literacy required; poor for quick setting.',
+ origin:'Slide rules, tuning scales.',difficulty:'Moderate.',
+ prefer:'The scale markings are the point, not just the value.',
+ period:'1930s-60s.'},
+'N01':{input:'Click to rock. Click-only.',
+ solves:'Mains-grade on/off with an unmistakable thrown state.',
+ use:'Common. Shines as the one switch that powers the whole unit.',
+ limits:'Visually heavy — wrong for minor options.',
+ origin:'Appliance and amplifier mains switches.',difficulty:'Intuitive.',
+ prefer:'The action is THE power decision for the device.'},
+'N02':{input:'Click a transport key; keys are mutually exclusive. Click-only.',
+ solves:'Media transport state with one lit verb at a time.',
+ use:'Common wherever playback exists. Shines with a moving mechanism to confirm state.',
+ limits:'The verb set is fixed by convention — do not repurpose.',
+ origin:'Tape decks.',difficulty:'Intuitive.',
+ prefer:'Anything plays, records, or rewinds.',
+ period:'1960s-80s tape era styling.'},
+'N03':{input:'Click a numbered preset. Click-only.',
+ solves:'Instant recall of a small set of saved destinations.',
+ use:'Common. Shines for stations, scenes, memory slots.',
+ limits:'Anonymous numbers — the user must remember the mapping.',
+ origin:'Car radio mechanical presets.',difficulty:'Intuitive.',
+ prefer:'A handful of favorites deserve one-press recall.',
+ period:'1950s-70s mechanical-preset styling.'},
+'N04':{input:'Drag either ring: inner and outer rotate independently. Drag-only, and the two targets need care in Emacs.',
+ solves:'Two related parameters in one knob footprint.',
+ use:'Specialty. Shines for coarse/fine or volume/tone pairs.',
+ limits:'Which ring is which must be learned; easy to grab the wrong one.',
+ origin:'Radio concentric controls.',difficulty:'Moderate.',
+ prefer:'Two parameters are so coupled they should live on one axis.',
+ period:'1940s-70s radio styling.'},
+'N05':{input:'Drag to spin; the LED ring shows position. Drag-only.',
+ solves:'Endless rotation where the value can also be set by the program.',
+ use:'Common on modern gear. Shines when presets move the value under the knob.',
+ limits:'No physical end stops — the ring is the only position cue.',
+ origin:'Digital synths and controllers.',difficulty:'Easy.',
+ prefer:'Software also writes the value the knob edits.',
+ period:'1980s onward.'},
+'N06':{input:'Click to turn the key through its positions. Click-only.',
+ solves:'Mode changes that should require deliberate, almost credentialed intent.',
+ use:'Specialty. Shines for maintenance/normal/locked modes.',
+ limits:'Slow by design; wrong for anything frequent.',
+ origin:'Machine and alarm key switches.',difficulty:'Easy.',
+ prefer:'Casual switching must be discouraged by the control itself.'},
+'N07':{input:'Drag along the track; a detent holds center. Drag-only.',
+ solves:'Balancing two sources with an honest neutral point.',
+ use:'Specialty. Shines for A/B blend, pan, crossfade.',
+ limits:'Only meaningful with exactly two poles.',
+ origin:'DJ mixers.',difficulty:'Intuitive.',
+ prefer:'The midpoint is a real state, not just halfway.'},
+'N08':{input:'Drag a wheel vertically to step its digit. Drag-only — Emacs wants per-digit +/-.',
+ solves:'Exact multi-digit numeric entry without a keyboard.',
+ use:'Specialty. Shines for frequencies, counts, codes set digit by digit.',
+ limits:'Slow for big changes; each digit is its own control.',
+ origin:'Avionics and test equipment.',difficulty:'Expert — digit-wise thinking.',
+ prefer:'Exactness beats speed and a keypad would be overkill.',
+ period:'1960s-80s.'},
+'N09':{input:'Click a switch to flip it. Click-only.',
+ solves:'A persistent configuration word set once and read rarely.',
+ use:'Specialty. Shines for boot-time or install-time options.',
+ limits:'Cryptic without its legend; not for live settings.',
+ origin:'Circuit-board DIP switches.',difficulty:'Expert — meaningless without the manual.',
+ prefer:'Config should be physical, deliberate, and survive resets.',
+ period:'1975-95 computing.'},
+'N10':{input:'Drag the inner jog for fine steps, the outer shuttle for velocity. Drag-only, two coupled gestures.',
+ solves:'Navigating a timeline at both frame and sweep speeds.',
+ use:'Specialty. Shines for scrubbing media or logs.',
+ limits:'Shuttle velocity mapping must be learned; hard to discover.',
+ origin:'Video edit decks.',difficulty:'Expert.',
+ prefer:'One control must cover both precision and distance.',
+ period:'1980s-90s edit suites.'},
+'R02':{input:'Drag to rotate the disc under the fixed hairline. Drag-only.',
+ solves:'Reading a continuous setting to a tenth of a division.',
+ use:'Specialty. Shines for calibration and lab-grade settings.',
+ limits:'Vernier reading is a skill; overkill for coarse values.',
+ origin:'Laboratory instruments.',difficulty:'Expert — vernier literacy.',
+ prefer:'The precision of the readback is the whole point.',
+ period:'1930s-60s lab styling.'},
+'R03':{input:'Click to throw the lever. Click-only.',
+ solves:'A binary with a big, satisfying, unmistakable throw.',
+ use:'Common on period gear. Shines where the thrown angle must be visible.',
+ limits:'Larger than a rocker for the same bit of state.',
+ origin:'Aviation and lab toggles.',difficulty:'Intuitive.',
+ prefer:'State must read from across the panel.'},
+'R04':{input:'Drag vertically to rotate. Drag-only.',
+ solves:'Same job as the rotary knob in a period skin.',
+ use:'Skin variant. Shines in 30s-50s compositions.',
+ limits:'As the rotary knob.',
+ origin:'Bakelite radio knobs.',difficulty:'Easy.',
+ prefer:'The console is dressed to that era.',
+ period:'1930s-50s.'},
+'R05':{input:'Drag each band vertically. Drag-only, one gesture per band.',
+ solves:'Shaping a curve by its bands, position = the curve itself.',
+ use:'Common as graphic EQ. Shines when the fader tops draw the response.',
+ limits:'Fixed bands; inter-band moves need many gestures.',
+ origin:'Filter banks, then graphic equalizers.',difficulty:'Easy.',
+ prefer:'The overall shape matters more than any single value.',
+ period:'1950s-60s studio filter styling.'},
+'R06':{input:'Click to snap to the next position. Click-only.',
+ solves:'One-of-few selection with an emphatic pointer.',
+ use:'Common on amps and lab gear. Shines for 2-4 coarse modes.',
+ limits:'Sequential stepping; the blade hides labels under it.',
+ origin:'Amplifier and instrument selectors.',difficulty:'Intuitive.',
+ prefer:'The selector should look like it belongs on a tube amp.',
+ period:'1940s-60s.'},
+'R12':{input:'Drag the handle along the slot. Drag-only.',
+ solves:'A signed level (boost/cut) around a marked zero.',
+ use:'Common on period hi-fi. Shines for tone and balance trims.',
+ limits:'Chrome furniture is wide for what it holds.',
+ origin:'Hi-fi tone controls.',difficulty:'Intuitive.',
+ prefer:'Zero is the default and deviation should be obvious.',
+ period:'1960s-70s hi-fi.'},
+'R14':{input:'Drag to rotate; the knurl ring turns with it. Drag-only.',
+ solves:'Slow, geared tuning across an engraved scale.',
+ use:'Skin variant for tuning. Shines in early-radio compositions.',
+ limits:'As the rotary knob; the engraved scale needs light to read.',
+ origin:'1920s receivers.',difficulty:'Easy.',
+ prefer:'The console leans into the earliest radio era.',
+ period:'1920s-30s.'},
+'R15':{input:'Drag the bandspread dial; click the band ring to switch bands. Mixed — the click half ports to Emacs, the drag half needs an idiom.',
+ solves:'One dial covering several ranges without losing resolution.',
+ use:'Specialty. Shines for shortwave-style band plus fine-tune.',
+ limits:'Band/bandspread interplay confuses newcomers.',
+ origin:'Multi-band receivers.',difficulty:'Expert.',
+ prefer:'The domain genuinely has bands (ranges, scopes, zoom levels).',
+ period:'1930s-50s.'},
+'R16':{input:'Click digits to buffer, OK to commit, C to clear. Click-only, fully Emacs-portable.',
+ solves:'Deliberate numeric entry with an explicit confirm step.',
+ use:'Common industrial. Shines for codes, channels, quantities.',
+ limits:'Slower than typing on a real keyboard when one exists.',
+ origin:'Industrial and security keypads.',difficulty:'Intuitive.',
+ prefer:'Entry must be explicit and cancellable, not live.'},
+'R18':{input:'Drag each lit column vertically; channels are independent. Drag-only.',
+ solves:'Two adjacent levels set by feel with lit numeric feedback.',
+ use:'Specialty. Shines for blend/mix pairs on processors.',
+ limits:'Compact but dense; the lit figure is the only precise cue.',
+ origin:'Compressor blend controls.',difficulty:'Easy.',
+ prefer:'Two coupled levels should sit shoulder to shoulder.',
+ period:'1970s-80s rack gear.'},
+'R19':{input:'Drag horizontally; the nearer flag follows, flags cannot cross. Drag-only.',
+ solves:'Marking a start/end region inside a visible signal.',
+ use:'Specialty. Shines for trims, loops, selections over a waveform.',
+ limits:'Flag semantics (S/E, no-cross) must be learned; small targets.',
+ origin:'Hardware samplers.',difficulty:'Expert.',
+ prefer:'The region IS the value and the data must stay visible.',
+ period:'1980s sampler styling.'},
+'R20':{input:'Drag each drum vertically to roll it. Drag-only.',
+ solves:'Stepped selection with a satisfying mechanical roll.',
+ use:'Specialty. Shines for paired coarse selections (EQ turnovers).',
+ limits:'Values hidden off-drum; sequential like all steppers.',
+ origin:'Channel-strip EQ rollers.',difficulty:'Easy.',
+ prefer:'A stepped pair should feel like machinery, not menus.',
+ period:'1960s console styling.'},
+'R21':{input:'Click a key; its LED takes the selection. Click-only.',
+ solves:'One-of-N program recall where the LED, not the key, carries state.',
+ use:'Common on rack gear. Shines for 4-8 named programs; maps directly onto a waybar workspace selector.',
+ limits:'One LED of state per key — no per-key value.',
+ origin:'Lexicon 224 program row.',difficulty:'Intuitive.',
+ prefer:'Selection state should read as a dot row even from an angle.',
+ period:'Late 1970s-80s digital rack.'},
+'R22':{input:'Click a zone to select it directly (A / AB / B). Click-only.',
+ solves:'Three-way routing with direct selection, no cycling.',
+ use:'Specialty. Shines for source/both/monitor selects.',
+ limits:'Three positions only; AB lighting both LEDs must be understood.',
+ origin:'Hi-fi speaker selectors.',difficulty:'Intuitive.',
+ prefer:'The middle state is a real combination, not a midpoint.'},
+'R23':{input:'Drag vertically to rotate. Drag-only.',
+ solves:'Same job as the rotary knob in a machined-metal skin.',
+ use:'Skin variant. Shines on 70s hi-fi faceplates.',
+ limits:'As the rotary knob.',
+ origin:'Spun-aluminum hi-fi knobs.',difficulty:'Easy.',
+ prefer:'The console is dressed silver-on-black.',
+ period:'1970s hi-fi.'},
+'R24':{input:'Click the stomp to latch; the jewel lights when engaged. Click-only.',
+ solves:'A rugged latch whose state glows at foot distance.',
+ use:'Common on pedals. Shines for engage/bypass.',
+ limits:'One bit; the jewel is the only state cue.',
+ origin:'Guitar stompboxes.',difficulty:'Intuitive.',
+ prefer:'The toggle should survive abuse and read from far away.'},
+'R27':{input:'Drag to step through the detents. Drag with detents — Emacs wants +/- steps.',
+ solves:'Stepped gain where each position is a calibrated, repeatable value.',
+ use:'Specialty. Shines for preamp gain in fixed dB steps.',
+ limits:'No values between detents by design.',
+ origin:'Neve-style console gain selectors.',difficulty:'Easy.',
+ prefer:'Repeatability beats continuous freedom.',
+ period:'1970s console styling.'},
+'R28':{input:'Click to step the disc to the next position, wrapping. Click-only.',
+ solves:'Heavy-duty source selection with an unmistakable handle.',
+ use:'Specialty. Shines for OFF/ON/COMBINE-style power routing.',
+ limits:'Sequential stepping; big footprint.',
+ origin:'Marine battery selectors.',difficulty:'Intuitive.',
+ prefer:'The selection routes something heavy and should look like it.'},
+'R29':{input:'Click the guard open, then click to throw; closing re-guards. Click-only, two-step.',
+ solves:'A switch that cannot be thrown by accident, period.',
+ use:'Specialty. Shines over anything irreversible.',
+ limits:'Two actions every time; reserve for genuine hazards.',
+ origin:'Aviation guarded switches.',difficulty:'Easy.',
+ prefer:'Like arm-to-fire but the guard state itself must be visible.'},
+'R32':{input:'Drag the dial to wind minutes on (turn to start); click the red knob to zero it (push to stop). Mixed — the stop is click-portable, the wind needs a key idiom in Emacs.',
+ solves:'Setting a duration and watching it run down, with OFF as a real position.',
+ use:'Common in kitchens and labs. Shines for timeboxes, pomodoros, sleep timers.',
+ limits:'One duration, coarse resolution; the demo runs a minute per second.',
+ origin:'Spring-driven interval timers.',difficulty:'Intuitive.',
+ prefer:'The remaining time should read as a dial position, not digits.'},
+'R33':{input:'Click an arrow or anywhere in its quadrant to step that direction. Click-only — the most Emacs-portable control in the kit, since it is literally arrow keys.',
+ solves:'Discrete navigation in four directions without a pointer position.',
+ use:'Common on remotes, cameras, avionics MFDs. Shines for menu and grid focus.',
+ limits:'Steps, not analog rate; diagonals need two presses; no held-repeat here.',
+ origin:'Remote-control and camera four-way pads.',difficulty:'Intuitive.',
+ prefer:'Focus moves through a grid or menu and each step is deliberate.'},
+'R34':{input:'Click a quadrant; the lever throws there and stays. Click-only, fully portable.',
+ solves:'One-of-four stateful selection where the selector itself shows the state.',
+ use:'Specialty. Shines for quadrant modes (channel pairs, axis pairs, routing).',
+ limits:'Exactly four states; diagonal labels need the printed legend.',
+ origin:'Test-panel quadrant switches with ball levers.',difficulty:'Intuitive.',
+ prefer:'The rocker steps a cursor; this holds a position. Reach for it when the four options are states, not directions.'},
+'R37':{input:'Click a row/column intersection to seat or pull a pin. Click-only, fully portable.',
+ solves:'Many-to-many routing where every active route stays simultaneously readable.',
+ use:'Specialty. Shines for source-to-destination mapping: audio routing, signal paths, rule wiring.',
+ limits:'Grid size is the ceiling; dense matrices need hover labels to stay legible.',
+ origin:'EMS VCS3 synthesizer pin matrix.',difficulty:'Easy — rows and columns must be read.',
+ prefer:'The patch bay shows one cable per route; the matrix shows the whole routing state at once.',
+ period:'Late 1960s-70s synth and routing gear.'},
+'R38':{input:'Press and hold; release anywhere drops to SAFE. Hold-based — pointer capture on web, a held key in Emacs; the only held-state control in the kit.',
+ solves:'Actions that must stop the moment the operator lets go.',
+ use:'Specialty. Shines for jog/feed, test firing, anything unsafe to walk away from.',
+ limits:'Fatigue is the design point — do not use it for long operations.',
+ origin:'Dead-man controls on trains, cranes, machine tools.',difficulty:'Intuitive.',
+ prefer:'Release-to-safe matters more than convenience.'},
+'R39':{input:'Click a hole; the wheel winds to the stop and returns, then the digit registers. Click-only, though the return animation is the point.',
+ solves:'Numeric entry with built-in pacing — larger digits genuinely take longer.',
+ use:'Specialty and nostalgic. Shines when entry should feel deliberate, or in period sets.',
+ limits:'Slow by design; ten digits only; no correction but a redial.',
+ origin:'Rotary telephone dials (Strowger pulse dialing).',difficulty:'Intuitive to those who have seen one.',
+ prefer:'The keypad enters numbers; the dial performs them.',
+ period:'1920s-70s telephony; hard-clashes with anything digital.'},
+'R40':{input:'Click a breaker to open or close it; TRIP pops one out under fault. A tripped breaker resets in two clicks: pull to off, then close. Click-only.',
+ solves:'Protective state the SYSTEM can change — no other control in the kit throws itself.',
+ use:'Common industrially. Shines for services/circuits that can fail independently: a tripped breaker is a crashed daemon.',
+ limits:'The tripped mid-position must be visually distinct or the story is lost.',
+ origin:'Aircraft and distribution breaker rows.',difficulty:'Easy — the two-step reset must be learned.',
+ prefer:'Faults should be visible at the switch, not in a separate alarm list.'},
+'R41':{input:'Press VERB or NOUN, type two digits, ENTR commits; V35 runs the lamp test, bad grammar lights OPR ERR. Click-only, fully portable.',
+ solves:'Reaching many functions from few keys via a verb-noun command grammar.',
+ use:'Specialty. Shines when the function space outgrows the panel space.',
+ limits:'The grammar must be memorized — the tradeoff is capability for discoverability.',
+ origin:'Apollo Guidance Computer DSKY.',difficulty:'Expert — by design.',
+ prefer:'Dozens of rare commands beat dozens of rare buttons.',
+ period:'1960s-70s spaceflight; the aesthetic survives in every command palette.'},
+'R42':{input:'Click to advance a step (or start from OFF); it then walks itself through the program. Click-only.',
+ solves:'A whole procedure shown as one rotating position — where you are, what came before, what is next.',
+ use:'Common on appliances and process gear. Shines for fixed multi-step sequences.',
+ limits:'The program is fixed at manufacture; skipping steps means clicking through them.',
+ origin:'Washing-machine and dishwasher cam timers.',difficulty:'Intuitive.',
+ prefer:'The sequence matters more than any single state — R30 shows a state, this shows a program.',
+ period:'1950s-80s appliance controls.'},
+'R48':{input:'Click to throw the blade open or closed. Click-only.',
+ solves:'A disconnect whose open state is physically visible — no lamp to trust, the gap IS the proof.',
+ use:'Specialty. Shines as a master isolate where "is it really off" matters.',
+ limits:'Semantically just a toggle; the value is entirely in the visible gap.',
+ origin:'Early power-station switchboards.',difficulty:'Intuitive.',
+ prefer:'A lamp says off; an air gap proves it. Use where proof beats economy of space.',
+ period:'1890s-1930s power switching; pure theater after that, and worth it.'},
+'R49':{input:'Drag each knob to its digit; the windows read the digits and the readout sums them. Drag with detents — Emacs wants per-digit +/-.',
+ solves:'Composing one precise value from independent per-decade digits.',
+ use:'Specialty. Shines for calibration values, resistances, counts where each digit is a deliberate choice.',
+ limits:'Range fixed by the decade count; slow for sweeping a value.',
+ origin:'General Radio decade resistance boxes.',difficulty:'Easy.',
+ prefer:'The thumbwheel sets a digit; this composes a NUMBER from digits with the arithmetic visible.',
+ period:'1930s-70s lab bench.'},
+'R50':{input:'Click one palm button, then the other within 0.5s; same hand twice or too slow faults. Click-only — the hardware holds both, the screen keeps the two-target-within-window grammar.',
+ solves:'Commands that must cost both hands — presence at two separated points before anything moves.',
+ use:'Specialty. Shines for genuinely destructive cycles where arm-to-fire is not enough.',
+ limits:'Deliberately annoying; the anti-tie-down rule must fault same-hand repeats or the guarantee is void.',
+ origin:'OSHA press and cutter controls.',difficulty:'Easy — the window must be learned.',
+ prefer:'Arm-to-fire proves intent; two-hand proves position. The strongest friction in the kit.'},
+'R51':{input:'Click a loop key: off → monitor → talk → off. Talk is exclusive; monitors are independent. Click-only, fully portable.',
+ solves:'Attending many channels at once while speaking on exactly one.',
+ use:'Specialty. Shines for channels, feeds, log streams — anything monitored in parallel.',
+ limits:'Flicker communicates activity, not content; past a dozen loops even controllers tiered them.',
+ origin:'NASA mission-control comm keysets.',difficulty:'Easy.',
+ prefer:'R21 selects one program; this holds many half-attended and one active.',
+ period:'1960s-90s console era; the pattern lives on in every notification mute matrix.'},
+'R52':{input:'Display plus the switch register: click a toggle to flip its bit and the activity pattern folds it in. The lamps take no input.',
+ solves:'Showing computation happening — activity, not just state.',
+ use:'Specialty. Shines for load, traffic, progress-with-texture; honest busy-ness.',
+ limits:'Patterns are read as rhythm and density, not decoded bit by bit.',
+ origin:'PDP-11/70 and IMSAI front panels.',difficulty:'Intuitive to watch, expert to read.',
+ prefer:'A spinner says busy; blinkenlights say HOW busy and with what rhythm.',
+ period:'1965-80 minicomputer era; the icon of computing at work.'},
+'R56':{input:'Drag the left half for temperature, the right for humidity. Drag-only, two surfaces.',
+ solves:'A categorical verdict from two values — the crossing point falls into a printed judgment.',
+ use:'Common on weather stations and comfort meters. Shines when the pair only matters as a combined condition: comfort, safe-operating region, duty envelope.',
+ limits:'The zone thresholds are opinions baked into the face; five zones is about the ceiling.',
+ origin:'Brass household comfort meters.',difficulty:'Intuitive — the verdict is literally written down.',
+ prefer:'N13 derives a number from the crossing, R55 reads two values separately; this one answers "is the combination OK" without asking you to do the math.'},
+'R54':{input:'Display in real use; drag vertically here to spin the tape. Drag-only for the demo.',
+ solves:'A long scale in a narrow window — the value sits at a fixed eye position with its neighborhood visible above and below.',
+ use:'Common in modern cockpits (airspeed, altitude) and rack meters. Shines when panel width is scarce and trend context matters.',
+ limits:'No at-a-glance needle angle; rate reads as scroll speed, which takes acclimation.',
+ origin:'Ki-57 remote tachometer; universal in glass cockpits since.',difficulty:'Easy.',
+ prefer:'The needle gauge gives angle-at-a-glance; the tape gives context-around-the-value in a tenth the width.'},
+'R55':{input:'Drag the left half for the left needle, right half for the right. Drag-only, two independent surfaces.',
+ solves:'Two related values in one instrument hole, each on its own mirrored scale.',
+ use:'Common on aircraft engine panels. Shines for pairs the eye checks together: fuel/oil, left/right, in/out.',
+ limits:'Both scales must share a range family or the mirroring misleads.',
+ origin:'Ki-57 fuel/oil pressure gauge; standard twin-engine instrument form.',difficulty:'Intuitive.',
+ prefer:'N13 crosses two needles to read their intersection; this houses two needles to read separately. Symmetry here means "both healthy looks symmetric".'},
+'R53':{input:'Display; click loads fresh paper. The real recorder takes no input.',
+ solves:'Cyclic time — the same hour lands at the same angle, so daily rhythm becomes shape.',
+ use:'Specialty. Shines for temperatures, loads, and rates with a daily or weekly cycle.',
+ limits:'One revolution of history; overlapping days need pen colors, like the hardware.',
+ origin:'Partlow and Honeywell circular chart recorders.',difficulty:'Easy.',
+ prefer:'The strip chart (N16) answers what just happened; this answers what this time of day usually looks like.',
+ period:'1900s-80s process recording.'},
+'10':{input:'Display; drag the gallery card to drive the demo value. Real use: none.',
+ solves:'A magnitude plus its rate of change, read peripherally.',
+ use:'Common. Shines where trend and zone matter more than digits.',
+ limits:'Coarse precision; needs face room.',
+ origin:'Moving-coil panel meters.',difficulty:'Intuitive.',
+ prefer:'The eye should catch movement, not read a number.'},
+'11':{input:'Display only; runs from the live demo signal.',
+ solves:'Two channels of level with peak behavior, at a glance.',
+ use:'Common in audio. Shines for stereo program level.',
+ limits:'Segment resolution; no history.',
+ origin:'LED meter bridges.',difficulty:'Intuitive.',
+ prefer:'Level must be watchable continuously without fatigue.'},
+'12':{input:'Display only.',
+ solves:'Signal strength in the smallest possible footprint.',
+ use:'Common. Shines in status bars and corners.',
+ limits:'Four levels of resolution, no more.',
+ origin:'Phone signal bars.',difficulty:'Intuitive.',
+ prefer:'One glance must answer roughly-how-strong.'},
+'13':{input:'Click a rung to set the demo level. Real use: display.',
+ solves:'A discrete level presented as steps rather than a continuum.',
+ use:'Common. Shines when the value naturally has rungs.',
+ limits:'Step count fixed by the ladder.',
+ origin:'Level ladders on meters and mixers.',difficulty:'Intuitive.',
+ prefer:'The scale is genuinely stepped (volume notches, severity).'},
+'14':{input:'Display; drag the gallery bar to drive the demo. Real use: none.',
+ solves:'How full, with zone warnings, in one horizontal read.',
+ use:'Common. Shines for capacity: fuel, disk, battery.',
+ limits:'One value; zones must be pre-agreed.',
+ origin:'Fuel gauges.',difficulty:'Intuitive.',
+ prefer:'The question is how much is left.'},
+'15':{input:'Display; click/drag drives the demo. Real use: none.',
+ solves:'Progress or proportion wrapped around a compact center.',
+ use:'Common in modern UI. Shines when the center holds the number.',
+ limits:'Angular reading is coarser than linear.',
+ origin:'Modern dashboard rings.',difficulty:'Intuitive.',
+ prefer:'A percentage needs to share space with its own label.',
+ period:'Modern (2010s UI) — pair with restraint on a period console.'},
+'16':{input:'Display only; live rolling data.',
+ solves:'The shape of recent history in a text-sized strip.',
+ use:'Common in dashboards. Shines inline next to a current value.',
+ limits:'No axes or absolute scale — trend only.',
+ origin:'Tufte sparklines.',difficulty:'Intuitive.',
+ prefer:'Direction-of-travel matters and space is text-sized.',
+ period:'Modern (2000s information design).'},
+'17':{input:'Display only; live signal.',
+ solves:'Raw signal texture over the last moments.',
+ use:'Specialty. Shines for audio/telemetry liveliness.',
+ limits:'Qualitative, not measured.',
+ origin:'Waveform strips on recorders.',difficulty:'Intuitive.',
+ prefer:'Presence and texture of a signal beat any single number.'},
+'N11':{input:'Display only; live trace.',
+ solves:'Seeing the waveform itself, not a summary of it.',
+ use:'Specialty. Shines for diagnosing shape, noise, clipping.',
+ limits:'Needs a real drawing surface everywhere it ports.',
+ origin:'Cathode-ray oscilloscopes.',difficulty:'Easy to watch, expert to interpret.',
+ prefer:'The shape of the signal is the information.'},
+'N12':{input:'Display only; live bands.',
+ solves:'Energy distribution across bands at a glance.',
+ use:'Common in audio. Shines for spectrum and per-core loads.',
+ limits:'Band resolution fixed; no phase/history.',
+ origin:'Spectrum analyzers and EQ displays.',difficulty:'Intuitive.',
+ prefer:'The question is where the energy sits, not how much total.'},
+'N13':{input:'Display only.',
+ solves:'Two related values on one face, their crossing point meaningful.',
+ use:'Specialty. Shines for forward/reflected power, in/out levels.',
+ limits:'Reading the iso-curves takes practice.',
+ origin:'Ham radio SWR meters.',difficulty:'Expert.',
+ prefer:'The ratio between two needles is the real value.'},
+'N14':{input:'Display only.',
+ solves:'A bounded scalar with universal instant recognition.',
+ use:'Common. Shines for temperature and anything temperature-like.',
+ limits:'Vertical footprint for one value.',
+ origin:'Mercury thermometers.',difficulty:'Intuitive.',
+ prefer:'The metaphor does the labeling for you.'},
+'N15':{input:'Display only.',
+ solves:'Industrial magnitude with legal-looking zone arcs.',
+ use:'Common industrial. Shines for pressure and load with red-line semantics.',
+ limits:'Face room; coarse read.',
+ origin:'Bourdon-tube pressure gauges (1849).',difficulty:'Intuitive.',
+ prefer:'The red zone must carry authority.'},
+'N16':{input:'Display only; live scrolling pen.',
+ solves:'A continuous value plotted against time, automatically.',
+ use:'Specialty. Shines for drift, cycles, events-over-hours.',
+ limits:'One channel per pen; horizontal footprint.',
+ origin:'Chart recorders.',difficulty:'Easy.',
+ prefer:'History matters as much as the current value.',
+ period:'Pre-digital logging (1900s-80s) styling.'},
+'N17':{input:'Display only.',
+ solves:'A signed relationship swinging around a marked zero.',
+ use:'Specialty. Shines for stereo correlation, balance, error.',
+ limits:'Meaning of + and - must be learned per domain.',
+ origin:'Broadcast correlation meters.',difficulty:'Moderate.',
+ prefer:'Zero is the healthy state and deviation is the signal.'},
+'N18':{input:'Display only.',
+ solves:'Charge state in discrete, honest cells.',
+ use:'Common. Shines for battery and quota displays.',
+ limits:'Cell resolution; green here is the LED green (sevgrn), not the phosphor green.',
+ origin:'Consumer battery gauges.',difficulty:'Intuitive.',
+ prefer:'Discrete cells match how users think about the resource.'},
+'R01':{input:'Display only; true VU ballistics on the live signal.',
+ solves:'Perceived loudness via standardized needle mass and dB law.',
+ use:'Common in audio. Shines as THE program-level meter.',
+ limits:'Slow by standard — misses fast peaks by design.',
+ origin:'The 1939 VU standard.',difficulty:'Easy.',
+ prefer:'Loudness as humans hear it beats instantaneous peaks.'},
+'R07':{input:'Display only.',
+ solves:'A dB read in a sealed porthole, panel-mount style.',
+ use:'Skin variant of the VU idea. Shines flush-mounted in rows.',
+ limits:'Small face, coarse scale.',
+ origin:'Panel meters on lab and broadcast gear.',difficulty:'Easy.',
+ prefer:'Several meters must sit in a disciplined row.',
+ period:'1950s-60s.'},
+'R08':{input:'Display only.',
+ solves:'A tuning/level window dressed in chrome for the living room.',
+ use:'Skin variant. Shines on consumer hi-fi faces.',
+ limits:'Ornamental furniture around a small read.',
+ origin:'Consumer hi-fi indicators.',difficulty:'Intuitive.',
+ prefer:'The console is consumer-facing, not lab-facing.',
+ period:'1960s-70s hi-fi.'},
+'R09':{input:'Display only.',
+ solves:'A flight-critical value with green/amber/red zones readable in the dark.',
+ use:'Specialty. Shines where out-of-zone must be instantly obvious.',
+ limits:'Zones must be authoritative or they train complacency.',
+ origin:'Aircraft engine and airspeed gauges.',difficulty:'Intuitive.',
+ prefer:'The zone, not the number, drives the response.'},
+'R13':{input:'Display only.',
+ solves:'A meter squeezed edgewise into a rack unit.',
+ use:'Common on rack gear. Shines where vertical space is the scarce resource.',
+ limits:'Compressed log scale takes a moment to read.',
+ origin:'Edgewise meters on processors.',difficulty:'Moderate.',
+ prefer:'Rack density forbids a round face.',
+ period:'1960s-80s rack.'},
+'R43':{input:'Drag: left/right banks, up/down pitches. The second 2D drag in the kit (after R26) — the hardest input model to port.',
+ solves:'Two-axis orientation read at a glance — no pair of scalar gauges can match it.',
+ use:'Specialty. Shines for anything with attitude, tilt, or balance across two axes.',
+ limits:'Needs interpretation training in the real world; here the drag teaches it.',
+ origin:'Cockpit artificial horizons.',difficulty:'Moderate.',
+ prefer:'The two values are physically one orientation, not two numbers.'},
+'R44':{input:'Drag to set the amber bug (commanded); the needle (actual) chases it with servo lag. Drag-only.',
+ solves:'Setpoint versus reality on one dial — the gap between bug and needle is the state.',
+ use:'Common in cockpits and process control. Shines for anything with a target and a lagging actual: thermostat, autopilot, motor speed.',
+ limits:'Wrap-around chase must take the short way or the story reads wrong.',
+ origin:'HSI heading bugs, synchro repeaters, motorized pointers.',difficulty:'Easy.',
+ prefer:'Showing only the actual hides the intent; showing both shows the system working.'},
+'R17':{input:'Display only; live trace. Screen chips switch the phosphor family.',
+ solves:'The round-tube scope look: waveform inside bezel and screws.',
+ use:'Skin variant of the oscilloscope. Shines in period lab compositions.',
+ limits:'Round face wastes corners; same porting need as any trace.',
+ origin:'Round-CRT lab scopes.',difficulty:'Easy to watch.',
+ prefer:'The era calls for a tube, not a rectangle.',
+ period:'1940s-60s.'},
+'18':{input:'Click cycles the demo state. Real use: display.',
+ solves:'One state, one lamp: ok, busy, fault at a glance.',
+ use:'Common everywhere. Shines beside the thing it describes.',
+ limits:'Color alone excludes colorblind users — pair with a label.',
+ origin:'Pilot lamps.',difficulty:'Intuitive.',
+ prefer:'A single state needs the smallest honest indicator.'},
+'19':{input:'Display only.',
+ solves:'A short status word with categorical color.',
+ use:'Common. Shines in lists and headers.',
+ limits:'Text-sized; not for continuous values.',
+ origin:'Tags and badges in modern UI.',difficulty:'Intuitive.',
+ prefer:'The state has a name and belongs inline.'},
+'20':{input:'Display only.',
+ solves:'Labeled values aligned for vertical scanning.',
+ use:'Common. Shines for 3-8 key/value facts.',
+ limits:'Static layout; long values break alignment.',
+ origin:'Instrument data plates.',difficulty:'Intuitive.',
+ prefer:'Several named numbers must be scannable in a column.'},
+'21':{input:'None — panel furniture.',
+ solves:'Naming a region so controls need shorter labels.',
+ use:'Common. Shines grouping related controls under one legend.',
+ limits:'Informational only.',
+ origin:'Engraved faceplate legends.',difficulty:'Intuitive.',
+ prefer:'A cluster needs a name more than each member does.'},
+'22':{input:'Display region; the overlay pair adds copy/clear.',
+ solves:'A place for streaming text output that is clearly not chrome.',
+ use:'Common in tools. Shines for logs and command output.',
+ limits:'Unstructured text; needs its own scroll discipline.',
+ origin:'Terminal wells in tool UIs.',difficulty:'Intuitive.',
+ prefer:'Output length is unbounded and text-shaped.'},
+'23':{input:'Display only; transient.',
+ solves:'A message that matters now but not forever.',
+ use:'Common. Shines for confirmations and background events.',
+ limits:'Easy to miss; sticky errors need a dismissal policy.',
+ origin:'Status lines and toasts.',difficulty:'Intuitive.',
+ prefer:'The message should not occupy permanent space.'},
+'26':{input:'Display only.',
+ solves:'A glowing digit with unmatched period warmth.',
+ use:'Specialty. Shines for counters and clocks meant to be loved.',
+ limits:'Neon is only ever orange — no color variants exist honestly; digits stack in depth.',
+ origin:'Nixie tubes.',difficulty:'Intuitive.',
+ prefer:'Warmth and era matter more than crispness.',
+ period:'1955-75 hard period: sits with keypads and telegraphs, clashes with VFD and LED seven-seg.'},
+'N20':{input:'Display only; flaps animate on change.',
+ solves:'A changed value that announces itself mechanically.',
+ use:'Specialty. Shines for arrivals-board style updates.',
+ limits:'Character set and flap speed are the charm and the constraint.',
+ origin:'Split-flap rail/airport boards.',difficulty:'Intuitive.',
+ prefer:'The moment of change deserves sound and motion.',
+ period:'1960s-90s boards.'},
+'N21':{input:'Display only.',
+ solves:'Digits readable at distance with segment honesty.',
+ use:'Common. Shines for counts, clocks, channel numbers.',
+ limits:'Digits and a few letters only.',
+ origin:'LED seven-segment displays.',difficulty:'Intuitive.',
+ prefer:'Numeric state should read like an appliance.',
+ period:'1975 onward.'},
+'N22':{input:'Display only; scrolls.',
+ solves:'More text than fits, one glowing window at a time.',
+ use:'Common on period appliances. Shines for track names, status crawl.',
+ limits:'Reading takes waiting; blue-green is the honest VFD color.',
+ origin:'Vacuum-fluorescent displays.',difficulty:'Intuitive.',
+ prefer:'A short crawl beats truncation.',
+ period:'1980s appliance/hi-fi.'},
+'R45':{input:'Display; the gallery makes each disc clickable. The real sign takes no input.',
+ solves:'Persistent visual state with zero holding power — the disc stays where it flipped.',
+ use:'Common on transit signs and industrial boards. Shines for slow-changing state that must survive power loss.',
+ limits:'Two colors, no glow (needs ambient light); flip rate limits animation.',
+ origin:'Flip-dot transit signs and magnetic indicators.',difficulty:'Intuitive.',
+ prefer:'The dot matrix glows and forgets; the flip-disc is dimmer but remembers.',
+ period:'1960s-90s transit era; still in service.'},
+'R46':{input:'Click = one pulse; the glow steps a cathode clockwise, wrapping with a carry blink. Click-only.',
+ solves:'A count where the counting itself is visible — position, motion, and carry.',
+ use:'Specialty. Shines for event counters and rate displays where each increment should be seen.',
+ limits:'One digit per tube; chains needed for big numbers, exactly like the hardware.',
+ origin:'Dekatron glow-transfer counter tubes.',difficulty:'Intuitive.',
+ prefer:'The nixie announces the digit; the Dekatron performs the increment.',
+ period:'1950s-60s counting gear; pre-IC logic made visible.'},
+'R47':{input:'Click the lever to cycle the gear; lamps pulse amber through transit. Click-only.',
+ solves:'Spatial status — each lamp is a physical thing in its physical place, plus an honest in-between state.',
+ use:'Specialty. Shines when components have positions (gear, doors, valves) and "moving" is a real state.',
+ limits:'The three-greens convention must be known; transit needs a timeout in real use.',
+ origin:'Aircraft landing-gear panels.',difficulty:'Intuitive.',
+ prefer:'The annunciator names conditions; this maps them onto the machine body.'},
+'N23':{input:'Click a cell to raise or cycle its alarm; ACK steadies the flashing master caution, RESET clears the board, TEST proves the bulbs. Click-only.',
+ solves:'Many named conditions, each with its own lit cell.',
+ use:'Specialty. Shines for fault boards where absence of light is health.',
+ limits:'Cell text must stay short; layout is fixed.',
+ origin:'Power-plant and aircraft annunciators.',difficulty:'Intuitive.',
+ prefer:'Every fault deserves a permanent, named home.'},
+'N24':{input:'Display only.',
+ solves:'A few states as colored jewels with period glow.',
+ use:'Common on period gear. Shines for power/signal/fault triples.',
+ limits:'Jewel green (jewel-g) is its own green — distinct from phosphor and VFD.',
+ origin:'Jewel pilot lamps.',difficulty:'Intuitive.',
+ prefer:'Indicator lights should be furniture, not pixels.',
+ period:'1930s-60s.'},
+'N25':{input:'Display only; counts.',
+ solves:'A resettable position count with mechanical drum digits.',
+ use:'Specialty. Shines for tape position, cycle counts.',
+ limits:'Relative number — meaningful only against a noted zero.',
+ origin:'Tape deck counters.',difficulty:'Easy.',
+ prefer:'A relative index beats an absolute timestamp.',
+ period:'1950s-80s decks.'},
+'N26':{input:'Display only; live time.',
+ solves:'Time of day, read culturally, instantly.',
+ use:'Common. Shines as console furniture that also works.',
+ limits:'Analog read is approximate by nature.',
+ origin:'Clocks.',difficulty:'Intuitive.',
+ prefer:'Approximate time at a glance beats digits.'},
+'N27':{input:'Display only.',
+ solves:'Where in the band the tuning currently sits.',
+ use:'Specialty. Shines paired with a tuning control.',
+ limits:'Furniture without its knob.',
+ origin:'Radio dial scales.',difficulty:'Intuitive.',
+ prefer:'Position-in-range should be visible independent of the control.',
+ period:'1930s-60s radio.'},
+'N28':{input:'Display only in the gallery; real bays patch.',
+ solves:'Which connections exist, shown as physical presence.',
+ use:'Specialty. Shines for routing state.',
+ limits:'Dense; jack labels carry all the meaning.',
+ origin:'Telephone and studio patch bays.',difficulty:'Moderate.',
+ prefer:'Connections are the state worth showing.'},
+'R10':{input:'Click steps the page. Screen chips switch the family. Otherwise display.',
+ solves:'Several small status pages in one fixed window.',
+ use:'Common on gear. Shines for SYS/NET/TIME-style rotations.',
+ limits:'One page visible; page order must be learned.',
+ origin:'Status LCDs on rack gear.',difficulty:'Intuitive.',
+ prefer:'A few short pages beat one crowded panel.',
+ period:'1980s-90s LCD era.'},
+'R11':{input:'Display only; the flag slides in on fault.',
+ solves:'A fault state that physically occludes the healthy read.',
+ use:'Specialty. Shines where ignoring a failure must be impossible.',
+ limits:'Binary; the barber pole means only one thing.',
+ origin:'Avionics warning flags.',difficulty:'Intuitive.',
+ prefer:'Failure must block the reading, not sit next to it.',
+ period:'1950s-70s avionics.'},
+'R25':{input:'Display only.',
+ solves:'Letters and digits in segments, still glowing, still period.',
+ use:'Specialty. Shines for short mode names (ZOOM, HALL).',
+ limits:'Fourteen segments approximate the alphabet, with charm.',
+ origin:'Alphanumeric LED displays.',difficulty:'Intuitive.',
+ prefer:'Words must glow like the rest of the rack.',
+ period:'1970s-80s.'},
+'R26':{input:'Drag the peak anywhere on the plot — the first 2D drag in the kit. Drag-only.',
+ solves:'A frequency response edited by grabbing the curve itself.',
+ use:'Common in DAWs. Shines for parametric EQ, any x/y parameter pair.',
+ limits:'Log-frequency mapping assumed knowledge; 2D drag is the hardest Emacs port in the kit.',
+ origin:'Mastering processor displays.',difficulty:'Expert.',
+ prefer:'Two coupled parameters live naturally on a plot.',
+ period:'Modern (digital-era display).'},
+'R35':{input:'Live readout initialized to today; click rolls midnight forward one day. Click-only.',
+ solves:'A compound value (weekday plus date) read at one index from coaxial discs.',
+ use:'Specialty. Shines wherever two coupled discrete values should share one pointer.',
+ limits:'The 31-slot date disc ignores short months — faithfully, since that is the real complication problem.',
+ origin:'Wristwatch day-date movements.',difficulty:'Intuitive.',
+ prefer:'Digits would work, but the mechanism itself is the display worth showing.',
+ period:'1950s-onward watchmaking; the exposed-movement look is the charm.'},
+'R36':{input:'Display; the gallery makes each dot clickable so the bitmap is paintable. The real part takes no input.',
+ solves:'A free-form bitmap in indicator form — glyphs, icons, patterns, tiny animations from one part.',
+ use:'Common in signs, elevators, synth modules. Shines when one small display must show anything.',
+ limits:'8x8 holds one glyph; multiplex flicker shows on camera; red-first technology.',
+ origin:'LED dot-matrix modules (HP displays, scrolling signs, the K4816 faceplate).',difficulty:'Intuitive.',
+ prefer:'Segments cover digits; reach for the matrix when the content is not characters.',
+ period:'1970s-90s LED-matrix era styling; the part is still made.'},
+'R30':{input:'Click steps the state, wrapping. Click-only.',
+ solves:'Current state shown by a pointer into labeled sectors.',
+ use:'Specialty. Shines for coarse machine states (RUN/STOP/TEST).',
+ limits:'Sector count fixed by the dial face.',
+ origin:'Ship engine telegraphs.',difficulty:'Intuitive.',
+ prefer:'State should read like a dial position, not a word.',
+ period:'1900s-50s marine styling.'},
+'R31':{input:'Click marks the current bearing. Screen chips switch the phosphor. Otherwise live display.',
+ solves:'Position around you, decaying like a real PPI.',
+ use:'Specialty. Shines for anything with bearing and range.',
+ limits:'The sweep metaphor implies periodic refresh.',
+ origin:'Radar plan-position indicators.',difficulty:'Easy to watch.',
+ prefer:'Angular position matters and the era wants a tube.',
+ period:'1940s-70s radar styling.'},
+};
+const INFO_FIELDS=[['input','input'],['solves','solves'],['use','use'],['limits','limits'],
+ ['origin','origin'],['difficulty','difficulty'],['prefer','prefer when'],['period','period']];
+const VLAMP_TITLES={off:'validation: not done',amber:'validation: in progress',green:'validation: done'};
+/* index tally: recounts every card lamp; setV calls it on every state change.
+ Clicking a row jumps to the next card in that state (cycles), so a count
+ that disagrees with a visual scan can be audited card by card. */
+const VJUMP={green:0,amber:0,off:0};
+function updateVTally(){
+ const el=$('vtally'); if(!el)return;
+ const lamps=[...document.querySelectorAll('.vlamp')];
+ const n={off:0,amber:0,green:0};
+ lamps.forEach(l=>{n[l.dataset.v]=(n[l.dataset.v]||0)+1;});
+ el.innerHTML=
+ `<div class="vrow" data-v="green"><span class="vdot" data-v="green"></span>done<span class="vn">${n.green}</span></div>`+
+ `<div class="vrow" data-v="amber"><span class="vdot" data-v="amber"></span>in progress<span class="vn">${n.amber}</span></div>`+
+ `<div class="vrow" data-v="off"><span class="vdot"></span>not done<span class="vn">${n.off}</span></div>`+
+ `<div class="vrow vtot">total<span class="vn">${lamps.length}</span></div>`;
+ el.querySelectorAll('.vrow[data-v]').forEach(row=>row.addEventListener('click',()=>{
+ VJUMP[row.dataset.v]=0; auditNext(row.dataset.v);
+ }));
+}
+/* audit stepper: a row click jumps to the first card in that state and docks a
+ floating pill; clicking the pill advances through the rest without scrolling
+ back to the index. The card list is re-read on every step, so lamp changes
+ mid-audit are picked up. Esc or the x dismisses. */
+const VAUDIT_LBL={green:'done',amber:'in progress',off:'not done'};
+function auditNext(v){
+ const cards=[...document.querySelectorAll('.vlamp')].filter(l=>l.dataset.v===v)
+ .map(l=>l.closest('.card'));
+ if(!cards.length){auditStop();return;}
+ const i=VJUMP[v]%cards.length; VJUMP[v]++;
+ location.hash='';location.hash=cards[i].id; /* re-fire :target ring on repeat visits */
+ let pill=$('vaudit');
+ if(!pill){pill=document.createElement('div');pill.id='vaudit';document.body.appendChild(pill);
+ pill.addEventListener('click',e=>{
+ if(e.target.classList.contains('vx')){auditStop();return;}
+ auditNext(pill.dataset.v);});}
+ pill.dataset.v=v;
+ pill.innerHTML=`<span class="vdot" data-v="${v==='off'?'':v}"></span>`+
+ `${VAUDIT_LBL[v]} ${i+1}/${cards.length} · next ▸<span class="vx" title="stop auditing">✕</span>`;
+}
+function auditStop(){const p=$('vaudit');if(p)p.remove();}
+addEventListener('keydown',e=>{if(e.key==='Escape')auditStop();});
+/* card(host, no, name, htmlOrBuild, note) — the declarative card record.
+ htmlOrBuild: a legacy stage-HTML string, or a builder function (stage, rd) => handle
+ that instantiates a GW.* widget into the stage; rd(txt) writes the card readout. */
+function card(host, no, name, html, note){
+ const isBuild=typeof html==='function';
+ const c=document.createElement('div'); c.className='card'; c.id='card-'+no;
+ c.innerHTML=`<div class="wname"><span class="no">${no}</span>${name}<span class="vlamp" data-v="off"></span></div>`+
+ `<div class="stagew">${isBuild?'':html}</div><div class="wrd" id="rd-${no}">—</div>`+
+ `<div class="opts"></div><div class="wnote">${note}</div>`;
+ const lamp=c.querySelector('.vlamp'), VKEY='gv-'+no, VSTATES=['off','amber','green'];
+ const setV=v=>{lamp.dataset.v=v;lamp.title=VLAMP_TITLES[v];updateVTally();};
+ setV(localStorage.getItem(VKEY)||'off');
+ lamp.addEventListener('click',e=>{e.stopPropagation();
+ const v=VSTATES[(VSTATES.indexOf(lamp.dataset.v)+1)%3];
+ setV(v);localStorage.setItem(VKEY,v);});
+ const inf=(typeof INFO!=='undefined')&&INFO[no];
+ if(inf){const d=document.createElement('details'); d.className='winfo';
+ let rows='';
+ for(const [k,lbl] of INFO_FIELDS) if(inf[k]) rows+=`<div class="ik">${lbl}</div><div class="iv">${inf[k]}</div>`;
+ if(inf.ref) rows+=`<div class="ik">reference</div><div class="iv"><a href="${inf.ref}" target="_blank">photo</a></div>`;
+ d.innerHTML=`<summary>spec sheet</summary><div class="igrid">${rows}</div>`;
+ c.appendChild(d);}
+ host.appendChild(c);
+ if(isBuild)c.gw=html(c.querySelector('.stagew'),txt=>setRd(no,txt));
+ return c;
+}
+function setRd(no,txt){const e=$('rd-'+no);if(e)e.textContent=txt;}
+/* drag helpers, seg7, buildBars live in widgets.js (GW) */
+
+/* ============ CONTROLS ============ */
+const C=$('controls');
+card(C,'01','Slide toggle',
+ (st,rd)=>GW.slideToggle(st,{on:true,onChange:(v,t)=>rd(t)}),
+ '<b>on / off, the touchscreen way.</b> The one born-digital control in the kit — the smartphone pill toggle the live net/bt/audio panels use. Click to flip; readout shows the state. Physical cousins: the bat-handle (R03) and the rocker (N01).');
+card(C,'02','Console key',
+ (st,rd)=>GW.consoleKeys(st,{onChange:(i,t)=>rd(t)}),
+ '<b>physical push button.</b> Mutually exclusive — click one to engage. Gold = live, terracotta = muted.');
+card(C,'03','Horizontal fader',
+ (st,rd)=>GW.faderH(st,{value:68,onChange:(v,t)=>rd(t)}),
+ '<b>continuous 0-100.</b> Per-device volume, brightness. Drag the gold cap; readout tracks.');
+card(C,'04','Vertical fader',
+ (st,rd)=>{let ready=false;
+ const upd=()=>{if(ready)rd('A '+Math.round(a.get())+' · B '+Math.round(b.get()));};
+ const a=GW.faderV(st,{value:60,onChange:upd});
+ const b=GW.faderV(st,{value:35,onChange:upd});
+ ready=true;upd();return {a,b};},
+ '<b>channel-strip style.</b> A mixer column per device. Drag each fader up or down.');
+card(C,'05','Rotary knob',
+ (st,rd)=>GW.knob(st,{value:53,onChange:v=>rd('gain '+Math.round(v))}),
+ '<b>dial in a value.</b> Volume/gain the analog way. Drag up/down to turn; readout shows the level.');
+card(C,'06','Segmented selector',
+ (st,rd)=>GW.segmented(st,{onChange:(i,t)=>rd(t)}),
+ '<b>pick one of a few.</b> Timer type, layout mode. Click a segment; readout names the choice. The lit segment ships in amber, green, or red — the accent chips below switch it.');
+card(C,'07','Chip toggle',
+ (st,rd)=>GW.chipToggle(st,{on:true,onChange:(v,t)=>rd(t)}),
+ '<b>inline binary.</b> A soft toggle inside a line of text. Click to flip; gold when on.');
+card(C,'08','Arm-to-fire',
+ (st,rd)=>GW.armButton(st,{onChange:(v,t)=>rd(t)}),
+ '<b>two-stage confirm.</b> Destructive actions. First click arms (red), second fires. Readout shows the stage.');
+card(C,'09','Lamp row',
+ (st,rd)=>GW.lampRow(st,{onChange:(i,t)=>rd(t)}),
+ '<b>actionable list item.</b> Lamp + name + status. Click to cycle idle → connecting → connected.');
+card(C,'24','Rotary selector',
+ (st,rd)=>GW.rotarySelector(st,{onChange:(v,t)=>rd(t)}),
+ '<b>pick one of N by position.</b> Printed detents, the pointer names the value. Click to turn; readout shows it.');
+card(C,'25','Slide-rule dial',
+ (st,rd)=>GW.slideRule(st,{onChange:(v,t)=>rd(t)}),
+ '<b>value on a printed scale.</b> A lit pointer glides a warm-backlit strip. Printed numerals are the majors; the units between them carry minor ticks. Click any of them to jump, or focus and press ←/→ (↑/↓) to step a unit; readout shows it. Four faces — warm backlit, chrome, 80s black glass, marantz blue — on the chips below.');
+card(C,'N01','Rocker power switch',
+ (st,rd)=>GW.rocker(st,{on:true,onChange:(v,t)=>rd(t)}),
+ '<b>hard on / off, lit legend.</b> A master power paddle — the pressed half glows. Click to rock.');
+card(C,'N02','Transport cluster',
+ (st,rd)=>GW.transport(st,{mode:'play',animate:!reduced,onChange:(m,t)=>rd(t)}),
+ '<b>run / pause / capture.</b> One mode lit at a time, reels turn while it plays. Click a button.');
+card(C,'N03','Radio preset bank',
+ (st,rd)=>GW.presetBank(st,{onChange:(i,t)=>rd(t)}),
+ '<b>pick one, the rest release.</b> The car-radio preset row — mechanically exclusive. Click one.');
+card(C,'N04','Concentric dual knob',
+ (st,rd)=>GW.dualKnob(st,{outer:50,inner:50,onChange:(o,i)=>rd('bass '+Math.round(o)+' · treble '+Math.round(i))}),
+ '<b>two values on one spindle.</b> Bass / treble, coarse / fine. Drag the outer ring or inner cap — each is independent.');
+card(C,'N05','Rotary encoder + LED ring',
+ (st,rd)=>GW.encoder(st,{value:7,onChange:(v,t)=>rd(t)}),
+ '<b>endless dial, lit position.</b> A detentless encoder; the LED arc lights to the level and the value keeps accumulating. Drag to turn.');
+card(C,'N06','Keyed mode switch',
+ (st,rd)=>GW.keySwitch(st,{index:1,onChange:(i,t)=>rd(t)}),
+ '<b>guarded three-position mode.</b> Off · on · run. The key-bit points at the live position. Click to turn.');
+card(C,'N07','Center-detented crossfader',
+ (st,rd)=>GW.crossfader(st,{onChange:(v,t)=>rd(t)}),
+ '<b>throw to either side of zero.</b> A fader notched at center — balance, L/R mix. Drag; readout shows the signed value.');
+card(C,'N08','Thumbwheel',
+ (st,rd)=>GW.thumbwheel(st,{value:42,onChange:(v,t)=>rd(t)}),
+ '<b>ribbed edge-wheel, one value.</b> A knurled wheel with a windowed number. Drag up/down to roll.');
+card(C,'N09','DIP-switch bank',
+ (st,rd)=>GW.dipBank(st,{onChange:(w,t)=>rd(t)}),
+ '<b>a bank of hard flags.</b> Hardware slide switches — up is on. Click each to flip; readout is the binary word.');
+card(C,'N10','Jog / shuttle wheel',
+ (st,rd)=>GW.jogWheel(st,{onChange:(v,t)=>rd(t)}),
+ '<b>scrub fine, shuttle fast.</b> The edit-deck wheel. Drag to jog the inner wheel; readout shows the position.');
+card(C,'R02','Calibrated vernier dial',
+ (st,rd)=>GW.vernierDial(st,{value:42.0,onChange:(v,t)=>rd(t)}),
+ '<b>precision on a rotating disc.</b> The big lab dial — the calibrated disc turns under a fixed hairline. Drag up/down; readout reads to a tenth. After a 1950s lab-oscillator vernier.');
+card(C,'R03','Bat-handle toggle',
+ (st,rd)=>GW.batToggle(st,{on:true,onChange:(v,t)=>rd(t)}),
+ '<b>the period power switch.</b> Chrome bat lever on a hex bushing, snaps up ON / down OFF. Click to throw it. After a lab-instrument panel toggle.');
+card(C,'R04','Bakelite fluted knob',
+ (st,rd)=>GW.flutedKnob(st,{value:63,onChange:(v,t)=>rd(t)}),
+ '<b>the skirted console knob.</b> Scalloped bakelite skirt, glossy dome, amber index over a printed 0-10 scale. Drag up/down to turn. After a vintage console mixer knob.');
+card(C,'R05','Filter slider bank',
+ (st,rd)=>GW.filterBank(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a wall of band faders.</b> Twelve bands on a screwed faceplate, dB rails both sides. Drag any cap; readout names band and level. Skins split three independent axes — panel (silver hi-fi after the Pioneer SG-9500 / studio black after the Technics SH-8065), cap shape (tall block fader after the Zaxcom Oasis / short ribbed / chrome T), and cap color (black white-index / red / green / blue / amber stripes / chrome / cream). The chips below mix them freely.');
+card(C,'R06','Chicken-head selector',
+ (st,rd)=>GW.chickenHead(st,{index:2,onChange:(i,t)=>rd(t)}),
+ '<b>the pointer-lever switch.</b> The tapered bakelite lever IS the indicator — it aims at the engraved position. Click to step through. After a modulator mode switch.');
+card(C,'R12','Chrome slot fader',
+ (st,rd)=>GW.slotFader(st,{value:-4,onChange:(v,t)=>rd(t)}),
+ '<b>the console gain slide.</b> A chrome T-handle rides a recessed slot, the dB scale engraved on the plate — gain above zero, cut below. Drag; readout shows signed dB. After a vintage channel-strip output fader.');
+card(C,'R14','Spade-pointer tuning knob',
+ (st,rd)=>GW.spadeKnob(st,{value:8.3,onChange:(v,t)=>rd(t)}),
+ '<b>the pointer rides the knob.</b> A knurled bakelite knob carries a bright spade pointer over a scale engraved into the panel itself. Drag up/down to tune. After a 1920s radio receiver dial.');
+card(C,'R15','Multi-band dial',
+ (st,rd)=>GW.multiBandDial(st,{onChange:(v,t)=>rd(t)}),
+ '<b>four scales, one needle.</b> Nested band arcs on a cream face; the active ring reads bold. Drag the left dial to tune; click the bandspread dial to switch bands. After a shortwave receiver dial pair.');
+card(C,'R16','Entry keypad',
+ (st,rd)=>GW.entryKeypad(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a composed entry instrument.</b> Worn keycaps feed the amber display; the lamp watches the buffer; the amber keys confirm and clear. Click the keys. After an industrial keypad prop.');
+card(C,'R18','Thumb-slide attenuator pair',
+ (st,rd)=>GW.thumbSlide(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a lit column of numbers, a thumb tab.</b> The scale glows inside the rail; the cream tab rides beside it and the nearest figure lights. Drag either channel. After a compressor blend/mix pair.');
+card(C,'R19','Waveform region editor',
+ (st,rd)=>GW.waveRegion(st,{start:22,end:76,onChange:(v,t)=>rd(t)}),
+ '<b>pick a region on the wave.</b> The monochrome LCD shows the sample; drag near either flag to move START or END, and the selection draws bright. After a sampler edit screen.');
+card(C,'R20','Drum roller selector',
+ (st,rd)=>GW.drumRoller(st,{onChange:(v,t)=>rd(t)}),
+ '<b>roll to the number.</b> The numbered drum shows through a tall window and the center value sits on an inverted chip. Drag either drum. After an equalizer tone selector.');
+card(C,'R21','LED program row',
+ (st,rd)=>GW.ledRow(st,{index:5,onChange:(v,t)=>rd(t)}),
+ '<b>identical keys, one lit dot.</b> The LED above the button carries the selection, not the key itself — the classic program-select row. Click a key; readout names the program. After a digital reverb’s program bank.');
+card(C,'R22','Three-position slide',
+ (st,rd)=>GW.pillSlide(st,{index:1,onChange:(v,t)=>rd(t)}),
+ '<b>a detented pill, three stops.</b> The chrome pill parks at A, AB, or B, and the LED pair below tells the truth — both light on AB. Click a position. After a drum machine variation switch.');
+card(C,'R23','Spun-aluminum knob',
+ (st,rd)=>GW.spunKnob(st,{value:62,onChange:(v,t)=>rd(t)}),
+ '<b>bright metal, machined rings.</b> A spun face with concentric tooling marks in a knurled grip ring, red index over dot ticks. Drag up/down. After a boutique pedal knob, confirmed by an avionics cluster.');
+card(C,'R24','Stomp switch + jewel',
+ (st,rd)=>GW.stompSwitch(st,{on:false,onChange:(v,t)=>rd(t)}),
+ '<b>the engage pair.</b> A chrome dome stomp and the faceted jewel that reports it — press to engage, the jewel lights; press again to bypass. Click it. After a tremolo pedal footswitch.');
+card(C,'R27','Winged gain selector',
+ (st,rd)=>GW.wingSelector(st,{index:7,onChange:(v,t)=>rd(t)}),
+ '<b>the red T-bar, stepped.</b> A bar-grip handle over a dot ring, snapping between labeled gain detents. Drag up/down to step it. After a classic mic-preamp gain switch.');
+card(C,'R28','Rotary disc switch',
+ (st,rd)=>GW.discSwitch(st,{index:1,onChange:(v,t)=>rd(t)}),
+ '<b>the master power disc.</b> The whole red disc turns, grip bar molded in — a heavy three-position selector for OFF, ON, and COMBINE. Click to step. After a marine battery switch.');
+card(C,'R29','Guarded toggle',
+ (st,rd)=>GW.guardedToggle(st,{on:true,onChange:(v,t)=>rd(t)}),
+ '<b>friction sized to consequence.</b> Guard posts flank the lever and a red collar marks it critical — the avionics way to prevent an accidental throw. Click to throw it deliberately. After a flight-deck switch panel.');
+card(C,'R32','Mechanical timer dial',
+ (st,rd)=>GW.timerDial(st,{minutes:0,onChange:(v,t)=>rd(t)}),
+ '<b>turn to start, push to stop.</b> Drag the knurled dial to wind minutes onto the fixed red index; it winds back down to OFF on its own (demo runs a minute per second). Click the red knob to stop it dead. After a mechanical interval timer.');
+card(C,'R33','Four-way rocker',
+ (st,rd)=>GW.rockerPad(st,{onChange:(v,t)=>rd(t)}),
+ '<b>discrete direction, one press at a time.</b> Click an arrow (the whole quadrant is the target) to step that way; the readout tracks the cursor position it drives. After a remote-control navigation pad.');
+card(C,'R34','Four-way toggle selector',
+ (st,rd)=>GW.fourWayToggle(st,{position:'C',onChange:(v,t)=>rd(t)}),
+ '<b>a lever that lives in one of four states.</b> Click a quadrant and the ball lever throws to A, B, C or D; the matching corner lamp takes the light. The rocker steps — this one selects. After a test-panel quadrant switch.');
+card(C,'R37','Pin routing matrix',
+ (st,rd)=>GW.pinMatrix(st,{onChange:(v,t)=>rd(t)}),
+ '<b>many-to-many routing you can read.</b> Sources are rows, destinations are columns; click an intersection to seat a pin and the route exists. Every simultaneous route stays visible. After the EMS VCS3 pin matrix.');
+card(C,'R38','Dead-man button',
+ (st,rd)=>GW.deadMan(st,{onChange:(v,t)=>rd(t)}),
+ '<b>active only while held.</b> Press and hold — the ring arms, the lamp runs, the dwell clock counts. Let go anywhere and it drops to SAFE. The kit has toggles and momentaries; this is the only control whose state IS your grip.');
+card(C,'R39','Rotary telephone dial',
+ (st,rd)=>GW.telephoneDial(st,{onChange:(v,t)=>rd(t)}),
+ '<b>numeric entry embodied as time.</b> Click a hole: the finger wheel winds to the stop and spins back, and the digit lands in the number. Bigger digits take longer, exactly like the pulses did. After a Western Electric dial.');
+card(C,'R40','Circuit breaker panel',
+ (st,rd)=>GW.breakerPanel(st,{onChange:(v,t)=>rd(t)}),
+ '<b>the first control the system can throw.</b> Click a breaker to close or open it; hit TRIP and a fault pops one out to the tripped mid-position, amber collar showing. A tripped breaker must be pulled to reset before it closes again. After an aircraft breaker row.');
+card(C,'R41','DSKY verb-noun panel',
+ (st,rd)=>GW.dsky(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a command grammar, not a button per command.</b> VERB is the action, NOUN is the operand: press VERB 3 5 ENTR for the lamp test. Wrong grammar lights OPR ERR. Two keys plus digits reach every function — the Apollo answer to a panel with too few buttons.');
+card(C,'R42','Cam-timer program drum',
+ (st,rd)=>GW.camTimer(st,{position:0,onChange:(v,t)=>rd(t)}),
+ '<b>a procedure as a rotating position.</b> The ring is the whole program — fill, wash, rinse, spin — and the pointer is where you are in it. Click to advance (or start from OFF); it then walks itself through the steps. After a washing-machine cam timer.');
+card(C,'R48','Knife switch',
+ (st,rd)=>GW.knifeSwitch(st,{closed:true,onChange:(v,t)=>rd(t)}),
+ '<b>the visible disconnect.</b> Copper blades either sit in their jaws or hang open in the air — you can SEE the circuit is broken, which is the entire safety argument. Click the handle to throw it. After early power-station switchboards.');
+card(C,'R49','Decade switch box',
+ (st,rd)=>GW.decadeBox(st,{digits:[3,5,0,0],onChange:(v,t)=>rd(t)}),
+ '<b>a number composed a digit at a time.</b> Four skirted knobs, one per decade — drag each to its digit and the total just IS their sum. The thumbwheel does one digit; this is how a lab built a precise value. After a General Radio decade resistance box.');
+card(C,'R50','Two-hand safety control',
+ (st,rd)=>GW.twoHandSafety(st,{onChange:(v,t)=>rd(t)}),
+ '<b>both hands, deliberately.</b> Click one palm button to arm the window, the other within half a second to cycle the press. Too slow — TIE-DOWN fault, start over. The hardware keeps hands out of the die; the grammar survives on screen. After OSHA press controls.');
+card(C,'R51','Voice-loop keyset',
+ (st,rd)=>GW.voiceLoop(st,{onChange:(v,t)=>rd(t)}),
+ '<b>attention management, not selection.</b> Each loop is independent: click to monitor (green bar, flickering with activity), again to talk (amber, exclusive — one voice out), again to drop. Flight controllers ran a dozen of these at once. After a mission-control comm keyset.');
+
+/* ============ METERS & GAUGES ============ */
+const M=$('meters');
+card(M,'10','Needle gauge',
+ (st,rd)=>GW.needleGauge(st,{value:50,onChange:(v,t)=>rd(t)}),
+ '<b>analog dial.</b> Throughput, battery, volume. Drag up/down to sweep the needle; readout tracks. <b>Smooth sweep wants a Cairo/GTK area.</b>');
+card(M,'11','Stereo VU (LED bar)',
+ (st,rd)=>GW.vuPair(st,{onChange:(v,t)=>rd(t)}),
+ '<b>live signal level.</b> The sound panel meter row, peak-hold outline. Runs a live signal; readout shows L/R.');
+card(M,'12','Mini signal (4-bar)',
+ (st,rd)=>GW.miniSig(st,{onChange:(v,t)=>rd(t)}),
+ '<b>compact activity.</b> Per-row "is this device playing". Live signal; readout shows activity %.');
+card(M,'13','Signal ladder',
+ (st,rd)=>GW.signalLadder(st,{value:3,onChange:(v,t)=>rd(t)}),
+ '<b>discrete strength.</b> Wifi bars, bt RSSI — a stepped 0-4. Click to cycle strength; readout shows bars.');
+card(M,'14','Linear fuel bar',
+ (st,rd)=>{const a=GW.fuelBar(st,{value:72,onChange:(v,t)=>rd(t)});
+ const b=GW.fuelBar(st,{value:40,onChange:(v,t)=>rd(t)});
+ a.set(72);return {a,b};},
+ '<b>a single 0-100.</b> Battery, disk, download. Drag either bar; warn tint under threshold; readout tracks.');
+card(M,'15','Radial ring',
+ (st,rd)=>GW.radialRing(st,{value:68,onChange:(v,t)=>rd(t)}),
+ '<b>percentage as a donut.</b> CPU, battery. Drag up/down to set; conic-gradient tracks the readout.');
+card(M,'16','Sparkline',
+ (st,rd)=>GW.sparkline(st,{onChange:(v,t)=>rd(t)}),
+ '<b>recent history.</b> Throughput/CPU over the last minute. Live trace; readout shows the current value.');
+card(M,'17','Waveform strip',
+ (st,rd)=>GW.waveStrip(st,{onChange:(v,t)=>rd(t)}),
+ '<b>audio waveform / scope.</b> A richer signal view. Live trace; readout shows amplitude. <b>Needs a drawing surface.</b>');
+card(M,'N11','Oscilloscope',
+ (st,rd)=>GW.scope(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a live phosphor trace.</b> A waveform swept over a graticule. Live; readout shows Vpp. <b>Needs a Cairo/GTK area for a smooth trace.</b>');
+card(M,'N12','Spectrum / EQ bars',
+ (st,rd)=>GW.eqBars(st,{onChange:(v,t)=>rd(t)}),
+ '<b>level across bands.</b> A live multi-band bar graph — spectrum, per-core CPU. Live; readout shows the peak band.');
+card(M,'N13','Crossed-needle meter',
+ (st,rd)=>GW.crossNeedle(st,{value:55,onChange:(v,t)=>rd(t)}),
+ '<b>two readings, one face.</b> Forward + reflected needles cross. Drag up/down to drive power; readout shows both. <b>Round build wants a drawing surface.</b>');
+card(M,'N14','Thermometer column',
+ (st,rd)=>GW.thermometer(st,{value:58,onChange:(v,t)=>rd(t)}),
+ '<b>a rising column.</b> The classic mercury tube — temperature, a climbing load. Drag up/down; readout shows the value.');
+card(M,'N15','Bourdon pressure gauge',
+ (st,rd)=>GW.bourdon(st,{value:45,onChange:(v,t)=>rd(t)}),
+ '<b>a round dial with a red zone.</b> Needle over a printed arc, redline in terracotta. Drag to set PSI; readout tracks. <b>Round sweep is a drawing-area job.</b>');
+card(M,'N16','Strip-chart recorder',
+ (st,rd)=>GW.stripChart(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a scrolling pen trace.</b> History scrolls left, the pen writes at the right edge. Live; readout shows the current value. <b>Real trace wants a drawing surface.</b>');
+card(M,'N17','Correlation meter (±)',
+ (st,rd)=>GW.corrMeter(st,{value:58,onChange:(v,t)=>rd(t)}),
+ '<b>deviation from center.</b> The needle rests at 0 and swings ±. Drag left/right; readout shows the signed value.');
+card(M,'N18','Battery-cell gauge',
+ (st,rd)=>GW.battCells(st,{value:62,onChange:(v,t)=>rd(t)}),
+ '<b>charge as discrete cells.</b> Filled cells count up, the last warn in terracotta. Drag left/right to set; readout shows %.');
+card(M,'R01','Moving-coil VU meter',
+ (st,rd)=>GW.mcVu(st,{onChange:(v,t)=>rd(t)}),
+ '<b>the classic cream-faced VU.</b> Printed dB arc, red zone past 0, ballistic needle. Runs the live signal; readout shows VU. After a vintage console meter pair.');
+card(M,'R07','Round panel meter',
+ (st,rd)=>GW.roundMeter(st,{value:50,onChange:(v,t)=>rd(t)}),
+ '<b>the porthole lab meter.</b> Cream dial in a round black bezel, printed dB arc, black needle. Drag up/down to drive it; readout shows dB. After a modulator front meter.');
+card(M,'R08','Chrome MIN/MAX indicator',
+ (st,rd)=>GW.chromeMinMax(st,{value:50,onChange:(v,t)=>rd(t)}),
+ '<b>the hi-fi trim window.</b> Polished chrome ring, brushed dome, a dark pointer sweeping MIN to MAX over the metal. Drag up/down; readout shows the position. After a hi-fi amplifier level window.');
+card(M,'R09','Black-face aviation gauge',
+ (st,rd)=>GW.aviationGauge(st,{value:52,onChange:(v,t)=>rd(t)}),
+ '<b>amber on black, zone-marked.</b> The cockpit round: amber ticks and numerals on a black face, painted caution and limit sectors, amber needle. Drag up/down; readout tracks. After a flight-instrument cluster.');
+card(M,'R13','Edgewise strip meter',
+ (st,rd)=>GW.edgeMeter(st,{value:.62,onChange:(v,t)=>rd(t)}),
+ '<b>a needle on a vertical scale.</b> The parchment strip reads attenuation down a compressed log scale; the bar rides its edge. Drag up/down; readout shows dB. After a channel-strip gain-reduction meter.');
+card(M,'R43','Attitude indicator',
+ (st,rd)=>GW.attitudeIndicator(st,{onChange:(v,t)=>rd(t)}),
+ '<b>orientation, not a number.</b> The horizon rolls and shifts behind the fixed amber airplane — two axes in one read. Drag it: left/right banks, up/down pitches. The first two-axis instrument in the kit. After a cockpit artificial horizon.');
+card(M,'R44','Heading bug + servo needle',
+ (st,rd)=>GW.headingBug(st,{value:90,onChange:(v,t)=>rd(t)}),
+ '<b>commanded versus actual on one dial.</b> Drag to park the amber bug where you want to go; the needle chases it with honest servo lag. The gap between them IS the information. After an HSI heading bug and synchro repeaters.');
+card(M,'R54','Vertical tape instrument',
+ (st,rd)=>GW.verticalTape(st,{value:24,onChange:(v,t)=>rd(t)}),
+ '<b>the scale moves, the index does not.</b> A scrolling tape behind a fixed amber index — your value is always at eye level and the neighborhood above and below stays visible. Drag to spin the tape. After the Ki-57 remote tachometer; modern cockpits tape everything.');
+card(M,'R55','Twin-needle gauge',
+ (st,rd)=>GW.twinNeedle(st,{fuel:2.4,oil:3.1,onChange:(v,t)=>rd(t)}),
+ '<b>two values, one housing, mirrored scales.</b> Fuel presses up the left arc, oil up the right, each with its own needle from the shared hub. Drag either half to set that side. Not the crossed-needle (N13) — these are read separately, not at their intersection. After the Ki-57 fuel/oil pressure gauge.');
+card(M,'R56','Comfort-zone crossed needles',
+ (st,rd)=>GW.comfortMeter(st,{temp:72,humidity:45,onChange:(v,t)=>rd(t)}),
+ '<b>two values, one verdict.</b> Temperature and humidity needles cross over a face printed with judgments — the crossing point lands in TOO WARM, TOO DRY, or JUST RIGHT. N13 derives a number from the crossing; this one derives an opinion. Drag each half. After a brass weather-station comfort meter.');
+card(M,'R53','Circular chart recorder',
+ (st,rd)=>GW.chartRecorder(st,{onChange:(v,t)=>rd(t)}),
+ '<b>cyclic time on round paper.</b> The pen sweeps a full day per revolution, so "same time yesterday" is the same angle and daily rhythm draws itself as a flower. Click for fresh paper. The strip chart shows a window; this shows the cycle. After a Partlow round-chart recorder.');
+card(M,'R17','Round CRT scope',
+ (st,rd)=>GW.roundCrt(st,{onChange:(v,t)=>rd(t)}),
+ '<b>the round-tube scope.</b> Pale phosphor face in a screwed porthole bezel, dark graticule, a bright live trace. Runs live; readout shows Vpp. After a 1950s bench oscilloscope.');
+
+/* ============ INDICATORS & READOUTS ============ */
+const I=$('indicators');
+card(I,'18','Status lamp',
+ (st,rd)=>GW.statusLamps(st,{onChange:(v,t)=>rd(t)}),
+ '<b>one-glance health.</b> Green ok · gold engaged · red fail · dim off · pulsing busy. Click any lamp to cycle its state.');
+card(I,'19','Badge / tag',
+ (st,rd)=>GW.badges(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a labelled flag.</b> MUTED, AIRPLANE, a band tag. Click a badge to cycle its variant.');
+card(I,'20','Tabular readout',
+ (st,rd)=>GW.tabularReadout(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a precise number.</b> Clock, countdown, volume %. Runs a live countdown; click to pause/resume.');
+card(I,'21','Engraved label',
+ (st,rd)=>GW.engravedLabel(st,{onChange:(v,t)=>rd(t)}),
+ '<b>section divider.</b> The hairline-flanked caps label with a count. Click to bump the count.');
+card(I,'22','Output well',
+ (st,rd)=>GW.outputWell(st,{onChange:(v,t)=>rd(t)}),
+ '<b>streaming step log.</b> Lamp-per-step with evidence. Click to stream the next step.');
+card(I,'23','Toast / status line',
+ (st,rd)=>GW.toast(st,{onChange:(v,t)=>rd(t)}),
+ '<b>transient confirmation.</b> The one-line result after an action. Click to fire the next toast.');
+card(I,'26','Nixie tube',
+ (st,rd)=>GW.nixie(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a single warm-glowing numeral.</b> One lit digit per tube, leading zeros dark. Click to increment the count.');
+card(I,'N20','Split-flap display',
+ (st,rd)=>GW.splitFlap(st,{animate:!reduced,onChange:(v,t)=>rd(t)}),
+ '<b>flips to the new value.</b> Each card drops to the next glyph with the mechanical clack. Auto-flips; click to flip now.');
+card(I,'N21','Seven-segment display',
+ (st,rd)=>GW.sevenSeg(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a lit-segment number.</b> Green segments, distinct from the nixie glow. Live countdown; click to add a minute.');
+card(I,'N22','VFD marquee',
+ (st,rd)=>GW.vfdMarquee(st,{onChange:(v,t)=>rd(t)}),
+ '<b>scrolling status line.</b> Teal glow, dot-matrix mesh, text tracks across. Scrolls live; click to change the message.');
+card(I,'N23','Annunciator panel',
+ (st,rd)=>GW.annunciator(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a grid of named alarms with the alarm lifecycle.</b> Cells light amber to warn, red to fault; any active alarm flashes MSTR CAUTION until ACK steadies it, a new alarm re-flashes it, RESET clears the board, TEST proves the bulbs. Click cells to raise alarms.');
+card(I,'N24','Jewel pilot lamps',
+ (st,rd)=>GW.jewels(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a faceted glass indicator.</b> The chunky bezel pilot lamp with a real bloom. Click a jewel to cycle red · amber · green · dark.');
+card(I,'N25','Tape counter',
+ (st,rd)=>GW.tapeCounter(st,{onChange:(v,t)=>rd(t)}),
+ '<b>rolling odometer digits.</b> Numbered wheels roll to the next figure. Rolls live; readout shows the total.');
+card(I,'N26','Analog clock',
+ (st,rd)=>GW.analogClock(st,{onChange:(v,t)=>rd(t)}),
+ '<b>time on a real face.</b> Engraved ticks, three hands, live seconds sweep. Runs live; readout shows the time.');
+card(I,'N27','Frequency-dial scale',
+ (st,rd)=>GW.freqScale(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a printed log scale.</b> Marks crowd low, spread high — a real logarithmic axis. Drag the pointer; readout shows the frequency.');
+card(I,'N28','Patch-bay jack field',
+ (st,rd)=>GW.patchBay(st,{onChange:(v,t)=>rd(t)}),
+ '<b>routing shown as patches.</b> A grid of jacks with cables between connected pairs. Click one jack then another to add/remove a cable.');
+card(I,'R10','Data matrix readout',
+ (st,rd)=>GW.dataMatrix(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a page of amber fields.</b> The console data block — several labeled values on one lit matrix. Click to cycle pages; readout names the page. After a cockpit data readout.');
+card(I,'R11','Warning flag window',
+ (st,rd)=>GW.warningFlag(st,{onChange:(v,t)=>rd(t)}),
+ '<b>the mechanical alarm flag.</b> A striped barber-pole slides into a window when the condition trips — not a lamp, a flag. Click to trip and clear it. After the VIB flag on an altimeter.');
+card(I,'R25','Fourteen-segment display',
+ (st,rd)=>GW.seg14(st,{onChange:(v,t)=>rd(t)}),
+ '<b>segments that spell.</b> The starburst alphanumeric — diagonals and split bars let it write words, not just digits. Click to cycle the word. After a pedal preset display.');
+card(I,'R26','Response graph',
+ (st,rd)=>GW.responseGraph(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a curve on labeled axes.</b> Log frequency across, dB up the side, and the amber peak is yours to place — drag it anywhere on the plot. After the response display of a mastering processor.');
+card(I,'R30','Telegraph indicator',
+ (st,rd)=>GW.telegraphIndicator(st,{onChange:(v,t)=>rd(t)}),
+ '<b>state on a pie of sectors.</b> The pointer and its little flag name the active state, engine-telegraph style. Click to step the state. After an electric indicator dial.');
+card(I,'R31','Radar sweep',
+ (st,rd)=>GW.radarSweep(st,{onChange:(v,t)=>rd(t)}),
+ '<b>the rotating scan.</b> A sweep beam circles the bearing ring, contacts bloom as it passes and fade after. Click to mark the current bearing. After a radar PPI scope.');
+card(I,'R35','Day-date disc calendar',
+ (st,rd)=>GW.dayDateCal(st,{onChange:(v,t)=>rd(t)}),
+ '<b>two coaxial discs, one index.</b> The outer ring carries the date, the inner disc the weekday; both read where the yellow hand points. Starts on today; click to roll midnight forward and watch the discs step. After a watch day-date movement.');
+card(I,'R36','LED dot matrix',
+ (st,rd)=>GW.dotMatrix(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a bitmap, one red dot at a time.</b> 64 discrete LEDs behind a tinted window — segments draw digits, but a matrix draws anything. Click dots to paint the pattern; the readout counts what is lit. After the K4816 pattern generator faceplate.');
+card(I,'R45','Flip-disc tile array',
+ (st,rd)=>GW.flipDisc(st,{onChange:(v,t)=>rd(t)}),
+ '<b>the mechanical pixel.</b> Each disc flips between its yellow face and its black back and then STAYS — no power, no glow, state that persists. Click a disc and watch it flip. The dot matrix glows; this one clacks. After transit flip-dot signs.');
+card(I,'R46','Dekatron counting ring',
+ (st,rd)=>GW.dekatron(st,{onChange:(v,t)=>rd(t)}),
+ '<b>a count as a position, and the counting is visible.</b> Each click is a pulse; the neon glow steps one cathode around the ring and carries on wrap. Nixies show the digit — the Dekatron shows the arithmetic. After glow-transfer counter tubes.');
+card(I,'R47','Landing gear indicator',
+ (st,rd)=>GW.gearIndicator(st,{onChange:(v,t)=>rd(t)}),
+ '<b>three greens or nothing.</b> A spatial lamp panel: nose and mains each get a light, green only when down and locked; the amber transit pulse means neither state. Click the wheel lever to cycle the gear. After a cockpit gear panel.');
+card(I,'R52','Blinkenlights front panel',
+ (st,rd)=>GW.blinkenlights(st,{onChange:(v,t)=>rd(t)}),
+ '<b>the machine thinking, out loud.</b> Address and data lamps ripple with the live computation; the switch register below is yours — flip bits and watch the pattern change. DIP switches hold config; this shows work happening. After a PDP-11/70 front panel.');
+
+/* ============ CONTROL WIRING ============ */
+/* 01 toggle */
+/* 01-09, 24, 25, N01-N10 extracted to widgets.js (GW.*) — wired at their card records */
+
+/* ============ METER WIRING (drag-driven) ============ */
+/* 10-17, N11-N18 extracted to widgets.js (GW.*) — wired at their card records */
+
+/* ============ INDICATOR WIRING ============ */
+/* 18-23, 26 extracted to widgets.js (GW.*) — wired at their card records */
+/* N20-N28 extracted to widgets.js (GW.*) — wired at their card records */
+
+/* ============ REFERENCE-BATCH (R) WIDGETS — SVG builders ============ */
+/* svgEl, polar, VUDB live in widgets.js (GW) */
+
+/* R01 ballistics: the page owns the signal chase; the meter just takes set(t) */
+function tickMcVu(){const a=lvl();const target=Math.min(1.02,a*1.12);
+ const t=MH.mcvu.get();MH.mcvu.set(t+(target-t)*(target>t?0.32:0.10));}
+
+/* R02-R06, R12, R14, R15 extracted to widgets.js (GW.*) */
+
+
+
+
+
+
+
+
+
+/* R07-R09 extracted to widgets.js (GW.*) */
+
+/* R10, R11 extracted to widgets.js (GW.*) */
+
+
+
+
+/* R13, R17 extracted to widgets.js (GW.*) */
+
+/* R25, R26, R30, R31 extracted to widgets.js (GW.*) */
+
+
+
+
+/* ============ LIVE SIGNAL LOOPS ============ */
+/* extracted meters: the page keeps the clock + demo signal, drives card handles */
+const MH={vu:$('card-11').gw,mini:$('card-12').gw,spark:$('card-16').gw,wave:$('card-17').gw,
+ scope:$('card-N11').gw,eq:$('card-N12').gw,strip:$('card-N16').gw,mcvu:$('card-R01').gw};
+const IH={flap:$('card-N20').gw,seven:$('card-N21').gw,vfd:$('card-N22').gw,
+ counter:$('card-N25').gw,clock:$('card-N26').gw};
+
+let ph=0;
+const eqBands=Array.from({length:11},(_,i)=>({v:0.4,ph:i*0.6}));
+function lvl(){return Math.max(0,Math.min(1,0.5+0.4*Math.sin(ph*1.3)+(Math.random()<0.15?Math.random()*0.4:0)-Math.random()*0.08));}
+function fastTick(){
+ ph+=0.09;
+ const a=lvl(),b=lvl();
+ MH.vu.set(a,b);MH.mini.set(a);
+ MH.spark.push(0.5+0.42*Math.sin(ph*0.9)+(Math.random()-0.5)*0.25);
+ const env=Math.min(1,0.6+0.3*Math.sin(ph*0.4));
+ const smp=[];for(let x=0;x<=170;x+=3)smp.push(Math.sin(x*0.18+ph*3)*Math.sin(x*0.05));
+ MH.wave.set(smp,env);
+ const senv=Math.min(1,0.6+0.3*Math.sin(ph*0.4));
+ const ssmp=[];for(let x=0;x<=176;x+=3)ssmp.push(Math.sin(x*0.16+ph*3.2)*senv);
+ MH.scope.set(ssmp,0.6+0.3*Math.sin(ph*0.4));
+ eqBands.forEach(bd=>{bd.ph+=0.16;bd.v=Math.max(0.05,Math.min(1,0.5+0.42*Math.sin(bd.ph)+(Math.random()<0.2?Math.random()*0.3:0)-Math.random()*0.06));});
+ MH.eq.set(eqBands.map(bd=>bd.v));
+ MH.strip.push((28+24*Math.sin(ph*0.7)*(0.6+0.4*Math.sin(ph*0.13))-(Math.random()-0.5)*4)/56);
+}
+/* indicator loops: builders own state + clicks; the page keeps the clocks and
+ the time source (tick contract) */
+function tickClock(){const d=new Date();IH.clock.set(d.getHours(),d.getMinutes(),d.getSeconds());}
+function tickFlap(){IH.flap.next();}
+function tickSeven(){IH.seven.tick();}
+function tickT(){$('card-20').gw.tick();}
+function tickVfd(){IH.vfd.tick();}
+function tickCounter(){IH.counter.set(IH.counter.get()+137);}
+
+/* static paint for reduced motion */
+function paintStatic(){
+ MH.vu.set(0.55,0.5);MH.mini.set(0.55);
+ MH.spark.fill(0.5);
+ MH.wave.set([],0.6);
+ eqBands.forEach((bd,i)=>bd.v=0.3+0.5*Math.abs(Math.sin(i*0.9)));
+ MH.eq.set(eqBands.map(bd=>bd.v));
+ MH.scope.set(Array.from({length:60},(_,i)=>Math.sin(i*0.5)*18/22),0.74);
+ MH.strip.set(Array.from({length:60},(_,i)=>0.5+Math.sin(i*0.3)*16/56),0.5);
+ tickClock();IH.flap.set(0);
+ MH.mcvu.set(.5);
+}
+
+if(!reduced){
+ setInterval(fastTick,80);
+ setInterval(tickMcVu,80);
+ setInterval(tickClock,1000);tickClock();
+ setInterval(tickFlap,1500);
+ setInterval(tickVfd,32);
+ setInterval(tickCounter,1600);
+ setInterval(tickSeven,1000);
+ setInterval(tickT,1000);
+ document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState='running');
+}else{
+ paintStatic();
+ document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState='paused');
+}
+
+/* R35, R36 extracted to widgets.js (GW.*) */
+
+
+/* R43, R44 extracted to widgets.js (GW.*) */
+
+/* R45-R47, R52 extracted to widgets.js (GW.*) */
+
+
+
+
+/* R53-R56 extracted to widgets.js (GW.*) */
+
+/* ---- screen-family chips: first round, the clearly-screen widgets ----
+ Defaults match each widget's shipped color (var fallbacks), so no family is applied until a chip is clicked.
+ The nixie stays chipless on purpose: neon is only ever orange, so alternates would be historically false. */
+screenChips('R10',$('card-R10').gw.el,['amber','green','red','blue','vfd'],'amber');
+screenChips('R17',$('card-R17').gw.el,['green','amber','red','blue','vfd'],'green');
+screenChips('R19',$('card-R19').gw.el,['white','green','amber','blue','vfd'],'white');
+screenChips('R31',$('card-R31').gw.el,['amber','green','red','blue','vfd'],'amber');
+screenChips('N11',$('card-N11').gw.el,['green','amber','red','blue','vfd'],'green');
+BOOST.forEach(no=>{const rd=document.getElementById('rd-'+no);
+ if(rd)rd.closest('.card').querySelector('.stagew').classList.add('boost');});
+
+/* cross-reference links: card IDs mentioned in notes and spec sheets jump to that card */
+(function(){const ids=new Set([...document.querySelectorAll('.card .no')].map(n=>n.textContent));
+ document.querySelectorAll('.wnote,.igrid .iv').forEach(el=>{
+ if(el.querySelector('a[href^="reference/"]'))return; // leave the photo-link rows alone
+ el.innerHTML=el.innerHTML.replace(/\b(R\d{2}|N\d{2})\b/g,
+ m=>ids.has(m)?`<a class="xref" href="#card-${m}">${m}</a>`:m);});})();
+
+/* style chips: shared demo rig for builders with constructor style opts —
+ reads a builder's STYLES table and drives setStyle on the live card instance */
+function styleChips(no,STYLES,AXES){
+ const h=$('card-'+no)?.gw; if(!h)return;
+ const cardEl=h.el.closest('.card');
+ const row=document.createElement('div'); row.className='famchips';
+ for(const [label,axis,def] of AXES){
+ const g=document.createElement('span'); g.className='fgroup';
+ const lab=document.createElement('span'); lab.className='lab'; lab.textContent=label; g.appendChild(lab);
+ const chips=[];
+ for(const [name,o] of Object.entries(STYLES[axis])){
+ const b=document.createElement('span'); b.className='fc'+(name===def?' on':'');
+ b.style.background=o.dot; b.title=name; chips.push(b);
+ b.addEventListener('click',()=>{h.setStyle(axis,name);
+ chips.forEach(x=>x.classList.toggle('on',x===b));});
+ g.appendChild(b);}
+ row.appendChild(g);}
+ cardEl.querySelector('.opts').appendChild(row);
+}
+styleChips('01',GW.slideToggle.STYLES,[['on','on','amber'],['off','off','dark'],['off text','offText','white'],['thumb','thumb','light']]);
+styleChips('R05',GW.filterBank.STYLES,[['panel','panel','silver'],['shape','caps','block'],['color','capColor','black']]);
+styleChips('06',GW.segmented.STYLES,[['accent','accent','amber']]);
+styleChips('25',GW.slideRule.STYLES,[['face','skin','warm']]);
+
+/* final tally pass: setV fires per card during build, but each card is still
+ detached at that moment, so the running counts lag by one — recount now */
+updateVTally();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/reference/slide-toggle.jpg b/docs/prototypes/reference/slide-toggle.jpg
new file mode 100644
index 0000000..c1c0b14
--- /dev/null
+++ b/docs/prototypes/reference/slide-toggle.jpg
Binary files differ
diff --git a/docs/prototypes/tokens-waybar.css b/docs/prototypes/tokens-waybar.css
new file mode 100644
index 0000000..d18ba23
--- /dev/null
+++ b/docs/prototypes/tokens-waybar.css
@@ -0,0 +1,34 @@
+/* generated from tokens.json by gen_tokens.py — do not edit by hand.
+ GTK CSS has no custom properties; reference these as @name, and use
+ alpha(@name, 0.5) where the web build uses rgba(var(--name),.5). */
+@define-color ground #151311;
+@define-color panel #100f0f;
+@define-color well #0a0c0d;
+@define-color raise #1a1917;
+@define-color silver #bfc4d0;
+@define-color cream #f3e7c5;
+@define-color steel #969385;
+@define-color dim #7c838a;
+@define-color slate #424f5e;
+@define-color slate_hi #54677d;
+@define-color wash #2c2f32;
+@define-color pass #74932f;
+@define-color fail #cb6b4d;
+@define-color phos #7fe0a0;
+@define-color phos_dim #1c3626;
+@define-color vfd #63e6c8;
+@define-color sevgrn #57d357;
+@define-color sevred #e2543f;
+@define-color sevoff #1a1613;
+@define-color jewel_r #ff5b45;
+@define-color jewel_a #ffb43a;
+@define-color jewel_g #6fce33;
+@define-color gold #e2a038;
+@define-color gold_hi #ffbe54;
+@define-color amber_grad_top #f2c76a;
+@define-color amber_grad_mid #d29638;
+@define-color amber_grad_bot #8f671f;
+@define-color amber_edge #7d5c16;
+@define-color amber_warn #f0b552;
+@define-color glow_hi #ffbe54;
+@define-color glow_lo #e2a038;
diff --git a/docs/prototypes/tokens.json b/docs/prototypes/tokens.json
new file mode 100644
index 0000000..6974805
--- /dev/null
+++ b/docs/prototypes/tokens.json
@@ -0,0 +1,46 @@
+{
+ "_note": "Single source of truth for the panel widget gallery's design tokens. Edit here, then run: python3 gen_tokens.py (regenerates the :root block in panel-widget-gallery.html plus tokens-waybar.css and tokens.el). The amber hue is defined once; its glow rgb triples are derived by the generator, so retuning amber is a one-line change here.",
+ "palette": {
+ "ground": "#151311",
+ "panel": "#100f0f",
+ "well": "#0a0c0d",
+ "raise": "#1a1917",
+ "silver": "#bfc4d0",
+ "cream": "#f3e7c5",
+ "steel": "#969385",
+ "dim": "#7c838a",
+ "slate": "#424f5e",
+ "slate-hi": "#54677d",
+ "wash": "#2c2f32",
+ "pass": "#74932f",
+ "fail": "#cb6b4d",
+ "phos": "#7fe0a0",
+ "phos-dim": "#1c3626",
+ "vfd": "#63e6c8",
+ "sevgrn": "#57d357",
+ "sevred": "#e2543f",
+ "sevoff": "#1a1613",
+ "jewel-r": "#ff5b45",
+ "jewel-a": "#ffb43a",
+ "jewel-g": "#6fce33"
+ },
+ "amber": {
+ "gold": "#e2a038",
+ "gold-hi": "#ffbe54",
+ "amber-grad-top": "#f2c76a",
+ "amber-grad-mid": "#d29638",
+ "amber-grad-bot": "#8f671f",
+ "amber-edge": "#7d5c16",
+ "amber-warn": "#f0b552"
+ },
+ "glow": {
+ "glow-hi": "gold-hi",
+ "glow-lo": "gold"
+ },
+ "font": {
+ "mono": "\"BerkeleyMono Nerd Font\",\"Berkeley Mono\",monospace"
+ },
+ "timing": {
+ "pulse-rate": "1s"
+ }
+}
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
new file mode 100644
index 0000000..c12e5b7
--- /dev/null
+++ b/docs/prototypes/widgets.js
@@ -0,0 +1,4491 @@
+/* widgets.js — retro-instrument widget library (GW namespace).
+ Classic script: load after the token :root block (tokens.json → gen_tokens.py),
+ before any GW.* call. Works from file:// — no modules, no build step.
+ Contract: each gallery card in panel-widget-gallery.html is the visual +
+ behavioral spec its builder is judged against.
+ Spec: docs/specs/2026-07-12-component-generation-spec.org */
+(function () {
+'use strict';
+const GW = {};
+
+/* ================= shared engine ================= */
+
+const SVGNS = 'http://www.w3.org/2000/svg';
+function svgEl(parent, tag, attrs) {
+ const e = document.createElementNS(SVGNS, tag);
+ for (const k in attrs) e.setAttribute(k, attrs[k]);
+ parent.appendChild(e); return e;
+}
+function polar(cx, cy, r, deg) {
+ const a = deg * Math.PI / 180;
+ return [cx + r * Math.sin(a), cy - r * Math.cos(a)];
+}
+
+/* ---- pointer-drag helpers (rect-ratio based, so CSS zoom is transparent) ---- */
+function dragX(el, onPct) {
+ el.style.touchAction = 'none';
+ const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (e.clientX - r.left) / r.width * 100))); };
+ el.addEventListener('pointerdown', e => {
+ el.setPointerCapture(e.pointerId); at(e);
+ const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); };
+ el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault();
+ });
+}
+function dragY(el, onPct) {
+ el.style.touchAction = 'none';
+ const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (r.bottom - e.clientY) / r.height * 100))); };
+ el.addEventListener('pointerdown', e => {
+ el.setPointerCapture(e.pointerId); at(e);
+ const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); };
+ el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault();
+ });
+}
+function dragDelta(el, get, set, opts) {
+ opts = opts || {}; el.style.touchAction = 'none';
+ const min = opts.min == null ? 0 : opts.min, max = opts.max == null ? 100 : opts.max, sens = opts.sens || ((max - min) / 160);
+ el.addEventListener('pointerdown', e => {
+ if (opts.stop) e.stopPropagation(); el.setPointerCapture(e.pointerId);
+ const y0 = e.clientY, v0 = get();
+ const mv = ev => { let v = v0 + (y0 - ev.clientY) * sens; set(Math.max(min, Math.min(max, v))); };
+ const up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); };
+ el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault();
+ });
+}
+
+/* ---- seven-segment digit builder ---- */
+const SEG = { '0': 'abcdef', '1': 'bc', '2': 'abged', '3': 'abgcd', '4': 'fbgc', '5': 'afgcd', '6': 'afgedc', '7': 'abc', '8': 'abcdefg', '9': 'abcdfg', '-': 'g', ' ': '' };
+function hseg(cy) { return `5,${cy} 7,${cy - 2} 17,${cy - 2} 19,${cy} 17,${cy + 2} 7,${cy + 2}`; }
+function vseg(cx, ty, by) { return `${cx},${ty} ${cx - 2},${ty + 2} ${cx - 2},${by - 2} ${cx},${by} ${cx + 2},${by - 2} ${cx + 2},${ty + 2}`; }
+const SEGPTS = { a: hseg(3), g: hseg(22), d: hseg(41), f: vseg(5, 4, 21), b: vseg(19, 4, 21), e: vseg(5, 23, 40), c: vseg(19, 23, 40) };
+function seg7(ch, cls) {
+ const lit = SEG[ch] || ''; let s = `<svg class="seg7 ${cls || ''}" viewBox="0 0 24 44">`;
+ for (const k of 'abcdefg') {
+ const on = lit.includes(k);
+ const col = on ? (cls === 'red' ? 'var(--sevred)' : 'var(--sevgrn)') : 'var(--sevoff)';
+ s += `<polygon points="${SEGPTS[k]}" fill="${col}"/>`;
+ }
+ return s + '</svg>';
+}
+
+function buildBars(el, n) { el.innerHTML = ''; for (let k = 0; k < n; k++) el.appendChild(document.createElement('i')); }
+
+/* ---- VU law: authentic nonlinear dB scale, dB → 0..1 sweep position ---- */
+const VUDB = [[-20, 0], [-10, .20], [-7, .31], [-5, .40], [-3, .50], [-2, .58], [-1, .66], [0, .75], [1, .84], [2, .91], [3, 1]];
+/* interpolate a dB reading from a 0..1 sweep position */
+function vuDb(t) {
+ let i = 0; while (i < VUDB.length - 2 && VUDB[i + 1][1] < t) i++;
+ const [d1, t1] = VUDB[i], [d2, t2] = VUDB[i + 1];
+ return d1 + (d2 - d1) * Math.max(0, Math.min(1, (t - t1) / (t2 - t1)));
+}
+
+/* ---- screen-color families: period phosphor/LCD palettes as scoped CSS vars.
+ Shipped literals are the fallbacks, so defaults are pixel-identical until applied. ---- */
+const SCREEN_FAMS = {
+ green: { '--scr-hi': '#7fe0a0', '--scr-ink': '#58b87e', '--scr-dim': '#3d5c46',
+ '--scr-bg1': '#0a120c', '--scr-bg2': '#050c07', '--scr-bgc': '#04140a', '--scr-bge': '#020a05', '--scr-brd': '#123018',
+ '--scr-grat': 'rgba(111,206,51,.18)', '--scr-gratc': 'rgba(111,206,51,.35)', '--scr-glow': 'rgba(127,224,160,.8)',
+ '--crt-face1': '#b9d8c0', '--crt-face2': '#8bb296', '--crt-glow': '#eef7ee' },
+ amber: { '--scr-hi': '#ffbe54', '--scr-ink': '#e2a038', '--scr-dim': '#7d5c16',
+ '--scr-bg1': '#140d06', '--scr-bg2': '#0a0705', '--scr-bgc': '#140d06', '--scr-bge': '#0a0603', '--scr-brd': '#33230e',
+ '--scr-grat': 'rgba(226,160,56,.18)', '--scr-gratc': 'rgba(226,160,56,.35)', '--scr-glow': 'rgba(255,190,84,.8)',
+ '--crt-face1': '#d8cba6', '--crt-face2': '#b3a276', '--crt-glow': '#ffe7c0' },
+ red: { '--scr-hi': '#ff9a4c', '--scr-ink': '#e0742e', '--scr-dim': '#5c3416',
+ '--scr-bg1': '#140a05', '--scr-bg2': '#0c0603', '--scr-bgc': '#140a05', '--scr-bge': '#0a0502', '--scr-brd': '#331c0c',
+ '--scr-grat': 'rgba(224,116,46,.18)', '--scr-gratc': 'rgba(224,116,46,.35)', '--scr-glow': 'rgba(255,154,76,.8)',
+ '--crt-face1': '#d8b9a6', '--crt-face2': '#b39276', '--crt-glow': '#ffdcc0' },
+ blue: { '--scr-hi': '#cfe4ff', '--scr-ink': '#94b8d8', '--scr-dim': '#3a4c60',
+ '--scr-bg1': '#0a0e14', '--scr-bg2': '#05080c', '--scr-bgc': '#0a1018', '--scr-bge': '#04070c', '--scr-brd': '#16283a',
+ '--scr-grat': 'rgba(148,184,216,.18)', '--scr-gratc': 'rgba(148,184,216,.35)', '--scr-glow': 'rgba(207,228,255,.8)',
+ '--crt-face1': '#c2d2dd', '--crt-face2': '#93a8b6', '--crt-glow': '#eaf2ff' },
+ vfd: { '--scr-hi': '#63e6c8', '--scr-ink': '#46b89e', '--scr-dim': '#1e4a40',
+ '--scr-bg1': '#06100d', '--scr-bg2': '#040b09', '--scr-bgc': '#041410', '--scr-bge': '#020a08', '--scr-brd': '#0e3028',
+ '--scr-grat': 'rgba(70,184,158,.18)', '--scr-gratc': 'rgba(70,184,158,.35)', '--scr-glow': 'rgba(99,230,200,.8)',
+ '--crt-face1': '#b0d8cd', '--crt-face2': '#84ada2', '--crt-glow': '#dcfff5' },
+ white: { '--scr-hi': '#f2f4f2', '--scr-ink': '#c8cac8', '--scr-dim': '#5a5c5a',
+ '--scr-bg1': '#0b0c0b', '--scr-bg2': '#070807', '--scr-bgc': '#0d0e0d', '--scr-bge': '#060706', '--scr-brd': '#2a2c2a',
+ '--scr-grat': 'rgba(200,202,200,.18)', '--scr-gratc': 'rgba(200,202,200,.35)', '--scr-glow': 'rgba(242,244,242,.7)',
+ '--crt-face1': '#d0d2d0', '--crt-face2': '#a8aaa8', '--crt-glow': '#f7f7f7' },
+};
+
+/* ================= widget builders =================
+ Every builder: GW.name(host, opts) → handle. host is an empty element the
+ widget renders into. opts.onChange(value, text) fires on every state change,
+ including the initial paint; text is the widget's canonical readout string. */
+
+const noop = () => {};
+
+/* 01 slide toggle — on/off pill. State colors ride CSS vars (--sw-*).
+ opts.onStyle / offStyle / offText / thumb pick a named style per axis from
+ GW.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's
+ setStyle(axis, name) restyles a live instance (the gallery chips use it). */
+GW.slideToggle = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const sw = document.createElement('span'); sw.className = 'switch'; host.appendChild(sw);
+ const setStyle = (axis, name) => {
+ const o = (GW.slideToggle.STYLES[axis] || {})[name];
+ if (!o) return;
+ for (const [k, v] of Object.entries(o.vars)) sw.style.setProperty(k, v);
+ };
+ setStyle('on', opts.onStyle || 'amber');
+ setStyle('off', opts.offStyle || 'dark');
+ setStyle('offText', opts.offText || 'white');
+ setStyle('thumb', opts.thumb || 'light');
+ let on;
+ const set = v => { on = !!v; sw.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
+ sw.addEventListener('click', () => set(!on));
+ set(opts.on !== undefined ? opts.on : true);
+ return { el: sw, get: () => on, set, setStyle };
+};
+/* named styles per axis: dot = swatch color for pickers, vars = --sw-* overrides */
+GW.slideToggle.STYLES = {
+ on: {
+ amber: { dot: 'var(--gold-hi)', vars: { '--sw-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--sw-on-brd': 'var(--gold-hi)', '--sw-on-ink': 'var(--panel)' } },
+ green: { dot: '#8fb944', vars: { '--sw-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--sw-on-brd': '#a9c95f', '--sw-on-ink': 'var(--panel)' } },
+ dark: { dot: '#242019', vars: { '--sw-on-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-on-brd': 'var(--slate)', '--sw-on-ink': 'var(--cream)' } },
+ },
+ off: {
+ dark: { dot: '#242019', vars: { '--sw-off-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-off-brd': 'var(--slate)' } },
+ red: { dot: 'var(--fail)', vars: { '--sw-off-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-off-brd': 'var(--fail)' } },
+ },
+ offText: {
+ white: { dot: 'var(--cream)', vars: { '--sw-off-ink': 'var(--cream)' } },
+ red: { dot: '#e0523a', vars: { '--sw-off-ink': '#e0523a' } },
+ black: { dot: '#14110e', vars: { '--sw-off-ink': '#14110e' } },
+ },
+ thumb: {
+ light: { dot: '#d8d2c4', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#f6f2e8,#b0aa9a)' } },
+ dark: { dot: '#2c2824', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#4a453e,#16130f)' } },
+ chrome: { dot: '#9aa2b4', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#dfe6f2,#6d7484)' } },
+ brass: { dot: '#b8944a', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#e2c47a,#8a6a24)' } },
+ },
+};
+
+/* 02 console keys — mutually exclusive push buttons; {label, red} per key */
+GW.consoleKeys = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const keys = opts.keys || [{ label: 'LIVE' }, { label: 'SCAN' }, { label: 'MUTED', red: true }];
+ const wrap = document.createElement('span'); host.appendChild(wrap);
+ const btns = keys.map(k => {
+ const b = document.createElement('button'); b.className = 'key'; b.textContent = k.label;
+ wrap.appendChild(b); return b;
+ });
+ let idx;
+ const set = i => {
+ idx = i;
+ btns.forEach((b, j) => { b.classList.remove('on', 'red'); if (j === i) b.classList.add(keys[i].red ? 'red' : 'on'); });
+ onChange(i, keys[i].label);
+ };
+ btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
+ set(opts.active || 0);
+ return { el: wrap, get: () => idx, set };
+};
+
+/* 03 horizontal fader — continuous 0-100 */
+GW.faderH = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const f = document.createElement('div'); f.className = 'fader';
+ f.innerHTML = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>';
+ host.appendChild(f);
+ let val;
+ const set = p => {
+ val = Math.max(0, Math.min(100, p));
+ f.querySelector('.fill').style.width = val + '%';
+ f.querySelector('.cap').style.left = val + '%';
+ onChange(val, 'level ' + Math.round(val));
+ };
+ dragX(f, set);
+ set(opts.value !== undefined ? opts.value : 68);
+ return { el: f, get: () => val, set };
+};
+
+/* 04 vertical fader — one channel-strip fader; compose per channel */
+GW.faderV = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const f = document.createElement('div'); f.className = 'vfader';
+ f.innerHTML = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>';
+ host.appendChild(f);
+ let val;
+ const set = p => {
+ val = Math.max(0, Math.min(100, p));
+ f.querySelector('.fill').style.height = val + '%';
+ f.querySelector('.cap').style.bottom = val + '%';
+ onChange(val, 'level ' + Math.round(val));
+ };
+ dragY(f, set);
+ set(opts.value !== undefined ? opts.value : 60);
+ return { el: f, get: () => val, set };
+};
+
+/* 05 rotary knob — drag up/down to turn, -150°..+150° sweep */
+GW.knob = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const min = opts.min !== undefined ? opts.min : 0, max = opts.max !== undefined ? opts.max : 100;
+ const k = document.createElement('span'); k.className = 'knob';
+ k.innerHTML = '<span class="ind"></span>';
+ host.appendChild(k);
+ let val;
+ const set = v => {
+ val = Math.max(min, Math.min(max, v));
+ k.querySelector('.ind').style.transform = `rotate(${-150 + (val - min) / (max - min) * 300}deg)`;
+ onChange(val, String(Math.round(val)));
+ };
+ dragDelta(k, () => val, set, { min, max });
+ set(opts.value !== undefined ? opts.value : 53);
+ return { el: k, get: () => val, set };
+};
+
+/* 06 segmented selector — pick one of a few. opts.accent picks the lit-segment
+ color from GW.segmented.STYLES (amber / green / red); defaults match the
+ stylesheet fallbacks. setStyle(axis, name) restyles a live instance. */
+GW.segmented = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const items = opts.items || ['TIMER', 'ALARM', 'POMO'];
+ const seg = document.createElement('div'); seg.className = 'seg'; host.appendChild(seg);
+ const setStyle = (axis, name) => {
+ const o = (GW.segmented.STYLES[axis] || {})[name];
+ if (!o) return;
+ for (const [k, v] of Object.entries(o.vars)) seg.style.setProperty(k, v);
+ };
+ setStyle('accent', opts.accent || 'amber');
+ const btns = items.map(t => { const b = document.createElement('button'); b.textContent = t; seg.appendChild(b); return b; });
+ let idx;
+ const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); };
+ btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
+ set(opts.active || 0);
+ return { el: seg, get: () => idx, set, setStyle };
+};
+/* named styles per axis: dot = swatch color for pickers, vars = --seg-* overrides */
+GW.segmented.STYLES = {
+ accent: {
+ amber: { dot: 'var(--gold-hi)', vars: { '--seg-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--seg-on-ink': 'var(--panel)' } },
+ green: { dot: '#8fb944', vars: { '--seg-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--seg-on-ink': 'var(--panel)' } },
+ red: { dot: 'var(--fail)', vars: { '--seg-on-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--seg-on-ink': 'var(--panel)' } },
+ },
+};
+
+/* 07 chip toggle — inline binary inside a line of text */
+GW.chipToggle = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const chip = document.createElement('span'); chip.className = 'chip';
+ chip.textContent = opts.label || 'discoverable on';
+ host.appendChild(chip);
+ let on;
+ const set = v => { on = !!v; chip.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
+ chip.addEventListener('click', () => set(!on));
+ set(opts.on !== undefined ? opts.on : true);
+ return { el: chip, get: () => on, set };
+};
+
+/* 08 arm-to-fire — two-stage confirm for destructive actions */
+GW.armButton = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const label = opts.label || 'forget', armLabel = opts.armLabel || label + '? again';
+ const a = document.createElement('button'); a.className = 'arm'; a.textContent = label;
+ host.appendChild(a);
+ let armed = false;
+ a.addEventListener('click', () => {
+ if (!armed) { armed = true; a.classList.add('armed'); a.textContent = armLabel; onChange('armed', 'ARMED'); }
+ else { armed = false; a.classList.remove('armed'); a.textContent = label; onChange('fired', 'FIRED · reset'); }
+ });
+ onChange('safe', 'SAFE');
+ return { el: a, get: () => (armed ? 'armed' : 'safe'), fire: () => a.click() };
+};
+
+/* 09 lamp row — actionable list item: lamp + name + status, click to cycle */
+GW.lampRow = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const name = opts.name || 'WH-1000XM4';
+ const states = opts.states || [['gold', 'tap to connect'], ['busy', 'connecting…'], ['', 'connected']];
+ const row = document.createElement('div'); row.className = 'lrow';
+ row.innerHTML = `<span class="lamp"></span><span class="who"><b></b></span><span class="what"></span>`;
+ row.querySelector('b').textContent = name;
+ host.appendChild(row);
+ let idx;
+ const set = i => {
+ idx = i;
+ row.querySelector('.lamp').className = 'lamp ' + states[i][0];
+ row.querySelector('.what').textContent = states[i][1];
+ onChange(i, states[i][1]);
+ };
+ row.addEventListener('click', () => set((idx + 1) % states.length));
+ set(opts.state || 0);
+ return { el: row, get: () => idx, set };
+};
+
+/* 24 rotary selector — pick one of five printed detents by position */
+GW.rotarySelector = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const values = opts.values || [4, 6, 8, 10, 12];
+ const fmt = opts.fmt || (v => 'size ' + v);
+ const POS = [[14, 53], [28, 18], [50, 7], [72, 18], [86, 53]], ANG = [-70, -35, 0, 35, 70];
+ const rs = document.createElement('span'); rs.className = 'rotsel';
+ values.forEach((v, i) => {
+ const p = document.createElement('span'); p.className = 'pos';
+ p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = v;
+ rs.appendChild(p);
+ });
+ const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = '<span class="ind"></span>'; rs.appendChild(k);
+ host.appendChild(rs);
+ let idx;
+ const set = i => {
+ idx = ((i % values.length) + values.length) % values.length;
+ rs.querySelector('.ind').style.transform = `rotate(${ANG[idx]}deg)`;
+ rs.querySelectorAll('.pos').forEach((p, j) => p.classList.toggle('on', j === idx));
+ onChange(values[idx], fmt(values[idx]));
+ };
+ k.addEventListener('click', () => set(idx + 1));
+ set(opts.index !== undefined ? opts.index : 2);
+ return { el: rs, get: () => values[idx], set };
+};
+
+/* 25 slide-rule dial — lit pointer on a printed scale. The printed numerals
+ are the majors; the integer units between them get minor ticks and are
+ selectable too. Click a numeral, a mark, or between marks; ←/→ (↑/↓) step
+ one unit. set() takes a stop index; get() returns the value. opts.skin picks
+ a face from GW.slideRule.STYLES (warm backlit / chrome / 80s black glass / marantz blue);
+ setStyle(axis, name) restyles a live instance. */
+GW.slideRule = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const majors = opts.values || [4, 6, 8, 10, 12];
+ const fmt = opts.fmt || (v => 'pos ' + v);
+ const X = [12, 51, 90, 129, 168];
+ /* expand to unit stops: every integer between adjacent numeric majors */
+ const stops = [];
+ majors.forEach((v, i) => {
+ stops.push({ v, x: X[i], major: true });
+ const b = majors[i + 1];
+ if (typeof v === 'number' && typeof b === 'number')
+ for (let u = v + 1; u < b; u++)
+ stops.push({ v: u, x: X[i] + (X[i + 1] - X[i]) * (u - v) / (b - v), major: false });
+ });
+ const t = document.createElement('span'); t.className = 'tuner';
+ t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value');
+ const setStyle = (axis, name) => {
+ const o = (GW.slideRule.STYLES[axis] || {})[name];
+ if (!o) return;
+ for (const [k, v] of Object.entries(o.vars)) t.style.setProperty(k, v);
+ };
+ setStyle('skin', opts.skin || 'warm');
+ stops.forEach(s => {
+ t.insertAdjacentHTML('beforeend', s.major
+ ? `<span class="tick" style="left:${s.x}px"></span><span class="mk" style="left:${s.x}px">${s.v}</span>`
+ : `<span class="tick mn" style="left:${s.x}px"></span>`);
+ });
+ const ndl = document.createElement('span'); ndl.className = 'ndl'; t.appendChild(ndl);
+ host.appendChild(t);
+ let idx;
+ const set = i => {
+ idx = Math.max(0, Math.min(stops.length - 1, i));
+ ndl.style.left = stops[idx].x + 'px';
+ t.querySelectorAll('.mk').forEach(m => m.classList.toggle('on', +m.textContent === stops[idx].v));
+ onChange(stops[idx].v, fmt(stops[idx].v));
+ };
+ t.addEventListener('click', e => {
+ const r = t.getBoundingClientRect();
+ /* rect is visual px but stop coords are layout px — divide the CSS zoom out */
+ const x = (e.clientX - r.left) * (t.offsetWidth / r.width);
+ let best = 0, bd = 1e9; stops.forEach((s, j) => { const d = Math.abs(s.x - x); if (d < bd) { bd = d; best = j; } });
+ set(best); t.focus();
+ });
+ t.addEventListener('keydown', e => {
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); set(idx - 1); }
+ else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); set(idx + 1); }
+ });
+ const initV = opts.value !== undefined ? opts.value : majors[opts.index !== undefined ? opts.index : 2];
+ const init = stops.findIndex(s => s.v === initV);
+ set(init < 0 ? 0 : init);
+ return { el: t, get: () => stops[idx].v, set, setStyle };
+};
+/* named faces: dot = swatch color for pickers, vars = --tn-* overrides.
+ warm restates the stylesheet fallbacks so switching back is exact. */
+GW.slideRule.STYLES = {
+ skin: {
+ warm: { dot: 'var(--gold-hi)', vars: {
+ '--tn-bg': 'linear-gradient(180deg,#191510,#0b0908)', '--tn-brd': '#2a251c',
+ '--tn-glow': 'rgba(var(--glow-lo),.12)', '--tn-tick': 'var(--steel)', '--tn-ink': 'var(--steel)',
+ '--tn-on': 'var(--gold-hi)', '--tn-onglow': 'rgba(var(--glow-hi),.6)',
+ '--tn-ndl': 'var(--fail)', '--tn-ndlglow': 'rgba(203,107,77,.85)' } },
+ chrome: { dot: '#dfe6f2', vars: {
+ '--tn-bg': 'linear-gradient(180deg,#e9e9e5,#bcbcb6)', '--tn-brd': '#8e8e88',
+ '--tn-glow': 'rgba(255,255,255,.28)', '--tn-tick': '#3c3a34', '--tn-ink': '#3c3a34',
+ '--tn-on': '#14110e', '--tn-onglow': 'none',
+ '--tn-ndl': 'var(--fail)', '--tn-ndlglow': 'rgba(203,107,77,.45)' } },
+ black: { dot: '#1c1a18', vars: {
+ '--tn-bg': 'linear-gradient(180deg,#17181a,#08090a)', '--tn-brd': '#000',
+ '--tn-glow': 'rgba(255,255,255,.05)', '--tn-tick': '#c8cac8', '--tn-ink': '#c8cac8',
+ '--tn-on': '#f2f4f2', '--tn-onglow': 'rgba(242,244,242,.5)',
+ '--tn-ndl': '#ff3b28', '--tn-ndlglow': 'rgba(255,59,40,.9)' } },
+ blue: { dot: '#8fb4d8', vars: {
+ '--tn-bg': 'linear-gradient(180deg,#0c1520,#070c14)', '--tn-brd': '#1c2c40',
+ '--tn-glow': 'rgba(120,170,230,.16)', '--tn-tick': '#8fb4d8', '--tn-ink': '#8fb4d8',
+ '--tn-on': '#cfe4ff', '--tn-onglow': 'rgba(150,200,255,.7)',
+ '--tn-ndl': '#e8f2ff', '--tn-ndlglow': 'rgba(180,220,255,.9)' } },
+ },
+};
+
+/* N01 rocker power switch — hard on/off, lit legend */
+GW.rocker = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const r = document.createElement('span'); r.className = 'rocker';
+ r.innerHTML = `<span class="half top">${opts.onLabel || 'ON'}</span><span class="half bot">${opts.offLabel || 'OFF'}</span>`;
+ host.appendChild(r);
+ let on;
+ const set = v => { on = !!v; r.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
+ r.addEventListener('click', () => set(!on));
+ set(opts.on !== undefined ? opts.on : true);
+ return { el: r, get: () => on, set };
+};
+
+/* N02 transport cluster — rew/play/stop/rec, one lit; reels turn while playing.
+ opts.animate: reels obey play state (default: not prefers-reduced-motion) */
+GW.transport = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const animate = opts.animate !== undefined ? opts.animate : !matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const MODES = { rew: '⏮', play: '▶', stop: '⏹', rec: '⏺' };
+ const NAMES = { rew: 'REW', play: 'PLAY', stop: 'STOP', rec: 'REC' };
+ const wrap = document.createElement('div');
+ wrap.style.cssText = 'display:flex;flex-direction:column;gap:6px;align-items:center';
+ wrap.innerHTML = `<div class="reels"><span class="reel spin"><i></i><i></i><i></i></span><span class="reel spin"><i></i><i></i><i></i></span></div><div class="transport"></div>`;
+ const bar = wrap.querySelector('.transport');
+ const btns = {};
+ for (const m of Object.keys(MODES)) {
+ const b = document.createElement('button'); b.className = 'tbtn' + (m === 'rec' ? ' rec' : '');
+ b.textContent = MODES[m]; bar.appendChild(b); btns[m] = b;
+ b.addEventListener('click', () => set(m));
+ }
+ host.appendChild(wrap);
+ let mode;
+ const set = m => {
+ mode = m;
+ for (const k in btns) btns[k].classList.toggle('on', k === m);
+ wrap.querySelectorAll('.reel.spin').forEach(r => r.style.animationPlayState = (m === 'play' && animate) ? 'running' : 'paused');
+ onChange(m, NAMES[m]);
+ };
+ /* initial: light the mode but leave reel play-state to CSS, matching load behavior */
+ mode = opts.mode || 'play';
+ btns[mode].classList.add('on');
+ onChange(mode, NAMES[mode]);
+ return { el: wrap, get: () => mode, set };
+};
+
+/* N03 radio preset bank — mechanically exclusive presets */
+GW.presetBank = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const items = opts.items || ['WIFI', 'ETH', 'CELL', 'OFF'];
+ const bank = document.createElement('div'); bank.className = 'radiobank'; host.appendChild(bank);
+ const btns = items.map(t => { const b = document.createElement('button'); b.className = 'preset'; b.textContent = t; bank.appendChild(b); return b; });
+ let idx;
+ const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); };
+ btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
+ set(opts.active || 0);
+ return { el: bank, get: () => idx, set };
+};
+
+/* N04 concentric dual knob — two values on one spindle, outer ring + inner cap */
+GW.dualKnob = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const dk = document.createElement('span'); dk.className = 'dualknob';
+ dk.innerHTML = `<span class="outer"><span class="tick" style="transform:rotate(-120deg)"></span><span class="tick" style="transform:rotate(120deg)"></span></span><span class="inner"><span class="ind"></span></span>`;
+ host.appendChild(dk);
+ const o = dk.querySelector('.outer'), n = dk.querySelector('.inner');
+ let oV, iV;
+ const paint = () => {
+ o.style.transform = `rotate(${-150 + oV / 100 * 300}deg)`;
+ n.style.transform = `rotate(${-150 + iV / 100 * 300}deg)`;
+ onChange(oV, iV);
+ };
+ dragDelta(o, () => oV, v => { oV = v; paint(); }, { min: 0, max: 100 });
+ dragDelta(n, () => iV, v => { iV = v; paint(); }, { min: 0, max: 100, stop: true });
+ oV = opts.outer !== undefined ? opts.outer : 50;
+ iV = opts.inner !== undefined ? opts.inner : 50;
+ paint();
+ return { el: dk, get: () => [oV, iV], set: (a, b) => { if (a !== undefined) oV = a; if (b !== undefined) iV = b; paint(); } };
+};
+
+/* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level */
+GW.encoder = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const enc = document.createElement('span'); enc.className = 'encoder'; host.appendChild(enc);
+ const R = 27, cx = 33, cy = 33;
+ for (let i = 0; i < 12; i++) {
+ const a = (i * 30 - 90) * Math.PI / 180;
+ const d = document.createElement('span'); d.className = 'led';
+ d.style.left = (cx + R * Math.cos(a)) + 'px'; d.style.top = (cy + R * Math.sin(a)) + 'px';
+ enc.appendChild(d);
+ }
+ const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = '<span class="ind"></span>'; enc.appendChild(k);
+ let val;
+ const set = v => {
+ val = v;
+ const lit = ((Math.floor(val) % 12) + 12) % 12;
+ enc.querySelectorAll('.led').forEach((l, i) => l.classList.toggle('on', i <= lit));
+ k.style.transform = `rotate(${val * 30}deg)`;
+ onChange(val, 'pos ' + Math.round(val));
+ };
+ dragDelta(enc, () => val, set, { min: 0, max: 100000, sens: 0.25 });
+ set(opts.value !== undefined ? opts.value : 7);
+ return { el: enc, get: () => val, set };
+};
+
+/* N06 keyed mode switch — guarded three-position mode, key-bit points at the live one */
+GW.keySwitch = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const items = opts.items || ['OFF', 'ON', 'RUN'];
+ const ANG = [-70, 0, 70], POS = [[16, 64], [50, 12], [84, 64]];
+ const kl = document.createElement('span'); kl.className = 'keylock';
+ items.forEach((t, i) => {
+ const p = document.createElement('span'); p.className = 'kpos';
+ p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = t;
+ kl.appendChild(p);
+ });
+ const body = document.createElement('span'); body.className = 'body';
+ body.innerHTML = '<span class="bit"></span><span class="barrel"></span>';
+ kl.appendChild(body); host.appendChild(kl);
+ let idx;
+ const set = i => {
+ idx = ((i % items.length) + items.length) % items.length;
+ body.querySelector('.bit').style.transform = `rotate(${ANG[idx]}deg)`;
+ kl.querySelectorAll('.kpos').forEach((p, j) => p.classList.toggle('on', j === idx));
+ onChange(idx, items[idx]);
+ };
+ body.addEventListener('click', () => set(idx + 1));
+ set(opts.index !== undefined ? opts.index : 1);
+ return { el: kl, get: () => idx, set };
+};
+
+/* N07 center-detented crossfader — throw to either side of zero */
+GW.crossfader = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const xf = document.createElement('div'); xf.className = 'xfader';
+ xf.innerHTML = `<div class="slot"></div><div class="detent"></div><span class="end" style="left:0">${opts.aLabel || 'A'}</span><span class="end" style="right:0">${opts.bLabel || 'B'}</span><div class="cap"></div>`;
+ host.appendChild(xf);
+ let pct;
+ const set = p => {
+ pct = Math.max(0, Math.min(100, p));
+ xf.querySelector('.cap').style.left = pct + '%';
+ const v = Math.round(pct - 50);
+ onChange(v, (v > 0 ? '+' : '') + v);
+ };
+ dragX(xf, set);
+ set(opts.value !== undefined ? opts.value : 50);
+ return { el: xf, get: () => Math.round(pct - 50), set };
+};
+
+/* N08 thumbwheel — knurled edge-wheel with a windowed two-digit value */
+GW.thumbwheel = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const w = document.createElement('div'); w.className = 'thumbw';
+ w.innerHTML = '<span class="thumbwheel"></span><span class="win"></span>';
+ host.appendChild(w);
+ let val;
+ const set = v => {
+ val = Math.round(v);
+ const s = ((val % 100) + 100) % 100;
+ w.querySelector('.win').textContent = String(s).padStart(2, '0');
+ onChange(s, 'value ' + s);
+ };
+ dragDelta(w.querySelector('.thumbwheel'), () => val, set, { min: 0, max: 99, sens: 0.2 });
+ set(opts.value !== undefined ? opts.value : 42);
+ return { el: w, get: () => ((val % 100) + 100) % 100, set };
+};
+
+/* N09 DIP-switch bank — hard flags, up is on; readout is the binary word */
+GW.dipBank = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const bits = opts.bits || [true, false, true, true, false, false];
+ const bank = document.createElement('span'); bank.className = 'dip'; host.appendChild(bank);
+ const sws = bits.map(on => {
+ const s = document.createElement('span'); s.className = 'dipsw' + (on ? ' on' : '');
+ s.innerHTML = '<i></i>'; bank.appendChild(s); return s;
+ });
+ const word = () => sws.map(x => x.classList.contains('on') ? '1' : '0').join('');
+ const upd = () => onChange(word(), word());
+ sws.forEach(s => s.addEventListener('click', () => { s.classList.toggle('on'); upd(); }));
+ upd();
+ return { el: bank, get: word, set: w => { sws.forEach((s, i) => s.classList.toggle('on', w[i] === '1')); upd(); } };
+};
+
+/* N10 jog / shuttle wheel — scrub fine; position accumulates without limit */
+GW.jogWheel = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const j = document.createElement('span'); j.className = 'jog';
+ j.innerHTML = `<span class="shuttle" style="--sh:40deg"></span><span class="inner"><span class="dimple"></span></span>`;
+ host.appendChild(j);
+ let val;
+ const set = v => {
+ val = v;
+ j.querySelector('.inner').style.transform = `rotate(${val * 4}deg)`;
+ onChange(val, 'pos ' + Math.round(val));
+ };
+ dragDelta(j, () => val, set, { min: -100000, max: 100000, sens: 0.5 });
+ set(opts.value !== undefined ? opts.value : 0);
+ return { el: j, get: () => val, set };
+};
+
+/* ---- shared SVG defs: gradients/filters referenced by url(#id) are
+ document-scoped, and several are used across widgets. Builders ensure the
+ defs they use; the first caller creates it, later calls are no-ops. ---- */
+let defsRoot = null;
+const defsMade = new Set();
+function defsHost() {
+ if (!defsRoot) {
+ const svg = document.createElementNS(SVGNS, 'svg');
+ svg.setAttribute('width', 0); svg.setAttribute('height', 0);
+ svg.setAttribute('aria-hidden', 'true');
+ svg.style.position = 'absolute';
+ defsRoot = document.createElementNS(SVGNS, 'defs');
+ svg.appendChild(defsRoot);
+ document.body.appendChild(svg);
+ }
+ return defsRoot;
+}
+function def(id, make) { if (defsMade.has(id)) return; defsMade.add(id); make(defsHost(), id); }
+function gradDef(id, kind, attrs, stops) {
+ def(id, d => {
+ const g = svgEl(d, kind, Object.assign({ id }, attrs));
+ for (const [offset, color] of stops) svgEl(g, 'stop', { offset, 'stop-color': color });
+ });
+}
+function stageSvg(host, cls, vw, vh) {
+ const s = document.createElementNS(SVGNS, 'svg');
+ s.setAttribute('class', cls); s.setAttribute('viewBox', `0 0 ${vw} ${vh}`);
+ s.setAttribute('width', vw); s.setAttribute('height', vh);
+ host.appendChild(s); return s;
+}
+/* instance-unique ids for defs that can't be shared (e.g. clip paths sized per instance) */
+let uidN = 0;
+function uid(prefix) { return prefix + '-' + (++uidN); }
+
+/* R02 calibrated vernier dial — the disc turns under a fixed hairline */
+GW.vernierDial = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg drag', 150, 150), cx = 75, cy = 75;
+ gradDef('vernFace', 'radialGradient', { cx: '50%', cy: '45%', r: '60%' }, [['0', '#f7f2da'], ['1', '#e4dfc2']]);
+ gradDef('bakeKnob', 'radialGradient', { cx: '42%', cy: '36%', r: '75%' }, [['0', '#37332c'], ['1', '#060505']]);
+ svgEl(s, 'circle', { cx, cy, r: 72, fill: '#0c0b0a', stroke: '#262320' });
+ const disc = svgEl(s, 'g', {});
+ svgEl(disc, 'circle', { cx, cy, r: 66, fill: 'url(#vernFace)', stroke: '#b7b29a', 'stroke-width': 1 });
+ for (let u = 0; u <= 100; u++) {
+ const a = u * 3, major = u % 10 === 0, half = u % 5 === 0;
+ const len = major ? 10 : half ? 7 : 4.5, [x1, y1] = polar(cx, cy, 63, a), [x2, y2] = polar(cx, cy, 63 - len, a);
+ svgEl(disc, 'line', { x1, y1, x2, y2, stroke: '#3a3128', 'stroke-width': major ? 1.3 : half ? 1 : .7 });
+ if (major) {
+ const g = svgEl(disc, 'g', { transform: `rotate(${a},${cx},${cy})` });
+ svgEl(g, 'text', { x: cx, y: cy - 44, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: '#3a3128' }).textContent = u;
+ }
+ }
+ for (let k = 0; k < 12; k++) { const [x, y] = polar(cx, cy, 24, k * 30); svgEl(s, 'circle', { cx: x, cy: y, r: 4.8, fill: 'url(#bakeKnob)' }); }
+ svgEl(s, 'circle', { cx, cy, r: 24, fill: 'url(#bakeKnob)', stroke: '#000', 'stroke-width': .6 });
+ svgEl(s, 'circle', { cx, cy, r: 16, fill: 'url(#bakeKnob)' });
+ svgEl(s, 'ellipse', { cx: 69, cy: 66, rx: 7, ry: 4, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-32,69,66)' });
+ svgEl(s, 'line', { x1: cx, y1: 6, x2: cx, y2: 22, stroke: 'var(--fail)', 'stroke-width': 2.2, 'stroke-linecap': 'round' });
+ let val;
+ const set = v => { val = v; disc.setAttribute('transform', `rotate(${-v * 3},75,75)`); onChange(val, val.toFixed(1)); };
+ dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.15 });
+ set(opts.value !== undefined ? opts.value : 42.0);
+ return { el: s, get: () => val, set };
+};
+
+/* R03 bat-handle toggle — chrome lever throws between lit legends */
+GW.batToggle = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 70, 90), cx = 35, cy = 44;
+ gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]);
+ gradDef('chromeG', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]);
+ gradDef('tipG', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]);
+ const lblOn = svgEl(s, 'text', { x: cx, y: 9, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' });
+ lblOn.textContent = opts.onLabel || 'ON';
+ const lblOff = svgEl(s, 'text', { x: cx, y: 87, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
+ lblOff.textContent = opts.offLabel || 'OFF';
+ const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 17 * Math.cos(a)) + ',' + (cy + 17 * Math.sin(a))); }
+ svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#nutG)', stroke: '#2b2822' });
+ svgEl(s, 'circle', { cx, cy, r: 9, fill: '#14110e', stroke: '#000' });
+ const lever = svgEl(s, 'g', {});
+ svgEl(lever, 'path', { d: `M ${cx - 3.2} ${cy} L ${cx - 1.8} 21 L ${cx + 1.8} 21 L ${cx + 3.2} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 });
+ svgEl(lever, 'ellipse', { cx, cy: 17, rx: 6.5, ry: 8, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 });
+ lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s';
+ let on;
+ const set = v => {
+ on = !!v;
+ lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)';
+ lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)');
+ lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)');
+ onChange(on, on ? 'ON' : 'OFF');
+ };
+ s.addEventListener('click', () => set(!on));
+ set(opts.on !== undefined ? opts.on : true);
+ return { el: s, get: () => on, set };
+};
+
+/* R04 bakelite fluted knob — scallop skirt over a printed 0-10 arc */
+GW.flutedKnob = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 54;
+ gradDef('bakeSk', 'radialGradient', { cx: '42%', cy: '36%', r: '80%' }, [['0', '#312d27'], ['1', '#050404']]);
+ gradDef('bakeDome', 'radialGradient', { cx: '40%', cy: '32%', r: '80%' }, [['0', '#3a362f'], ['1', '#0a0908']]);
+ for (let i = 0; i <= 10; i++) {
+ const a = -135 + i * 27, major = i % 5 === 0;
+ const [x1, y1] = polar(cx, cy, 38, a), [x2, y2] = polar(cx, cy, major ? 45 : 43.5, a);
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--steel)', 'stroke-width': major ? 1.5 : 1 });
+ if (major) {
+ const [x, y] = polar(cx, cy, 50.5, a);
+ svgEl(s, 'text', { x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': 8, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = i;
+ }
+ }
+ for (let k = 0; k < 18; k++) { const [x, y] = polar(cx, cy, 31, k * 20); svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: 'url(#bakeSk)' }); }
+ svgEl(s, 'circle', { cx, cy, r: 31, fill: 'url(#bakeSk)', stroke: '#000', 'stroke-width': .6 });
+ const idx = svgEl(s, 'line', { x1: cx, y1: cy - 30, x2: cx, y2: cy - 21, stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round' });
+ svgEl(s, 'circle', { cx, cy, r: 19, fill: 'url(#bakeDome)' });
+ svgEl(s, 'ellipse', { cx: 49, cy: 46, rx: 6, ry: 3.5, fill: 'rgba(255,255,255,.14)', transform: 'rotate(-28,49,46)' });
+ let val;
+ const set = v => { val = v; idx.setAttribute('transform', `rotate(${v * 2.7 - 135},55,54)`); onChange(val, (val / 10).toFixed(1) + ' / 10'); };
+ dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.25 });
+ set(opts.value !== undefined ? opts.value : 63);
+ return { el: s, get: () => val, set };
+};
+
+/* R05 filter slider bank — a dense fader wall on a real faceplate. Period skins
+ as constructor opts backed by GW.filterBank.STYLES, three independent axes:
+ panel (silver hi-fi aluminum / studio black), caps — the cap SHAPE
+ (chrome T / short ribbed / tall block fader), and capColor — the cap
+ FINISH (black white-index / color stripes / chrome / cream). opts.style
+ picks a native trio (silver+chrome+chrome, studio+ribbed+red);
+ opts.panel / opts.caps / opts.capColor override an axis; setStyle(axis, name)
+ restyles live (values kept). After the Pioneer SG-9500, the Technics
+ SH-8065, and the Zaxcom Oasis block faders. */
+GW.filterBank = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const freqs = opts.freqs || [78, 113, 163, 235, 339, 487, 701, 1010, 1450, 2090, 3010, 4340];
+ const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48, 38, 26, 44, 32]).slice(0, freqs.length);
+ while (vals.length < freqs.length) vals.push(30);
+ const fmtHz = f => f < 1000 ? f : (f / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
+ const ST = GW.filterBank.STYLES;
+ const native = {
+ silver: { caps: 'chrome', capColor: 'chrome' },
+ studio: { caps: 'ribbed', capColor: 'red' },
+ };
+ const nat = native[opts.style] || {};
+ const cur = {
+ panel: ST.panel[opts.panel] ? opts.panel : (ST.panel[opts.style] ? opts.style : 'silver'),
+ caps: ST.caps[opts.caps] ? opts.caps : (nat.caps || 'block'),
+ capColor: ST.capColor[opts.capColor] ? opts.capColor : (nat.capColor || 'black'),
+ };
+ gradDef('fbPlateSilver', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8d8d4'], ['.5', '#c6c6c1'], ['1', '#adada7']]);
+ gradDef('fbPlateStudio', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#221f1c'], ['1', '#131110']]);
+ gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]);
+ gradDef('fbCapBlack', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#2a2724'], ['1', '#171412']]);
+ gradDef('fbCapCream', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2ecd8'], ['1', '#cfc8ae']]);
+ const s = stageSvg(host, 'rsvg', 250, 112);
+ const y0 = 20, y1 = 92, x0 = 36, dx = (214 - x0) / (freqs.length - 1);
+ const yOf = db => y0 + db / 60 * (y1 - y0);
+ const caps = [];
+ const place = i => caps[i].setAttribute('transform', `translate(0,${yOf(vals[i])})`);
+ const drawCap = (g, kind, x) => {
+ const c = ST.capColor[cur.capColor];
+ if (kind === 'chrome') {
+ svgEl(g, 'rect', { x: x - 6, y: -3.25, width: 12, height: 6.5, rx: .9, fill: c.body, stroke: c.edge, 'stroke-width': .6 });
+ /* bare metal needs no painted stripe — chrome-on-chrome stays lineless */
+ if (cur.capColor !== 'chrome')
+ svgEl(g, 'line', { x1: x - 6, y1: 0, x2: x + 6, y2: 0, stroke: c.index, 'stroke-width': 1 });
+ svgEl(g, 'line', { x1: x - 6, y1: 2, x2: x + 6, y2: 2, stroke: c.rib, 'stroke-width': .5 });
+ } else if (kind === 'ribbed') {
+ svgEl(g, 'rect', { x: x - 5, y: -4, width: 10, height: 8, rx: 1, fill: c.body, stroke: c.edge, 'stroke-width': .6 });
+ for (const dy of [-2.4, 2.4])
+ svgEl(g, 'line', { x1: x - 4, y1: dy, x2: x + 4, y2: dy, stroke: c.rib, 'stroke-width': .6 });
+ svgEl(g, 'line', { x1: x - 5, y1: 0, x2: x + 5, y2: 0, stroke: c.index, 'stroke-width': 1.1 });
+ } else { /* block — tall ribbed fader after the Zaxcom Oasis; fine rib
+ groups above and below a mid-cap index line that marks the value */
+ svgEl(g, 'rect', { x: x - 4.5, y: -8, width: 9, height: 16, rx: 1.1, fill: c.body, stroke: c.edge, 'stroke-width': .6 });
+ for (const dy of [-6.4, -4.8, -3.2, -1.6, 1.6, 3.2, 4.8, 6.4])
+ svgEl(g, 'line', { x1: x - 3.7, y1: dy, x2: x + 3.7, y2: dy, stroke: c.rib, 'stroke-width': .6 });
+ svgEl(g, 'line', { x1: x - 4.5, y1: 0, x2: x + 4.5, y2: 0, stroke: c.index, 'stroke-width': 1.2 });
+ }
+ };
+ const set = (i, db) => {
+ db = Math.max(0, Math.min(60, db)); vals[i] = db; place(i);
+ onChange({ band: i, hz: freqs[i], db }, `${fmtHz(freqs[i])} Hz · −${Math.round(db)} dB`);
+ };
+ const render = () => {
+ const st = ST.panel[cur.panel];
+ s.replaceChildren(); caps.length = 0;
+ svgEl(s, 'rect', { x: 1.5, y: 1.5, width: 247, height: 109, rx: 5, fill: st.plate, stroke: st.edge, 'stroke-width': 1 });
+ svgEl(s, 'rect', { x: 3, y: 3, width: 244, height: 106, rx: 4, fill: 'none', stroke: st.bevel, 'stroke-width': .7 });
+ [[9, 9], [241, 9], [9, 103], [241, 103]].forEach(([sx, sy], k) => {
+ svgEl(s, 'circle', { cx: sx, cy: sy, r: 2.7, fill: st.screw, stroke: 'rgba(0,0,0,.55)', 'stroke-width': .6 });
+ svgEl(s, 'line', { x1: sx - 1.9, y1: sy, x2: sx + 1.9, y2: sy, stroke: 'rgba(0,0,0,.6)', 'stroke-width': .7, transform: `rotate(${25 + k * 40},${sx},${sy})` });
+ });
+ for (let db = 0; db <= 60; db += 10) {
+ const y = yOf(db), major = db % 20 === 0, len = major ? 4 : 2.5;
+ svgEl(s, 'line', { x1: 24 - len, y1: y, x2: 24, y2: y, stroke: st.dim, 'stroke-width': major ? .9 : .6 });
+ svgEl(s, 'line', { x1: 226, y1: y, x2: 226 + len, y2: y, stroke: st.dim, 'stroke-width': major ? .9 : .6 });
+ if (major) {
+ svgEl(s, 'text', { x: 17, y: y + 2, 'text-anchor': 'end', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: st.ink }).textContent = db;
+ svgEl(s, 'text', { x: 233, y: y + 2, 'text-anchor': 'start', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: st.ink }).textContent = db;
+ }
+ }
+ svgEl(s, 'text', { x: 20, y: 100, 'text-anchor': 'end', 'font-size': 4.6, 'font-family': 'var(--mono)', fill: st.dim }).textContent = 'dB';
+ freqs.forEach((f, i) => {
+ const x = x0 + i * dx;
+ svgEl(s, 'text', { x, y: 13, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', fill: st.ink }).textContent = fmtHz(f);
+ svgEl(s, 'line', { x1: x, y1: y0, x2: x, y2: y1, stroke: st.track, 'stroke-width': st.trackW, 'stroke-linecap': 'round' });
+ if (st.trackHi) svgEl(s, 'line', { x1: x - st.trackW / 2 - .35, y1: y0, x2: x - st.trackW / 2 - .35, y2: y1, stroke: st.trackHi, 'stroke-width': .7 });
+ const cap = svgEl(s, 'g', {});
+ drawCap(cap, cur.caps, x);
+ caps.push(cap); place(i);
+ const hit = svgEl(s, 'rect', { x: x - dx / 2, y: y0 - 6, width: dx, height: y1 - y0 + 12, fill: 'transparent' });
+ hit.style.cursor = 'ns-resize';
+ dragY(hit, pct => set(i, (100 - pct) / 100 * 60));
+ });
+ };
+ const setStyle = (axis, name) => {
+ if (!ST[axis] || !ST[axis][name]) return;
+ cur[axis] = name; render();
+ };
+ render();
+ onChange(null, 'drag a band');
+ return { el: s, get: () => vals.slice(), set, setStyle };
+};
+/* named styles per axis: dot = swatch color for pickers. caps is the cap
+ shape; capColor is the cap finish (body + rib + index line), applicable to
+ any shape. */
+GW.filterBank.STYLES = {
+ panel: {
+ silver: { dot: '#c9c9c5', plate: 'url(#fbPlateSilver)', edge: '#8e8e88', bevel: 'rgba(255,255,255,.4)', ink: '#33332f', dim: '#5c5c56', screw: '#9c9c96', track: '#121210', trackW: 2.0, trackHi: null },
+ studio: { dot: '#1c1a18', plate: 'url(#fbPlateStudio)', edge: '#000', bevel: 'rgba(255,255,255,.06)', ink: '#b9b4a6', dim: '#6f6a5e', screw: '#3c3832', track: '#050505', trackW: 2.2, trackHi: 'rgba(255,255,255,.05)' },
+ },
+ caps: {
+ block: { dot: '#2c2824' },
+ ribbed: { dot: '#6f6a5e' },
+ chrome: { dot: '#dfe6f2' },
+ },
+ capColor: {
+ black: { dot: '#211e1b', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: '#f2f4f2', edge: '#000' },
+ red: { dot: 'var(--sevred)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--sevred)', edge: '#000' },
+ green: { dot: 'var(--sevgrn)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--sevgrn)', edge: '#000' },
+ blue: { dot: '#4f9fe0', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: '#4f9fe0', edge: '#000' },
+ amber: { dot: 'var(--gold-hi)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--gold-hi)', edge: '#000' },
+ chrome: { dot: '#dfe6f2', body: 'url(#sfChrome)', rib: 'rgba(0,0,0,.28)', index: '#14110e', edge: '#3c3a34' },
+ cream: { dot: '#efe9d4', body: 'url(#fbCapCream)', rib: 'rgba(0,0,0,.18)', index: '#3a3128', edge: '#8a8474' },
+ },
+};
+
+/* R06 chicken-head selector — tapered lever aims at the position */
+GW.chickenHead = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const items = opts.items || [['OFF', -60], ['LO', -20], ['MID', 20], ['HI', 60]];
+ const s = stageSvg(host, 'rsvg press', 110, 96), cx = 55, cy = 58;
+ gradDef('chickG', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#35312b'], ['1', '#070606']]);
+ const lbls = [];
+ items.forEach(([lbl, a]) => {
+ const [tx, ty] = polar(cx, cy, 42, a);
+ const t = svgEl(s, 'text', { x: tx, y: ty + 2.5, 'text-anchor': 'middle', 'font-size': 6.5, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
+ t.textContent = lbl; lbls.push(t);
+ const [mx1, my1] = polar(cx, cy, 33, a), [mx2, my2] = polar(cx, cy, 29, a);
+ svgEl(s, 'line', { x1: mx1, y1: my1, x2: mx2, y2: my2, stroke: 'var(--steel)', 'stroke-width': 1.2 });
+ });
+ svgEl(s, 'circle', { cx, cy, r: 12, fill: '#0c0b0a' });
+ const lever = svgEl(s, 'g', {});
+ svgEl(lever, 'path', { d: `M ${cx} ${cy - 29} L ${cx + 8.5} ${cy - 4} Q ${cx + 9} ${cy + 8} ${cx + 5} ${cy + 11} A 7 7 0 0 1 ${cx - 5} ${cy + 11} Q ${cx - 9} ${cy + 8} ${cx - 8.5} ${cy - 4} Z`, fill: 'url(#chickG)', stroke: '#000', 'stroke-width': .7 });
+ svgEl(lever, 'line', { x1: cx, y1: cy - 26, x2: cx, y2: cy + 6, stroke: '#4a463e', 'stroke-width': 1.4, 'stroke-linecap': 'round' });
+ lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s';
+ let idx;
+ const set = i => {
+ idx = ((i % items.length) + items.length) % items.length;
+ lever.style.transform = `rotate(${items[idx][1]}deg)`;
+ lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)'));
+ onChange(idx, items[idx][0]);
+ };
+ s.addEventListener('click', () => set(idx + 1));
+ set(opts.index !== undefined ? opts.index : 2);
+ return { el: s, get: () => idx, set };
+};
+
+/* R12 chrome slot fader — engraved dB scale, chrome T-handle in a screwed plate */
+GW.slotFader = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg drag', 90, 150), cx = 45, yTop = 30, yBot = 120;
+ const yOf = db => yTop + (12 - db) / 36 * (yBot - yTop);
+ gradDef('sfPlate', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#242019'], ['1', '#131110']]);
+ gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]);
+ gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
+ svgEl(s, 'rect', { x: 14, y: 8, width: 62, height: 134, rx: 6, fill: 'url(#sfPlate)', stroke: '#0a0908', 'stroke-width': 1.5 });
+ svgEl(s, 'rect', { x: 15.5, y: 9.5, width: 59, height: 131, rx: 5, fill: 'none', stroke: 'rgba(255,255,255,.05)', 'stroke-width': 1 });
+ for (const sy of [16, 134]) {
+ svgEl(s, 'circle', { cx, cy: sy, r: 3.2, fill: 'url(#sfScrew)', stroke: '#14110e', 'stroke-width': .6 });
+ const [x1, y1] = polar(cx, sy, 3, 112), [x2, y2] = polar(cx, sy, 3, 292);
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#221f1a', 'stroke-width': 1 });
+ }
+ svgEl(s, 'rect', { x: 40.5, y: yTop - 4, width: 9, height: yBot - yTop + 8, rx: 4, fill: '#0b0a09', stroke: '#050404' });
+ for (const db of [12, 8, 4, 0, -4, -8, -12, -16, -20, -24]) {
+ const y = yOf(db);
+ svgEl(s, 'line', { x1: 26, y1: y, x2: 39, y2: y, stroke: 'var(--steel)', 'stroke-width': db === 0 ? 1.3 : .8, opacity: db === 0 ? 1 : .75 });
+ svgEl(s, 'line', { x1: 51, y1: y, x2: 64, y2: y, stroke: 'var(--steel)', 'stroke-width': db === 0 ? 1.3 : .8, opacity: db === 0 ? 1 : .75 });
+ svgEl(s, 'text', { x: 23, y: y + 2.2, 'text-anchor': 'end', 'font-size': 6, 'font-family': 'var(--mono)', fill: db === 0 ? 'var(--cream)' : 'var(--steel)' }).textContent = Math.abs(db);
+ }
+ const handle = svgEl(s, 'g', {});
+ svgEl(handle, 'polygon', { points: '45,0 58,-5 58,5', fill: '#b9b6ae', stroke: '#5e5a52', 'stroke-width': .5 });
+ svgEl(handle, 'rect', { x: 58, y: -7, width: 22, height: 14, rx: 3.5, fill: 'url(#sfChrome)', stroke: '#4e4a42', 'stroke-width': .6 });
+ svgEl(handle, 'rect', { x: 60, y: -5.2, width: 18, height: 2.2, rx: 1, fill: 'rgba(255,255,255,.4)' });
+ let db;
+ const set = v => {
+ db = Math.max(-24, Math.min(12, v));
+ handle.setAttribute('transform', `translate(0,${30 + (12 - db) / 36 * 90})`);
+ onChange(db, (db > 0 ? '+' : db < 0 ? '−' : '') + Math.abs(db).toFixed(1) + ' dB');
+ };
+ dragY(s, pct => set(-24 + pct / 100 * 36));
+ set(opts.value !== undefined ? opts.value : -4);
+ return { el: s, get: () => db, set };
+};
+
+/* R14 spade-pointer tuning knob — engraved relief arc, knurl ring turns with it */
+GW.spadeKnob = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg drag', 140, 124), cx = 70, cy = 82;
+ const sweep = v => -80 + v / 10 * 160;
+ gradDef('spadeKnob', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#33302a'], ['1', '#080706']]);
+ gradDef('spadeMetal', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e9e7e0'], ['1', '#8d897f']]);
+ /* engraved arc: every stroke carries a faint light twin offset below (incised relief) */
+ const engrave = (x1, y1, x2, y2, w) => {
+ svgEl(s, 'line', { x1, y1: y1 + .8, x2, y2: y2 + .8, stroke: 'rgba(255,255,255,.13)', 'stroke-width': w });
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#050404', 'stroke-width': w });
+ };
+ for (let i = 0; i <= 40; i++) {
+ const v = i / 4, a = sweep(v), major = i % 4 === 0;
+ const [x1, y1] = polar(cx, cy, major ? 50 : 52, a), [x2, y2] = polar(cx, cy, 58, a);
+ engrave(x1, y1, x2, y2, major ? 1.6 : .9);
+ if (major) {
+ const [nx, ny] = polar(cx, cy, 65, a);
+ svgEl(s, 'text', { x: nx, y: ny + 3.2, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: 'rgba(255,255,255,.13)' }).textContent = v;
+ svgEl(s, 'text', { x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: '#0a0908' }).textContent = v;
+ }
+ }
+ const grp = svgEl(s, 'g', {});
+ svgEl(grp, 'path', { d: `M ${cx - 2} ${cy - 28} L ${cx - 4.5} ${cy - 42} L ${cx} ${cy - 54} L ${cx + 4.5} ${cy - 42} L ${cx + 2} ${cy - 28} Z`, fill: 'url(#spadeMetal)', stroke: '#55524a', 'stroke-width': .6 });
+ svgEl(grp, 'circle', { cx, cy, r: 30, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .8 });
+ svgEl(grp, 'circle', { cx, cy, r: 28.5, fill: 'none', stroke: '#0a0908', 'stroke-width': 3, 'stroke-dasharray': '2.2 2.2' });
+ svgEl(grp, 'circle', { cx, cy, r: 22, fill: 'url(#spadeKnob)' });
+ svgEl(grp, 'ellipse', { cx: cx - 8, cy: cy - 9, rx: 8, ry: 5, fill: 'rgba(255,255,255,.08)', transform: `rotate(-30,${cx - 8},${cy - 9})` });
+ let val;
+ const set = v => {
+ val = Math.max(0, Math.min(10, v));
+ grp.setAttribute('transform', `rotate(${-80 + val / 10 * 160},70,82)`);
+ onChange(val, val.toFixed(1));
+ };
+ dragDelta(s, () => val, set, { min: 0, max: 10, sens: .05 });
+ set(opts.value !== undefined ? opts.value : 8.3);
+ return { el: s, get: () => val, set };
+};
+
+/* R15 multi-band dial — nested arcs, one needle; the bandspread dial selects the ring */
+GW.multiBandDial = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const ranges = opts.ranges || [[0.54, 1.6], [1.6, 5.1], [5.1, 15.5], [15.5, 30.5]];
+ const s = stageSvg(host, 'rsvg', 190, 110), cx = 62, cy = 62;
+ const sweep = t => -70 + t * 140;
+ gradDef('mbFace', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#eee5c8'], ['1', '#dcd2ae']]);
+ gradDef('spadeKnob', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#33302a'], ['1', '#080706']]);
+ svgEl(s, 'rect', { x: 4, y: 4, width: 182, height: 102, rx: 12, fill: '#171412', stroke: '#060505' });
+ svgEl(s, 'rect', { x: 9, y: 9, width: 172, height: 92, rx: 9, fill: 'url(#mbFace)', stroke: '#0a0908' });
+ const INK = '#2c2318';
+ const rings = [];
+ [18, 26, 34, 42].forEach(r => {
+ const ring = svgEl(s, 'g', {});
+ const [x1, y1] = polar(cx, cy, r, -70), [x2, y2] = polar(cx, cy, r, 70);
+ svgEl(ring, 'path', { d: `M ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2}`, fill: 'none', stroke: INK, 'stroke-width': .9 });
+ for (let i = 0; i <= 8; i++) {
+ const a = sweep(i / 8), [tx1, ty1] = polar(cx, cy, r, a), [tx2, ty2] = polar(cx, cy, r + 3, a);
+ svgEl(ring, 'line', { x1: tx1, y1: ty1, x2: tx2, y2: ty2, stroke: INK, 'stroke-width': .8 });
+ }
+ rings.push(ring);
+ });
+ const needle = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 46, stroke: '#1a1613', 'stroke-width': 1.6, 'stroke-linecap': 'round' });
+ svgEl(s, 'circle', { cx, cy, r: 7, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 });
+ svgEl(s, 'text', { x: 14, y: 100, 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'MEGACYCLES';
+ /* bandspread dial (click = band select) */
+ const bx = 142, by = 56;
+ for (let i = 0; i < 12; i++) { const a = i * 30, [x1, y1] = polar(bx, by, 24, a), [x2, y2] = polar(bx, by, 21, a); svgEl(s, 'line', { x1, y1, x2, y2, stroke: INK, 'stroke-width': .8 }); }
+ svgEl(s, 'circle', { cx: bx, cy: by, r: 17, fill: 'none', stroke: INK, 'stroke-width': .7, opacity: .5 });
+ const spread = svgEl(s, 'line', { x1: bx, y1: by, x2: bx, y2: by - 22, stroke: '#1a1613', 'stroke-width': 1.4, 'stroke-linecap': 'round' });
+ svgEl(s, 'circle', { cx: bx, cy: by, r: 6, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 });
+ svgEl(s, 'text', { x: bx, y: 96, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'BANDSPREAD';
+ let val, band;
+ const set = (v, b) => {
+ val = Math.max(0, Math.min(100, v)); band = b;
+ needle.setAttribute('transform', `rotate(${-70 + val / 100 * 140},62,62)`);
+ spread.setAttribute('transform', `rotate(${-45 + b * 30},142,56)`);
+ rings.forEach((g, i) => g.setAttribute('opacity', i === b ? '1' : '.4'));
+ const [lo, hi] = ranges[b]; const mc = lo + val / 100 * (hi - lo);
+ onChange({ band: b, mc }, `B${b + 1} · ${mc.toFixed(2)} Mc`);
+ };
+ const dragHit = svgEl(s, 'rect', { x: 9, y: 9, width: 100, height: 92, fill: 'transparent' });
+ dragHit.style.cursor = 'ns-resize';
+ dragDelta(dragHit, () => val, v => set(v, band), { min: 0, max: 100 });
+ const clickHit = svgEl(s, 'rect', { x: 112, y: 9, width: 69, height: 92, fill: 'transparent' });
+ clickHit.style.cursor = 'pointer';
+ clickHit.addEventListener('click', () => set(val, (band + 1) % ranges.length));
+ set(opts.value !== undefined ? opts.value : 45, opts.band !== undefined ? opts.band : 2);
+ return { el: s, get: () => [val, band], set };
+};
+
+/* R16 entry keypad — worn keys feed the amber display; lamps watch state */
+GW.entryKeypad = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg', 150, 190);
+ gradDef('kpKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#dbd8cf'], ['1', '#a29d92']]);
+ gradDef('kpAmber', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'var(--amber-grad-top)'], ['1', 'var(--amber-grad-mid)']]);
+ const lamp = cy => svgEl(s, 'circle', { cx: 13, cy, r: 6.5, fill: '#3a0f0a', stroke: '#14100e', 'stroke-width': 1.5 });
+ const lampTop = lamp(20), lampBot = lamp(38);
+ svgEl(s, 'rect', { x: 26, y: 8, width: 116, height: 32, rx: 5, fill: '#0a0806', stroke: '#2c261d', 'stroke-width': 2 });
+ const disp = svgEl(s, 'text', { x: 84, y: 31, 'text-anchor': 'middle', 'font-size': 16, 'letter-spacing': '.22em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' });
+ svgEl(s, 'rect', { x: 24, y: 48, width: 118, height: 138, rx: 8, fill: '#1a1714', stroke: '#0a0908', 'stroke-width': 1.5 });
+ let buf = '';
+ const render = () => {
+ disp.textContent = buf.padEnd(6, '–');
+ lampTop.setAttribute('fill', buf ? 'var(--jewel-r)' : '#3a0f0a');
+ };
+ const press = k => {
+ if (k === '✓') { onChange(buf, buf ? 'OK · ' + buf : 'empty'); buf = ''; render(); return; }
+ if (k === '✗') {
+ buf = ''; render(); onChange(buf, 'cleared');
+ lampBot.setAttribute('fill', 'var(--jewel-r)');
+ setTimeout(() => lampBot.setAttribute('fill', '#3a0f0a'), 350); return;
+ }
+ if (buf.length < 6) { buf += k; render(); onChange(buf, buf); }
+ };
+ const KEYS = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['✓', '0', '✗']];
+ const jitter = [-2, 1, -1, 2, 0, -2, 1, -1, 2, 0, -1, 1];
+ KEYS.forEach((row, r) => row.forEach((k, c) => {
+ const x = 47 + c * 36, y = 68 + r * 32, amber = (k === '✓' || k === '✗');
+ const g = svgEl(s, 'g', {}); g.style.cursor = 'pointer'; g.style.transition = 'transform .07s';
+ svgEl(g, 'rect', { x: x - 14, y: y - 11, width: 28, height: 24, rx: 4, fill: amber ? 'url(#kpAmber)' : 'url(#kpKey)', stroke: '#4e4a42', 'stroke-width': 1, 'stroke-opacity': .8 });
+ svgEl(g, 'text', { x, y: y + 6, 'text-anchor': 'middle', 'font-size': 13, 'font-weight': 700, 'font-family': 'var(--mono)', fill: k === '✓' ? '#3f5a1f' : k === '✗' ? '#7a2a1a' : '#2b2721', transform: `rotate(${jitter[r * 3 + c]},${x},${y})` }).textContent = k;
+ g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); press(k); });
+ }));
+ render(); onChange('', 'enter a code');
+ return { el: s, get: () => buf, press };
+};
+
+/* R18 thumb-slide attenuator pair — lit numeral strip, cream side tab */
+GW.thumbSlide = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const chans = (opts.channels || [{ name: 'BLEND', v: 74 }, { name: 'MIX', v: 92 }]).map(c => ({ name: c.name, v: c.v }));
+ const s = stageSvg(host, 'rsvg', 130, 150), y0 = 22, y1 = 122;
+ gradDef('thRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]);
+ gradDef('thTab', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2efe6'], ['1', '#c6c1b3']]);
+ gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
+ const parts = [];
+ const set = (i, v) => {
+ v = Math.max(0, Math.min(100, Math.round(v))); chans[i].v = v;
+ const p = parts[i];
+ p.tab.setAttribute('transform', `translate(0,${28 + (100 - v) * 0.88})`);
+ p.nums.forEach(t => {
+ const on = Math.abs(parseInt(t.textContent) - v) <= 5;
+ t.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--gold)'); t.setAttribute('opacity', on ? '1' : '.55');
+ });
+ onChange(chans.map(c => c.v), `${chans[0].name} ${chans[0].v} · ${chans[1].name} ${chans[1].v}`);
+ };
+ chans.forEach((ch, i) => {
+ const x = 40 + i * 50;
+ svgEl(s, 'rect', { x: x - 9, y: y0 - 10, width: 18, height: y1 - y0 + 20, rx: 3, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 });
+ for (const sy of [y0 - 6, y1 + 6]) {
+ svgEl(s, 'circle', { cx: x, cy: sy, r: 2.4, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 });
+ svgEl(s, 'line', { x1: x - 1.8, y1: sy - 1, x2: x + 1.8, y2: sy + 1, stroke: '#14110e', 'stroke-width': .8 });
+ }
+ svgEl(s, 'rect', { x: x - 5, y: y0, width: 10, height: y1 - y0, fill: '#0d0a08', stroke: '#050404' });
+ const nums = [];
+ for (let n = 0; n <= 10; n++) {
+ const val = 100 - n * 10, y = y0 + 6 + n * (y1 - y0 - 12) / 10;
+ const t = svgEl(s, 'text', { x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .55 });
+ t.textContent = val; nums.push(t);
+ }
+ const tab = svgEl(s, 'g', {});
+ svgEl(tab, 'rect', { x: x + 7, y: -6, width: 8, height: 12, rx: 2, fill: 'url(#thTab)', stroke: '#7a766a', 'stroke-width': .6 });
+ svgEl(tab, 'rect', { x: x + 8.2, y: -1, width: 5.6, height: 1.6, fill: 'rgba(0,0,0,.25)' });
+ svgEl(s, 'text', { x, y: 145, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name;
+ const hit = svgEl(s, 'rect', { x: x - 12, y: y0 - 10, width: 30, height: y1 - y0 + 20, fill: 'transparent' });
+ hit.style.cursor = 'ns-resize';
+ parts.push({ tab, nums });
+ dragY(hit, pct => set(i, pct));
+ });
+ set(0, chans[0].v); set(1, chans[1].v);
+ return { el: s, get: () => chans.map(c => c.v), set };
+};
+
+/* R19 waveform region editor — monochrome LCD, draggable S/E flags */
+GW.waveRegion = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg', 190, 100), x0 = 10, x1 = 180, yMid = 48, N = 85;
+ svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 98, rx: 6, fill: 'var(--scr-bg1,#0b0c0b)', stroke: 'var(--scr-brd,#2a2c2a)', 'stroke-width': 2 });
+ svgEl(s, 'text', { x: 12, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C1:START';
+ svgEl(s, 'text', { x: 78, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C2:OFF';
+ svgEl(s, 'text', { x: 136, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C3:END';
+ svgEl(s, 'line', { x1: x0, y1: yMid, x2: x1, y2: yMid, stroke: '#3a3c3a', 'stroke-width': .6, 'stroke-dasharray': '1.5 2' });
+ const bars = [];
+ for (let i = 0; i < N; i++) {
+ const x = x0 + (i / (N - 1)) * (x1 - x0);
+ const env = Math.abs(Math.sin(i * 0.19)) * Math.abs(Math.sin(i * 0.045)) * (i % 17 < 11 ? 1 : .25);
+ const h = Math.max(1.2, env * 26);
+ bars.push(svgEl(s, 'rect', { x: x - 0.8, y: yMid - h, width: 1.6, height: h * 2, fill: 'var(--scr-dim,#5a5c5a)' }));
+ }
+ const flag = lbl => {
+ const g = svgEl(s, 'g', {});
+ svgEl(g, 'line', { x1: 0, y1: 18, x2: 0, y2: 78, stroke: 'var(--scr-hi,#f2f4f2)', 'stroke-width': 1 });
+ svgEl(g, 'rect', { x: lbl === 'S' ? 0 : -8, y: 70, width: 8, height: 8, fill: 'var(--scr-hi,#f2f4f2)' });
+ svgEl(g, 'text', { x: lbl === 'S' ? 4 : -4, y: 76.5, 'text-anchor': 'middle', 'font-size': 6.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = lbl;
+ return g;
+ };
+ const flagS = flag('S'), flagE = flag('E');
+ svgEl(s, 'rect', { x: 10, y: 84, width: 78, height: 11, fill: 'var(--scr-hi,#e8eae8)' });
+ svgEl(s, 'text', { x: 14, y: 92.5, 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = 'ENC:ZOOM(1x)';
+ svgEl(s, 'text', { x: 96, y: 92.5, 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'A-3 M:[S]';
+ svgEl(s, 'rect', { x: 148, y: 84, width: 32, height: 11, fill: 'var(--scr-hi,#e8eae8)' });
+ svgEl(s, 'text', { x: 164, y: 92.5, 'text-anchor': 'middle', 'font-size': 6.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = 'MENU';
+ s.style.cursor = 'ew-resize';
+ let S, E;
+ const set = (sv, ev) => {
+ S = Math.max(0, Math.min(96, sv)); E = Math.max(4, Math.min(100, ev));
+ const xOf = p => x0 + p / 100 * (x1 - x0);
+ flagS.setAttribute('transform', `translate(${xOf(S)},0)`);
+ flagE.setAttribute('transform', `translate(${xOf(E)},0)`);
+ bars.forEach((b, i) => {
+ const p = i / (bars.length - 1) * 100;
+ b.setAttribute('fill', (p >= S && p <= E) ? 'var(--scr-hi,#f2f4f2)' : 'var(--scr-dim,#5a5c5a)');
+ });
+ onChange({ s: S, e: E }, `S ${Math.round(S)}% · E ${Math.round(E)}%`);
+ };
+ dragX(s, pct => {
+ /* move whichever flag is nearer the pointer */
+ if (Math.abs(pct - S) <= Math.abs(pct - E)) set(Math.min(pct, E - 4), E);
+ else set(S, Math.max(pct, S + 4));
+ });
+ set(opts.start !== undefined ? opts.start : 22, opts.end !== undefined ? opts.end : 76);
+ return { el: s, get: () => ({ s: S, e: E }), set };
+};
+
+/* R20 drum roller selector — numbered paper drum in a window, center chip reads it */
+GW.drumRoller = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const chans = (opts.channels || [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }]).map(c => ({ name: c.name, v: c.v }));
+ const s = stageSvg(host, 'rsvg', 130, 140), cy = 76, step = 17;
+ gradDef('thRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]);
+ gradDef('drPaper', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#cfc6a8'], ['.5', '#ece4c8'], ['1', '#c6bd9e']]);
+ gradDef('drCurve', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'rgba(0,0,0,.4)'], ['.28', 'rgba(0,0,0,0)'], ['.72', 'rgba(0,0,0,0)'], ['1', 'rgba(0,0,0,.4)']]);
+ const localDefs = svgEl(s, 'defs', {});
+ svgEl(s, 'text', { x: 65, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'EQUALIZER';
+ const grps = [];
+ const set = (i, v) => {
+ v = Math.max(1, Math.min(10, v)); chans[i].v = v;
+ const p = grps[i];
+ p.nums.style.transform = `translateY(${(v - p.base) * step}px)`;
+ p.chip.textContent = Math.round(v);
+ onChange(chans.map(c => c.v), `${chans[0].name} ${Math.round(chans[0].v)} · ${chans[1].name} ${Math.round(chans[1].v)}`);
+ };
+ chans.forEach((ch, i) => {
+ const x = 45 + i * 50, cid = uid('drClip');
+ svgEl(s, 'text', { x: x + 18, y: 34, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '+';
+ svgEl(s, 'text', { x: x + 18, y: 124, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '−';
+ svgEl(s, 'rect', { x: x - 12, y: 24, width: 24, height: 104, rx: 4, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 });
+ const clip = svgEl(localDefs, 'clipPath', { id: cid });
+ svgEl(clip, 'rect', { x: x - 9, y: 28, width: 18, height: 96 });
+ svgEl(s, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drPaper)' });
+ const g = svgEl(s, 'g', { 'clip-path': `url(#${cid})` });
+ const nums = svgEl(g, 'g', {}); nums.style.transition = 'transform .12s';
+ for (let n = 1; n <= 10; n++)
+ svgEl(nums, 'text', { x, y: cy + 3 + (ch.v - n) * step, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#2b2418' }).textContent = n;
+ svgEl(g, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drCurve)', 'pointer-events': 'none' });
+ svgEl(s, 'rect', { x: x - 8, y: cy - 6.5, width: 16, height: 13, rx: 1.5, fill: '#1a1613', opacity: .92 });
+ const chip = svgEl(s, 'text', { x, y: cy + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' });
+ svgEl(s, 'text', { x, y: 136, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name;
+ const hit = svgEl(s, 'rect', { x: x - 14, y: 24, width: 28, height: 104, fill: 'transparent' });
+ hit.style.cursor = 'ns-resize';
+ grps.push({ nums, chip, base: ch.v });
+ dragDelta(hit, () => chans[i].v, v => set(i, v), { min: 1, max: 10, sens: .08 });
+ });
+ set(0, chans[0].v); set(1, chans[1].v);
+ return { el: s, get: () => chans.map(c => c.v), set };
+};
+
+/* R21 LED program row — exclusive select, the LED above the key carries the state */
+GW.ledRow = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const items = opts.items || ['Sm Hall B', 'VocPlate', 'Lg Hall B', 'Chamber', 'ParcPlate', 'Sm Hall A', 'Room A', 'Const Plate'];
+ const s = stageSvg(host, 'rsvg', 190, 58);
+ gradDef('lrKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#2b2823'], ['1', '#171512']]);
+ const leds = [];
+ let idx;
+ const set = i => {
+ idx = i;
+ leds.forEach((l, k) => {
+ const on = k === i;
+ l.setAttribute('fill', on ? 'var(--jewel-r)' : '#3a0f0a');
+ l.setAttribute('style', on ? 'filter:drop-shadow(0 0 3px rgba(255,91,69,.8))' : '');
+ });
+ onChange(idx, `${idx + 1} · ${items[idx]}`);
+ };
+ items.forEach((_, i) => {
+ const x = 16 + i * 22.6;
+ svgEl(s, 'text', { x, y: 9, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = i + 1;
+ leds.push(svgEl(s, 'circle', { cx: x, cy: 17, r: 2.4, fill: '#3a0f0a' }));
+ const g = svgEl(s, 'g', {}); g.style.cursor = 'pointer'; g.style.transition = 'transform .07s';
+ svgEl(g, 'rect', { x: x - 8.5, y: 24, width: 17, height: 22, rx: 2.5, fill: 'url(#lrKey)', stroke: '#0a0908', 'stroke-width': 1 });
+ svgEl(g, 'rect', { x: x - 6.5, y: 26.5, width: 13, height: 3, rx: 1.5, fill: 'rgba(255,255,255,.06)' });
+ g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); set(i); });
+ });
+ svgEl(s, 'text', { x: 95, y: 56, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'PROGRAM';
+ set(opts.index !== undefined ? opts.index : 5);
+ return { el: s, get: () => idx, set };
+};
+
+/* R22 three-position slide — chrome pill between detents, honest LED pair */
+GW.pillSlide = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const positions = opts.positions || ['A', 'AB', 'B'];
+ const s = stageSvg(host, 'rsvg press', 110, 64), detX = [34, 55, 76];
+ gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]);
+ svgEl(s, 'text', { x: 55, y: 10, 'text-anchor': 'middle', 'font-size': 5.5, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'BASIC · VARIATION';
+ svgEl(s, 'rect', { x: 20, y: 16, width: 70, height: 14, rx: 7, fill: '#0b0a09', stroke: '#000', 'stroke-width': 1 });
+ svgEl(s, 'rect', { x: 21, y: 17, width: 68, height: 5, rx: 2.5, fill: 'rgba(255,255,255,.04)' });
+ const pill = svgEl(s, 'rect', { x: -13, y: 18.5, width: 26, height: 9, rx: 4.5, fill: 'url(#sfChrome)', stroke: '#4e4a42', 'stroke-width': .6 });
+ pill.style.transition = 'transform .12s';
+ const lbls = positions.map((lbl, i) => {
+ const t = svgEl(s, 'text', { x: detX[i], y: 40, 'text-anchor': 'middle', 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--dim)' });
+ t.textContent = lbl; return t;
+ });
+ svgEl(s, 'rect', { x: 32, y: 45, width: 46, height: 11, rx: 2, fill: '#141210', stroke: '#060505' });
+ const leds = [44, 66].map(x => svgEl(s, 'circle', { cx: x, cy: 50.5, r: 2.4, fill: '#3a0f0a' }));
+ let idx;
+ const set = i => {
+ idx = i;
+ pill.setAttribute('transform', `translate(${detX[i]},0)`);
+ lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)'));
+ const lit = [i === 0 || i === 1, i === 2 || i === 1];
+ leds.forEach((l, k) => l.setAttribute('fill', lit[k] ? 'var(--jewel-r)' : '#3a0f0a'));
+ onChange(idx, positions[i]);
+ };
+ s.addEventListener('click', e => {
+ const r = s.getBoundingClientRect();
+ const x = (e.clientX - r.left) / r.width * 110;
+ set(x < 45 ? 0 : x < 66 ? 1 : 2);
+ });
+ set(opts.index !== undefined ? opts.index : 1);
+ return { el: s, get: () => idx, set };
+};
+
+/* R23 spun-aluminum knob — machined rings in a knurled grip, red index */
+GW.spunKnob = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52;
+ gradDef('spunFace', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#e8e6e0'], ['.7', '#b3b0a8'], ['1', '#8a877e']]);
+ for (let k = 0; k < 8; k++) { const [x, y] = polar(cx, cy, 45, -135 + k * 38.6); svgEl(s, 'circle', { cx: x, cy: y, r: 1.3, fill: 'var(--steel)' }); }
+ for (let k = 0; k < 22; k++) { const [x, y] = polar(cx, cy, 36, k * 16.36); svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: '#14110e' }); }
+ svgEl(s, 'circle', { cx, cy, r: 36, fill: '#1a1714', stroke: '#000', 'stroke-width': .8 });
+ svgEl(s, 'circle', { cx, cy, r: 30, fill: 'url(#spunFace)', stroke: '#6e6b63', 'stroke-width': .8 });
+ for (const r of [24, 18, 12, 6])
+ svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: r % 12 === 0 ? 'rgba(0,0,0,.12)' : 'rgba(255,255,255,.3)', 'stroke-width': .6 });
+ const idx = svgEl(s, 'line', { x1: cx, y1: cy - 28, x2: cx, y2: cy - 10, stroke: 'var(--fail)', 'stroke-width': 2.6, 'stroke-linecap': 'round' });
+ svgEl(s, 'ellipse', { cx: cx - 9, cy: cy - 11, rx: 9, ry: 5, fill: 'rgba(255,255,255,.28)', transform: `rotate(-32,${cx - 9},${cy - 11})` });
+ svgEl(s, 'text', { x: cx, y: 103, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'SPEED';
+ let val;
+ const set = v => {
+ val = Math.max(0, Math.min(100, v));
+ idx.setAttribute('transform', `rotate(${-135 + val / 100 * 270},${cx},${cy})`);
+ onChange(val, Math.round(val) + '%');
+ };
+ dragDelta(s, () => val, set, { min: 0, max: 100 });
+ set(opts.value !== undefined ? opts.value : 62);
+ return { el: s, get: () => val, set };
+};
+
+/* R24 stomp switch + jewel — press to engage, the jewel reports */
+GW.stompSwitch = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55;
+ gradDef('stompDome', 'radialGradient', { cx: '40%', cy: '30%', r: '80%' }, [['0', '#f4f2ec'], ['.55', '#b9b6ae'], ['1', '#6e6b63']]);
+ gradDef('stompJewel', 'radialGradient', { cx: '38%', cy: '30%', r: '80%' }, [['0', '#ffe9b0'], ['.45', 'var(--jewel-a)'], ['1', '#5c3d0c']]);
+ def('avGlow', d => {
+ const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
+ svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
+ });
+ const glow = svgEl(s, 'circle', { cx, cy: 22, r: 14, fill: 'rgba(255,180,58,.35)', filter: 'url(#avGlow)', opacity: 0 });
+ const jewel = svgEl(s, 'circle', { cx, cy: 22, r: 9, fill: '#241f1b', stroke: '#0a0908', 'stroke-width': 1.5 });
+ for (let k = 0; k < 6; k++) {
+ const a1 = k * 60, a2 = k * 60 + 30;
+ const [x1, y1] = polar(cx, 22, 9, a1), [x2, y2] = polar(cx, 22, 9, a2);
+ svgEl(s, 'path', { d: `M ${cx} 22 L ${x1} ${y1} A 9 9 0 0 1 ${x2} ${y2} Z`, fill: k % 2 ? 'rgba(255,255,255,.09)' : 'rgba(0,0,0,.10)', 'pointer-events': 'none' });
+ }
+ for (let k = 0; k < 20; k++) { const [x, y] = polar(cx, 72, 25, k * 18); svgEl(s, 'circle', { cx: x, cy: y, r: 2.4, fill: '#14110e' }); }
+ svgEl(s, 'circle', { cx, cy: 72, r: 25, fill: '#1a1714', stroke: '#000', 'stroke-width': .8 });
+ const dome = svgEl(s, 'g', {}); dome.style.transition = 'transform .08s';
+ svgEl(dome, 'circle', { cx, cy: 72, r: 19, fill: 'url(#stompDome)', stroke: '#55524a', 'stroke-width': .8 });
+ svgEl(dome, 'ellipse', { cx: cx - 6, cy: 64, rx: 8, ry: 4.5, fill: 'rgba(255,255,255,.5)', transform: `rotate(-24,${cx - 6},64)` });
+ let on;
+ const set = v => {
+ on = !!v;
+ jewel.setAttribute('fill', on ? 'url(#stompJewel)' : '#241f1b');
+ glow.setAttribute('opacity', on ? '1' : '0');
+ onChange(on, on ? 'ENGAGED' : 'bypass');
+ };
+ s.addEventListener('click', () => { dome.style.transform = 'translateY(1.5px)'; setTimeout(() => dome.style.transform = '', 90); set(!on); });
+ set(opts.on !== undefined ? opts.on : false);
+ return { el: s, get: () => on, set };
+};
+
+/* R27 winged gain selector — red T-bar over a dot ring, stepped detents */
+GW.wingSelector = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const steps = opts.steps || [-80, -70, -60, -50, -40, -30, -20, -10, -5, 0, 5, 10];
+ const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52;
+ gradDef('wingRed', 'radialGradient', { cx: '40%', cy: '32%', r: '80%' }, [['0', '#d98a6f'], ['.6', 'var(--fail)'], ['1', '#6e2415']]);
+ steps.forEach((db, i) => {
+ const a = -135 + i / (steps.length - 1) * 270;
+ const [dx, dy] = polar(cx, cy, 38, a);
+ svgEl(s, 'circle', { cx: dx, cy: dy, r: 1.5, fill: 'var(--steel)' });
+ if (i % 2 === 1 || db === 0 || db === 10 || db === -80) {
+ const [tx, ty] = polar(cx, cy, 46.5, a);
+ svgEl(s, 'text', { x: tx, y: ty + 2.2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = db > 0 ? '+' + db : db;
+ }
+ });
+ const grp = svgEl(s, 'g', {}); grp.style.transition = 'transform .09s';
+ svgEl(grp, 'rect', { x: cx - 6.5, y: cy - 30, width: 13, height: 60, rx: 6, fill: 'url(#wingRed)', stroke: '#4e150a', 'stroke-width': .8 });
+ svgEl(grp, 'circle', { cx, cy, r: 15, fill: 'url(#wingRed)', stroke: '#4e150a', 'stroke-width': .8 });
+ svgEl(grp, 'line', { x1: cx, y1: cy - 28, x2: cx, y2: cy - 18, stroke: 'var(--cream)', 'stroke-width': 2, 'stroke-linecap': 'round' });
+ svgEl(grp, 'ellipse', { cx: cx - 4, cy: cy - 8, rx: 5, ry: 3, fill: 'rgba(255,255,255,.18)', transform: `rotate(-30,${cx - 4},${cy - 8})` });
+ let idx;
+ const set = v => {
+ idx = Math.max(0, Math.min(steps.length - 1, Math.round(v)));
+ grp.style.transform = `rotate(${-135 + idx / (steps.length - 1) * 270}deg)`;
+ grp.style.transformOrigin = `${cx}px ${cy}px`;
+ const db = steps[idx];
+ onChange(db, (db > 0 ? '+' : '') + db + ' dB');
+ };
+ dragDelta(s, () => idx, set, { min: 0, max: steps.length - 1, sens: .05 });
+ set(opts.index !== undefined ? opts.index : 7);
+ return { el: s, get: () => idx, set };
+};
+
+/* R28 rotary disc switch — the whole disc turns between heavy positions */
+GW.discSwitch = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const positions = opts.positions || [['OFF', -45], ['ON', 45], ['COMBINE', 135]];
+ const s = stageSvg(host, 'rsvg press', 110, 116), cx = 55, cy = 58;
+ gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]);
+ gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
+ svgEl(s, 'circle', { cx, cy, r: 48, fill: '#1a1714', stroke: '#060505', 'stroke-width': 1.5 });
+ for (let k = 0; k < 4; k++) {
+ const [x, y] = polar(cx, cy, 44, 45 + k * 90);
+ svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 });
+ }
+ const lbls = positions.map(([lbl, a]) => {
+ const [tx, ty] = polar(cx, cy, 42, a);
+ const t = svgEl(s, 'text', { x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 5.8, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
+ t.textContent = lbl; return t;
+ });
+ svgEl(s, 'circle', { cx, cy, r: 36, fill: 'url(#discRed)', stroke: '#4e150a', 'stroke-width': 1 });
+ const grp = svgEl(s, 'g', {}); grp.style.transition = 'transform .15s'; grp.style.transformOrigin = `${cx}px ${cy}px`;
+ svgEl(grp, 'rect', { x: cx - 27, y: cy - 8, width: 54, height: 16, rx: 8, fill: 'url(#discRed)', stroke: '#8f3520', 'stroke-width': 1 });
+ svgEl(grp, 'rect', { x: cx - 25, y: cy - 6.5, width: 50, height: 4, rx: 2, fill: 'rgba(255,255,255,.16)' });
+ svgEl(grp, 'line', { x1: cx + 20, y1: cy, x2: cx + 26, y2: cy, stroke: 'var(--cream)', 'stroke-width': 2.5, 'stroke-linecap': 'round' });
+ svgEl(s, 'ellipse', { cx: cx - 12, cy: cy - 14, rx: 12, ry: 6, fill: 'rgba(255,255,255,.10)', transform: `rotate(-28,${cx - 12},${cy - 14})` });
+ let idx;
+ const set = i => {
+ idx = i;
+ grp.style.transform = `rotate(${positions[i][1]}deg)`;
+ lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)'));
+ onChange(idx, positions[i][0]);
+ };
+ s.addEventListener('click', () => set((idx + 1) % positions.length));
+ set(opts.index !== undefined ? opts.index : 1);
+ return { el: s, get: () => idx, set };
+};
+
+/* R29 guarded toggle — guard posts + red collar mark the critical throw */
+GW.guardedToggle = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52;
+ gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]);
+ gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]);
+ gradDef('chromeG', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]);
+ gradDef('tipG', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]);
+ const lblOn = svgEl(s, 'text', { x: cx, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' });
+ lblOn.textContent = opts.onLabel || 'ON';
+ const lblOff = svgEl(s, 'text', { x: cx, y: 96, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
+ lblOff.textContent = opts.offLabel || 'OFF';
+ svgEl(s, 'circle', { cx, cy, r: 13, fill: 'url(#discRed)', stroke: '#4e150a', 'stroke-width': 1 });
+ const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 11 * Math.cos(a)) + ',' + (cy + 11 * Math.sin(a))); }
+ svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#nutG)', stroke: '#2b2822' });
+ svgEl(s, 'circle', { cx, cy, r: 6, fill: '#14110e', stroke: '#000' });
+ for (const gx of [16, 74]) {
+ svgEl(s, 'rect', { x: gx - 6, y: cy - 24, width: 12, height: 48, rx: 6, fill: 'url(#nutG)', stroke: '#2b2822', 'stroke-width': 1 });
+ svgEl(s, 'rect', { x: gx - 4, y: cy - 21, width: 3.5, height: 42, rx: 2, fill: 'rgba(255,255,255,.14)' });
+ }
+ const lever = svgEl(s, 'g', {});
+ svgEl(lever, 'path', { d: `M ${cx - 3} ${cy} L ${cx - 2} 26 L ${cx + 2} 26 L ${cx + 3} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 });
+ svgEl(lever, 'rect', { x: cx - 4.5, y: 16, width: 9, height: 12, rx: 2, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 });
+ for (let r = 0; r < 4; r++) svgEl(lever, 'line', { x1: cx - 3.5, y1: 18.5 + r * 2.4, x2: cx + 3.5, y2: 18.5 + r * 2.4, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 });
+ lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s';
+ let on;
+ const set = v => {
+ on = !!v;
+ lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)';
+ lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)');
+ lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)');
+ onChange(on, on ? 'ON' : 'OFF');
+ };
+ s.addEventListener('click', () => set(!on));
+ set(opts.on !== undefined ? opts.on : true);
+ return { el: s, get: () => on, set };
+};
+
+/* R32 mechanical timer dial — dial rotates under a fixed index; wind by drag, stop by the red knob.
+ Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set. */
+GW.timerDial = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg', 158, 132), cx = 62, cy = 58;
+ gradDef('mtFace', 'radialGradient', { cx: '50%', cy: '42%', r: '75%' }, [['0', '#282320'], ['1', '#14110e']]);
+ gradDef('mtRed', 'radialGradient', { cx: '38%', cy: '32%', r: '80%' }, [['0', '#e0523a'], ['1', '#8f2416']]);
+ gradDef('mtHub', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#c9c4b8'], ['1', '#6e685c']]);
+ /* bezel + knurled coin edge */
+ svgEl(s, 'circle', { cx, cy, r: 50, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy, r: 47.5, fill: 'none', stroke: '#3a352c', 'stroke-width': 4, 'stroke-dasharray': '2 1.6' });
+ svgEl(s, 'circle', { cx, cy, r: 44, fill: 'url(#mtFace)', stroke: '#0a0908', 'stroke-width': 1 });
+ /* rotating scale: OFF at 0, minutes climb clockwise at 4.5 deg/min (60 at 270) */
+ const rot = svgEl(s, 'g', {});
+ for (let m = 0; m <= 60; m += 5) {
+ const major = m % 20 === 0;
+ const [x1, y1] = polar(cx, cy, 40, m * 4.5), [x2, y2] = polar(cx, cy, major ? 34 : 37, m * 4.5);
+ svgEl(rot, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': major ? 1.2 : .55, opacity: major ? .9 : .55 });
+ }
+ for (const m of [20, 40, 60]) {
+ const [x, y] = polar(cx, cy, 27, m * 4.5);
+ svgEl(rot, 'text', { x, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = String(m);
+ }
+ const [ox, oy] = polar(cx, cy, 27, 0);
+ svgEl(rot, 'text', { x: ox, y: oy + 3, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#e0523a' }).textContent = 'OFF';
+ const [mx, my] = polar(cx, cy, 16, 180);
+ svgEl(rot, 'text', { x: mx, y: my + 2.5, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'MINUTES';
+ /* hub with screw slot */
+ svgEl(s, 'circle', { cx, cy, r: 9, fill: 'url(#mtHub)', stroke: '#3c382f', 'stroke-width': 1 });
+ svgEl(s, 'line', { x1: cx - 5.5, y1: cy, x2: cx + 5.5, y2: cy, stroke: '#4a4438', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy, r: 2.2, fill: '#8f897b' });
+ /* fixed red index at top + winding-direction arrow */
+ svgEl(s, 'line', { x1: cx, y1: cy - 44, x2: cx, y2: cy - 32, stroke: '#e0523a', 'stroke-width': 1.6 });
+ svgEl(s, 'path', { d: 'M 122 14 A 26 26 0 0 1 136 26', fill: 'none', stroke: 'var(--steel)', 'stroke-width': 1.6 });
+ svgEl(s, 'polygon', { points: '136,26 130.5,24.5 134.5,19.5', fill: 'var(--steel)' });
+ /* engraved plate captions */
+ const cap = (t, y) => svgEl(s, 'text', { x: 4, y, 'font-size': 6.2, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = t;
+ cap('PUSH TO STOP', 118); cap('TURN TO START', 128);
+ let min;
+ const set = m => {
+ min = Math.max(0, Math.min(60, m));
+ rot.setAttribute('transform', `rotate(${(-min * 4.5).toFixed(2)},${cx},${cy})`);
+ onChange(min, min > 0.5 ? 'T-' + Math.round(min) + ' MIN' : 'OFF');
+ };
+ /* dial drag surface (under the knob in DOM so the knob wins the overlap) */
+ const hit = svgEl(s, 'circle', { cx, cy, r: 50, fill: 'transparent' });
+ hit.style.cursor = 'grab';
+ dragDelta(hit, () => min, set, { min: 0, max: 60 });
+ /* red stop knob: fluted edge, domed center */
+ const knob = svgEl(s, 'g', {});
+ svgEl(knob, 'circle', { cx: 126, cy: 104, r: 20, fill: 'url(#mtRed)', stroke: '#5c150c', 'stroke-width': 1.5 });
+ svgEl(knob, 'circle', { cx: 126, cy: 104, r: 18, fill: 'none', stroke: 'rgba(0,0,0,.4)', 'stroke-width': 3.4, 'stroke-dasharray': '3 2.4' });
+ svgEl(knob, 'circle', { cx: 126, cy: 104, r: 8.5, fill: 'url(#mtRed)', stroke: 'rgba(0,0,0,.3)', 'stroke-width': .8 });
+ svgEl(knob, 'ellipse', { cx: 121, cy: 98, rx: 6, ry: 3.4, fill: 'rgba(255,255,255,.18)', transform: 'rotate(-28,121,98)' });
+ knob.style.cursor = 'pointer';
+ knob.addEventListener('click', () => { set(0); onChange(0, 'STOP · OFF'); });
+ set(opts.minutes !== undefined ? opts.minutes : 0);
+ if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
+ setInterval(() => { if (min > 0) { set(min - 1); if (min === 0) onChange(0, 'DING · OFF'); } }, 1000);
+ return { el: s, get: () => min, set };
+};
+
+/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press */
+GW.rockerPad = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55;
+ gradDef('rk4Pad', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#33302b'], ['1', '#141210']]);
+ gradDef('rk4Arr', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#e9e4d6'], ['1', '#9d988b']]);
+ gradDef('rk4Nub', 'radialGradient', { cx: '38%', cy: '30%', r: '85%' }, [['0', '#4a453e'], ['1', '#0d0c0a']]);
+ /* recessed plate + rubber pad */
+ svgEl(s, 'rect', { x: 3, y: 3, width: 104, height: 104, rx: 18, fill: '#211e1a', stroke: '#0c0b09', 'stroke-width': 1.5 });
+ svgEl(s, 'circle', { cx, cy, r: 45, fill: '#0f0d0b' });
+ svgEl(s, 'circle', { cx, cy, r: 41, fill: 'url(#rk4Pad)', stroke: '#060505', 'stroke-width': 1 });
+ /* four arrows, pointing outward */
+ const ARR = { up: '55,22 45,36 65,36', down: '55,88 45,74 65,74', left: '22,55 36,45 36,65', right: '88,55 74,45 74,65' };
+ const arrows = {};
+ for (const [dir, pts] of Object.entries(ARR))
+ arrows[dir] = svgEl(s, 'polygon', { points: pts, fill: 'url(#rk4Arr)', stroke: '#3c382f', 'stroke-width': .7 });
+ /* center pivot nub */
+ svgEl(s, 'circle', { cx, cy, r: 7, fill: 'url(#rk4Nub)', stroke: '#060505', 'stroke-width': .8 });
+ svgEl(s, 'ellipse', { cx: cx - 2, cy: cy - 2.5, rx: 2.6, ry: 1.7, fill: 'rgba(255,255,255,.25)' });
+ /* quadrant hit zones (drawn last so they win), press flash + tracked position */
+ let rx = 0, ry = 0;
+ const press = (dir, dx, dy) => {
+ rx += dx; ry += dy;
+ onChange({ x: rx, y: ry, dir }, dir.toUpperCase() + ' · x ' + rx + ' y ' + ry);
+ arrows[dir].setAttribute('fill', 'var(--gold-hi)');
+ setTimeout(() => arrows[dir].setAttribute('fill', 'url(#rk4Arr)'), 160);
+ };
+ const zone = (dir, pts, dx, dy) => {
+ const z = svgEl(s, 'polygon', { points: pts, fill: 'transparent' });
+ z.style.cursor = 'pointer'; z.addEventListener('click', () => press(dir, dx, dy));
+ };
+ zone('up', `${cx},${cy} 20,20 90,20`, 0, 1);
+ zone('down', `${cx},${cy} 20,90 90,90`, 0, -1);
+ zone('left', `${cx},${cy} 20,20 20,90`, -1, 0);
+ zone('right', `${cx},${cy} 90,20 90,90`, 1, 0);
+ onChange({ x: 0, y: 0, dir: null }, 'x 0 y 0');
+ return { el: s, get: () => ({ x: rx, y: ry }) };
+};
+
+/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state */
+GW.fourWayToggle = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
+ gradDef('fw4Chrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e8e6e0'], ['.5', '#9a968c'], ['1', '#55524a']]);
+ gradDef('fw4Ball', 'radialGradient', { cx: '36%', cy: '30%', r: '80%' }, [['0', '#f2f0ea'], ['.55', '#8e8a80'], ['1', '#3c3a34']]);
+ /* printed panel graphics: axes, circle, diagonal square, labels */
+ svgEl(s, 'line', { x1: 8, y1: cy, x2: 122, y2: cy, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .7 });
+ svgEl(s, 'line', { x1: cx, y1: 8, x2: cx, y2: 122, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .7 });
+ svgEl(s, 'text', { x: cx + 2, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'Y';
+ svgEl(s, 'text', { x: 116, y: cy - 4, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'X';
+ svgEl(s, 'circle', { cx, cy, r: 42, fill: 'none', stroke: 'var(--steel)', 'stroke-width': .9, opacity: .8 });
+ const QUAD = { A: 315, B: 45, C: 135, D: 225 };
+ const diag = Object.values(QUAD).map(a => polar(cx, cy, 42, a));
+ svgEl(s, 'polygon', { points: diag.map(p => p.join(',')).join(' '), fill: 'none', stroke: 'var(--steel)', 'stroke-width': .8, opacity: .8 });
+ const LAMP_AT = { A: [14, 14], B: [116, 14], C: [116, 116], D: [14, 116] };
+ const lamps = {};
+ for (const [q, a] of Object.entries(QUAD)) {
+ const [lx, ly] = polar(cx, cy, 42, a);
+ const [px, py] = LAMP_AT[q];
+ svgEl(s, 'line', { x1: lx, y1: ly, x2: px, y2: py, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .5 });
+ svgEl(s, 'circle', { cx: lx, cy: ly, r: 6.5, fill: '#14110e', stroke: 'var(--steel)', 'stroke-width': .9 });
+ svgEl(s, 'text', { x: lx, y: ly + 2.8, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = q;
+ svgEl(s, 'circle', { cx: px, cy: py, r: 5.5, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 });
+ lamps[q] = svgEl(s, 'circle', { cx: px, cy: py, r: 3.6, fill: '#3a3426' });
+ }
+ /* bezel + boot */
+ svgEl(s, 'circle', { cx, cy, r: 20, fill: '#0d0c0a', stroke: '#2a2722', 'stroke-width': 1.5 });
+ svgEl(s, 'circle', { cx, cy, r: 15, fill: '#17140f', stroke: '#060505', 'stroke-width': 1 });
+ /* lever: shaft + grip rotate to the selected diagonal; ball pivot stays centered */
+ const lever = svgEl(s, 'g', {});
+ svgEl(lever, 'polygon', { points: `${cx - 3},${cy} ${cx + 3},${cy} ${cx + 2},${cy - 26} ${cx - 2},${cy - 26}`, fill: 'url(#fw4Chrome)', stroke: '#3c3a34', 'stroke-width': .6 });
+ svgEl(lever, 'rect', { x: cx - 3.4, y: cy - 40, width: 6.8, height: 16, rx: 3, fill: 'url(#fw4Chrome)', stroke: '#3c3a34', 'stroke-width': .6 });
+ svgEl(s, 'circle', { cx, cy, r: 8.5, fill: 'url(#fw4Ball)', stroke: '#26241f', 'stroke-width': .8 });
+ svgEl(s, 'ellipse', { cx: cx - 2.6, cy: cy - 3, rx: 2.8, ry: 1.9, fill: 'rgba(255,255,255,.5)' });
+ let pos = null;
+ const set = q => {
+ pos = q;
+ lever.setAttribute('transform', `rotate(${QUAD[q]},${cx},${cy})`);
+ for (const k of Object.keys(QUAD)) {
+ lamps[k].setAttribute('fill', k === q ? 'var(--jewel-a)' : '#3a3426');
+ lamps[k].setAttribute('filter', k === q ? 'drop-shadow(0 0 4px rgba(255,180,58,.8))' : 'none');
+ }
+ onChange(pos, 'POS ' + q);
+ };
+ /* quadrant click zones (diagonal quadrants, drawn last) */
+ const zone = (q, pts) => {
+ const z = svgEl(s, 'polygon', { points: pts, fill: 'transparent' });
+ z.style.cursor = 'pointer'; z.addEventListener('click', () => set(q));
+ };
+ zone('A', `${cx},${cy} ${cx},5 5,5 5,${cy}`);
+ zone('B', `${cx},${cy} ${cx},5 125,5 125,${cy}`);
+ zone('C', `${cx},${cy} 125,${cy} 125,125 ${cx},125`);
+ zone('D', `${cx},${cy} ${cx},125 5,125 5,${cy}`);
+ set(opts.position || 'C');
+ return { el: s, get: () => pos, set };
+};
+
+/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many */
+GW.pinMatrix = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV'];
+ const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD'];
+ const pins = new Set(opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA']); /* a legible default patch */
+ const s = stageSvg(host, 'rsvg press', 160, 110);
+ const X0 = 42, Y0 = 30, DX = 19, DY = 15.5;
+ svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
+ COLS.forEach((c, j) => svgEl(s, 'text', { x: X0 + j * DX, y: 16, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = c);
+ ROWS.forEach((r, i) => svgEl(s, 'text', { x: 34, y: Y0 + i * DY + 2, 'text-anchor': 'end', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = r);
+ ROWS.forEach((r, i) => COLS.forEach((c, j) => {
+ const cx = X0 + j * DX, cy = Y0 + i * DY, key = r + '>' + c;
+ svgEl(s, 'circle', { cx, cy, r: 3.4, fill: '#0a0908', stroke: '#2c2820', 'stroke-width': 1 });
+ const cap = svgEl(s, 'circle', { cx, cy, r: 4.4, fill: 'var(--gold)', stroke: '#7d5c16', 'stroke-width': 1, opacity: pins.has(key) ? 1 : 0, filter: 'drop-shadow(0 1px 2px rgba(0,0,0,.6))' });
+ const hit = svgEl(s, 'circle', { cx, cy, r: 8, fill: 'transparent' });
+ hit.style.cursor = 'pointer';
+ hit.addEventListener('click', () => {
+ const on = !pins.has(key);
+ if (on) pins.add(key); else pins.delete(key);
+ cap.setAttribute('opacity', on ? 1 : 0);
+ onChange([...pins], (on ? r + ' → ' + c : 'pulled ' + r + ' → ' + c) + ' · ' + pins.size + ' routes');
+ });
+ }));
+ onChange([...pins], pins.size + ' routes');
+ return { el: s, get: () => [...pins] };
+};
+
+/* R38 dead-man button — state exists only while the pointer holds it down */
+GW.deadMan = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg', 120, 110), cx = 60, cy = 58;
+ gradDef('dmBtn', 'radialGradient', { cx: '40%', cy: '32%', r: '85%' }, [['0', '#3a3631'], ['1', '#16130f']]);
+ svgEl(s, 'rect', { x: 4, y: 4, width: 112, height: 102, rx: 12, fill: '#211e1a', stroke: '#0c0b09', 'stroke-width': 1.5 });
+ svgEl(s, 'text', { x: cx, y: 18, 'text-anchor': 'middle', 'font-size': 6.2, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.caption || 'HOLD TO RUN';
+ const lamp = svgEl(s, 'circle', { cx: 104, cy: 16, r: 4.5, fill: '#1e2a12' });
+ const ring = svgEl(s, 'circle', { cx, cy, r: 33, fill: 'none', stroke: '#2c2820', 'stroke-width': 3 });
+ const btn = svgEl(s, 'g', {});
+ svgEl(btn, 'circle', { cx, cy, r: 28, fill: 'url(#dmBtn)', stroke: '#060505', 'stroke-width': 1.5 });
+ svgEl(btn, 'circle', { cx, cy, r: 22, fill: 'none', stroke: 'rgba(255,255,255,.07)', 'stroke-width': 1 });
+ svgEl(btn, 'text', { x: cx, y: cy + 2.5, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--silver)' }).textContent = opts.label || 'RUN';
+ btn.style.cursor = 'pointer';
+ let t0 = null, iv = null;
+ const stop = () => {
+ if (t0 === null) return;
+ const held = ((Date.now() - t0) / 1000).toFixed(1);
+ t0 = null; clearInterval(iv);
+ btn.removeAttribute('transform');
+ ring.setAttribute('stroke', '#2c2820'); ring.removeAttribute('filter');
+ lamp.setAttribute('fill', '#1e2a12'); lamp.removeAttribute('filter');
+ onChange(false, 'SAFE (held ' + held + 's)');
+ };
+ btn.addEventListener('pointerdown', e => {
+ btn.setPointerCapture(e.pointerId);
+ t0 = Date.now();
+ btn.setAttribute('transform', 'translate(0,1.5)');
+ ring.setAttribute('stroke', 'var(--gold)'); ring.setAttribute('filter', 'drop-shadow(0 0 5px rgba(var(--glow-lo),.7))');
+ lamp.setAttribute('fill', 'var(--pass)'); lamp.setAttribute('filter', 'drop-shadow(0 0 4px rgba(116,147,47,.8))');
+ onChange(true, 'RUNNING 0.0s');
+ iv = setInterval(() => { if (t0 !== null) onChange(true, 'RUNNING ' + ((Date.now() - t0) / 1000).toFixed(1) + 's'); }, 100);
+ e.preventDefault();
+ });
+ btn.addEventListener('pointerup', stop);
+ btn.addEventListener('pointercancel', stop);
+ onChange(false, 'SAFE');
+ return { el: s, get: () => t0 !== null };
+};
+
+/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns */
+GW.telephoneDial = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 63;
+ const DIGITS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
+ const angOf = i => (60 - i * 30 + 360) % 360; /* 1 at 60cw, counterclockwise to 0 at 150 */
+ const STOP = 105;
+ gradDef('tdWheel', 'radialGradient', { cx: '42%', cy: '36%', r: '85%' }, [['0', '#8f8a7e'], ['1', '#4a463e']]);
+ svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1e1a16', stroke: '#0a0908', 'stroke-width': 1 });
+ /* rotating finger wheel: ring + hole openings (dark wells punched in the metal) */
+ const wheel = svgEl(s, 'g', {});
+ svgEl(wheel, 'circle', { cx, cy, r: 46, fill: 'none', stroke: 'url(#tdWheel)', 'stroke-width': 17, opacity: .95 });
+ DIGITS.forEach((_, i) => {
+ const [x, y] = polar(cx, cy, 38, angOf(i));
+ svgEl(wheel, 'circle', { cx: x, cy: y, r: 7.2, fill: '#1e1a16', stroke: '#26221c', 'stroke-width': 1.4 });
+ });
+ /* fixed digits ON TOP so they read through the holes (they stay put while the wheel spins) */
+ DIGITS.forEach((d, i) => {
+ const [x, y] = polar(cx, cy, 38, angOf(i));
+ svgEl(s, 'text', { x, y: y + 3, 'text-anchor': 'middle', 'font-size': 8.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = d;
+ });
+ svgEl(s, 'circle', { cx, cy, r: 16, fill: 'url(#tdWheel)', stroke: '#26221c', 'stroke-width': 1 });
+ /* finger stop */
+ const [fx, fy] = polar(cx, cy, 52, STOP);
+ svgEl(s, 'rect', { x: fx - 2.5, y: fy - 8, width: 5, height: 16, rx: 2, fill: '#b8b2a4', stroke: '#3c382f', 'stroke-width': 1, transform: `rotate(${STOP},${fx},${fy})` });
+ let dialed = '', spinning = false;
+ const setW = a => wheel.setAttribute('transform', `rotate(${a},${cx},${cy})`);
+ const finish = d => { dialed = (dialed + d).slice(-10); onChange(dialed, dialed); spinning = false; };
+ DIGITS.forEach((d, i) => {
+ const [x, y] = polar(cx, cy, 38, angOf(i));
+ const hit = svgEl(s, 'circle', { cx: x, cy: y, r: 8.5, fill: 'transparent' });
+ hit.style.cursor = 'pointer';
+ hit.addEventListener('click', () => {
+ if (spinning) return; spinning = true;
+ const R = (STOP - angOf(i) + 360) % 360;
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) { setW(R); setTimeout(() => { setW(0); finish(d); }, 160); return; }
+ const t0 = performance.now(), Tf = R / 300 * 1000, Tb = R / 450 * 1000;
+ const step = t => {
+ const el = t - t0;
+ if (el < Tf) { setW(R * el / Tf); requestAnimationFrame(step); }
+ else if (el < Tf + 80) { setW(R); requestAnimationFrame(step); }
+ else if (el < Tf + 80 + Tb) { setW(R * (1 - (el - Tf - 80) / Tb)); requestAnimationFrame(step); }
+ else { setW(0); finish(d); }
+ };
+ requestAnimationFrame(step);
+ });
+ });
+ onChange('', 'dial a number');
+ return { el: s, get: () => dialed };
+};
+
+/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step */
+GW.breakerPanel = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const NAMES = opts.names || ['MAIN', 'PUMP', 'LAMP', 'AUX'];
+ const s = stageSvg(host, 'rsvg press', 160, 110);
+ svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
+ const brk = [];
+ const draw = () => {
+ let closed = 0, trip = null;
+ brk.forEach(b => {
+ if (b.state === 'on') closed++; if (b.state === 'tripped') trip = b.name;
+ b.lamp.setAttribute('fill', b.state === 'on' ? 'var(--pass)' : '#1e2a12');
+ b.lamp.setAttribute('filter', b.state === 'on' ? 'drop-shadow(0 0 3px rgba(116,147,47,.8))' : 'none');
+ b.handle.setAttribute('y', b.state === 'on' ? 40 : b.state === 'tripped' ? 57 : 74);
+ b.collar.setAttribute('opacity', b.state === 'tripped' ? 1 : 0);
+ });
+ onChange(brk.map(b => b.state), closed + '/' + NAMES.length + ' closed' + (trip ? ' · ' + trip + ' TRIPPED' : ''));
+ };
+ NAMES.forEach((name, i) => {
+ const x = 14 + i * 30;
+ svgEl(s, 'rect', { x, y: 36, width: 20, height: 52, rx: 4, fill: '#14110e', stroke: '#2c2820', 'stroke-width': 1 });
+ const lamp = svgEl(s, 'circle', { cx: x + 10, cy: 26, r: 4, fill: '#1e2a12' });
+ const collar = svgEl(s, 'rect', { x: x + 4, y: 69, width: 12, height: 5, fill: 'var(--amber-warn)', opacity: 0 });
+ const handle = svgEl(s, 'rect', { x: x + 4, y: 40, width: 12, height: 14, rx: 2, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
+ svgEl(s, 'text', { x: x + 10, y: 98, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = name;
+ const hit = svgEl(s, 'rect', { x, y: 20, width: 20, height: 80, fill: 'transparent' });
+ hit.style.cursor = 'pointer';
+ const b = { name, state: i < 3 ? 'on' : 'off', lamp, collar, handle };
+ hit.addEventListener('click', () => { b.state = b.state === 'on' ? 'off' : b.state === 'off' ? 'on' : 'off'; draw(); });
+ brk.push(b);
+ });
+ const tb = svgEl(s, 'g', {});
+ svgEl(tb, 'rect', { x: 132, y: 40, width: 22, height: 16, rx: 4, fill: 'var(--fail)', stroke: '#5c150c', 'stroke-width': 1 });
+ svgEl(tb, 'text', { x: 143, y: 50.5, 'text-anchor': 'middle', 'font-size': 6, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = 'TRIP';
+ tb.style.cursor = 'pointer';
+ tb.addEventListener('click', () => { for (let i = brk.length - 1; i >= 0; i--) if (brk[i].state === 'on') { brk[i].state = 'tripped'; break; } draw(); });
+ draw();
+ return { el: s, get: () => brk.map(b => b.state) };
+};
+
+/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb */
+GW.dsky = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const LAMPS = ['UPLINK', 'TEMP', 'GIMBAL', 'PROG', 'RESTART', 'OPR ERR'];
+ const el = document.createElement('div'); el.className = 'dsky'; host.appendChild(el);
+ el.innerHTML =
+ `<div class="lampcol">${LAMPS.map(l => `<div class="sl" data-l="${l}">${l}</div>`).join('')}</div>` +
+ `<div class="right"><div class="wins">` +
+ `<div class="win" data-w="prog"><span class="wl">PROG</span><span class="wd"></span></div>` +
+ `<div class="win" data-w="verb"><span class="wl">VERB</span><span class="wd"></span></div>` +
+ `<div class="win" data-w="noun"><span class="wl">NOUN</span><span class="wd"></span></div>` +
+ `</div><div class="pad"></div></div>`;
+ const win = w => el.querySelector(`[data-w="${w}"]`);
+ const setWin = (w, txt) => { win(w).querySelector('.wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); };
+ const lampEl = l => el.querySelector(`[data-l="${l}"]`);
+ let mode = null, vals = { prog: ' ', verb: ' ', noun: ' ' }, entry = '';
+ const KEYS = ['VERB', 'NOUN', 'CLR', 'ENTR', 'RSET', '7', '8', '9', '4', '5', '6', '1', '2', '3', '0'];
+ const pad = el.querySelector('.pad');
+ const hot = () => { el.querySelectorAll('.win').forEach(w => w.classList.toggle('hot', w.dataset.w === mode)); };
+ const oprErr = () => { lampEl('OPR ERR').classList.add('on'); setTimeout(() => lampEl('OPR ERR').classList.remove('on'), 1200); onChange(vals, 'OPR ERR'); };
+ const commit = () => {
+ if (vals.verb.trim().length < 2) { oprErr(); return; }
+ const v = vals.verb, n = vals.noun.trim();
+ if (v === '35') {
+ LAMPS.forEach(l => lampEl(l).classList.add('on'));
+ setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('on')), 1400);
+ onChange(vals, 'V35 · lamp test');
+ }
+ else if (v === '16' && n === '36') { vals.prog = '16'; setWin('prog', vals.prog); onChange(vals, 'V16 N36 · monitor clock'); }
+ else onChange(vals, 'V' + v + (n ? ' N' + n : '') + ' ENTR');
+ vals.verb = ' '; vals.noun = ' '; entry = ''; mode = null; hot();
+ };
+ KEYS.forEach(k => {
+ const b = document.createElement('button'); b.className = 'key'; b.textContent = k;
+ b.addEventListener('click', () => {
+ if (k === 'VERB' || k === 'NOUN') { mode = k.toLowerCase(); entry = ''; vals[mode] = ' '; setWin(mode, ' '); hot(); }
+ else if (k === 'CLR') { if (mode) { vals[mode] = ' '; entry = ''; setWin(mode, ' '); } }
+ else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('on')); onChange(vals, 'RSET'); }
+ else if (k === 'ENTR') commit();
+ else if (/\d/.test(k)) {
+ if (!mode) { oprErr(); return; }
+ entry = (entry + k).slice(0, 2); vals[mode] = entry.padEnd(2, ' '); setWin(mode, vals[mode]);
+ }
+ });
+ pad.appendChild(b);
+ });
+ vals.prog = '00'; setWin('prog', vals.prog); setWin('verb', ' '); setWin('noun', ' ');
+ onChange(vals, 'VERB ## ENTR');
+ return { el, get: () => ({ ...vals }) };
+};
+
+/* R42 cam-timer program drum — the program is a ring; the pointer self-advances through it.
+ Runs its own reduced-motion-gated 2 s step interval once started. */
+GW.camTimer = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const STEPS = opts.steps || ['OFF', 'FILL', 'WASH', 'WASH', 'WASH', 'RINSE', 'RINSE', 'DRAIN', 'SPIN', 'SPIN', 'FLUFF', 'COOL'];
+ const COLS = opts.colors || { OFF: '#3a3426', FILL: '#54677d', WASH: '#d29638', RINSE: '#46b89e', DRAIN: '#969385', SPIN: '#cb6b4d', FLUFF: '#c9b98a', COOL: '#7c99b0' };
+ const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
+ svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
+ STEPS.forEach((st, i) => {
+ const a0 = i * 30 - 90, a1 = a0 + 30;
+ const p0o = polar(cx, cy, 52, a0 + 90), p1o = polar(cx, cy, 52, a1 + 90), p0i = polar(cx, cy, 38, a0 + 90), p1i = polar(cx, cy, 38, a1 + 90);
+ svgEl(s, 'path', { d: `M ${p0i[0]} ${p0i[1]} L ${p0o[0]} ${p0o[1]} A 52 52 0 0 1 ${p1o[0]} ${p1o[1]} L ${p1i[0]} ${p1i[1]} A 38 38 0 0 0 ${p0i[0]} ${p0i[1]} Z`, fill: COLS[st], opacity: .75, stroke: '#0a0908', 'stroke-width': .8 });
+ const [tx, ty] = polar(cx, cy, 45, i * 30 + 15);
+ svgEl(s, 'text', { x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.4, 'font-weight': 700, 'font-family': 'var(--mono)', fill: st === 'OFF' ? 'var(--cream)' : '#14110e' }).textContent = st === 'OFF' ? 'OFF' : st[0];
+ });
+ svgEl(s, 'circle', { cx, cy, r: 34, fill: '#211e1a', stroke: '#0a0908', 'stroke-width': 1.5 });
+ STEPS.forEach((st, i) => {
+ if (i === 0) return; const [lx, ly] = polar(cx, cy, 27, i * 30 + 15);
+ svgEl(s, 'text', { x: lx, y: ly + 1.8, 'text-anchor': 'middle', 'font-size': 4.2, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = st;
+ });
+ svgEl(s, 'circle', { cx, cy, r: 17, fill: '#14110e', stroke: '#2c2820', 'stroke-width': 1.5 });
+ const ptr = svgEl(s, 'polygon', { points: `${cx - 2.5},${cy} ${cx + 2.5},${cy} ${cx},${cy - 31}`, fill: 'var(--gold-hi)', filter: 'drop-shadow(0 0 3px rgba(var(--glow-lo),.7))' });
+ svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
+ let pos;
+ const set = i => {
+ pos = i;
+ ptr.setAttribute('transform', `rotate(${i * 30 + 15},${cx},${cy})`);
+ onChange(pos, pos === 0 ? 'OFF' : 'STEP ' + pos + ' · ' + STEPS[pos]);
+ };
+ s.style.cursor = 'pointer';
+ s.addEventListener('click', () => set((pos + 1) % STEPS.length));
+ set(opts.position !== undefined ? opts.position : 0);
+ if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
+ setInterval(() => { if (pos > 0) { const n = (pos + 1) % STEPS.length; set(n); if (n === 0) onChange(0, 'CYCLE DONE · OFF'); } }, 2000);
+ return { el: s, get: () => pos, set };
+};
+
+/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw */
+GW.knifeSwitch = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 130, 110);
+ gradDef('ksCu', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d09a5c'], ['1', '#8a5a2a']]);
+ const HX = 32, HY = 74; /* hinge pivot */
+ svgEl(s, 'rect', { x: 6, y: 56, width: 118, height: 44, rx: 6, fill: '#22262a', stroke: '#0c0d0e', 'stroke-width': 1.5 });
+ const lamp = svgEl(s, 'circle', { cx: 116, cy: 22, r: 4.5, fill: '#1e2a12' });
+ svgEl(s, 'text', { x: 116, y: 36, 'text-anchor': 'middle', 'font-size': 4.8, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'LIVE';
+ /* hinge post (left) and jaw clip (right), both on the base */
+ svgEl(s, 'rect', { x: HX - 6, y: HY - 6, width: 12, height: 16, rx: 2, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .8 });
+ svgEl(s, 'rect', { x: 96, y: HY - 9, width: 5, height: 18, rx: 1.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .7 });
+ svgEl(s, 'rect', { x: 104, y: HY - 9, width: 5, height: 18, rx: 1.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .7 });
+ /* blade + upright handle, hinged at (HX,HY) */
+ const blade = svgEl(s, 'g', {});
+ svgEl(blade, 'rect', { x: HX, y: HY - 3, width: 71, height: 6, rx: 2.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .8 });
+ svgEl(blade, 'rect', { x: 88, y: HY - 26, width: 7, height: 26, rx: 3, fill: '#14110e', stroke: '#000', 'stroke-width': 1 });
+ svgEl(blade, 'circle', { cx: 91.5, cy: HY - 26, r: 5, fill: '#14110e', stroke: '#000', 'stroke-width': 1 });
+ svgEl(s, 'circle', { cx: HX, cy: HY, r: 3, fill: '#5c3a18' });
+ blade.style.transformBox = 'view-box'; blade.style.transformOrigin = `${HX}px ${HY}px`;
+ blade.style.transition = 'transform .28s ease';
+ let closed;
+ const set = v => {
+ closed = !!v;
+ blade.style.transform = closed ? 'rotate(0deg)' : 'rotate(-46deg)';
+ lamp.setAttribute('fill', closed ? 'var(--pass)' : '#1e2a12');
+ lamp.setAttribute('filter', closed ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' : 'none');
+ onChange(closed, closed ? 'CLOSED · live' : 'OPEN · visibly dead');
+ };
+ s.style.cursor = 'pointer';
+ s.addEventListener('click', () => set(!closed));
+ set(opts.closed !== undefined ? opts.closed : true);
+ return { el: s, get: () => closed, set };
+};
+
+/* R49 decade box — four skirted knobs, one digit each; the value is their sum */
+GW.decadeBox = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const MUL = [1000, 100, 10, 1], LBL = ['x1000', 'x100', 'x10', 'x1'];
+ const digs = (opts.digits || [3, 5, 0, 0]).slice();
+ const s = stageSvg(host, 'rsvg', 160, 100);
+ svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
+ const wins = [], knobs = [];
+ const total = () => digs.reduce((a, d, i) => a + d * MUL[i], 0);
+ const draw = () => {
+ wins.forEach((w, i) => w.textContent = String(digs[i]));
+ knobs.forEach((k, i) => k.setAttribute('transform', `rotate(${digs[i] * 36},${28 + i * 35},58)`));
+ onChange(total(), total().toLocaleString('en-US') + ' Ω');
+ };
+ MUL.forEach((_, i) => {
+ const cx = 28 + i * 35, cy = 58;
+ svgEl(s, 'rect', { x: cx - 8, y: 16, width: 16, height: 12, rx: 2, fill: '#0a0806', stroke: '#2c2820', 'stroke-width': 1 });
+ wins.push(svgEl(s, 'text', { x: cx, y: 25.5, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }));
+ svgEl(s, 'circle', { cx, cy, r: 15, fill: '#211e1a', stroke: '#0a0908', 'stroke-width': 1.5 }); /* skirt */
+ for (let t = 0; t < 10; t++) {
+ const [x1, y1] = polar(cx, cy, 14, t * 36), [x2, y2] = polar(cx, cy, 12, t * 36);
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--steel)', 'stroke-width': .6, opacity: .7 });
+ }
+ const k = svgEl(s, 'g', {});
+ svgEl(k, 'circle', { cx, cy, r: 10, fill: '#2c2824', stroke: '#060505', 'stroke-width': 1 });
+ svgEl(k, 'line', { x1: cx, y1: cy - 9, x2: cx, y2: cy - 4, stroke: 'var(--gold-hi)', 'stroke-width': 1.8 });
+ knobs.push(k);
+ svgEl(s, 'text', { x: cx, y: 88, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = LBL[i];
+ const hit = svgEl(s, 'circle', { cx, cy, r: 16, fill: 'transparent' });
+ hit.style.cursor = 'ns-resize';
+ dragDelta(hit, () => digs[i], v => { digs[i] = Math.round(Math.max(0, Math.min(9, v))); draw(); }, { min: 0, max: 9, sens: 9 / 70 });
+ });
+ draw();
+ return { el: s, get: () => total() };
+};
+
+/* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle */
+GW.twoHandSafety = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 160, 100);
+ svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
+ const runLamp = svgEl(s, 'circle', { cx: 80, cy: 24, r: 5.5, fill: '#1e2a12' });
+ svgEl(s, 'text', { x: 80, y: 40, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'CYCLE';
+ const mkBtn = (cx, lbl) => {
+ const g = svgEl(s, 'g', {});
+ svgEl(g, 'ellipse', { cx, cy: 70, rx: 19, ry: 6, fill: '#0d0b09' });
+ const cap = svgEl(g, 'circle', { cx, cy: 62, r: 16, fill: '#8f2416', stroke: '#5c150c', 'stroke-width': 1.5 });
+ svgEl(g, 'ellipse', { cx: cx - 5, cy: 56, rx: 6, ry: 3.4, fill: 'rgba(255,255,255,.22)' });
+ svgEl(s, 'text', { x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = lbl;
+ g.style.cursor = 'pointer'; return { g, cap };
+ };
+ const L = mkBtn(32, 'LEFT PALM'), R = mkBtn(128, 'RIGHT PALM');
+ let armed = null, armT = null, busy = false;
+ const flash = cap => { cap.setAttribute('fill', '#e0523a'); setTimeout(() => cap.setAttribute('fill', '#8f2416'), 200); };
+ const fault = () => { armed = null; clearTimeout(armT); onChange('fault', 'TIE-DOWN FAULT · release and retry'); };
+ const press = side => {
+ if (busy) return;
+ if (armed === null) {
+ armed = side; flash(side === 'L' ? L.cap : R.cap);
+ onChange('armed', side + ' armed · other hand within 0.5s');
+ armT = setTimeout(fault, 500); return;
+ }
+ if (armed === side) { fault(); return; } /* same hand twice = tie-down */
+ clearTimeout(armT); armed = null; busy = true;
+ flash(side === 'L' ? L.cap : R.cap);
+ runLamp.setAttribute('fill', 'var(--pass)');
+ runLamp.setAttribute('filter', 'drop-shadow(0 0 5px rgba(116,147,47,.8))');
+ onChange('running', 'CYCLE RUNNING');
+ setTimeout(() => {
+ runLamp.setAttribute('fill', '#1e2a12'); runLamp.removeAttribute('filter');
+ busy = false; onChange('ready', 'ready');
+ }, 1500);
+ };
+ L.g.addEventListener('click', () => press('L'));
+ R.g.addEventListener('click', () => press('R'));
+ onChange('ready', 'ready');
+ return { el: s, press };
+};
+
+/* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker */
+GW.voiceLoop = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const LOOPS = opts.loops || ['FD', 'GNC', 'ECOM', 'SURG', 'A/G', 'NET1', 'NET2', 'PAO'];
+ const el = document.createElement('div'); el.className = 'vloop'; host.appendChild(el);
+ const keys = LOOPS.map(l => {
+ const k = document.createElement('div'); k.className = 'vk';
+ k.innerHTML = l + '<span class="bar"></span>'; k.dataset.state = '0'; el.appendChild(k); return k;
+ });
+ /* a legible default: FD + A/G monitored, GNC talking */
+ keys[0].dataset.state = '1'; keys[0].classList.add('mon');
+ keys[4].dataset.state = '1'; keys[4].classList.add('mon');
+ keys[1].dataset.state = '2'; keys[1].classList.add('mon', 'tlk');
+ const refresh = () => {
+ const mon = keys.filter(k => k.dataset.state !== '0').length;
+ const tlk = keys.findIndex(k => k.dataset.state === '2');
+ onChange(keys.map(k => k.dataset.state), mon === 0 ? 'all loops dropped' : mon + ' monitored' + (tlk >= 0 ? ' · talk ' + LOOPS[tlk] : ''));
+ };
+ keys.forEach(k => k.addEventListener('click', () => {
+ const st = k.dataset.state;
+ if (st === '0') { k.dataset.state = '1'; k.classList.add('mon'); }
+ else if (st === '1') {
+ keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('tlk'); } });
+ k.dataset.state = '2'; k.classList.add('tlk');
+ }
+ else { k.dataset.state = '0'; k.classList.remove('mon', 'tlk', 'act'); }
+ refresh();
+ }));
+ if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
+ setInterval(() => {
+ const t = performance.now() / 1000;
+ keys.forEach((k, i) => {
+ if (k.dataset.state === '0') { k.classList.remove('act'); return; }
+ k.classList.toggle('act', Math.sin(t * (1.1 + i * 0.37) + i * 2.1) > 0.55);
+ });
+ }, 300);
+ refresh();
+ return { el, get: () => keys.map(k => k.dataset.state) };
+};
+
+/* ---- meters & gauges ----
+ Tick contract: the page owns the clock and the signal; live meters expose
+ value-driven handles (set/push) that repaint synchronously and fire
+ onChange(value, text) like every other builder. Display-side state that
+ belongs to the instrument (peak-hold, history buffers) lives in here. */
+
+/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc */
+GW.needleGauge = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let v = opts.value ?? 50;
+ const el = document.createElement('div'); el.className = 'gauge';
+ el.innerHTML = `<div class="dial"><div class="arc"></div>
+ <div class="tk" style="transform:rotate(-60deg)"></div><div class="tk" style="transform:rotate(0)"></div><div class="tk" style="transform:rotate(60deg)"></div>
+ <div class="ndl"></div><div class="hub"></div></div><div class="gv"><span>0</span>%</div>`;
+ host.appendChild(el);
+ const ndl = el.querySelector('.ndl'), num = el.querySelector('.gv span');
+ function set(nv) {
+ v = nv;
+ ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`;
+ num.textContent = Math.round(v);
+ onChange(v, 'value ' + Math.round(v) + '%');
+ }
+ dragDelta(el, () => v, set, { min: 0, max: 100 });
+ set(v);
+ return { el, get: () => v, set };
+};
+
+/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both */
+GW.vuPair = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const n = opts.bars || 16;
+ const el = document.createElement('div'); el.className = 'vu';
+ el.innerHTML = `<div class="vurow"><span class="ch">L</span><span class="vubar"></span></div>
+ <div class="vurow"><span class="ch">R</span><span class="vubar"></span></div>`;
+ host.appendChild(el);
+ const bars = el.querySelectorAll('.vubar');
+ bars.forEach(b => buildBars(b, n));
+ const pkL = { v: 0 }, pkR = { v: 0 };
+ function paint(bar, l, pk) {
+ const b = bar.children, lit = Math.round(l * n);
+ pk.v = Math.max(lit, (pk.v || 0) - 0.4); const p = Math.round(pk.v);
+ for (let k = 0; k < n; k++) {
+ let c = k < lit ? (k >= n - 2 ? 'clip' : k >= n - 4 ? 'hot' : 'on') : '';
+ if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'peak';
+ b[k].className = c;
+ }
+ }
+ let lv = 0, rv = 0;
+ function set(l, r) {
+ lv = l; rv = r;
+ paint(bars[0], l, pkL); paint(bars[1], r, pkR);
+ onChange([l, r], 'L ' + Math.round(l * 100) + ' · R ' + Math.round(r * 100));
+ }
+ return { el, get: () => [lv, rv], set };
+};
+
+/* 12 mini 4-bar signal — compact activity meter; set(level) */
+GW.miniSig = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const el = document.createElement('span'); el.className = 'sig';
+ el.innerHTML = '<i></i><i></i><i></i><i></i>';
+ host.appendChild(el);
+ let v = 0;
+ function set(l) {
+ v = l;
+ const b = el.children, lit = Math.round(l * 4);
+ for (let k = 0; k < 4; k++) b[k].className = k < lit ? (k >= 3 ? 'clip' : k >= 2 ? 'hot' : 'on') : '';
+ onChange(l, Math.round(l * 100) + '%');
+ }
+ return { el, get: () => v, set };
+};
+
+/* 13 signal ladder — stepped 0-4 strength; click cycles */
+GW.signalLadder = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let v = opts.value ?? 3;
+ const el = document.createElement('span'); el.className = 'ladder';
+ el.innerHTML = '<i></i><i></i><i></i><i></i>';
+ host.appendChild(el);
+ function set(nv) {
+ v = nv;
+ const bars = el.children;
+ for (let i = 0; i < bars.length; i++) bars[i].style.background = i < v ? 'var(--gold)' : 'var(--wash)';
+ onChange(v, v + '/4');
+ }
+ el.onclick = () => set((v + 1) % 5);
+ set(v);
+ return { el, get: () => v, set };
+};
+
+/* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set */
+GW.fuelBar = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const warnAt = opts.warnAt ?? 20;
+ let v = opts.value ?? 50;
+ const el = document.createElement('div'); el.className = 'bar';
+ el.innerHTML = '<span></span>';
+ host.appendChild(el);
+ const fill = el.querySelector('span');
+ function set(p) {
+ v = p;
+ fill.style.width = p + '%';
+ el.classList.toggle('warn', p < warnAt);
+ onChange(p, Math.round(p) + '%');
+ }
+ dragX(el, set);
+ set(v);
+ return { el, get: () => v, set };
+};
+
+/* 15 radial ring — percentage donut; drag up/down to set */
+GW.radialRing = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let v = opts.value ?? 68;
+ const el = document.createElement('span'); el.className = 'ring';
+ el.innerHTML = '<b></b>';
+ host.appendChild(el);
+ const num = el.querySelector('b');
+ function set(nv) {
+ v = nv;
+ el.style.setProperty('--p', v);
+ num.textContent = Math.round(v);
+ onChange(v, Math.round(v) + '%');
+ }
+ dragDelta(el, () => v, set, { min: 0, max: 100 });
+ set(v);
+ return { el, get: () => v, set };
+};
+
+/* 16 sparkline — rolling history trace; push(v) appends a sample, fill(v) levels it */
+GW.sparkline = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const n = opts.samples || 40;
+ const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
+ const el = document.createElement('span'); el.className = 'spark';
+ el.innerHTML = '<svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg>';
+ host.appendChild(el);
+ const line = el.querySelector('polyline');
+ const clamp = x => Math.max(0, Math.min(1, x));
+ function paint() {
+ line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 170},${44 - clamp(v) * 40 - 2}`).join(' '));
+ onChange(hist[n - 1], String(Math.round(clamp(hist[n - 1]) * 100)));
+ }
+ function push(v) { hist.push(v); hist.shift(); paint(); }
+ function fill(v) { hist.fill(v); paint(); }
+ return { el, get: () => hist[n - 1], push, fill };
+};
+
+/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1 */
+GW.waveStrip = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const el = document.createElement('span'); el.className = 'wave';
+ el.innerHTML = '<svg viewBox="0 0 170 38" preserveAspectRatio="none"><path fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg>';
+ host.appendChild(el);
+ const path = el.querySelector('path');
+ let amp = 0;
+ function set(samples, a) {
+ amp = a;
+ let d = 'M0 19';
+ if (samples.length < 2) d += ' L170 19';
+ else for (let i = 0; i < samples.length; i++)
+ d += ` L${(i / (samples.length - 1) * 170).toFixed(1)} ${(19 + samples[i] * 14).toFixed(1)}`;
+ path.setAttribute('d', d);
+ onChange(amp, 'amp ' + Math.round(amp * 100) + '%');
+ }
+ set([], opts.amp ?? 0.6);
+ return { el, get: () => amp, set };
+};
+
+/* N11 oscilloscope — sampled phosphor trace; set(samples, vpp) with samples in -1..1 */
+GW.scope = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const el = document.createElement('span'); el.className = 'scope';
+ el.innerHTML = '<span class="grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>';
+ host.appendChild(el);
+ const line = el.querySelector('polyline');
+ let vpp = 0;
+ function set(samples, v) {
+ vpp = v;
+ const n = samples.length;
+ line.setAttribute('points', samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + s * 22).toFixed(1)}`).join(' '));
+ onChange(vpp, 'Vpp ' + Math.round(vpp * 100));
+ }
+ return { el, get: () => vpp, set };
+};
+
+/* N12 spectrum / EQ bars — set(values) paints one column per band */
+GW.eqBars = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const bands = opts.bands || 11, cells = opts.cells || 9;
+ const el = document.createElement('span'); el.className = 'eq';
+ host.appendChild(el);
+ for (let b = 0; b < bands; b++) {
+ const band = document.createElement('span'); band.className = 'band';
+ for (let s = 0; s < cells; s++) band.appendChild(document.createElement('i'));
+ el.appendChild(band);
+ }
+ let vals = [];
+ function set(values) {
+ vals = values;
+ let peak = 0;
+ for (let b = 0; b < bands; b++) {
+ const col = el.children[b].children, val = values[b], lit = Math.round(val * cells);
+ peak = Math.max(peak, val);
+ for (let k = 0; k < cells; k++) {
+ let c = '';
+ if (k < lit) c = (k >= cells - 1 ? 'clip' : k >= cells - 3 ? 'hot' : 'on');
+ col[k].className = c;
+ }
+ }
+ onChange(vals, 'peak ' + Math.round(peak * 100) + '%');
+ }
+ return { el, get: () => vals, set };
+};
+
+/* N13 crossed-needle meter — one drive value, FWD and RFL needles cross */
+GW.crossNeedle = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let v = opts.value ?? 55;
+ const el = document.createElement('div'); el.className = 'crossm';
+ el.innerHTML = `<div class="face"><div class="arc"></div><div class="nA"></div><div class="nB"></div></div>
+ <div class="lbl"><span>FWD</span><span>RFL</span></div>`;
+ host.appendChild(el);
+ const face = el.querySelector('.face'), nA = el.querySelector('.nA'), nB = el.querySelector('.nB');
+ function set(nv) {
+ v = nv;
+ const fwd = v, rfl = v * 0.68;
+ nA.style.transform = `rotate(${-42 + fwd / 100 * 84}deg)`;
+ nB.style.transform = `rotate(${42 - rfl / 100 * 84}deg)`;
+ onChange(v, 'FWD ' + Math.round(fwd) + ' · RFL ' + Math.round(rfl));
+ }
+ dragDelta(face, () => v, set, { min: 0, max: 100 });
+ set(v);
+ return { el, get: () => v, set };
+};
+
+/* N14 thermometer column — mercury rises with the value */
+GW.thermometer = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let v = opts.value ?? 58;
+ const el = document.createElement('div'); el.className = 'thermo';
+ el.innerHTML = `<div class="scale"><span>90</span><span>60</span><span>30</span></div>
+ <div class="wrapcol"><div class="tube"><div class="fill"></div></div><div class="bulb"></div></div>`;
+ host.appendChild(el);
+ const fill = el.querySelector('.fill');
+ function set(nv) {
+ v = nv;
+ fill.style.height = v + '%';
+ onChange(v, Math.round(30 + v / 100 * 60) + '°');
+ }
+ dragDelta(el, () => v, set, { min: 0, max: 100 });
+ set(v);
+ return { el, get: () => v, set };
+};
+
+/* N15 bourdon pressure gauge — needle over a printed arc with a redline */
+GW.bourdon = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let v = opts.value ?? 45;
+ const el = document.createElement('div'); el.className = 'bourdon';
+ el.innerHTML = `<svg viewBox="0 0 82 82"><path d="M14 62 A34 34 0 0 1 68 62" fill="none" stroke="#2c2f32" stroke-width="3"/>
+ <path d="M52 22 A34 34 0 0 1 68 62" fill="none" stroke="#cb6b4d" stroke-width="3"/></svg>
+ <div class="ndl"></div><div class="hub"></div><div class="cap">PSI</div>`;
+ host.appendChild(el);
+ const ndl = el.querySelector('.ndl');
+ function set(nv) {
+ v = nv;
+ ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`;
+ onChange(v, Math.round(v / 100 * 160) + ' PSI');
+ }
+ dragDelta(el, () => v, set, { min: 0, max: 100 });
+ set(v);
+ return { el, get: () => v, set };
+};
+
+/* N16 strip-chart recorder — scrolling history, pen rides the newest value;
+ push(v) appends, set(samples, current) replaces the trace (values 0..1) */
+GW.stripChart = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const n = opts.samples || 60;
+ const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
+ const el = document.createElement('span'); el.className = 'strip';
+ el.innerHTML = '<span class="rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline/></svg><span class="pen" style="top:31px"></span>';
+ host.appendChild(el);
+ const line = el.querySelector('polyline'), pen = el.querySelector('.pen');
+ const clamp = x => Math.max(0, Math.min(1, x));
+ const yPx = v => 59 - clamp(v) * 56;
+ function paint(current) {
+ line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 176},${yPx(v).toFixed(1)}`).join(' '));
+ pen.style.top = yPx(current) + 'px';
+ onChange(current, String(Math.round(clamp(current) * 100)));
+ }
+ function push(v) { hist.push(v); hist.shift(); paint(v); }
+ function set(samples, current) {
+ for (let i = 0; i < n; i++) hist[i] = samples[i] ?? 0.5;
+ paint(current ?? hist[n - 1]);
+ }
+ return { el, get: () => hist[n - 1], push, set };
+};
+
+/* N17 correlation meter — needle rests at 0 and swings ±; drag left/right */
+GW.corrMeter = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let p = opts.value ?? 58;
+ const el = document.createElement('div'); el.className = 'corr';
+ el.innerHTML = `<div class="face"><div class="arc"></div><div class="zero"></div><div class="ndl"></div></div>
+ <div class="lbl"><span>−1</span><span>0</span><span>+1</span></div>`;
+ host.appendChild(el);
+ const ndl = el.querySelector('.ndl');
+ function set(np) {
+ p = np;
+ const v = (p - 50) / 50;
+ ndl.style.transform = `rotate(${v * 38}deg)`;
+ onChange(v, (v >= 0 ? '+' : '') + v.toFixed(2));
+ }
+ dragX(el, set);
+ set(p);
+ return { el, get: () => p, set };
+};
+
+/* N18 battery-cell gauge — charge as discrete cells, the low end warns */
+GW.battCells = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const cells = opts.cells || 8, warnAt = opts.warnAt ?? 25;
+ let p = opts.value ?? 62;
+ const el = document.createElement('div'); el.className = 'batt';
+ el.innerHTML = '<div class="cells"></div><span class="nub"></span>';
+ host.appendChild(el);
+ const cc = el.querySelector('.cells');
+ for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'cell'; cc.appendChild(c); }
+ function set(np) {
+ p = np;
+ const lit = Math.round(p / 100 * cells);
+ for (let i = 0; i < cells; i++) cc.children[i].className = 'cell' + (i < lit ? ' on' : '') + ((p <= warnAt && i < lit) ? ' warn' : '');
+ onChange(p, Math.round(p) + '%');
+ }
+ dragX(el, set);
+ set(p);
+ return { el, get: () => p, set };
+};
+
+/* R01 moving-coil VU — pivot below the face, authentic nonlinear dB scale;
+ set(t) positions instantly (0..1.02); ballistics belong to the signal owner */
+GW.mcVu = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg', 150, 96);
+ const cx = 75, cy = 112, sweep = t => -43 + t * 86;
+ const glassId = uid('vuGlass'), clipId = uid('vuFace');
+ const defs = svgEl(s, 'defs', {});
+ const g1 = svgEl(defs, 'linearGradient', { id: glassId, x1: 0, y1: 0, x2: 0, y2: 1 });
+ svgEl(g1, 'stop', { offset: '0', 'stop-color': 'rgba(255,255,255,.13)' });
+ svgEl(g1, 'stop', { offset: '.35', 'stop-color': 'rgba(255,255,255,0)' });
+ const clip = svgEl(defs, 'clipPath', { id: clipId });
+ svgEl(clip, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3 });
+ svgEl(s, 'rect', { x: .5, y: .5, width: 149, height: 95, rx: 7, fill: '#16130f', stroke: '#060505' });
+ svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: 'var(--cream)' });
+ const face = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
+ const arcPt = t => polar(cx, cy, 58, sweep(t));
+ const [ax1, ay1] = arcPt(0), [ax2, ay2] = arcPt(1);
+ svgEl(face, 'path', { d: `M ${ax1} ${ay1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.2 });
+ const [rx1, ry1] = arcPt(.75);
+ svgEl(face, 'path', { d: `M ${rx1} ${ry1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3.5 });
+ for (const [db, t] of VUDB) {
+ const a = sweep(t), [x1, y1] = polar(cx, cy, 55, a), [x2, y2] = polar(cx, cy, 63, a);
+ svgEl(face, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1.1 });
+ }
+ for (const [db, t] of VUDB) {
+ if (![-20, -10, -5, -3, 0, 1, 2, 3].includes(db)) continue;
+ const [x, y] = polar(cx, cy, 69, sweep(t));
+ svgEl(face, 'text', {
+ x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.8, 'font-family': 'var(--mono)',
+ fill: db > 0 ? 'var(--fail)' : '#3a3128'
+ }).textContent = Math.abs(db);
+ }
+ svgEl(face, 'text', {
+ x: 75, y: 78, 'text-anchor': 'middle', 'font-size': 11, 'font-weight': 700,
+ 'font-family': 'var(--mono)', fill: '#3a3128'
+ }).textContent = 'VU';
+ const needle = svgEl(face, 'line', {
+ x1: cx, y1: cy, x2: cx, y2: cy - 62, stroke: '#1a1613', 'stroke-width': 1.6,
+ transform: `rotate(${sweep(.35)},${cx},${cy})`
+ });
+ svgEl(face, 'circle', { cx, cy: 88, r: 9, fill: '#16130f' });
+ svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: `url(#${glassId})` });
+ let t = opts.value ?? .35;
+ function set(nt) {
+ t = Math.max(0, Math.min(1.02, nt));
+ needle.setAttribute('transform', `rotate(${sweep(Math.min(1, t))},${cx},${cy})`);
+ const db = vuDb(Math.min(1, t));
+ onChange(t, (db > 0 ? '+' : '') + db.toFixed(1) + ' VU');
+ }
+ return { el: s, get: () => t, set };
+};
+
+/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down */
+GW.roundMeter = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg drag', 110, 104);
+ const cx = 55, fy = 54, py = 74, sweep = t => -40 + t * 80;
+ const clipId = uid('rndFace');
+ const defs = svgEl(s, 'defs', {});
+ const clip = svgEl(defs, 'clipPath', { id: clipId });
+ svgEl(clip, 'circle', { cx, cy: fy, r: 40 });
+ svgEl(s, 'circle', { cx, cy: fy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy: fy, r: 40, fill: 'var(--cream)' });
+ const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
+ const pt = (r, t) => polar(cx, py, r, sweep(t));
+ const [ax1, ay1] = pt(44, 0), [ax2, ay2] = pt(44, 1);
+ svgEl(g, 'path', { d: `M ${ax1} ${ay1} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.1 });
+ const [rzx, rzy] = pt(44, .75);
+ svgEl(g, 'path', { d: `M ${rzx} ${rzy} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3 });
+ for (const [db, t] of VUDB) {
+ const [x1, y1] = pt(41.5, t), [x2, y2] = pt(47, t);
+ svgEl(g, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1 });
+ }
+ for (const [db, t] of VUDB) {
+ if (![-20, -10, -5, -3, 0, 3].includes(db)) continue;
+ const [x, y] = pt(52, t);
+ svgEl(g, 'text', {
+ x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)',
+ fill: db > 0 ? 'var(--fail)' : '#3a3128'
+ }).textContent = Math.abs(db);
+ }
+ svgEl(g, 'text', {
+ x: cx, y: fy + 14, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700,
+ 'font-family': 'var(--mono)', fill: '#3a3128'
+ }).textContent = 'dB';
+ const needle = svgEl(g, 'line', {
+ x1: cx, y1: py, x2: cx, y2: py - 46, stroke: '#1a1613', 'stroke-width': 1.5,
+ transform: `rotate(0,${cx},${py})`
+ });
+ svgEl(g, 'circle', { cx, cy: py, r: 5, fill: '#141110' });
+ svgEl(s, 'ellipse', { cx: cx - 8, cy: fy - 22, rx: 20, ry: 9, fill: 'rgba(255,255,255,.07)', transform: `rotate(-18,${cx - 8},${fy - 22})` });
+ let v = opts.value ?? 50;
+ function set(nv) {
+ v = nv;
+ const t = Math.max(0, Math.min(1, v / 100));
+ needle.setAttribute('transform', `rotate(${-40 + t * 80},${cx},${py})`);
+ const db = vuDb(t);
+ onChange(v, (db > 0 ? '+' : '') + db.toFixed(1) + ' dB');
+ }
+ dragDelta(s, () => v, set, { min: 0, max: 100 });
+ set(v);
+ return { el: s, get: () => v, set };
+};
+
+/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome */
+GW.chromeMinMax = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg drag', 110, 104);
+ const cx = 55, cy = 58;
+ gradDef('chrRing', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#eceef0'], ['.5', '#9aa0a8'], ['1', '#54585e']]);
+ gradDef('chrDome', 'radialGradient', { cx: '48%', cy: '20%', r: '85%' }, [['0', '#e6e8ec'], ['1', '#82878f']]);
+ svgEl(s, 'circle', { cx, cy: 54, r: 47, fill: 'url(#chrRing)' });
+ svgEl(s, 'circle', { cx, cy: 54, r: 40, fill: '#101214', stroke: '#2c2f33', 'stroke-width': 1 });
+ svgEl(s, 'path', { d: `M 21 60 A 34 34 0 0 1 89 60 Z`, fill: 'url(#chrDome)', stroke: '#55595f', 'stroke-width': .5 });
+ for (const r of [14, 22, 30])
+ svgEl(s, 'path', { d: `M ${cx - r} 60 A ${r} ${r} 0 0 1 ${cx + r} 60`, fill: 'none', stroke: 'rgba(255,255,255,.13)', 'stroke-width': .7 });
+ const ptr = svgEl(s, 'line', { x1: cx, y1: cy - 10, x2: cx, y2: cy - 31, stroke: '#17191b', 'stroke-width': 2.2, 'stroke-linecap': 'round' });
+ svgEl(s, 'circle', { cx, cy, r: 6, fill: '#23262a' });
+ for (let k = 0; k < 8; k++) {
+ const [x, y] = polar(cx, cy, 6, k * 45);
+ svgEl(s, 'circle', { cx: x, cy: y, r: 1.3, fill: '#23262a' });
+ }
+ svgEl(s, 'circle', { cx, cy, r: 1.6, fill: '#0c0d0e' });
+ svgEl(s, 'text', {
+ x: 24, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em',
+ 'font-family': 'var(--mono)', fill: '#d7dade'
+ }).textContent = 'MIN';
+ svgEl(s, 'text', {
+ x: 86, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em',
+ 'font-family': 'var(--mono)', fill: '#d7dade'
+ }).textContent = 'MAX';
+ svgEl(s, 'text', {
+ x: cx, y: 84, 'text-anchor': 'middle', 'font-size': 7, 'font-style': 'italic',
+ 'font-family': 'var(--mono)', fill: '#787c82'
+ }).textContent = 'dupre.';
+ svgEl(s, 'ellipse', { cx: 47, cy: 34, rx: 22, ry: 9, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-16,47,34)' });
+ let v = opts.value ?? 50;
+ function set(nv) {
+ v = Math.max(0, Math.min(100, nv));
+ ptr.setAttribute('transform', `rotate(${-55 + v / 100 * 110},${cx},${cy})`);
+ onChange(v, v <= 2 ? 'MIN' : v >= 98 ? 'MAX' : Math.round(v) + '%');
+ }
+ dragDelta(s, () => v, set, { min: 0, max: 100 });
+ set(v);
+ return { el: s, get: () => v, set };
+};
+
+/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme */
+GW.aviationGauge = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg drag', 110, 104);
+ const cx = 55, cy = 52, sweep = t => -135 + t * 270;
+ def('avGlow', d => {
+ const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
+ svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
+ });
+ svgEl(s, 'circle', { cx, cy, r: 47, fill: '#1b1917', stroke: '#060505', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy, r: 41, fill: '#0a0908', stroke: '#3a352c', 'stroke-width': 1 });
+ const zone = (t1, t2, color) => {
+ const [x1, y1] = polar(cx, cy, 37, sweep(t1)), [x2, y2] = polar(cx, cy, 37, sweep(t2));
+ svgEl(s, 'path', { d: `M ${x1} ${y1} A 37 37 0 ${(t2 - t1) > .5 ? 1 : 0} 1 ${x2} ${y2}`, fill: 'none', stroke: color, 'stroke-width': 3, opacity: .85 });
+ };
+ zone(.15, .6, 'var(--pass)'); zone(.6, .85, 'var(--gold)'); zone(.85, 1, 'var(--fail)');
+ for (let i = 0; i <= 40; i++) {
+ const t = i / 40, major = i % 5 === 0, a = sweep(t);
+ const [x1, y1] = polar(cx, cy, 40, a), [x2, y2] = polar(cx, cy, major ? 32 : 36, a);
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--gold)', 'stroke-width': major ? 1.6 : .8, opacity: major ? 1 : .7 });
+ if (major) {
+ const [nx, ny] = polar(cx, cy, 25, a);
+ svgEl(s, 'text', {
+ x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 6.5, 'font-family': 'var(--mono)',
+ fill: 'var(--gold-hi)'
+ }).textContent = i / 5;
+ }
+ }
+ svgEl(s, 'text', {
+ x: cx, y: cy + 16, 'text-anchor': 'middle', 'font-size': 5.5, 'letter-spacing': '.14em',
+ 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .8
+ }).textContent = '×100 RPM';
+ const needle = svgEl(s, 'g', {});
+ svgEl(needle, 'line', {
+ x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 4,
+ 'stroke-linecap': 'round', opacity: .5, filter: 'url(#avGlow)'
+ });
+ svgEl(needle, 'line', {
+ x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 2.2,
+ 'stroke-linecap': 'round'
+ });
+ svgEl(s, 'circle', { cx, cy, r: 5, fill: '#1b1917', stroke: '#3a352c' });
+ svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 24, rx: 18, ry: 8, fill: 'rgba(255,255,255,.05)', transform: `rotate(-18,${cx - 10},${cy - 24})` });
+ let v = opts.value ?? 52;
+ function set(nv) {
+ v = Math.max(0, Math.min(100, nv));
+ needle.setAttribute('transform', `rotate(${-135 + v / 100 * 270},${cx},${cy})`);
+ onChange(v, (v / 100 * 8).toFixed(1) + ' ×100 rpm');
+ }
+ dragDelta(s, () => v, set, { min: 0, max: 100 });
+ set(v);
+ return { el: s, get: () => v, set };
+};
+
+/* R13 edgewise strip meter — compressed log scale, the bar rides the edge */
+GW.edgeMeter = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const SCALE = [[0, .08], [3, .26], [6, .44], [12, .62], [20, .78], [40, .94]];
+ const s = stageSvg(host, 'rsvg drag', 70, 130);
+ const wy0 = 12, wy1 = 118;
+ const faceId = uid('edgeFace');
+ const defs = svgEl(s, 'defs', {});
+ const pg = svgEl(defs, 'linearGradient', { id: faceId, x1: 0, y1: 0, x2: 1, y2: 0 });
+ svgEl(pg, 'stop', { offset: '0', 'stop-color': '#efe6ca' });
+ svgEl(pg, 'stop', { offset: '1', 'stop-color': '#d9cfae' });
+ svgEl(s, 'rect', { x: 8, y: 6, width: 54, height: 118, rx: 8, fill: '#171412', stroke: '#060505', 'stroke-width': 1.5 });
+ svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: wy1 - wy0, fill: `url(#${faceId})`, stroke: '#0a0908' });
+ const yOf = f => wy0 + f * (wy1 - wy0);
+ for (const [db, f] of SCALE) {
+ const y = yOf(f);
+ svgEl(s, 'line', { x1: 19, y1: y, x2: 40, y2: y, stroke: '#3a3128', 'stroke-width': 1 });
+ svgEl(s, 'text', {
+ x: 52, y: y + 2.4, 'text-anchor': 'end', 'font-size': 7, 'font-family': 'var(--mono)',
+ fill: '#3a3128'
+ }).textContent = db;
+ }
+ for (let i = 0; i < SCALE.length - 1; i++) {
+ const y = yOf((SCALE[i][1] + SCALE[i + 1][1]) / 2);
+ svgEl(s, 'line', { x1: 19, y1: y, x2: 32, y2: y, stroke: '#3a3128', 'stroke-width': .6, opacity: .7 });
+ }
+ const bar = svgEl(s, 'polygon', { points: '56,0 24,-1.4 24,1.4', fill: '#1a1613' });
+ svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: 24, fill: 'rgba(255,255,255,.08)' });
+ let frac = opts.value ?? .62;
+ function set(f) {
+ frac = Math.max(.08, Math.min(.94, f));
+ bar.setAttribute('transform', `translate(0,${wy0 + frac * (wy1 - wy0)})`);
+ let i = 0; while (i < SCALE.length - 2 && SCALE[i + 1][1] < frac) i++;
+ const [d1, f1] = SCALE[i], [d2, f2] = SCALE[i + 1];
+ const db = d1 + (d2 - d1) * Math.max(0, Math.min(1, (frac - f1) / (f2 - f1)));
+ onChange(frac, '−' + db.toFixed(1) + ' dB');
+ }
+ dragY(s, pct => set(.08 + (100 - pct) / 100 * .86));
+ set(frac);
+ return { el: s, get: () => frac, set };
+};
+
+/* R17 round CRT scope — pale phosphor face; the trace is the widget's own
+ animation, so it lives here with its reduced-motion gate */
+GW.roundCrt = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg', 110, 104);
+ const cx = 55, cy = 52;
+ const faceId = uid('rcrtFace'), clipId = uid('rcrtClip');
+ gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
+ def('avGlow', d => {
+ const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
+ svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
+ });
+ /* the face gradient reads screen-family vars from this widget's subtree,
+ so it must live in the local defs, not the shared def sink */
+ const defs = svgEl(s, 'defs', {});
+ const ph = svgEl(defs, 'radialGradient', { id: faceId, cx: '50%', cy: '44%', r: '70%' });
+ svgEl(ph, 'stop', { offset: '0', 'stop-color': 'var(--crt-face1,#b9d8c0)' });
+ svgEl(ph, 'stop', { offset: '1', 'stop-color': 'var(--crt-face2,#8bb296)' });
+ const clip = svgEl(defs, 'clipPath', { id: clipId });
+ svgEl(clip, 'circle', { cx, cy, r: 38 });
+ svgEl(s, 'circle', { cx, cy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 });
+ for (let k = 0; k < 4; k++) {
+ const [x, y] = polar(cx, cy, 43, 45 + k * 90);
+ svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 });
+ }
+ svgEl(s, 'circle', { cx, cy, r: 38, fill: `url(#${faceId})` });
+ const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
+ for (let i = -4; i <= 4; i++) {
+ svgEl(g, 'line', { x1: cx + i * 9, y1: cy - 38, x2: cx + i * 9, y2: cy + 38, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 });
+ svgEl(g, 'line', { x1: cx - 38, y1: cy + i * 9, x2: cx + 38, y2: cy + i * 9, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 });
+ }
+ const glow = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: 'var(--crt-glow,#eef7ee)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' });
+ const trace = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: '#f4fcf4', 'stroke-width': 1.3 });
+ svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 16, rx: 20, ry: 10, fill: 'rgba(255,255,255,.10)', transform: `rotate(-20,${cx - 10},${cy - 16})` });
+ let tph = 0;
+ function tick() {
+ tph += 0.12;
+ const amp = 8 + 3 * Math.sin(tph * 0.35);
+ let pts = '';
+ for (let x = -38; x <= 38; x += 2) {
+ const y = Math.sin(x * 0.55 + tph * 2.5) * amp;
+ pts += `${cx + x},${(cy + y).toFixed(1)} `;
+ }
+ trace.setAttribute('points', pts.trim());
+ glow.setAttribute('points', pts.trim());
+ onChange(amp, 'Vpp ' + Math.round(amp * 2 * 4.5));
+ }
+ tick();
+ if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 80);
+ return { el: s, tick };
+};
+
+/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */
+GW.attitudeIndicator = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg', 130, 130);
+ const cx = 65, cy = 65;
+ const clipId = uid('aiClip');
+ gradDef('aiSky', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#54677d'], ['1', '#3a4c60']]);
+ gradDef('aiGnd', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#5c4630'], ['1', '#3c2e1e']]);
+ const defs = svgEl(s, 'defs', {});
+ const clip = svgEl(defs, 'clipPath', { id: clipId });
+ svgEl(clip, 'circle', { cx, cy, r: 48 });
+ svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
+ const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
+ const ball = svgEl(g, 'g', {});
+ svgEl(ball, 'rect', { x: cx - 90, y: cy - 180, width: 180, height: 180, fill: 'url(#aiSky)' });
+ svgEl(ball, 'rect', { x: cx - 90, y: cy, width: 180, height: 180, fill: 'url(#aiGnd)' });
+ svgEl(ball, 'line', { x1: cx - 90, y1: cy, x2: cx + 90, y2: cy, stroke: '#f3e7c5', 'stroke-width': 1.4 });
+ for (const p of [-20, -10, 10, 20]) {
+ const y = cy - p * 1.6, w = p % 20 === 0 ? 16 : 10;
+ svgEl(ball, 'line', { x1: cx - w, y1: y, x2: cx + w, y2: y, stroke: '#f3e7c5', 'stroke-width': .8, opacity: .85 });
+ svgEl(ball, 'text', {
+ x: cx + w + 3, y: y + 2, 'font-size': 4.6, 'font-family': 'var(--mono)',
+ fill: '#f3e7c5', opacity: .85
+ }).textContent = String(Math.abs(p));
+ }
+ /* fixed bank scale + pointer + miniature aircraft */
+ for (const b of [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60]) {
+ const [x1, y1] = polar(cx, cy, 47, b), [x2, y2] = polar(cx, cy, b % 30 === 0 ? 42 : 44, b);
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': b === 0 ? 1.6 : .9, opacity: .9 });
+ }
+ svgEl(s, 'polygon', { points: `${cx - 3.5},${cy - 38} ${cx + 3.5},${cy - 38} ${cx},${cy - 44}`, fill: 'var(--gold-hi)' });
+ svgEl(s, 'path', {
+ d: `M ${cx - 22} ${cy} L ${cx - 7} ${cy} L ${cx - 4} ${cy + 4} M ${cx + 22} ${cy} L ${cx + 7} ${cy} L ${cx + 4} ${cy + 4}`,
+ fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round'
+ });
+ svgEl(s, 'circle', { cx, cy, r: 2, fill: 'var(--gold-hi)' });
+ let bank = 0, pitch = 0;
+ function set(b, p) {
+ bank = Math.max(-60, Math.min(60, b)); pitch = Math.max(-20, Math.min(20, p));
+ ball.setAttribute('transform', `rotate(${(-bank).toFixed(1)},${cx},${cy}) translate(0,${(pitch * 1.6).toFixed(1)})`);
+ onChange({ bank, pitch }, 'BANK ' + (bank < 0 ? 'L' : bank > 0 ? 'R' : '') + Math.abs(Math.round(bank)) +
+ ' · PITCH ' + (pitch >= 0 ? '+' : '-') + String(Math.abs(Math.round(pitch))).padStart(2, '0'));
+ }
+ s.style.touchAction = 'none'; s.style.cursor = 'move';
+ s.addEventListener('pointerdown', e => {
+ s.setPointerCapture(e.pointerId);
+ const x0 = e.clientX, y0 = e.clientY, b0 = bank, p0 = pitch;
+ const mv = ev => set(b0 + (ev.clientX - x0) * 0.4, p0 + (ev.clientY - y0) * 0.25);
+ const up = () => { s.removeEventListener('pointermove', mv); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); };
+ s.addEventListener('pointermove', mv); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up); e.preventDefault();
+ });
+ set(opts.bank ?? 0, opts.pitch ?? 0);
+ return { el: s, get: () => ({ bank, pitch }), set };
+};
+
+/* R44 heading bug + servo needle — drag parks the command; the needle chases
+ with honest servo lag (widget-owned animation, reduced-motion gated) */
+GW.headingBug = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg', 130, 130);
+ const cx = 65, cy = 65;
+ svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 });
+ for (let d = 0; d < 360; d += 10) {
+ const maj = d % 30 === 0;
+ const [x1, y1] = polar(cx, cy, 48, d), [x2, y2] = polar(cx, cy, maj ? 42 : 45, d);
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': maj ? 1 : .5, opacity: maj ? .9 : .5 });
+ }
+ for (let d = 0; d < 360; d += 30) {
+ const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : String(d / 10);
+ const [x, y] = polar(cx, cy, 35, d);
+ svgEl(s, 'text', {
+ x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': d % 90 === 0 ? 8 : 6.4, 'font-weight': 700,
+ 'font-family': 'var(--mono)', fill: d % 90 === 0 ? 'var(--cream)' : 'var(--steel)'
+ }).textContent = lbl;
+ }
+ svgEl(s, 'line', { x1: cx, y1: cy - 56, x2: cx, y2: cy - 48, stroke: 'var(--fail)', 'stroke-width': 2 });
+ const bug = svgEl(s, 'path', {
+ d: `M ${cx - 6} ${cy - 49} L ${cx - 6} ${cy - 44} L ${cx - 2.5} ${cy - 44} L ${cx} ${cy - 47} L ${cx + 2.5} ${cy - 44} L ${cx + 6} ${cy - 44} L ${cx + 6} ${cy - 49} Z`,
+ fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': .7
+ });
+ const needle = svgEl(s, 'g', {});
+ svgEl(needle, 'polygon', {
+ points: `${cx - 2},${cy + 10} ${cx + 2},${cy + 10} ${cx + 1.2},${cy - 40} ${cx},${cy - 44} ${cx - 1.2},${cy - 40}`,
+ fill: '#bfc4d0', stroke: '#4a4e58', 'stroke-width': .6
+ });
+ svgEl(s, 'circle', { cx, cy, r: 4.5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
+ let cmd = opts.value ?? 90, act = cmd;
+ const draw = () => {
+ bug.setAttribute('transform', `rotate(${cmd.toFixed(1)},${cx},${cy})`);
+ needle.setAttribute('transform', `rotate(${act.toFixed(1)},${cx},${cy})`);
+ onChange({ cmd, act }, 'CMD ' + String(Math.round((cmd % 360 + 360) % 360)).padStart(3, '0') +
+ ' · ACT ' + String(Math.round((act % 360 + 360) % 360)).padStart(3, '0'));
+ };
+ function set(v) { cmd = (v % 360 + 360) % 360; draw(); }
+ dragDelta(s, () => cmd, set, { min: -100000, max: 100000, sens: 1.2 });
+ draw();
+ if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
+ setInterval(() => {
+ const diff = ((cmd - act + 540) % 360) - 180;
+ if (Math.abs(diff) < 0.4) { if (act !== cmd) { act = cmd; draw(); } return; }
+ act = (act + diff * 0.07 + 360) % 360; draw();
+ }, 80);
+ return { el: s, get: () => ({ cmd, act }), set };
+};
+
+/* R53 circular chart recorder — a day per revolution; the pen draws the cycle
+ (widget-owned clock, reduced-motion paints the full day once); click for fresh paper */
+GW.chartRecorder = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 130, 130);
+ const cx = 65, cy = 65;
+ svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy, r: 54, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': .8 });
+ for (const r of [20, 31, 42])
+ svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: '#b8ae98', 'stroke-width': .5 });
+ for (let h = 0; h < 24; h++) {
+ const a = h * 15;
+ const [x1, y1] = polar(cx, cy, 54, a), [x2, y2] = polar(cx, cy, h % 6 === 0 ? 12 : 48, a);
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#b8ae98', 'stroke-width': h % 6 === 0 ? .6 : .35, opacity: .8 });
+ if (h % 6 === 0) {
+ const [tx, ty] = polar(cx, cy, 50, a + 5);
+ svgEl(s, 'text', {
+ x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
+ fill: '#6e6552'
+ }).textContent = String(h).padStart(2, '0');
+ }
+ }
+ const trace = svgEl(s, 'path', { d: '', fill: 'none', stroke: '#8f2416', 'stroke-width': 1.1, 'stroke-linejoin': 'round' });
+ const pen = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 54, stroke: '#4a463e', 'stroke-width': 1 });
+ const penDot = svgEl(s, 'circle', { cx, cy: cy - 30, r: 2, fill: '#8f2416' });
+ svgEl(s, 'circle', { cx, cy, r: 4, fill: '#4a463e' });
+ const val = h => 52 + 30 * Math.sin(h / 24 * 2 * Math.PI - 2.1) + 12 * Math.sin(h / 24 * 6 * Math.PI) + 4 * Math.sin(h * 2.7);
+ const rOf = v => 12 + Math.max(0, Math.min(100, v)) * 0.36;
+ let pts = [], hour = 0;
+ const draw = () => {
+ const a = hour * 15, r = rOf(val(hour));
+ pts.push(polar(cx, cy, r, a));
+ if (pts.length > 1) trace.setAttribute('d', 'M ' + pts.map(p => p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' L '));
+ pen.setAttribute('transform', `rotate(${a},${cx},${cy})`);
+ const [px, py] = polar(cx, cy, r, a);
+ penDot.setAttribute('cx', px); penDot.setAttribute('cy', py);
+ onChange(hour, String(Math.floor(hour)).padStart(2, '0') + ':' + (hour % 1 >= 0.5 ? '30' : '00') + ' · ' + Math.round(val(hour)) + '%');
+ };
+ const reset = () => { pts = []; hour = 0; trace.setAttribute('d', ''); draw(); };
+ s.style.cursor = 'pointer';
+ s.addEventListener('click', reset);
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ for (hour = 0; hour < 24; hour += 0.25) draw();
+ hour = 23.75;
+ } else {
+ reset();
+ setInterval(() => { hour += 0.25; if (hour >= 24) { pts = []; hour = 0; } draw(); }, 375);
+ }
+ return { el: s, get: () => hour, reset };
+};
+
+/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive */
+GW.verticalTape = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg', 70, 130);
+ const CX = 38, CY = 65;
+ const MIN = 5, MAX = 35, PPU = 5.5; /* rpm x100, px per unit */
+ const clipId = uid('vtClip');
+ const defs = svgEl(s, 'defs', {});
+ const clip = svgEl(defs, 'clipPath', { id: clipId });
+ svgEl(clip, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4 });
+ svgEl(s, 'rect', { x: 2, y: 2, width: 66, height: 126, rx: 7, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
+ svgEl(s, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4, fill: '#0d0c0a', stroke: '#2c2820', 'stroke-width': 1 });
+ const tapeG = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
+ const tape = svgEl(tapeG, 'g', {});
+ for (let u = MIN; u <= MAX; u++) {
+ const y = -u * PPU; /* higher value further up the tape */
+ const maj = u % 5 === 0;
+ svgEl(tape, 'line', { x1: maj ? 20 : 26, y1: y, x2: 34, y2: y, stroke: 'var(--cream)', 'stroke-width': maj ? 1.1 : .55, opacity: maj ? .95 : .6 });
+ if (maj) svgEl(tape, 'text', {
+ x: 48, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700,
+ 'font-family': 'var(--mono)', fill: 'var(--cream)'
+ }).textContent = String(u);
+ }
+ /* fixed index: side pointer + line across the window at center height */
+ svgEl(s, 'polygon', { points: `8,${CY} 15,${CY - 4} 15,${CY + 4}`, fill: 'var(--gold-hi)' });
+ svgEl(s, 'line', { x1: 15, y1: CY, x2: 62, y2: CY, stroke: 'var(--gold-hi)', 'stroke-width': 1, opacity: .85 });
+ svgEl(s, 'text', {
+ x: CX, y: 126, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.1em',
+ 'font-family': 'var(--mono)', fill: 'var(--steel)'
+ }).textContent = 'RPM x100';
+ let v = opts.value ?? 24;
+ function set(nv) {
+ v = Math.max(MIN, Math.min(MAX, nv));
+ tape.setAttribute('transform', `translate(0,${(CY + v * PPU).toFixed(1)})`);
+ onChange(v, 'RPM ' + Math.round(v * 100));
+ }
+ s.style.cursor = 'ns-resize';
+ dragDelta(s, () => v, set, { min: MIN, max: MAX, sens: (MAX - MIN) / 140 });
+ set(v);
+ return { el: s, get: () => v, set };
+};
+
+/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles */
+GW.twinNeedle = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg', 120, 110);
+ const cx = 60, cy = 62, R = 44;
+ svgEl(s, 'circle', { cx, cy, r: 52, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy, r: 46, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 });
+ /* mirrored arcs: left = FUEL (0 at bottom-left up to 4 at top), right = OIL */
+ const angL = v => -170 + v / 4 * 160; /* 0..4 -> -170..-10 (left side, cw from top) */
+ const angR = v => 170 - v / 4 * 160; /* 0..4 -> 170..10 (right side) */
+ for (let v = 0; v <= 4; v++) {
+ for (const ang of [angL(v), angR(v)]) {
+ const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 5, ang);
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': 1, opacity: .9 });
+ const [tx, ty] = polar(cx, cy, R - 11, ang);
+ svgEl(s, 'text', {
+ x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)',
+ fill: 'var(--cream)'
+ }).textContent = String(v);
+ }
+ }
+ for (let v = 0.5; v < 4; v += 0.5) {
+ if (v % 1 === 0) continue;
+ for (const ang of [angL(v), angR(v)]) {
+ const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 3, ang);
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': .5, opacity: .55 });
+ }
+ }
+ svgEl(s, 'text', {
+ x: cx - 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em',
+ 'font-family': 'var(--mono)', fill: 'var(--steel)'
+ }).textContent = 'FUEL';
+ svgEl(s, 'text', {
+ x: cx + 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em',
+ 'font-family': 'var(--mono)', fill: 'var(--steel)'
+ }).textContent = 'OIL';
+ svgEl(s, 'text', {
+ x: cx, y: cy - 26, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.06em',
+ 'font-family': 'var(--mono)', fill: 'var(--steel)'
+ }).textContent = 'kg/cm2';
+ const needle = color => {
+ const g = svgEl(s, 'g', {});
+ svgEl(g, 'polygon', {
+ points: `${cx - 1.6},${cy + 8} ${cx + 1.6},${cy + 8} ${cx + 1},${cy - R + 8} ${cx},${cy - R + 4} ${cx - 1},${cy - R + 8}`,
+ fill: color, stroke: 'rgba(0,0,0,.4)', 'stroke-width': .5
+ }); return g;
+ };
+ const nF = needle('#e0523a'), nO = needle('#bfc4d0');
+ svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
+ let vF = opts.fuel ?? 2.4, vO = opts.oil ?? 3.1;
+ const draw = () => {
+ nF.setAttribute('transform', `rotate(${angL(vF).toFixed(1)},${cx},${cy})`);
+ nO.setAttribute('transform', `rotate(${angR(vO).toFixed(1)},${cx},${cy})`);
+ onChange([vF, vO], 'FUEL ' + vF.toFixed(1) + ' · OIL ' + vO.toFixed(1));
+ };
+ function set(f, o) { vF = Math.max(0, Math.min(4, f)); vO = Math.max(0, Math.min(4, o)); draw(); }
+ /* each half is its own drag surface */
+ const half = (x, get, setV) => {
+ const hit = svgEl(s, 'rect', { x, y: 10, width: 60, height: 104, fill: 'transparent' });
+ hit.style.cursor = 'ns-resize';
+ dragDelta(hit, get, v => { setV(Math.max(0, Math.min(4, v))); draw(); }, { min: 0, max: 4, sens: 4 / 110 });
+ };
+ half(0, () => vF, v => vF = v);
+ half(60, () => vO, v => vO = v);
+ draw();
+ return { el: s, get: () => [vF, vO], set };
+};
+
+/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts */
+GW.comfortMeter = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg', 130, 122);
+ const cx = 65, cy = 60;
+ const clipId = uid('cmClip');
+ gradDef('cmBrass', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8b25c'], ['.5', '#a07c30'], ['1', '#6e5218']]);
+ const defs = svgEl(s, 'defs', {});
+ svgEl(s, 'circle', { cx, cy, r: 56, fill: 'url(#cmBrass)', stroke: '#4a3610', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy, r: 53, fill: 'none', stroke: '#7a5c1e', 'stroke-width': 2.5, 'stroke-dasharray': '2 1.4' });
+ svgEl(s, 'circle', { cx, cy, r: 49, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': 1 });
+ const PT = [38, 96], PH = [92, 96]; /* temp pivot (BL), humidity pivot (BR) */
+ const angT = t => 105 - (t - 40) / 60 * 70; /* 40..100F -> bearing 105..35 from PT */
+ const angH = h => -105 + h / 100 * 70; /* 0..100% -> bearing -105..-35 from PH */
+ /* scale ticks along each sweep, near the rim */
+ for (let t = 40; t <= 100; t += 10) {
+ const a = angT(t);
+ const [x1, y1] = polar(PT[0], PT[1], 86, a), [x2, y2] = polar(PT[0], PT[1], 82, a);
+ if (Math.hypot(x1 - cx, y1 - cy) < 48) {
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': t % 20 === 0 ? 1 : .5 });
+ if (t % 20 === 0) {
+ const [tx, ty] = polar(PT[0], PT[1], 78, a);
+ svgEl(s, 'text', {
+ x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
+ fill: '#3c382f'
+ }).textContent = String(t);
+ }
+ }
+ }
+ for (let h = 0; h <= 100; h += 10) {
+ const a = angH(h);
+ const [x1, y1] = polar(PH[0], PH[1], 86, a), [x2, y2] = polar(PH[0], PH[1], 82, a);
+ if (Math.hypot(x1 - cx, y1 - cy) < 48) {
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': h % 20 === 0 ? 1 : .5 });
+ if (h % 20 === 0) {
+ const [tx, ty] = polar(PH[0], PH[1], 78, a);
+ svgEl(s, 'text', {
+ x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
+ fill: '#3c382f'
+ }).textContent = String(h);
+ }
+ }
+ }
+ /* printed zone verdicts; the active one goes red */
+ const zones = {};
+ const zone = (key, txt, x, y, rot, size) => {
+ zones[key] = svgEl(s, 'text', {
+ x, y, 'text-anchor': 'middle',
+ 'font-size': size || 5.2, 'font-weight': 700, 'letter-spacing': '.06em', 'font-family': 'var(--mono)',
+ fill: '#8f897b', transform: `rotate(${rot},${x},${y})`
+ }); zones[key].textContent = txt;
+ };
+ zone('humid', 'TOO HUMID', 42, 34, -32);
+ zone('warm', 'TOO WARM', 89, 34, 32);
+ zone('right', 'JUST RIGHT', 65, 58, 0, 6.2);
+ zone('dry', 'TOO DRY', 38, 74, 28);
+ zone('cold', 'TOO COLD', 93, 74, -28);
+ svgEl(s, 'text', {
+ x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.6, 'letter-spacing': '.18em',
+ 'font-family': 'var(--mono)', fill: '#3c382f'
+ }).textContent = 'WEATHER STATION';
+ /* needles: temp from bottom-left, humidity from bottom-right — clipped to the face */
+ const nclip = svgEl(defs, 'clipPath', { id: clipId });
+ svgEl(nclip, 'circle', { cx, cy, r: 49 });
+ const ng = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
+ const needle = () => {
+ const g = svgEl(ng, 'g', {});
+ svgEl(g, 'line', { x1: 0, y1: 0, x2: 0, y2: -84, stroke: '#c23a28', 'stroke-width': 1.6, 'stroke-linecap': 'round' });
+ svgEl(g, 'circle', { cx: 0, cy: 0, r: 2.6, fill: '#8f2416' }); return g;
+ };
+ const nT = needle(), nH = needle();
+ nT.setAttribute('transform', `translate(${PT[0]},${PT[1]})`);
+ nH.setAttribute('transform', `translate(${PH[0]},${PH[1]})`);
+ let vT = opts.temp ?? 72, vH = opts.humidity ?? 45;
+ const verdict = () => vT > 78 ? 'warm' : vT < 62 ? 'cold' : vH > 60 ? 'humid' : vH < 30 ? 'dry' : 'right';
+ const draw = () => {
+ nT.setAttribute('transform', `translate(${PT[0]},${PT[1]}) rotate(${angT(vT).toFixed(1)})`);
+ nH.setAttribute('transform', `translate(${PH[0]},${PH[1]}) rotate(${angH(vH).toFixed(1)})`);
+ const v = verdict();
+ for (const k of Object.keys(zones)) zones[k].setAttribute('fill', k === v ? '#c23a28' : '#8f897b');
+ onChange([vT, vH], Math.round(vT) + 'F · ' + Math.round(vH) + '% RH · ' + zones[v].textContent);
+ };
+ function set(t, h) { vT = Math.max(40, Math.min(100, t)); vH = Math.max(0, Math.min(100, h)); draw(); }
+ const half = (x, get, setV, min, max) => {
+ const hit = svgEl(s, 'rect', { x, y: 8, width: 65, height: 106, fill: 'transparent' });
+ hit.style.cursor = 'ns-resize';
+ dragDelta(hit, get, v => { setV(Math.max(min, Math.min(max, v))); draw(); }, { min, max, sens: (max - min) / 110 });
+ };
+ half(0, () => vT, v => vT = v, 40, 100);
+ half(65, () => vH, v => vH = v, 0, 100);
+ draw();
+ return { el: s, get: () => [vT, vH], set };
+};
+
+/* ================= indicators & readouts ================= */
+
+/* 18 status lamps — one lamp per health state; click any lamp to cycle it */
+GW.statusLamps = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const CLS = ['lamp', 'lamp gold', 'lamp red', 'lamp off', 'lamp busy'];
+ const NAMES = ['ok', 'engaged', 'fault', 'off', 'busy'];
+ const states = (opts.states || [0, 1, 2, 3, 4]).slice();
+ const wrap = document.createElement('span');
+ wrap.style.cssText = 'display:inline-flex;gap:9px;align-items:center';
+ const lamps = states.map((st, i) => {
+ const l = document.createElement('span'); l.className = CLS[st]; l.style.cursor = 'pointer';
+ l.addEventListener('click', () => set(i, states[i] + 1));
+ wrap.appendChild(l); return l;
+ });
+ host.appendChild(wrap);
+ function set(i, st) {
+ states[i] = ((st % CLS.length) + CLS.length) % CLS.length;
+ lamps[i].className = CLS[states[i]];
+ onChange(states.slice(), NAMES[states[i]]);
+ }
+ onChange(states.slice(), states.length === 5 ? 'five states' : states.length + ' states');
+ return { el: wrap, get: () => states.slice(), set };
+};
+
+/* 19 badges — labelled flags; click a badge to cycle its variant */
+GW.badges = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const CLS = ['badge', 'badge red', 'badge ghost'];
+ const items = opts.items || [['TUNNEL', 0], ['LOW BATT', 1], ['2.4G', 2]];
+ const vars = items.map(it => it[1]);
+ const wrap = document.createElement('span');
+ const els = items.map(([txt, v], i) => {
+ const b = document.createElement('span'); b.className = CLS[v]; b.textContent = txt; b.style.cursor = 'pointer';
+ b.addEventListener('click', () => set(i, vars[i] + 1));
+ wrap.appendChild(b);
+ if (i < items.length - 1) wrap.appendChild(document.createTextNode(' '));
+ return b;
+ });
+ host.appendChild(wrap);
+ function set(i, v) {
+ vars[i] = ((v % CLS.length) + CLS.length) % CLS.length;
+ els[i].className = CLS[vars[i]];
+ onChange(vars.slice(), items[i][0]);
+ }
+ onChange(vars.slice(), items.length === 3 ? 'three variants' : items.length + ' variants');
+ return { el: wrap, get: () => vars.slice(), set };
+};
+
+/* 20 tabular readout — mm:ss countdown; builder owns the state, the page
+ drives tick() on its own clock (tick contract); click pauses/resumes */
+GW.tabularReadout = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const wrap = document.createElement('div'); wrap.style.textAlign = 'center';
+ wrap.innerHTML = '<div class="readout"></div><span class="u"></span>';
+ const out = wrap.querySelector('.readout');
+ wrap.querySelector('.u').textContent = opts.unit || 'timer';
+ host.appendChild(wrap);
+ let secs, run = opts.run !== undefined ? !!opts.run : true;
+ const draw = () => {
+ out.textContent = String(Math.floor(secs / 60)).padStart(2, '0') + ':' + String(secs % 60).padStart(2, '0');
+ onChange(secs, run ? 'running' : 'paused');
+ };
+ const set = v => { secs = ((v % 3600) + 3600) % 3600; draw(); };
+ out.addEventListener('click', () => { run = !run; draw(); });
+ set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10);
+ return { el: wrap, get: () => secs, set, tick: () => { if (run) set(secs - 1); } };
+};
+
+/* 21 engraved label — hairline-flanked caps label with a count; click bumps 1-9 */
+GW.engravedLabel = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const e = document.createElement('span'); e.className = 'engrave';
+ e.append(opts.label || 'outputs');
+ const c = document.createElement('span'); c.className = 'cnt'; e.appendChild(c);
+ host.appendChild(e);
+ let n;
+ const set = v => { n = v; c.textContent = '· ' + n; onChange(n, 'count ' + n); };
+ e.addEventListener('click', () => set(n % 9 + 1));
+ set(opts.count !== undefined ? opts.count : 3);
+ return { el: e, get: () => n, set };
+};
+
+/* 22 output well — streaming step log, lamp per step; click streams the next
+ demo step; push([lampCls, name, evidence]) appends programmatically */
+GW.outputWell = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const seed = opts.seed || [['', 'Link', 'wlp170s0 · @Hyatt'], ['gold', 'DNS', 'resolving…']];
+ const steps = opts.steps || [['gold', 'Probe', '8.8.8.8 …'], ['', 'Gateway', '10.0.0.1 ok'], ['', 'DNS', '1.1.1.1 ok'], ['red', 'Retry', 'timeout']];
+ const keep = opts.keep || 5;
+ const w = document.createElement('div'); w.className = 'owell'; host.appendChild(w);
+ const add = s => {
+ const d = document.createElement('div'); d.className = 'ostep';
+ d.innerHTML = `<span class="lamp ${s[0]}"></span><span><b>${s[1]}</b><span class="ev">${s[2]}</span></span>`;
+ w.appendChild(d); while (w.children.length > keep) w.removeChild(w.firstChild);
+ };
+ seed.forEach(add);
+ const push = s => { add(s); onChange(s, '+ ' + s[1]); };
+ let i = 0;
+ w.addEventListener('click', () => { i = (i + 1) % steps.length; push(steps[i]); });
+ onChange(null, 'click to stream');
+ return { el: w, push };
+};
+
+/* 23 toast — one-line transient confirmation; click fires the next demo
+ message; fire(msg) shows any message with the fade-in */
+GW.toast = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const msgs = opts.msgs || ['link up · 300 Mbps', 'bt paired — WH-1000XM4', 'profile switched', 'joined @Hyatt_WiFi — saved'];
+ const t = document.createElement('span'); t.className = 'toastw';
+ t.textContent = opts.text || msgs[msgs.length - 1];
+ host.appendChild(t);
+ const fire = msg => {
+ t.textContent = msg; t.style.opacity = '0';
+ requestAnimationFrame(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '1'; });
+ };
+ let n = 0;
+ t.addEventListener('click', () => { n++; fire(msgs[n % msgs.length]); onChange(n, 'fired ' + n); });
+ onChange(0, 'click to fire');
+ return { el: t, fire };
+};
+
+/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments */
+GW.nixie = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const digits = opts.digits || 2;
+ const max = Math.pow(10, digits);
+ const nx = document.createElement('span'); nx.className = 'nixie';
+ for (let i = 0; i < digits; i++) {
+ const tu = document.createElement('span'); tu.className = 'tube'; tu.innerHTML = '<b></b>'; nx.appendChild(tu);
+ }
+ host.appendChild(nx);
+ let v;
+ const set = x => {
+ v = ((x % max) + max) % max;
+ const s = String(v).padStart(digits, '0');
+ [...nx.children].forEach((tu, i) => {
+ tu.classList.toggle('off', i < digits - 1 && v < Math.pow(10, digits - 1 - i));
+ tu.querySelector('b').textContent = s[i];
+ });
+ onChange(v, s);
+ };
+ nx.addEventListener('click', () => set(v + 1));
+ set(opts.value !== undefined ? opts.value : 8);
+ return { el: nx, get: () => v, set };
+};
+
+/* N20 split-flap — windows flip to the next word with the mechanical clack;
+ the page drives next() on its own cadence; first paint is silent like the original */
+GW.splitFlap = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const words = opts.words || ['DNS ', 'LINK', 'SYNC', 'WIFI', 'SCAN'];
+ const animate = opts.animate !== undefined ? !!opts.animate : true;
+ const cells = opts.cells || 3;
+ const f = document.createElement('span'); f.className = 'flap';
+ for (let i = 0; i < cells; i++) {
+ const d = document.createElement('span'); d.className = 'flapd'; d.innerHTML = '<b></b>'; f.appendChild(d);
+ }
+ host.appendChild(f);
+ let idx = 0;
+ const paint = () => {
+ const w = words[idx].padEnd(cells + 1, ' ');
+ [...f.children].forEach((dd, i) => {
+ const ch = w[i] || ' ', bel = dd.querySelector('b');
+ if (bel.textContent !== ch) {
+ bel.textContent = ch;
+ if (animate) { dd.classList.remove('flip'); void dd.offsetWidth; dd.classList.add('flip'); }
+ }
+ });
+ };
+ const set = i => { idx = ((i % words.length) + words.length) % words.length; paint(); onChange(idx, words[idx].trim()); };
+ f.addEventListener('click', () => set(idx + 1));
+ paint();
+ return { el: f, get: () => idx, set, next: () => set(idx + 1) };
+};
+
+/* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick();
+ click adds a minute */
+GW.sevenSeg = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const sv = document.createElement('span'); sv.className = 'seven'; host.appendChild(sv);
+ let secs;
+ const set = v => {
+ secs = ((v % 3600) + 3600) % 3600;
+ const mm = String(Math.floor(secs / 60)).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0');
+ sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '<span class="colon"><i></i><i></i></span>' + seg7(ss[0]) + seg7(ss[1]);
+ onChange(secs, mm + ':' + ss);
+ };
+ sv.addEventListener('click', () => set(secs + 60));
+ set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10);
+ return { el: sv, get: () => secs, set, tick: () => set(secs - 1) };
+};
+
+/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message */
+GW.vfdMarquee = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const msgs = opts.msgs || ['ARCHSETUP · NET OK · BT 2 · SND 62%', 'WIFI @Hyatt · 300 Mbps · VPN UP', 'BATTERY 84% · DISK 61% · TEMP 47C'];
+ const W = opts.width || 176;
+ const m = document.createElement('span'); m.className = 'vfdm';
+ m.innerHTML = '<span class="txt"></span><span class="mesh"></span>';
+ const t = m.querySelector('.txt'); t.textContent = msgs[0];
+ host.appendChild(m);
+ let mi = 0, x = W;
+ m.addEventListener('click', () => {
+ mi = (mi + 1) % msgs.length; t.textContent = msgs[mi]; x = W;
+ onChange(mi, 'msg ' + (mi + 1) + '/' + msgs.length);
+ });
+ onChange(0, 'scrolling');
+ return {
+ el: m, get: () => mi,
+ tick: () => { x -= 1.1; if (x < -t.offsetWidth) x = W; t.style.left = x + 'px'; }
+ };
+};
+
+/* N23 annunciator — named alarm grid with the raise → MSTR CAUTION → ACK → RESET
+ lifecycle; TEST proves the bulbs then restores the board */
+GW.annunciator = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const CLS = ['acell', 'acell warn', 'acell fault'];
+ const NAMES = ['ok', 'warn', 'fault'];
+ const cells = opts.cells || [['SYNC', 0], ['LOW BATT', 1], ['LINK', 0], ['NO DNS', 2], ['VPN', 0], ['DISK', 0]];
+ const wrap = document.createElement('div'); wrap.className = 'annwrap';
+ const grid = document.createElement('div'); grid.className = 'annun'; wrap.appendChild(grid);
+ const bar = document.createElement('div'); bar.className = 'annbar';
+ const mc = document.createElement('span'); mc.className = 'mc'; mc.textContent = 'MSTR CAUTION'; bar.appendChild(mc);
+ ['ACK', 'TEST', 'RESET'].forEach(a => {
+ const b = document.createElement('button'); b.className = 'key'; b.dataset.k = a; b.textContent = a; bar.appendChild(b);
+ });
+ wrap.appendChild(bar); host.appendChild(wrap);
+ const els = cells.map(([label, st]) => {
+ const c = document.createElement('span'); c.className = CLS[st]; c.textContent = label; grid.appendChild(c); return c;
+ });
+ let acked = false;
+ const active = () => grid.querySelectorAll('.warn,.fault').length;
+ const refresh = () => {
+ const n = active();
+ mc.classList.toggle('on', n > 0); mc.classList.toggle('fl', n > 0 && !acked);
+ onChange(n, n === 0 ? 'clear' : n + ' active · ' + (acked ? 'ACK' : 'UNACK'));
+ };
+ els.forEach((c, i) => c.addEventListener('click', () => {
+ let j = CLS.indexOf(c.className.trim()); if (j < 0) j = 0; j = (j + 1) % CLS.length; c.className = CLS[j];
+ if (j > 0) acked = false; /* a new alarm re-arms the flasher */
+ refresh(); onChange(j, cells[i][0] + ' → ' + NAMES[j]);
+ }));
+ bar.querySelectorAll('.key').forEach(k => k.addEventListener('click', () => {
+ const a = k.dataset.k;
+ if (a === 'ACK') { if (active() > 0) acked = true; refresh(); }
+ else if (a === 'RESET') { els.forEach(c => c.className = 'acell'); acked = false; refresh(); }
+ else if (a === 'TEST') {
+ const prev = els.map(c => c.className);
+ els.forEach(c => c.className = 'acell fault');
+ mc.classList.add('on', 'fl'); onChange(null, 'lamp test');
+ setTimeout(() => { els.forEach((c, i) => c.className = prev[i]); refresh(); }, 1100);
+ }
+ }));
+ refresh();
+ return {
+ el: wrap, get: () => els.map(c => Math.max(0, CLS.indexOf(c.className.trim()))),
+ set: (i, st) => { els[i].className = CLS[st]; if (st > 0) acked = false; refresh(); }
+ };
+};
+
+/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark */
+GW.jewels = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const cols = ['var(--jewel-r)', 'var(--jewel-a)', 'var(--jewel-g)'];
+ const NAMES = ['red', 'amber', 'green'];
+ const init = opts.states || [0, 1, 2, -1]; /* index into cols; -1 = dark */
+ const wrap = document.createElement('span');
+ wrap.style.cssText = 'display:inline-flex;gap:10px;align-items:center';
+ init.forEach(st => {
+ const j = document.createElement('span'); j.className = 'jewel' + (st < 0 ? ' dim' : '');
+ if (st >= 0) j.style.setProperty('--jc', cols[st]);
+ j.addEventListener('click', () => {
+ if (j.classList.contains('dim')) { j.classList.remove('dim'); j.style.setProperty('--jc', cols[0]); onChange(0, 'red'); return; }
+ const cur = j.style.getPropertyValue('--jc').trim(); const i = cols.indexOf(cur);
+ if (i >= cols.length - 1 || i < 0) { j.classList.add('dim'); onChange(-1, 'dark'); }
+ else { j.style.setProperty('--jc', cols[i + 1]); onChange(i + 1, NAMES[i + 1]); }
+ });
+ wrap.appendChild(j);
+ });
+ host.appendChild(wrap);
+ onChange(null, 'click to cycle');
+ return { el: wrap };
+};
+
+/* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit */
+GW.tapeCounter = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const wheels = opts.wheels || 6, redFrom = opts.redFrom !== undefined ? opts.redFrom : 4;
+ const ct = document.createElement('span'); ct.className = 'counter'; host.appendChild(ct);
+ for (let idx = 0; idx < wheels; idx++) {
+ const w = document.createElement('span'); w.className = 'cwheel' + (idx >= redFrom ? ' redw' : '');
+ const col = document.createElement('span'); col.className = 'col';
+ for (let n = -1; n <= 10; n++) { const sp = document.createElement('span'); sp.textContent = ((n + 10) % 10); col.appendChild(sp); }
+ w.appendChild(col); ct.appendChild(w);
+ }
+ const max = Math.pow(10, wheels);
+ let v;
+ const set = x => {
+ v = ((Math.round(x) % max) + max) % max;
+ const s = String(v).padStart(wheels, '0');
+ [...ct.children].forEach((w, i) => { w.querySelector('.col').style.top = (-(+s[i] + 1) * 34) + 'px'; });
+ onChange(v, s);
+ };
+ set(opts.value !== undefined ? opts.value : 471300);
+ return { el: ct, get: () => v, set };
+};
+
+/* N26 analog clock — engraved ticks + three hands; the page owns the time source
+ and drives set(h, m, s); silent until the first set, like the live original */
+GW.analogClock = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const cl = document.createElement('span'); cl.className = 'clock';
+ for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); }
+ cl.insertAdjacentHTML('beforeend', '<span class="hh"></span><span class="mh"></span><span class="sh"></span><span class="pin"></span>');
+ host.appendChild(cl);
+ const hh = cl.querySelector('.hh'), mh = cl.querySelector('.mh'), sh = cl.querySelector('.sh');
+ const set = (h, m, s) => {
+ sh.style.transform = `rotate(${s * 6}deg)`;
+ mh.style.transform = `rotate(${m * 6 + s * 0.1}deg)`;
+ hh.style.transform = `rotate(${(h % 12) * 30 + m * 0.5}deg)`;
+ onChange([h, m, s], String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'));
+ };
+ return { el: cl, set };
+};
+
+/* N27 frequency-dial scale — printed log axis, marks crowd low and spread high;
+ drag the pointer to tune */
+GW.freqScale = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const W = opts.width || 184;
+ const marks = opts.marks || [0.5, 1, 2, 3, 5, 7, 10, 14, 20];
+ const unit = opts.unit || 'MHz';
+ const lo = Math.log10(marks[0]), hi = Math.log10(marks[marks.length - 1]);
+ const px = m => 6 + ((Math.log10(m) - lo) / (hi - lo)) * (W - 12);
+ const fs = document.createElement('span'); fs.className = 'freqscale';
+ const band = document.createElement('span'); band.className = 'band'; band.textContent = unit; fs.appendChild(band);
+ marks.forEach(m => {
+ const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '11px'; fs.appendChild(tk);
+ const mk = document.createElement('span'); mk.className = 'mk'; mk.style.left = px(m) + 'px'; mk.textContent = m; fs.appendChild(mk);
+ });
+ for (let m = marks[0]; m <= marks[marks.length - 1]; m += (m < 2 ? 0.25 : m < 10 ? 1 : 2)) {
+ const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '6px'; tk.style.opacity = '.5'; fs.appendChild(tk);
+ }
+ const ptr = document.createElement('span'); ptr.className = 'fptr'; fs.appendChild(ptr);
+ host.appendChild(fs);
+ let pct;
+ const set = p => {
+ pct = Math.max(0, Math.min(100, p));
+ ptr.style.left = (6 + pct / 100 * (W - 12)) + 'px';
+ const f = Math.pow(10, lo + (pct / 100) * (hi - lo));
+ onChange(f, (f < 10 ? f.toFixed(1) : Math.round(f)) + ' ' + unit);
+ };
+ dragX(fs, set);
+ set(opts.value !== undefined ? opts.value : 45);
+ return { el: fs, get: () => pct, set };
+};
+
+/* N28 patch bay — jack grid with SVG cables; click one jack then another to
+ patch or unpatch the pair; redraws itself on window resize */
+GW.patchBay = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const rows = opts.rows || 2, cols = opts.cols || 4;
+ const patch = document.createElement('div'); patch.className = 'patch';
+ const jacks = [];
+ for (let r = 0; r < rows; r++) {
+ const rowEl = document.createElement('div'); rowEl.className = 'row';
+ for (let c = 0; c < cols; c++) { const j = document.createElement('span'); j.className = 'jack'; j.dataset.id = (r * cols + c); rowEl.appendChild(j); jacks.push(j); }
+ patch.appendChild(rowEl);
+ }
+ const svg = document.createElementNS(SVGNS, 'svg'); patch.appendChild(svg);
+ host.appendChild(patch);
+ let conns = (opts.conns || ['0-5', '2-7']).slice(); let pending = null;
+ const key = (a, b) => Math.min(a, b) + '-' + Math.max(a, b);
+ const draw = () => {
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
+ const pr = patch.getBoundingClientRect();
+ jacks.forEach(j => j.classList.remove('hot'));
+ conns.forEach(pair => {
+ const [a, b] = pair.split('-').map(Number);
+ const ra = jacks[a].getBoundingClientRect(), rb = jacks[b].getBoundingClientRect();
+ const x1 = ra.left - pr.left + ra.width / 2, y1 = ra.top - pr.top + ra.height / 2;
+ const x2 = rb.left - pr.left + rb.width / 2, y2 = rb.top - pr.top + rb.height / 2;
+ const p = document.createElementNS(SVGNS, 'path');
+ const my = Math.max(y1, y2) + 16; p.setAttribute('d', `M${x1} ${y1} C${x1} ${my} ${x2} ${my} ${x2} ${y2}`); svg.appendChild(p);
+ jacks[a].classList.add('hot'); jacks[b].classList.add('hot');
+ });
+ onChange(conns.slice(), conns.length ? conns.map(c => { const [a, b] = c.split('-'); return a + '↔' + b; }).join(' ') : 'no cables');
+ };
+ jacks.forEach(j => {
+ j.addEventListener('click', () => {
+ const id = +j.dataset.id;
+ if (pending == null) { pending = id; j.classList.add('sel'); return; }
+ if (pending === id) { pending = null; j.classList.remove('sel'); return; }
+ const k = key(pending, id); const i = conns.indexOf(k); if (i >= 0) conns.splice(i, 1); else conns.push(k);
+ jacks[pending].classList.remove('sel'); pending = null; draw();
+ });
+ });
+ draw(); window.addEventListener('resize', draw);
+ return { el: patch, get: () => conns.slice(), set: c => { conns = c.slice(); draw(); } };
+};
+
+/* R10 data matrix readout — a page of labeled amber fields; click cycles pages;
+ a page marked live re-renders on the builder's own 1s clock (reduced-motion gated) */
+GW.dataMatrix = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const pages = opts.pages || [
+ ['SYS', () => ['CPU 42 MEM 61', 'DSK 58 TMP 47C', 'ARCH 6.18 LTS']],
+ ['NET', () => ['WIFI @HYATT 300M', 'VPN UP DNS OK', 'BT 2 PAIRED']],
+ ['TIME', () => {
+ const d = new Date(), p = n => String(n).padStart(2, '0');
+ return [`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`,
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`,
+ 'UP 14D 06:12'];
+ }, true]];
+ const s = stageSvg(host, 'rsvg press', 190, 74);
+ def('dmxGlow', d => {
+ const fl = svgEl(d, 'filter', { id: 'dmxGlow', x: '-30%', y: '-30%', width: '160%', height: '160%' });
+ svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.1 });
+ });
+ /* the background gradient reads screen-family vars from this widget's subtree,
+ so it lives in the local defs, not the shared def sink */
+ const bgId = uid('dmxBg');
+ const defs = svgEl(s, 'defs', {});
+ const bg = svgEl(defs, 'linearGradient', { id: bgId, x1: 0, y1: 0, x2: 0, y2: 1 });
+ svgEl(bg, 'stop', { offset: '0', 'stop-color': 'var(--scr-bg1,#140d06)' });
+ svgEl(bg, 'stop', { offset: '1', 'stop-color': 'var(--scr-bg2,#0a0705)' });
+ svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 72, rx: 5, fill: `url(#${bgId})`, stroke: '#2c261d' });
+ const lines = [];
+ for (let r = 0; r < 3; r++) {
+ const glow = svgEl(s, 'text', {
+ x: 12, y: 24 + r * 18, 'font-size': 9, 'letter-spacing': '.15em',
+ 'font-family': 'var(--mono)', fill: 'var(--scr-hi,var(--gold-hi))', opacity: .6, filter: 'url(#dmxGlow)'
+ });
+ const crisp = svgEl(s, 'text', {
+ x: 12, y: 24 + r * 18, 'font-size': 9, 'letter-spacing': '.15em',
+ 'font-family': 'var(--mono)', fill: 'var(--scr-hi,var(--gold-hi))'
+ });
+ lines.push([glow, crisp]);
+ }
+ let pi;
+ const set = i => {
+ pi = ((i % pages.length) + pages.length) % pages.length;
+ const [name, rowsFn] = pages[pi];
+ rowsFn().forEach((txt, r) => { lines[r][0].textContent = txt; lines[r][1].textContent = txt; });
+ onChange(pi, 'page ' + name);
+ };
+ s.addEventListener('click', () => set(pi + 1));
+ set(opts.page || 0);
+ if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
+ setInterval(() => { if (pages[pi][2]) set(pi); }, 1000);
+ return { el: s, get: () => pi, set };
+};
+
+/* R11 warning flag window — the striped mechanical flag slides into the window
+ when the condition trips; click trips and clears it */
+GW.warningFlag = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 120, 64);
+ def('barber', d => {
+ const pat = svgEl(d, 'pattern', { id: 'barber', width: 10, height: 10, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' });
+ svgEl(pat, 'rect', { x: 0, y: 0, width: 10, height: 10, fill: '#17130c' });
+ svgEl(pat, 'rect', { x: 0, y: 0, width: 5, height: 10, fill: 'var(--gold)' });
+ });
+ const clipId = uid('flagWin');
+ const defs = svgEl(s, 'defs', {});
+ const clip = svgEl(defs, 'clipPath', { id: clipId });
+ svgEl(clip, 'rect', { x: 14, y: 20, width: 56, height: 24 });
+ svgEl(s, 'rect', { x: 11, y: 17, width: 62, height: 30, rx: 3, fill: '#0a0908', stroke: '#2c261d', 'stroke-width': 2 });
+ const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
+ const stripes = svgEl(g, 'rect', { x: 14, y: 20, width: 56, height: 24, fill: 'url(#barber)' });
+ stripes.style.transition = 'transform .18s'; stripes.style.transform = 'translateX(-58px)';
+ svgEl(s, 'text', {
+ x: 92, y: 35, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.14em',
+ 'font-family': 'var(--mono)', fill: 'var(--steel)'
+ }).textContent = opts.label || 'VIB';
+ let on;
+ const set = v => { on = !!v; stripes.style.transform = on ? 'translateX(0)' : 'translateX(-58px)'; onChange(on, on ? 'FLAG' : 'clear'); };
+ s.addEventListener('click', () => set(!on));
+ set(opts.on || false);
+ return { el: s, get: () => on, set };
+};
+
+/* R25 fourteen-segment display — the starburst alphanumeric that spells words;
+ click cycles the word */
+GW.seg14 = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const words = opts.words || ['ZOOM', 'ECHO', 'TAPE', 'MOOD', 'HALL', 'COMP'];
+ const MAP = {
+ A: ['a', 'b', 'c', 'e', 'f', 'g1', 'g2'], C: ['a', 'd', 'e', 'f'],
+ D: ['a', 'b', 'c', 'd', 'i', 'l'], E: ['a', 'd', 'e', 'f', 'g1', 'g2'], H: ['b', 'c', 'e', 'f', 'g1', 'g2'],
+ L: ['d', 'e', 'f'], M: ['b', 'c', 'e', 'f', 'h', 'j'], O: ['a', 'b', 'c', 'd', 'e', 'f'],
+ P: ['a', 'b', 'e', 'f', 'g1', 'g2'], T: ['a', 'i', 'l'], Z: ['a', 'd', 'j', 'k']
+ };
+ const s = stageSvg(host, 'rsvg press', 130, 54);
+ svgEl(s, 'rect', { x: 1, y: 1, width: 128, height: 52, rx: 5, fill: '#0a0f08', stroke: '#1c2a16', 'stroke-width': 2 });
+ /* segment shapes in a 22x36 local box */
+ const TH = 2.6, W = 22, H = 36, mid = H / 2;
+ const segs = {
+ a: [[2, 0], [W - 2, 0], [W - 4, TH], [4, TH]],
+ d: [[2, H], [W - 2, H], [W - 4, H - TH], [4, H - TH]],
+ f: [[0, 2], [TH, 4], [TH, mid - 2], [0, mid - 1]],
+ e: [[0, H - 2], [TH, H - 4], [TH, mid + 2], [0, mid + 1]],
+ b: [[W, 2], [W - TH, 4], [W - TH, mid - 2], [W, mid - 1]],
+ c: [[W, H - 2], [W - TH, H - 4], [W - TH, mid + 2], [W, mid + 1]],
+ g1: [[2, mid], [4, mid - TH / 2], [W / 2 - 1, mid - TH / 2], [W / 2 - 1, mid + TH / 2], [4, mid + TH / 2]],
+ g2: [[W - 2, mid], [W - 4, mid - TH / 2], [W / 2 + 1, mid - TH / 2], [W / 2 + 1, mid + TH / 2], [W - 4, mid + TH / 2]],
+ i: [[W / 2 - TH / 2, 3], [W / 2 + TH / 2, 3], [W / 2 + TH / 2, mid - 2], [W / 2 - TH / 2, mid - 2]],
+ l: [[W / 2 - TH / 2, H - 3], [W / 2 + TH / 2, H - 3], [W / 2 + TH / 2, mid + 2], [W / 2 - TH / 2, mid + 2]],
+ h: [[3, 3], [5.5, 3], [W / 2 - 2, mid - 3], [W / 2 - 4.5, mid - 3]],
+ j: [[W - 3, 3], [W - 5.5, 3], [W / 2 + 2, mid - 3], [W / 2 + 4.5, mid - 3]],
+ k: [[3, H - 3], [5.5, H - 3], [W / 2 - 2, mid + 3], [W / 2 - 4.5, mid + 3]],
+ m: [[W - 3, H - 3], [W - 5.5, H - 3], [W / 2 + 2, mid + 3], [W / 2 + 4.5, mid + 3]]
+ };
+ const cells = [];
+ for (let d = 0; d < 4; d++) {
+ const x0 = 13 + d * 28, y0 = 9, cell = {};
+ for (const k in segs) {
+ cell[k] = svgEl(s, 'polygon', { points: segs[k].map(p => `${x0 + p[0]},${y0 + p[1]}`).join(' '), fill: 'var(--sevoff)' });
+ }
+ cells.push(cell);
+ }
+ let wi;
+ const set = i => {
+ wi = ((i % words.length) + words.length) % words.length;
+ const word = words[wi].padEnd(4, ' ');
+ cells.forEach((cell, d) => {
+ const lit = MAP[word[d]] || [];
+ for (const k in cell) {
+ const on = lit.includes(k);
+ cell[k].setAttribute('fill', on ? 'var(--sevgrn)' : 'var(--sevoff)');
+ cell[k].setAttribute('style', on ? 'filter:drop-shadow(0 0 2.5px rgba(87,211,87,.6))' : '');
+ }
+ });
+ onChange(wi, words[wi]);
+ };
+ s.addEventListener('click', () => set(wi + 1));
+ set(opts.index || 0);
+ return { el: s, get: () => wi, set };
+};
+
+/* R26 response graph — log-frequency axes with a draggable amber peak; 2D drag
+ places the peak in both axes at once */
+GW.responseGraph = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg', 190, 110);
+ const RG = { x0: 30, x1: 182, y0: 8, y1: 86, fLo: Math.log10(32), fHi: Math.log10(16000), db: 12 };
+ def('avGlow', d => {
+ const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
+ svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
+ });
+ svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 108, rx: 5, fill: '#0d1410', stroke: '#22301f', 'stroke-width': 2 });
+ const FREQS = [32, 64, 128, 250, 500, 1000, 2000, 4000, 8000, 16000];
+ const xOf = f => RG.x0 + (Math.log10(f) - RG.fLo) / (RG.fHi - RG.fLo) * (RG.x1 - RG.x0);
+ const yOf = db => RG.y0 + (RG.db - db) / (2 * RG.db) * (RG.y1 - RG.y0);
+ FREQS.forEach(f => {
+ const x = xOf(f);
+ svgEl(s, 'line', { x1: x, y1: RG.y0, x2: x, y2: RG.y1, stroke: '#22301f', 'stroke-width': .6 });
+ svgEl(s, 'text', {
+ x, y: RG.y1 + 9, 'text-anchor': 'middle', 'font-size': 4.8, 'font-family': 'var(--mono)',
+ fill: 'var(--steel)'
+ }).textContent = f < 1000 ? f : (f / 1000) + 'k';
+ });
+ for (const db of [12, 6, 0, -6, -12]) {
+ const y = yOf(db);
+ svgEl(s, 'line', { x1: RG.x0, y1: y, x2: RG.x1, y2: y, stroke: '#22301f', 'stroke-width': db === 0 ? 1 : .6 });
+ svgEl(s, 'text', {
+ x: RG.x0 - 4, y: y + 2, 'text-anchor': 'end', 'font-size': 4.8, 'font-family': 'var(--mono)',
+ fill: 'var(--steel)'
+ }).textContent = (db > 0 ? '+' : '') + db;
+ }
+ const glowLine = svgEl(s, 'polyline', { points: '', fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' });
+ const curve = svgEl(s, 'polyline', { points: '', fill: 'none', stroke: 'var(--gold)', 'stroke-width': 1.6 });
+ const handle = svgEl(s, 'circle', { r: 4, fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': 1 });
+ handle.style.cursor = 'move';
+ let fc, gain;
+ const set = (f, g) => {
+ fc = Math.max(32, Math.min(16000, f)); gain = Math.max(-12, Math.min(12, g));
+ const sigma = 0.28; let pts = '';
+ for (let i = 0; i <= 76; i++) {
+ const lf = RG.fLo + i / 76 * (RG.fHi - RG.fLo);
+ const db = gain * Math.exp(-Math.pow(lf - Math.log10(fc), 2) / (2 * sigma * sigma));
+ pts += `${xOf(Math.pow(10, lf)).toFixed(1)},${yOf(db).toFixed(1)} `;
+ }
+ curve.setAttribute('points', pts.trim()); glowLine.setAttribute('points', pts.trim());
+ handle.setAttribute('cx', xOf(fc)); handle.setAttribute('cy', yOf(gain));
+ const fLbl = fc < 1000 ? Math.round(fc) + ' Hz' : (fc / 1000).toFixed(1) + ' kHz';
+ onChange([fc, gain], `${fLbl} · ${gain >= 0 ? '+' : ''}${gain.toFixed(1)} dB`);
+ };
+ /* 2D drag: the plot places the peak in both axes at once */
+ s.style.touchAction = 'none';
+ s.addEventListener('pointerdown', e => {
+ s.setPointerCapture(e.pointerId);
+ const move = ev => {
+ const r = s.getBoundingClientRect(), sx = 190 / r.width, sy = 110 / r.height;
+ const px = (ev.clientX - r.left) * sx, py = (ev.clientY - r.top) * sy;
+ const lf = RG.fLo + Math.max(0, Math.min(1, (px - RG.x0) / (RG.x1 - RG.x0))) * (RG.fHi - RG.fLo);
+ const g = RG.db - Math.max(0, Math.min(1, (py - RG.y0) / (RG.y1 - RG.y0))) * 2 * RG.db;
+ set(Math.pow(10, lf), g);
+ };
+ move(e);
+ const up = () => { s.removeEventListener('pointermove', move); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); };
+ s.addEventListener('pointermove', move); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up);
+ e.preventDefault();
+ });
+ set(opts.fc !== undefined ? opts.fc : 1200, opts.gain !== undefined ? opts.gain : 6.5);
+ return { el: s, get: () => [fc, gain], set };
+};
+
+/* R30 telegraph indicator — the pointer names the active state on labeled
+ sectors, engine-telegraph style; click steps the state */
+GW.telegraphIndicator = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const states = opts.states || ['OK', 'TEST', 'BUSY', 'STOP', 'ATTN', 'FAULT'];
+ const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55, N = states.length;
+ svgEl(s, 'circle', { cx, cy, r: 50, fill: '#171412', stroke: '#060505', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy, r: 43, fill: 'var(--cream)', stroke: '#b7b29a', 'stroke-width': 1 });
+ states.forEach((st, i) => {
+ const a = i * 360 / N - 90 + 180 / N;
+ const [lx1, ly1] = polar(cx, cy, 9, i * 360 / N), [lx2, ly2] = polar(cx, cy, 43, i * 360 / N);
+ svgEl(s, 'line', { x1: lx1, y1: ly1, x2: lx2, y2: ly2, stroke: '#2b2418', 'stroke-width': 1 });
+ const g = svgEl(s, 'g', { transform: `rotate(${a + 90},${cx},${cy})` });
+ svgEl(g, 'text', {
+ x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 7, 'font-weight': 700,
+ 'font-family': 'var(--mono)', fill: '#2b2418'
+ }).textContent = st;
+ });
+ const ptr = svgEl(s, 'g', {}); ptr.style.transition = 'transform .18s'; ptr.style.transformOrigin = `${cx}px ${cy}px`;
+ svgEl(ptr, 'polygon', { points: `${cx - 2.5},${cy} ${cx},${cy - 38} ${cx + 2.5},${cy}`, fill: 'var(--fail)', stroke: '#7a2a1a', 'stroke-width': .5 });
+ svgEl(ptr, 'rect', { x: cx + 1, y: cy - 16, width: 10, height: 7, rx: 1, fill: 'var(--gold)', stroke: '#7d5c16', 'stroke-width': .6 });
+ svgEl(s, 'circle', { cx, cy, r: 8, fill: '#171412', stroke: '#000' });
+ let si;
+ const set = i => {
+ si = ((i % N) + N) % N;
+ ptr.style.transform = `rotate(${si * 360 / N + 180 / N}deg)`;
+ onChange(si, states[si]);
+ };
+ s.addEventListener('click', () => set(si + 1));
+ set(opts.index || 0);
+ return { el: s, get: () => si, set };
+};
+
+/* R31 radar sweep — the beam circles the bearing ring on the builder's own
+ clock (reduced-motion gated), contacts bloom as it passes; click marks the bearing */
+GW.radarSweep = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const contacts = opts.contacts || [[60, .55], [205, .7], [318, .4]];
+ const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
+ const clipId = uid('radarClip');
+ const defs = svgEl(s, 'defs', {});
+ const clip = svgEl(defs, 'clipPath', { id: clipId });
+ svgEl(clip, 'circle', { cx, cy, r: 46 });
+ svgEl(s, 'circle', { cx, cy, r: 62, fill: '#1a1714', stroke: '#060505', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy, r: 52, fill: 'var(--scr-bg1,#120b05)', stroke: 'var(--scr-brd,#3a2a12)', 'stroke-width': 1 });
+ for (let d = 0; d < 360; d += 30) {
+ const [x1, y1] = polar(cx, cy, 51, d), [x2, y2] = polar(cx, cy, 47, d);
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--scr-ink,var(--gold))', 'stroke-width': 1, opacity: .8 });
+ const g = svgEl(s, 'g', { transform: `rotate(${d},${cx},${cy})` });
+ svgEl(g, 'text', {
+ x: cx, y: cy - 47.5, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
+ fill: 'var(--scr-ink,var(--gold))', opacity: .9
+ }).textContent = String(d).padStart(3, '0');
+ }
+ for (let d = 0; d < 360; d += 10) {
+ if (d % 30 === 0) continue;
+ const [x1, y1] = polar(cx, cy, 51, d), [x2, y2] = polar(cx, cy, 49, d);
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--scr-ink,var(--gold))', 'stroke-width': .6, opacity: .5 });
+ }
+ const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
+ svgEl(g, 'circle', { cx, cy, r: 46, fill: 'var(--scr-bg2,#0d0803)' });
+ for (const r of [15, 30, 45])
+ svgEl(g, 'circle', { cx, cy, r, fill: 'none', stroke: 'var(--scr-ink,var(--gold))', 'stroke-opacity': .18, 'stroke-width': .7 });
+ const sweep = svgEl(g, 'g', {});
+ for (let t = 0; t < 5; t++) {
+ const a0 = -14 + t * 2.8, a1 = a0 + 2.8;
+ const [x1, y1] = polar(cx, cy, 46, a0), [x2, y2] = polar(cx, cy, 46, a1);
+ svgEl(sweep, 'path', {
+ d: `M ${cx} ${cy} L ${x1} ${y1} A 46 46 0 0 1 ${x2} ${y2} Z`,
+ fill: 'var(--scr-ink,var(--gold))', 'fill-opacity': (0.04 + t * 0.045).toFixed(3)
+ });
+ }
+ svgEl(sweep, 'line', {
+ x1: cx, y1: cy, x2: cx, y2: cy - 46, stroke: 'var(--scr-hi,var(--gold-hi))', 'stroke-width': 1.4,
+ transform: `rotate(0,${cx},${cy})`, opacity: .95
+ });
+ const blips = contacts.map(([bd, rf]) => {
+ const [x, y] = polar(cx, cy, 46 * rf, bd);
+ return svgEl(g, 'circle', { cx: x, cy: y, r: 2.2, fill: 'var(--scr-hi,var(--gold-hi))', opacity: 0 });
+ });
+ svgEl(s, 'circle', { cx, cy, r: 3, fill: 'var(--scr-ink,var(--gold))', opacity: .8 });
+ const mark = svgEl(s, 'text', {
+ x: cx, y: 124, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em',
+ 'font-family': 'var(--mono)', fill: 'var(--steel)'
+ });
+ mark.textContent = 'BEARING —';
+ let a = opts.bearing !== undefined ? opts.bearing : 210;
+ const tick = () => {
+ a = (a + 2.6) % 360;
+ sweep.setAttribute('transform', `rotate(${a},${cx},${cy})`);
+ contacts.forEach(([bd], i) => {
+ const d = (a - bd + 360) % 360; /* degrees since beam passed */
+ blips[i].setAttribute('opacity', d < 200 ? Math.max(0, .95 - d / 200) : 0);
+ });
+ mark.textContent = mark.textContent.startsWith('MARK') ? mark.textContent :
+ 'BEARING ' + String(Math.round(a) % 360).padStart(3, '0');
+ };
+ s.addEventListener('click', () => {
+ const b = String(Math.round(a) % 360).padStart(3, '0');
+ mark.textContent = 'MARK ' + b;
+ onChange(a, 'mark ' + b);
+ });
+ tick(); onChange(a, 'sweeping');
+ if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 50);
+ return { el: s, get: () => a, tick };
+};
+
+/* R35 day-date disc calendar — coaxial printed discs rotate so today reads
+ under the fixed hand; starts on today; click rolls midnight forward */
+GW.dayDateCal = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
+ const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65, HAND = 135;
+ gradDef('ddFace', 'radialGradient', { cx: '46%', cy: '40%', r: '75%' }, [['0', '#efe9da'], ['1', '#cfc8b6']]);
+ /* movement plate + white calendar disc */
+ svgEl(s, 'circle', { cx, cy, r: 62, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
+ for (let k = 0; k < 5; k++) {
+ const [x, y] = polar(cx, cy, 58, 20 + k * 72);
+ svgEl(s, 'circle', { cx: x, cy: y, r: 1.8, fill: '#060505' });
+ }
+ svgEl(s, 'circle', { cx, cy, r: 54, fill: 'url(#ddFace)', stroke: '#8f897b', 'stroke-width': .8 });
+ /* date ring: 31 radial numerals on its own group */
+ const dateG = svgEl(s, 'g', {});
+ for (let d = 1; d <= 31; d++) {
+ const a = (d - 1) * (360 / 31);
+ const g = svgEl(dateG, 'g', { transform: `rotate(${a},${cx},${cy})` });
+ svgEl(g, 'text', {
+ x: cx, y: cy - 46, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700,
+ 'font-family': 'var(--mono)', fill: '#14110e'
+ }).textContent = String(d);
+ }
+ /* inner weekday disc: 7 names twice around, separated by a hairline ring */
+ svgEl(s, 'circle', { cx, cy, r: 38, fill: 'none', stroke: '#8f897b', 'stroke-width': .7 });
+ const dayG = svgEl(s, 'g', {});
+ for (let i = 0; i < 14; i++) {
+ const a = i * (360 / 14);
+ const g = svgEl(dayG, 'g', { transform: `rotate(${a},${cx},${cy})` });
+ svgEl(g, 'text', {
+ x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 6.8, 'font-weight': 700,
+ 'font-family': 'var(--mono)', fill: '#14110e'
+ }).textContent = DAYS[i % 7];
+ }
+ /* fixed yellow hand from hub toward the read index, and the hub */
+ const [hx, hy] = polar(cx, cy, 44, HAND);
+ svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: '#e8cf4a', 'stroke-width': 3, 'stroke-linecap': 'round' });
+ svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 });
+ svgEl(s, 'circle', { cx, cy, r: 6, fill: '#b8b2a4', stroke: '#5c574c', 'stroke-width': 1.2 });
+ svgEl(s, 'circle', { cx, cy, r: 2.4, fill: '#7a7466' });
+ s.style.cursor = 'pointer';
+ let date, day;
+ const set = (d, w) => {
+ date = d; day = w;
+ dateG.setAttribute('transform', `rotate(${(HAND - (d - 1) * (360 / 31)).toFixed(2)},${cx},${cy})`);
+ dayG.setAttribute('transform', `rotate(${(HAND - w * (360 / 14)).toFixed(2)},${cx},${cy})`);
+ onChange([d, w], DAYS[w] + ' ' + d);
+ };
+ s.addEventListener('click', () => set(date % 31 + 1, (day + 1) % 7));
+ const now = new Date();
+ set(opts.date !== undefined ? opts.date : now.getDate(),
+ opts.day !== undefined ? opts.day : now.getDay());
+ return { el: s, get: () => [date, day], set };
+};
+
+/* R36 LED dot matrix — 8x8 paintable bitmap behind a tinted window; click dots
+ to paint; starts on the reference K */
+GW.dotMatrix = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const N = 8, STEP = 11, X0 = 10.5, Y0 = 10.5;
+ const glyph = opts.glyph || ['10000100', '10001000', '10010000', '11100000', '10100000', '10010000', '10001000', '10000100'];
+ const s = stageSvg(host, 'rsvg press', 108, 108);
+ svgEl(s, 'rect', { x: 2, y: 2, width: 104, height: 104, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
+ svgEl(s, 'rect', { x: 6, y: 6, width: 96, height: 96, rx: 5, fill: '#0d0505', stroke: '#241010', 'stroke-width': 1 });
+ let lit = 0;
+ const paint = (d, on) => {
+ d.on = on;
+ d.el.setAttribute('fill', on ? '#ff4a30' : '#2a1210');
+ d.el.setAttribute('filter', on ? 'drop-shadow(0 0 3.5px rgba(255,74,48,.85))' : 'none');
+ };
+ for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
+ const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2, cy: Y0 + r * STEP + STEP / 2, r: 4.1, fill: '#2a1210' });
+ el.style.cursor = 'pointer';
+ const d = { el, on: false };
+ paint(d, glyph[r][c] === '1'); if (d.on) lit++;
+ el.addEventListener('click', () => {
+ paint(d, !d.on); lit += d.on ? 1 : -1;
+ onChange(lit, lit + ' / 64 lit');
+ });
+ }
+ onChange(lit, lit + ' / 64 lit');
+ return { el: s, get: () => lit };
+};
+
+/* R45 flip-disc tile array — bistable mechanical pixels; scaleX flip with the
+ color swap at the midpoint; click a disc to flip it */
+GW.flipDisc = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const COLS = 7, ROWS = 5, STEP = 14, X0 = 13, Y0 = 13;
+ const glyph = opts.glyph || ['0000100', '0000010', '1111111', '0000010', '0000100']; /* arrow right */
+ const s = stageSvg(host, 'rsvg press', 120, 92);
+ svgEl(s, 'rect', { x: 2, y: 2, width: 116, height: 88, rx: 7, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
+ let lit = 0;
+ for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
+ const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2 - 2, cy: Y0 + r * STEP + STEP / 2 - 2, r: 5.6, stroke: '#060505', 'stroke-width': .8 });
+ el.style.transformBox = 'fill-box'; el.style.transformOrigin = 'center'; el.style.transition = 'transform .11s ease-in';
+ el.style.cursor = 'pointer';
+ const d = { el, on: glyph[r][c] === '1' };
+ el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); if (d.on) lit++;
+ el.addEventListener('click', () => {
+ d.on = !d.on; lit += d.on ? 1 : -1;
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); }
+ else {
+ el.style.transform = 'scaleX(0)';
+ setTimeout(() => { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); el.style.transform = 'scaleX(1)'; }, 115);
+ }
+ onChange(lit, lit + ' discs showing');
+ });
+ }
+ onChange(lit, lit + ' discs showing');
+ return { el: s, get: () => lit };
+};
+
+/* R46 dekatron counting ring — each click pulses the neon glow one cathode
+ around; the wrap flashes the carry dot */
+GW.dekatron = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 120, 120), cx = 60, cy = 60;
+ svgEl(s, 'circle', { cx, cy, r: 52, fill: '#171310', stroke: '#060505', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy, r: 46, fill: '#0d0a08', stroke: '#2a221c', 'stroke-width': 1 });
+ svgEl(s, 'ellipse', { cx: cx - 14, cy: cy - 18, rx: 20, ry: 11, fill: 'rgba(255,255,255,.05)', transform: `rotate(-24,${cx - 14},${cy - 18})` });
+ const dots = [];
+ for (let i = 0; i < 10; i++) {
+ const [x, y] = polar(cx, cy, 34, i * 36);
+ svgEl(s, 'text', {
+ x: polar(cx, cy, 43, i * 36)[0], y: polar(cx, cy, 43, i * 36)[1] + 2, 'text-anchor': 'middle', 'font-size': 5.2,
+ 'font-family': 'var(--mono)', fill: 'var(--steel)'
+ }).textContent = String(i);
+ dots.push(svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: '#3a2a20' }));
+ }
+ const carry = svgEl(s, 'circle', { cx, cy: cy - 6, r: 3, fill: '#2a1c14' });
+ svgEl(s, 'text', {
+ x: cx, y: cy + 8, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.12em',
+ 'font-family': 'var(--mono)', fill: 'var(--steel)'
+ }).textContent = 'CARRY';
+ let pos = opts.count || 0, carries = 0;
+ const draw = () => {
+ dots.forEach((d, i) => {
+ const on = i === pos;
+ d.setAttribute('fill', on ? '#ff9a4c' : '#3a2a20');
+ d.setAttribute('filter', on ? 'drop-shadow(0 0 5px rgba(255,154,76,.9))' : 'none');
+ });
+ onChange(pos, 'COUNT ' + pos + (carries ? ' · CARRY x' + carries : ''));
+ };
+ s.style.cursor = 'pointer';
+ s.addEventListener('click', () => {
+ pos = (pos + 1) % 10;
+ if (pos === 0) {
+ carries++; carry.setAttribute('fill', '#ff9a4c');
+ carry.setAttribute('filter', 'drop-shadow(0 0 4px rgba(255,154,76,.9))');
+ setTimeout(() => { carry.setAttribute('fill', '#2a1c14'); carry.removeAttribute('filter'); }, 220);
+ }
+ draw();
+ });
+ draw();
+ return { el: s, get: () => pos };
+};
+
+/* R47 landing gear indicator — three greens or nothing; the lever cycles
+ down → transit (amber pulse) → up */
+GW.gearIndicator = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 140, 110);
+ svgEl(s, 'rect', { x: 2, y: 2, width: 136, height: 106, rx: 9, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
+ const POS = [['NOSE', 48, 28], ['L', 24, 74], ['R', 72, 74]];
+ const lamps = {};
+ POS.forEach(([n, x, y]) => {
+ svgEl(s, 'circle', { cx: x, cy: y, r: 9, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1.5 });
+ lamps[n] = svgEl(s, 'circle', { cx: x, cy: y, r: 6.2, fill: '#16240f' });
+ svgEl(s, 'text', {
+ x, y: y + 18, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em',
+ 'font-family': 'var(--mono)', fill: 'var(--steel)'
+ }).textContent = n;
+ });
+ /* gear lever: slot + wheel-shaped knob */
+ svgEl(s, 'rect', { x: 106, y: 22, width: 8, height: 66, rx: 4, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 });
+ const lever = svgEl(s, 'g', {});
+ svgEl(lever, 'rect', { x: 107.5, y: 36, width: 5, height: 26, fill: '#8f8a7e' });
+ svgEl(lever, 'circle', { cx: 110, cy: 32, r: 8, fill: '#26221c', stroke: '#4a463e', 'stroke-width': 2.4 });
+ svgEl(lever, 'circle', { cx: 110, cy: 32, r: 3, fill: '#4a463e' });
+ svgEl(s, 'text', {
+ x: 110, y: 100, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em',
+ 'font-family': 'var(--mono)', fill: 'var(--steel)'
+ }).textContent = 'GEAR';
+ let state = opts.state || 'down', busy = false;
+ const paint = () => {
+ const c = state === 'down' ? 'var(--pass)' : state === 'transit' ? 'var(--amber-warn)' : '#16240f';
+ for (const n of Object.keys(lamps)) {
+ lamps[n].setAttribute('fill', c);
+ lamps[n].setAttribute('filter', state === 'down' ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' :
+ state === 'transit' ? 'drop-shadow(0 0 4px rgba(var(--glow-lo),.7))' : 'none');
+ lamps[n].style.animation = state === 'transit' ? 'pulse var(--pulse-rate) ease-in-out infinite' : 'none';
+ }
+ lever.setAttribute('transform', state === 'up' ? 'translate(0,-8)' : state === 'transit' ? 'translate(0,-4)' : '');
+ onChange(state, state === 'down' ? 'GEAR DOWN · 3 GREEN' : state === 'up' ? 'GEAR UP' : 'IN TRANSIT');
+ };
+ s.style.cursor = 'pointer';
+ s.addEventListener('click', () => {
+ if (busy) return;
+ const target = state === 'down' ? 'up' : 'down';
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) { state = target; paint(); return; }
+ busy = true; state = 'transit'; paint();
+ setTimeout(() => { state = target; busy = false; paint(); }, 1500);
+ });
+ paint();
+ return { el: s, get: () => state };
+};
+
+/* R52 blinkenlights front panel — address/data lamps ripple with a live
+ pseudo-PC (builder-owned clock, reduced-motion gated) that folds in the
+ switch register; flip the SR bits and the pattern changes */
+GW.blinkenlights = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 170, 100);
+ svgEl(s, 'rect', { x: 2, y: 2, width: 166, height: 96, rx: 8, fill: '#3a1420', stroke: '#060505', 'stroke-width': 1.5 });
+ svgEl(s, 'rect', { x: 8, y: 8, width: 154, height: 62, rx: 5, fill: '#14090e' });
+ const mkRow = (y, lbl) => {
+ const row = [];
+ svgEl(s, 'text', {
+ x: 12, y: y + 2.6, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)',
+ fill: '#b08a96'
+ }).textContent = lbl;
+ for (let i = 0; i < 12; i++) row.push(svgEl(s, 'circle', { cx: 46 + i * 10, cy: y, r: 3, fill: '#2a1210' }));
+ return row;
+ };
+ const addr = mkRow(24, 'ADDR'), data = mkRow(46, 'DATA');
+ const paint = (row, val) => row.forEach((d, i) => {
+ const on = (val >> (11 - i)) & 1;
+ d.setAttribute('fill', on ? '#ffb43a' : '#2a1210');
+ d.setAttribute('filter', on ? 'drop-shadow(0 0 3px rgba(255,180,58,.8))' : 'none');
+ });
+ /* switch register: 8 mini toggles */
+ let sr = opts.sr !== undefined ? opts.sr : 0b10100101;
+ const toggles = [];
+ for (let i = 0; i < 8; i++) {
+ const x = 46 + i * 14;
+ svgEl(s, 'rect', { x: x - 3, y: 76, width: 6, height: 16, rx: 3, fill: '#0d0b09', stroke: '#4a2432', 'stroke-width': 1 });
+ const t = svgEl(s, 'circle', { cx: x, cy: 80, r: 4, fill: '#8f8a7e', stroke: '#3c382f', 'stroke-width': 1 });
+ t.style.cursor = 'pointer';
+ const hit = svgEl(s, 'rect', { x: x - 7, y: 72, width: 14, height: 24, fill: 'transparent' });
+ hit.style.cursor = 'pointer';
+ hit.addEventListener('click', () => { sr ^= (1 << (7 - i)); draw(); });
+ toggles.push(t);
+ }
+ svgEl(s, 'text', {
+ x: 12, y: 83, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)',
+ fill: '#b08a96'
+ }).textContent = 'SR';
+ let pc = 0o1234;
+ const draw = () => {
+ toggles.forEach((t, i) => t.setAttribute('cy', ((sr >> (7 - i)) & 1) ? 76 : 84));
+ onChange(sr, 'SR ' + sr.toString(8).padStart(3, '0') + ' (octal)');
+ };
+ const tick = () => { pc = ((pc * 5 + sr + 1) & 0xFFF); paint(addr, pc); paint(data, (pc ^ (pc << 3) ^ sr) & 0xFFF); };
+ draw(); tick();
+ if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 250);
+ return { el: s, get: () => sr, tick };
+};
+
+/* ---- widget CSS: injected once, grows as builders move in ---- */
+/* widget-internal CSS (moved from the gallery <style> block; gallery keeps page furniture) */
+const GW_CSS = `
+/* ---- shared primitives ---- */
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse var(--pulse-rate) ease-in-out infinite}
+/* standard pulse: 1s ease-in-out — the norm for every pulsing / flashing element */
+@keyframes pulse{50%{opacity:.25}}
+
+/* slide toggle — after the iPod-hold-switch pill: dome thumb, revealed state engraving.
+ Flex layout with fixed px only: absolute insets and stretched widths misrender under stage zoom. */
+.switch{display:inline-flex;vertical-align:middle;align-items:center;justify-content:space-between;
+ width:52px;height:23px;border-radius:12px;padding:2px 7px 2px 2px;cursor:pointer;
+ background:var(--sw-off-bg,linear-gradient(180deg,#15130f,#242019));
+ border:1px solid var(--sw-off-brd,var(--slate));
+ box-shadow:inset 0 1.5px 3px rgba(0,0,0,.55)}
+.switch::before{content:"OFF";order:2;font-size:7px;letter-spacing:.06em;font-weight:700;
+ font-family:var(--mono);color:var(--sw-off-ink,var(--cream))}
+.switch::after{content:"";order:1;width:17px;height:17px;border-radius:50%;
+ background:var(--sw-thumb,radial-gradient(circle at 35% 28%,#f6f2e8,#b0aa9a));
+ box-shadow:0 1px 2.5px rgba(0,0,0,.65),inset 0 1px 0 rgba(255,255,255,.25)}
+.switch.on{background:var(--sw-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold)));
+ border-color:var(--sw-on-brd,var(--gold-hi));
+ padding:2px 2px 2px 7px;box-shadow:inset 0 1.5px 3px rgba(0,0,0,.3)}
+.switch.on::before{content:"ON";order:1;color:var(--sw-on-ink,var(--panel))}
+.switch.on::after{order:2}
+.switch.red{background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail);padding:2px 2px 2px 7px}
+.switch.red::before{content:"OFF";order:1;color:var(--cream)}
+.switch.red::after{order:2}
+
+.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:8px 12px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.key:hover{color:var(--gold);border-color:var(--gold)}
+.key:active{transform:translateY(1px)}
+.key.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi);font-weight:700}
+.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.key.off{opacity:.4}
+
+.chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px}
+.chip.on{color:var(--gold);border-color:var(--gold)}
+
+.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px}
+.badge.red{background:var(--fail);color:var(--cream)}
+.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+
+/* fader */
+.fader{width:150px;height:16px;position:relative;cursor:pointer;touch-action:none}
+.fader .slot{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold))}
+.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px;
+ background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)}
+/* vertical fader */
+.vfader{width:16px;height:64px;position:relative;cursor:pointer;touch-action:none}
+.vfader .slot{position:absolute;left:6px;top:0;bottom:0;width:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.vfader .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,var(--amber-grad-bot),var(--gold))}
+.vfader .cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px;
+ background:linear-gradient(90deg,var(--amber-grad-mid),var(--amber-grad-top));border:1px solid var(--amber-edge)}
+
+/* rotary knob */
+.knob{width:52px;height:52px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none;
+ background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c;
+ box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
+.knob .ind{position:absolute;left:50%;top:5px;width:2px;height:16px;background:var(--gold-hi);
+ margin-left:-1px;transform-origin:50% 21px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)}
+
+/* needle gauge */
+.gauge{width:96px;cursor:ns-resize;touch-action:none}
+.gauge .dial{position:relative;height:48px;overflow:hidden}
+.gauge .arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%}
+.gauge .tk{position:absolute;left:50%;top:1px;width:1.5px;height:8px;margin-left:-.75px;background:var(--steel);transform-origin:50% 47px}
+.gauge .ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(0deg);border-radius:2px;box-shadow:0 0 6px rgba(var(--glow-hi),.5);
+ transition:transform .12s cubic-bezier(.3,1.3,.5,1)}
+.gauge .hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)}
+.gauge .gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums}
+
+/* segmented VU / LED bar */
+.vu{width:170px;display:flex;flex-direction:column;gap:5px}
+.vurow{display:flex;align-items:center;gap:7px}
+.vurow .ch{color:var(--steel);font-size:.6rem;width:8px}
+.vubar{flex:1;display:flex;gap:2px;height:9px}
+.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3}
+.vubar i.on{opacity:1;background:var(--pass)}.vubar i.hot{opacity:1;background:var(--gold)}
+.vubar i.clip{opacity:1;background:var(--fail)}.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px}
+
+/* mini 4-bar signal */
+.sig{display:flex;align-items:flex-end;gap:2px;height:18px}
+.sig i{width:4px;background:var(--wash);border-radius:1px}
+.sig i:nth-child(1){height:5px}.sig i:nth-child(2){height:9px}.sig i:nth-child(3){height:13px}.sig i:nth-child(4){height:17px}
+.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)}
+
+/* signal ladder (wifi bars) */
+.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px;cursor:pointer}
+.ladder i{width:5px;background:var(--wash);border-radius:1px}
+.ladder i:nth-child(1){height:6px}.ladder i:nth-child(2){height:10px}
+.ladder i:nth-child(3){height:14px}.ladder i:nth-child(4){height:18px}
+
+/* linear progress / fuel bar */
+.bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none}
+.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold));border-radius:6px}
+.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+
+/* radial ring */
+.ring{width:60px;height:60px;border-radius:50%;cursor:ns-resize;touch-action:none;
+ background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);
+ display:grid;place-items:center;position:relative}
+.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums}
+
+/* tabular readout */
+.readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em;cursor:pointer}
+.readout small{color:var(--dim);font-size:12px;font-weight:400}
+.readout .u{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px}
+
+/* sparkline */
+.spark{width:170px;height:44px}
+.spark svg{display:block;width:100%;height:100%}
+
+/* lamp row (list item) */
+.lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px}
+.lrow:hover{background:var(--wash)}
+.lrow .who{color:var(--silver)}.lrow .who b{color:var(--cream)}
+.lrow .what{margin-left:auto;color:var(--dim);font-size:11px}
+
+/* arm-to-fire */
+.arm{font:inherit;font-size:11.5px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;
+ border-radius:8px;padding:7px 12px}
+.arm.armed{background:rgba(203,107,77,.12);border-color:var(--fail);color:var(--fail)}
+
+/* stepper / segmented selector */
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden}
+.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer}
+.seg button:last-child{border-right:0}
+.seg button.on{background:var(--seg-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold)));color:var(--seg-on-ink,var(--panel));font-weight:700}
+
+/* engraved section label */
+.engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer;
+ display:flex;align-items:center;gap:9px}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave::before{max-width:10px}
+.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none}
+
+/* waveform strip */
+.wave{width:170px;height:38px}
+.wave svg{width:100%;height:100%;display:block}
+
+/* toast */
+.toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px;cursor:pointer}
+
+/* output well (log step) */
+.owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px;cursor:pointer}
+.ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0}
+.ostep .lamp{margin-top:3px;width:7px;height:7px}
+.ostep b{color:var(--cream);font-weight:700}.ostep .ev{color:var(--steel);display:block;font-size:10.5px}
+
+/* rotary selector */
+.rotsel{position:relative;width:118px;height:74px}
+.rotsel>.knob{position:absolute;left:50%;top:20px;margin-left:-26px;cursor:pointer}
+.rotsel .pos{position:absolute;font-size:9px;color:var(--dim);transform:translate(-50%,-50%);letter-spacing:.02em}
+.rotsel .pos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
+
+/* slide-rule tuner dial */
+.tuner{width:180px;height:46px;position:relative;border-radius:6px;overflow:hidden;cursor:pointer;
+ background:var(--tn-bg,linear-gradient(180deg,#191510,#0b0908));border:1px solid var(--tn-brd,#2a251c);
+ box-shadow:inset 0 0 20px var(--tn-glow,rgba(var(--glow-lo),.12)),inset 0 1px 0 rgba(255,255,255,.03)}
+.tuner .tick{position:absolute;top:6px;width:1px;height:11px;background:var(--tn-tick,var(--steel));transform:translateX(-50%)}
+.tuner .tick.mn{height:6px;opacity:.5}
+.tuner .mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--tn-ink,var(--steel));font-size:10px}
+.tuner .mk.on{color:var(--tn-on,var(--gold-hi));text-shadow:0 0 6px var(--tn-onglow,rgba(var(--glow-hi),.6))}
+.tuner:focus{outline:1px solid rgba(var(--glow-lo),.5);outline-offset:2px}
+.tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
+.tuner .ndl{position:absolute;top:3px;bottom:3px;width:2px;margin-left:-1px;border-radius:1px;
+ background:var(--tn-ndl,var(--fail));box-shadow:0 0 7px var(--tn-ndlglow,rgba(203,107,77,.85));transition:left .25s}
+
+/* nixie tube */
+.nixie{display:inline-flex;gap:5px;cursor:pointer}
+.nixie .tube{width:30px;height:44px;border-radius:5px;position:relative;overflow:hidden;
+ background:radial-gradient(circle at 50% 38%,#241a12,#0b0807);border:1px solid #2c261d;
+ display:grid;place-items:center;box-shadow:inset 0 0 12px rgba(0,0,0,.6)}
+.nixie .tube b{font-size:26px;font-weight:400;color:#ff9a3c;
+ text-shadow:0 0 6px rgba(255,140,50,.85),0 0 15px rgba(255,120,40,.45)}
+.nixie .tube.off b{color:#3a2a1c;text-shadow:none}
+.nixie .tube::after{content:"";position:absolute;inset:0;pointer-events:none;
+ background:linear-gradient(180deg,rgba(255,255,255,.06),transparent 32%)}
+
+/* ===== candidate-unique CSS ===== */
+/* rocker */
+.rocker{width:66px;height:40px;border-radius:7px;cursor:pointer;position:relative;
+ background:linear-gradient(180deg,#25211c,#141210);border:1px solid #34302a;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5);overflow:hidden}
+.rocker .half{position:absolute;left:0;right:0;height:50%;display:flex;align-items:center;justify-content:center;
+ font-size:12px;letter-spacing:.06em;color:var(--dim)}
+.rocker .top{top:0;border-bottom:1px solid #0c0b0a;background:linear-gradient(180deg,#211d19,#181513)}
+.rocker .bot{bottom:0;background:linear-gradient(180deg,#141210,#100e0c);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)}
+.rocker.on .top{background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color:var(--panel);font-weight:700;box-shadow:0 0 10px rgba(var(--glow-hi),.35)}
+.rocker.on .bot{color:var(--steel)}
+.rocker:not(.on) .top{color:var(--steel)}
+.rocker:not(.on) .bot{background:linear-gradient(180deg,#2a1512,#1c0f0d);color:var(--fail);box-shadow:inset 0 3px 5px rgba(0,0,0,.55),0 0 8px rgba(203,107,77,.25)}
+
+/* transport */
+.reels{display:flex;gap:16px;align-items:center;justify-content:center;margin-bottom:2px}
+.reel{width:26px;height:26px;border-radius:50%;border:2px solid #34302a;position:relative;
+ background:radial-gradient(circle at 45% 40%,#211d18,#0d0b09)}
+.reel::before{content:"";position:absolute;inset:9px;border-radius:50%;border:1px solid #4a443a;background:#161310}
+.reel i{position:absolute;left:50%;top:50%;width:2px;height:11px;background:var(--steel);margin:-5.5px 0 0 -1px;transform-origin:50% 5.5px}
+.reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(var(--glow-hi),.55)}
+.reel i:nth-child(2){transform:rotate(120deg)}.reel i:nth-child(3){transform:rotate(240deg)}
+.transport{display:flex;gap:5px}
+.tbtn{font:inherit;font-size:12px;color:var(--silver);cursor:pointer;width:30px;height:26px;display:grid;place-items:center;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:6px}
+.tbtn:hover{color:var(--gold);border-color:var(--gold)}
+.tbtn.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi)}
+.tbtn.rec.on{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+
+/* radio bank */
+.radiobank{display:flex;gap:0;border:1px solid #0c0b0a;border-radius:7px;overflow:hidden;
+ background:#0c0b0a;padding:3px;box-shadow:inset 0 2px 4px rgba(0,0,0,.5)}
+.preset{font:inherit;font-size:11px;color:var(--dim);cursor:pointer;padding:8px 9px;border:0;border-radius:4px;
+ background:linear-gradient(180deg,#211d19,#161310);box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}
+.preset+.preset{margin-left:3px}
+.preset:hover{color:var(--silver)}
+.preset.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));font-weight:700;
+ box-shadow:inset 0 2px 4px rgba(0,0,0,.4)}
+
+/* concentric dual knob */
+.dualknob{width:64px;height:64px;position:relative}
+.dualknob .outer{width:64px;height:64px;border-radius:50%;position:absolute;inset:0;cursor:ns-resize;touch-action:none;
+ background:radial-gradient(circle at 40% 35%,#2a2622,#100e0c);border:1px solid #3a352c;
+ box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
+.dualknob .outer .tick{position:absolute;left:50%;top:3px;width:2px;height:9px;background:var(--steel);margin-left:-1px;transform-origin:50% 29px}
+.dualknob .inner{width:34px;height:34px;border-radius:50%;position:absolute;left:15px;top:15px;cursor:ns-resize;touch-action:none;
+ background:radial-gradient(circle at 40% 35%,#37322a,#1a1713);border:1px solid #4a443a;box-shadow:0 2px 4px rgba(0,0,0,.5)}
+.dualknob .inner .ind{position:absolute;left:50%;top:3px;width:2px;height:11px;background:var(--gold-hi);margin-left:-1px;
+ transform-origin:50% 14px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)}
+
+/* rotary encoder + LED ring */
+.encoder{position:relative;width:66px;height:66px;display:grid;place-items:center;cursor:ns-resize;touch-action:none}
+.encoder .led{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--wash);
+ left:50%;top:50%;margin:-2.5px}
+.encoder .led.on{background:var(--gold);box-shadow:0 0 5px 1px rgba(var(--glow-lo),.7)}
+.encoder .knob{width:40px;height:40px;cursor:ns-resize}
+.encoder .knob .ind{transform-origin:50% 15px;top:4px;height:12px}
+
+/* keyed mode switch */
+.keylock{position:relative;width:110px;height:70px}
+.keylock .body{position:absolute;left:50%;top:30px;margin-left:-21px;width:42px;height:42px;border-radius:50%;
+ background:radial-gradient(circle at 42% 36%,#2a2622,#0f0d0b);border:1px solid #3a352c;cursor:pointer;
+ box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
+.keylock .barrel{position:absolute;left:50%;top:50%;width:5px;height:5px;margin:-2.5px;border-radius:1px;background:#0a0908;
+ box-shadow:0 0 2px rgba(0,0,0,.8)}
+.keylock .bit{position:absolute;left:50%;top:50%;width:3px;height:15px;margin:-15px 0 0 -1.5px;background:var(--gold-hi);
+ border-radius:1px;transform-origin:50% 100%;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .2s}
+.keylock .kpos{position:absolute;font-size:9px;color:var(--dim);letter-spacing:.05em;transform:translate(-50%,-50%)}
+.keylock .kpos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
+
+/* crossfader */
+.xfader{width:160px;height:22px;position:relative;cursor:pointer;touch-action:none}
+.xfader .slot{position:absolute;top:9px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18}
+.xfader .detent{position:absolute;top:2px;left:50%;width:1px;height:18px;background:var(--steel);margin-left:-.5px;opacity:.7}
+.xfader .end{position:absolute;top:11px;font-size:9px;color:var(--steel);transform:translateY(-50%)}
+.xfader .cap{position:absolute;top:3px;width:9px;height:16px;border-radius:2px;margin-left:-4.5px;transition:left .05s;
+ background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)}
+
+/* thumbwheel */
+.thumbw{display:flex;align-items:center;gap:10px}
+.thumbwheel{width:34px;height:52px;border-radius:6px;cursor:ns-resize;touch-action:none;position:relative;overflow:hidden;
+ border:1px solid #34302a;box-shadow:inset 0 0 8px rgba(0,0,0,.6);
+ background:repeating-linear-gradient(0deg,#0e0c0a 0 2px,#221e19 2px 3px,#2f2a23 3px 5px,#221e19 5px 6px)}
+.thumbwheel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.55),transparent 30%,transparent 70%,rgba(0,0,0,.55))}
+.thumbw .win{color:var(--cream);font-size:15px;font-weight:700;font-variant-numeric:tabular-nums;
+ background:var(--well);border:1px solid #231f18;border-radius:4px;padding:3px 8px}
+
+/* DIP bank */
+.dip{display:flex;gap:3px;padding:6px 7px;background:#0f1a2a;border:1px solid #24344a;border-radius:5px;
+ box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
+.dipsw{width:12px;height:26px;background:#0a1220;border-radius:2px;position:relative;cursor:pointer;border:1px solid #1c2c42}
+.dipsw i{position:absolute;left:1px;right:1px;height:11px;border-radius:1px;background:linear-gradient(180deg,#eae4d2,#b4ad98);
+ bottom:1px;transition:bottom .12s,top .12s}
+.dipsw.on i{bottom:auto;top:1px;background:linear-gradient(180deg,var(--amber-grad-top),var(--gold))}
+
+/* jog / shuttle */
+.jog{position:relative;width:74px;height:74px;cursor:ns-resize;touch-action:none}
+.jog .shuttle{position:absolute;inset:0;border-radius:50%;border:6px solid #1a1713;
+ background:conic-gradient(from -90deg,#3a352c 0 var(--sh,40deg),#141210 0 360deg);
+ box-shadow:inset 0 0 8px rgba(0,0,0,.6)}
+.jog .inner{position:absolute;inset:12px;border-radius:50%;transition:transform .05s linear;
+ background:radial-gradient(circle at 42% 36%,#2c2822,#100e0c);border:1px solid #3a352c;box-shadow:0 2px 5px rgba(0,0,0,.6)}
+.jog .dimple{position:absolute;left:50%;top:7px;width:8px;height:8px;margin-left:-4px;border-radius:50%;
+ background:radial-gradient(circle at 40% 35%,#151210,#000);box-shadow:inset 0 1px 2px rgba(0,0,0,.9)}
+
+/* oscilloscope — screen-family vars with the original green as fallback */
+.scope{width:176px;height:78px;border-radius:6px;position:relative;overflow:hidden;
+ background:radial-gradient(circle at 50% 45%,var(--scr-bgc,#04140a),var(--scr-bge,#020a05));
+ border:1px solid var(--scr-brd,#123018);
+ box-shadow:inset 0 0 16px rgba(0,0,0,.7)}
+.scope .grat{position:absolute;inset:0;opacity:.5;
+ background-image:linear-gradient(var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px),linear-gradient(90deg,var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px);
+ background-size:22px 19.5px}
+.scope .grat::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:var(--scr-gratc,rgba(111,206,51,.35))}
+.scope svg{position:absolute;inset:0;width:100%;height:100%}
+.scope polyline{fill:none;stroke:var(--scr-hi,var(--phos));stroke-width:1.6;filter:drop-shadow(0 0 3px var(--scr-glow,rgba(127,224,160,.8)))}
+
+/* voice-loop keyset */
+.vloop{display:grid;grid-template-columns:repeat(4,52px);gap:4px}
+.vk{display:flex;flex-direction:column;align-items:stretch;gap:4px;font-size:7px;letter-spacing:.05em;
+ text-align:center;padding:6px 4px 4px;cursor:pointer;
+ color:var(--dim);background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-radius:4px;
+ font-family:var(--mono)}
+.vk .bar{height:3px;width:40px;align-self:center;border-radius:2px;background:#16240f}
+.vk.mon{color:var(--silver)}
+.vk.mon .bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
+.vk.mon.act .bar{background:#a8d84a;box-shadow:0 0 7px rgba(150,200,60,.9)}
+.vk.tlk{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));
+ border-color:var(--gold-hi);font-weight:700}
+.vk.tlk .bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
+
+/* spectrum / EQ */
+.eq{display:flex;align-items:flex-end;gap:3px;height:60px}
+.eq .band{width:9px;display:flex;flex-direction:column-reverse;gap:2px;height:100%}
+.eq .band i{height:5px;border-radius:1px;background:var(--wash);opacity:.3}
+.eq .band i.on{opacity:1;background:var(--pass)}
+.eq .band i.hot{opacity:1;background:var(--gold)}
+.eq .band i.clip{opacity:1;background:var(--fail)}
+
+/* crossed-needle */
+.crossm{width:120px}
+.crossm .face{position:relative;height:56px;overflow:hidden;cursor:ns-resize;touch-action:none}
+.crossm .arc{position:absolute;inset:2px 4px -56px;border:2px solid var(--wash);border-radius:50%}
+.crossm .nA{position:absolute;left:14px;bottom:2px;width:2px;height:52px;background:var(--gold-hi);transform-origin:50% 100%;
+ border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)}
+.crossm .nB{position:absolute;right:14px;bottom:2px;width:2px;height:52px;background:var(--fail);transform-origin:50% 100%;
+ border-radius:2px;box-shadow:0 0 5px rgba(203,107,77,.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)}
+.crossm .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
+
+/* thermometer */
+.thermo{display:flex;align-items:flex-end;gap:6px;height:74px;cursor:ns-resize;touch-action:none}
+.thermo .tube{width:12px;height:64px;border-radius:6px 6px 0 0;position:relative;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.thermo .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#a35a3f,var(--fail))}
+.thermo .bulb{width:20px;height:20px;border-radius:50%;background:var(--fail);position:absolute;left:-4px;bottom:-9px;
+ box-shadow:0 0 6px rgba(203,107,77,.5)}
+.thermo .scale{display:flex;flex-direction:column;justify-content:space-between;height:64px;font-size:9px;color:var(--steel)}
+.thermo .wrapcol{position:relative;padding-bottom:9px}
+
+/* bourdon */
+.bourdon{width:82px;height:82px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none;
+ background:radial-gradient(circle at 48% 42%,#1d1a16,#0c0b09);border:2px solid #2c261d;
+ box-shadow:inset 0 2px 5px rgba(0,0,0,.6)}
+.bourdon svg{position:absolute;inset:0}
+.bourdon .ndl{position:absolute;left:50%;top:50%;width:2px;height:32px;margin:-32px 0 0 -1px;background:var(--gold-hi);
+ transform-origin:50% 100%;border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .12s cubic-bezier(.3,1.2,.5,1)}
+.bourdon .hub{position:absolute;left:50%;top:50%;width:8px;height:8px;margin:-4px;border-radius:50%;background:var(--gold)}
+.bourdon .cap{position:absolute;left:0;right:0;bottom:12px;text-align:center;font-size:8px;letter-spacing:.16em;color:var(--steel)}
+
+/* strip-chart */
+.strip{width:176px;height:62px;border-radius:5px;position:relative;overflow:hidden;
+ background:#0c0e0f;border:1px solid #231f18}
+.strip .rule{position:absolute;inset:0;opacity:.4;
+ background-image:linear-gradient(90deg,rgba(150,147,133,.14) 1px,transparent 1px);background-size:16px 100%}
+.strip svg{position:absolute;inset:0;width:100%;height:100%}
+.strip polyline{fill:none;stroke:var(--gold);stroke-width:1.4}
+.strip .pen{position:absolute;right:2px;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold-hi);
+ box-shadow:0 0 6px rgba(var(--glow-hi),.8);transition:top .08s linear}
+
+/* correlation */
+.corr{width:150px;cursor:pointer;touch-action:none}
+.corr .face{position:relative;height:44px;overflow:hidden}
+.corr .arc{position:absolute;inset:0 0 -44px;border:2px solid var(--wash);border-radius:50%}
+.corr .zero{position:absolute;left:50%;top:2px;width:1px;height:10px;background:var(--gold);margin-left:-.5px}
+.corr .ndl{position:absolute;left:50%;bottom:0;width:2px;height:38px;background:var(--gold-hi);transform-origin:50% 100%;
+ border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .1s ease-out}
+.corr .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
+
+/* battery */
+.batt{display:flex;align-items:center;cursor:pointer;touch-action:none}
+.batt .cells{display:flex;gap:2px;padding:3px;border:1px solid #34302a;border-radius:4px;background:#0d0f10}
+.batt .cell{width:12px;height:26px;border-radius:1px;background:var(--wash);opacity:.35}
+.batt .cell.on{opacity:1;background:linear-gradient(180deg,var(--pass),#5c7526)}
+.batt .cell.warn.on{background:linear-gradient(180deg,var(--fail),#a04a34)}
+.batt .nub{width:4px;height:12px;background:#34302a;border-radius:0 2px 2px 0}
+
+/* split-flap */
+.flap{display:inline-flex;gap:4px;cursor:pointer}
+.flapd{width:30px;height:42px;border-radius:5px;position:relative;overflow:hidden;
+ background:linear-gradient(180deg,#211d18,#141210);border:1px solid #34302a;box-shadow:0 2px 4px rgba(0,0,0,.5)}
+.flapd b{position:absolute;inset:0;display:grid;place-items:center;font-size:24px;color:var(--cream);
+ text-shadow:0 1px 2px rgba(0,0,0,.7)}
+.flapd::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:rgba(0,0,0,.65);box-shadow:0 1px 0 rgba(255,255,255,.03)}
+.flapd.flip b{animation:flipdrop .3s ease-in}
+@keyframes flipdrop{0%{transform:translateY(-42%) scaleY(.4);opacity:.3}100%{transform:none;opacity:1}}
+
+/* seven-segment */
+.seven{display:inline-flex;gap:6px;padding:6px 9px;border-radius:6px;background:#0a0806;border:1px solid #231f18;cursor:pointer;
+ box-shadow:inset 0 0 10px rgba(0,0,0,.6)}
+.seg7{width:22px;height:40px;filter:drop-shadow(0 0 3px rgba(87,211,87,.55))}
+.seg7.red{filter:drop-shadow(0 0 3px rgba(226,84,63,.55))}
+.seven .colon{align-self:center;display:flex;flex-direction:column;gap:9px}
+.seven .colon i{width:4px;height:4px;border-radius:50%;background:var(--sevgrn);box-shadow:0 0 4px rgba(87,211,87,.7)}
+
+/* DSKY verb/noun panel */
+.dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px}
+.dsky .lampcol{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px}
+.dsky .sl{font-size:6.5px;letter-spacing:.04em;display:flex;align-items:center;justify-content:center;text-align:center;
+ background:#141210;color:#5c574c;border-radius:2px;border:1px solid #26221c}
+.dsky .sl.on{background:linear-gradient(180deg,var(--amber-warn),var(--gold));color:var(--panel);font-weight:700;
+ box-shadow:0 0 6px rgba(var(--glow-lo),.5)}
+.dsky .right{display:flex;flex-direction:column;gap:6px}
+.dsky .wins{display:flex;gap:8px}
+.dsky .win{display:flex;flex-direction:column;align-items:center;gap:2px}
+.dsky .win .wl{font-size:6px;letter-spacing:.14em;color:var(--steel)}
+.dsky .win .wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px}
+.dsky .win .wd .seg7{width:13px;height:24px}
+.dsky .win.hot .wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)}
+.dsky .pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px}
+.dsky .pad .key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em}
+
+/* VFD marquee */
+.vfdm{width:176px;height:34px;border-radius:5px;overflow:hidden;position:relative;cursor:pointer;
+ background:linear-gradient(180deg,#04100e,#020807);border:1px solid #123028;box-shadow:inset 0 0 12px rgba(0,0,0,.6)}
+.vfdm .txt{position:absolute;top:50%;transform:translateY(-50%);white-space:nowrap;font-size:15px;letter-spacing:.22em;
+ color:var(--vfd);text-shadow:0 0 6px rgba(99,230,200,.65)}
+.vfdm .mesh{position:absolute;inset:0;pointer-events:none;opacity:.35;
+ background-image:radial-gradient(rgba(0,0,0,.6) 40%,transparent 41%);background-size:3px 3px}
+
+/* annunciator */
+.annwrap{display:flex;flex-direction:column;gap:6px}
+.annbar{display:flex;gap:5px;align-items:center}
+.mc{font-size:7px;letter-spacing:.08em;padding:4px 8px;border-radius:3px;background:#141210;color:#5c574c;
+ border:1px solid #26221c;text-align:center}
+.mc.on{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream);font-weight:700;
+ box-shadow:0 0 8px rgba(203,107,77,.5)}
+.mc.on.fl{animation:pulse var(--pulse-rate) ease-in-out infinite}
+.annbar .key{padding:3px 7px;font-size:7.5px;border-radius:4px}
+.annun{display:grid;grid-template-columns:repeat(3,1fr);gap:3px}
+.acell{font-size:8.5px;letter-spacing:.08em;text-align:center;color:var(--dim);padding:6px 4px;border-radius:3px;cursor:pointer;
+ background:#141210;border:1px solid #262320;line-height:1.2}
+.acell.warn{color:var(--panel);background:linear-gradient(180deg,var(--amber-warn),var(--gold));font-weight:700;box-shadow:0 0 8px rgba(var(--glow-lo),.4)}
+.acell.fault{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));font-weight:700;box-shadow:0 0 8px rgba(203,107,77,.4);animation:pulse var(--pulse-rate) ease-in-out infinite}
+
+/* jewel */
+.jewel{width:24px;height:24px;border-radius:50%;position:relative;cursor:pointer;
+ background:radial-gradient(circle at 36% 30%,rgba(255,255,255,.75),var(--jc) 42%,#3a0d08 100%);
+ box-shadow:0 0 10px 1px var(--jc),inset 0 -2px 3px rgba(0,0,0,.5)}
+.jewel::after{content:"";position:absolute;inset:0;border-radius:50%;
+ background:conic-gradient(from 0deg,rgba(255,255,255,.12) 0 22deg,transparent 22deg 45deg,rgba(0,0,0,.18) 45deg 67deg,transparent 67deg 90deg);
+ background-repeat:repeat}
+.jewel.dim{background:radial-gradient(circle at 36% 30%,rgba(120,120,120,.3),#241f1b 55%,#0e0c0a);box-shadow:inset 0 -2px 3px rgba(0,0,0,.5)}
+
+/* tape counter */
+.counter{display:inline-flex;gap:2px;padding:4px;background:#0c0b0a;border:1px solid #2c261d;border-radius:4px;
+ box-shadow:inset 0 1px 3px rgba(0,0,0,.6)}
+.cwheel{width:18px;height:34px;border-radius:2px;position:relative;overflow:hidden;
+ background:linear-gradient(180deg,#efe9d6,#c7c0ac);box-shadow:inset 0 0 3px rgba(0,0,0,.3)}
+.cwheel .col{position:absolute;left:0;right:0;text-align:center;color:#1a1613;font-size:18px;font-weight:700;line-height:34px;
+ transition:top .35s cubic-bezier(.4,1.4,.5,1)}
+.cwheel .col span{display:block;height:34px}
+.cwheel::before,.cwheel::after{content:"";position:absolute;left:0;right:0;height:9px;z-index:2;pointer-events:none}
+.cwheel::before{top:0;background:linear-gradient(180deg,rgba(0,0,0,.4),transparent)}
+.cwheel::after{bottom:0;background:linear-gradient(0deg,rgba(0,0,0,.4),transparent)}
+.counter .redw{background:linear-gradient(180deg,#e7b46a,#cf9440)}
+
+/* analog clock */
+.clock{width:78px;height:78px;border-radius:50%;position:relative;
+ background:radial-gradient(circle at 46% 40%,#1c1915,#0c0b09);border:2px solid #2c261d;box-shadow:inset 0 2px 5px rgba(0,0,0,.6)}
+.clock .tk{position:absolute;left:50%;top:4px;width:1.5px;height:6px;background:var(--steel);margin-left:-.75px;transform-origin:50% 35px}
+.clock .hh{position:absolute;left:50%;top:50%;width:3px;height:20px;margin:-20px 0 0 -1.5px;background:var(--cream);
+ transform-origin:50% 100%;border-radius:2px}
+.clock .mh{position:absolute;left:50%;top:50%;width:2px;height:28px;margin:-28px 0 0 -1px;background:var(--silver);
+ transform-origin:50% 100%;border-radius:2px}
+.clock .sh{position:absolute;left:50%;top:50%;width:1px;height:30px;margin:-30px 0 0 -.5px;background:var(--gold-hi);
+ transform-origin:50% 100%;box-shadow:0 0 4px rgba(var(--glow-hi),.5)}
+.clock .pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold)}
+
+/* frequency-dial scale */
+.freqscale{width:184px;height:44px;position:relative;border-radius:5px;overflow:hidden;cursor:pointer;touch-action:none;
+ background:linear-gradient(180deg,#191510,#0b0908);border:1px solid #2a251c;
+ box-shadow:inset 0 0 16px rgba(var(--glow-lo),.1)}
+.freqscale .tick{position:absolute;top:5px;width:1px;background:var(--steel);transform:translateX(-50%)}
+.freqscale .mk{position:absolute;bottom:6px;transform:translateX(-50%);color:var(--steel);font-size:9px}
+.freqscale .band{position:absolute;bottom:2px;left:6px;color:var(--gold);font-size:8px;letter-spacing:.18em}
+.freqscale .fptr{position:absolute;top:3px;bottom:14px;width:2px;margin-left:-1px;border-radius:1px;
+ background:var(--fail);box-shadow:0 0 7px rgba(203,107,77,.85)}
+
+/* patch-bay */
+.patch{padding:7px 9px;background:#141210;border:1px solid #2c261d;border-radius:5px;position:relative}
+.patch .row{display:flex;gap:9px}
+.patch .row+.row{margin-top:9px}
+.patch .jack{width:13px;height:13px;border-radius:50%;cursor:pointer;background:radial-gradient(circle at 40% 35%,#3a352c,#0a0908 70%);
+ border:1px solid #4a443a;box-shadow:inset 0 1px 2px rgba(0,0,0,.8)}
+.patch .jack.hot{background:radial-gradient(circle at 40% 35%,var(--amber-edge),#1a1408 70%)}
+.patch .jack.sel{border-color:var(--gold-hi);box-shadow:0 0 6px 1px rgba(var(--glow-hi),.7)}
+.patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%}
+.patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round}
+
+/* ===== reference-batch (R) widgets — SVG-first, after period hardware ===== */
+.rsvg{display:block}
+.rsvg.drag{cursor:ns-resize;touch-action:none}
+.rsvg.press{cursor:pointer}
+
+/* transport reels spin (was a page-side keyframe injector) */
+@keyframes reelspin{to{transform:rotate(360deg)}}
+.reel.spin{animation:reelspin 2.6s linear infinite}
+`;
+function ensureCss() {
+ if (document.getElementById('gw-css') || !GW_CSS) return;
+ const st = document.createElement('style'); st.id = 'gw-css'; st.textContent = GW_CSS;
+ document.head.appendChild(st);
+}
+if (document.head) ensureCss();
+else document.addEventListener('DOMContentLoaded', ensureCss);
+
+Object.assign(GW, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS });
+window.GW = GW;
+})();