aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/prototypes/panel-widget-gallery.html3336
-rw-r--r--docs/prototypes/reference/slide-toggle.jpgbin0 -> 8023 bytes
-rw-r--r--docs/prototypes/widgets.js4298
-rw-r--r--docs/specs/2026-07-12-component-generation-spec.org74
-rw-r--r--tests/gallery-probes/README.md13
-rw-r--r--tests/gallery-probes/probe-fams.mjs64
-rw-r--r--tests/gallery-probes/probe.mjs120
-rw-r--r--todo.org31
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-pill-toggle-render.jpgbin0 -> 8023 bytes
9 files changed, 4949 insertions, 2987 deletions
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html
index d67034b..2f703e8 100644
--- a/docs/prototypes/panel-widget-gallery.html
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -54,9 +54,28 @@ h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
.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}
@@ -69,7 +88,10 @@ body[data-size="2"] .wrap,body[data-size="3"] .wrap{max-width:none}
.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:.74rem;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
+.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}
@@ -80,12 +102,21 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1}
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"]{background:var(--gold);border-color:#7d5c16;box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)}
+.vlamp[data-v="green"]{background:var(--pass);border-color:#4a5c22;box-shadow:0 0 6px 1px rgba(116,147,47,.6)}
.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}
+ 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}
-.wnote{color:var(--dim);font-size:.85rem;line-height:1.45}
+.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) */
@@ -99,506 +130,31 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1}
.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}
-
-/* ---- 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}}
-
-.switch{width:40px;height:21px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);
- position:relative;cursor:pointer}
-.switch::after{content:"";position:absolute;top:2px;left:2px;width:15px;height:15px;border-radius:50%;
- background:var(--dim);transition:left .15s}
-.switch.on{background:var(--slate);border-color:var(--gold)}
-.switch.on::after{left:21px;background:var(--gold)}
-.switch.red{background:rgba(203,107,77,.2);border-color:var(--fail)}
-.switch.red::after{background:var(--fail);left:2px}
-
-.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:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color: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:linear-gradient(180deg,#191510,#0b0908);border:1px solid #2a251c;
- box-shadow:inset 0 0 20px 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(--steel);transform:translateX(-50%)}
-.tuner .mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--steel);font-size:10px}
-.tuner .mk.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.6)}
-.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(--fail);box-shadow:0 0 7px 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,1fr);gap:4px}
-.vk{position:relative;font-size:7px;letter-spacing:.05em;text-align:center;padding:7px 4px 9px;cursor:pointer;
- color:var(--dim);background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-radius:4px;
- font-family:var(--mono)}
-.vk .bar{position:absolute;left:4px;right:4px;bottom:3px;height:3px;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)}
-
-/* screen-family chips (alt phosphor / tube colors per screen widget) */
-.famchips{display:flex;gap:7px;align-items:center;margin-top:1px}
+.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}
-/* 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}
-
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style>
</head>
-<body data-size="3">
+<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.
@@ -606,60 +162,115 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1}
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" data-sz="2">M</button>
- <button class="key on" data-sz="3">L</button>
+ <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>
+ </nav>
</div>
</header>
-<h2>Palette — design tokens</h2>
-<p class="secnote">Read live from the generated :root block (tokens.json via gen_tokens.py), so this section always shows
- exactly what the widgets render with. Hex tokens get a swatch; the glow entries are rgb triples used inside
- rgba() glows; mono and pulse-rate are the non-color tokens.</p>
-<div class="palgrid" id="palette"></div>
-
-<h2>Controls — take input</h2>
+<h2 id="sec-controls">Controls — take input</h2>
<div class="grid" id="controls"></div>
-<h2>Meters &amp; gauges — live analog value</h2>
+<h2 id="sec-meters">Meters &amp; gauges — live analog value</h2>
<div class="grid" id="meters"></div>
-<h2>Indicators &amp; readouts — state or number</h2>
+<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));
}));
-
-/* ---- palette section: read the generated :root block live ---- */
+(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;
- let rootRule=null;
- for(const sh of document.styleSheets){
- for(const r of sh.cssRules){ if(r.selectorText===':root'){rootRule=r;break;} }
- if(rootRule)break;
- }
- if(!rootRule)return;
- const st=rootRule.style;
- for(let i=0;i<st.length;i++){
- const name=st[i]; if(!name.startsWith('--'))continue;
- const val=st.getPropertyValue(name).trim();
- const isHex=/^#[0-9a-f]{3,8}$/i.test(val);
- const isTriple=/^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}$/.test(val);
- const esc=val.replace(/&/g,'&amp;').replace(/</g,'&lt;');
- const sw=isHex?`<div class="sw" style="background:${val}"></div>`
- :isTriple?`<div class="sw" style="background:rgb(${val})"></div>`
- :`<div class="sw txt">${esc}</div>`;
- const tile=document.createElement('div'); tile.className='swatch';
- tile.innerHTML=`${sw}<div class="swn">${name}</div><div class="swv">${isTriple?esc+' (glow rgb)':esc}</div>`;
- host.appendChild(tile);
- }
+ 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 ----
@@ -671,44 +282,20 @@ document.querySelectorAll('.szbar .key').forEach(b=>b.addEventListener('click',(
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. */
-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'},
-};
+/* 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 lab=document.createElement('span'); lab.className='lab'; lab.textContent='screen'; row.appendChild(lab);
+ 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);
- row.querySelectorAll('.fc').forEach(x=>x.classList.toggle('on',x===b));
+ g.querySelectorAll('.fc').forEach(x=>x.classList.toggle('on',x===b));
setRd(no,'screen '+f);});
- row.appendChild(b);});
- cardEl.insertBefore(row,cardEl.querySelector('.wrd'));
+ 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
@@ -719,10 +306,12 @@ function screenChips(no,el,fams,def){
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 everywhere. Shines as a master control: power, mute, enable.',
+ 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:'Electrical panels generally.',difficulty:'Intuitive.',
- prefer:'The state persists and gets glanced at more than changed.'},
+ 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.',
@@ -1428,1797 +1017,465 @@ const INFO={
};
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'};
+/* 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 c=document.createElement('div'); c.className='card';
- c.innerHTML=`<div class="wname"><span class="no">${no}</span>${name}</div>`+
- `<div class="stagew">${html}</div><div class="wrd" id="rd-${no}">—</div><div class="wnote">${note}</div>`;
+ 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];};
+ 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); return c;
+ 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;}
-function buildBars(el,n){el.innerHTML='';for(let k=0;k<n;k++)el.appendChild(document.createElement('i'));}
-
-/* ---- pointer-drag helpers ---- */
-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>';
-}
+/* drag helpers, seg7, buildBars live in widgets.js (GW) */
/* ============ CONTROLS ============ */
const C=$('controls');
-card(C,'01','Toggle switch',
- `<span id="toggle01"><span class="switch on"></span></span>`,
- '<b>on / off.</b> The faceplate master control — power, mute, enable. Click to flip; readout shows the state.');
+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',
- `<span id="keys02"><button class="key on">LIVE</button><button class="key">SCAN</button><button class="key red">MUTED</button></span>`,
+ (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',
- `<div class="fader" id="f1"><div class="slot"><div class="fill" style="width:68%"></div></div><div class="cap" style="left:68%"></div></div>`,
+ (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',
- `<div class="vfader" id="v1"><div class="slot"><div class="fill" style="height:60%"></div></div><div class="cap" style="bottom:60%"></div></div>
- <div class="vfader" id="v2"><div class="slot"><div class="fill" style="height:35%"></div></div><div class="cap" style="bottom:35%"></div></div>`,
+ (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',
- `<span class="knob" id="knob"><span class="ind" id="kind"></span></span>`,
+ (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',
- `<div class="seg" id="seg06"><button class="on">TIMER</button><button>ALARM</button><button>POMO</button></div>`,
+ (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.');
card(C,'07','Chip toggle',
- `<span class="chip on" id="chip07">discoverable on</span>`,
+ (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',
- `<button class="arm" id="arm">forget</button>`,
+ (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',
- `<div class="lrow" id="lrow9"><span class="lamp gold"></span><span class="who"><b>WH-1000XM4</b></span><span class="what">tap to connect</span></div>`,
+ (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',
- `<span class="rotsel" id="rotsel24">
- <span class="pos" style="left:14%;top:53%">4</span><span class="pos" style="left:28%;top:18%">6</span><span class="pos on" style="left:50%;top:7%">8</span><span class="pos" style="left:72%;top:18%">10</span><span class="pos" style="left:86%;top:53%">12</span>
- <span class="knob"><span class="ind"></span></span>
- </span>`,
+ (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',
- `<span class="tuner" id="tuner" tabindex="0" role="slider" aria-label="slide-rule value">
- <span class="tick" style="left:12px"></span><span class="tick" style="left:51px"></span><span class="tick" style="left:90px"></span><span class="tick" style="left:129px"></span><span class="tick" style="left:168px"></span>
- <span class="mk" style="left:12px">4</span><span class="mk" style="left:51px">6</span><span class="mk on" style="left:90px">8</span><span class="mk" style="left:129px">10</span><span class="mk" style="left:168px">12</span>
- <span class="ndl" id="tundl" style="left:90px"></span>
- </span>`,
+ (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. Click a mark to jump, or focus it and press ←/→ (↑/↓); readout shows it.');
card(C,'N01','Rocker power switch',
- `<span class="rocker on" id="rocker"><span class="half top">ON</span><span class="half bot">OFF</span></span>`,
+ (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',
- `<div style="display:flex;flex-direction:column;gap:6px;align-items:center">
- <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" id="tport">
- <button class="tbtn" data-t="rew" onclick="transport(this)">⏮</button>
- <button class="tbtn on" data-t="play" onclick="transport(this)">▶</button>
- <button class="tbtn" data-t="stop" onclick="transport(this)">⏹</button>
- <button class="tbtn rec" data-t="rec" onclick="transport(this)">⏺</button>
- </div></div>`,
+ (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',
- `<div class="radiobank" id="rbank">
- <button class="preset on" onclick="preset(this)">WIFI</button>
- <button class="preset" onclick="preset(this)">ETH</button>
- <button class="preset" onclick="preset(this)">CELL</button>
- <button class="preset" onclick="preset(this)">OFF</button>
- </div>`,
+ (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',
- `<span class="dualknob">
- <span class="outer" id="dko"><span class="tick" style="transform:rotate(-120deg)"></span><span class="tick" style="transform:rotate(120deg)"></span></span>
- <span class="inner" id="dki"><span class="ind"></span></span>
- </span>`,
+ (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',
- `<span class="encoder" id="enc"></span>`,
+ (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',
- `<span class="keylock" id="keylock6">
- <span class="kpos" style="left:16%;top:64%">OFF</span><span class="kpos on" style="left:50%;top:12%">ON</span><span class="kpos" style="left:84%;top:64%">RUN</span>
- <span class="body" onclick="keyTurn()"><span class="bit" id="kbit"></span><span class="barrel"></span></span>
- </span>`,
+ (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',
- `<div class="xfader" id="xf">
- <div class="slot"></div><div class="detent"></div>
- <span class="end" style="left:0">A</span><span class="end" style="right:0">B</span>
- <div class="cap" id="xfcap" style="left:50%"></div>
- </div>`,
+ (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',
- `<div class="thumbw"><span class="thumbwheel" id="thumbwheel"></span><span class="win" id="thval">42</span></div>`,
+ (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',
- `<span class="dip" id="dips">
- <span class="dipsw on" onclick="dip(this)"><i></i></span><span class="dipsw" onclick="dip(this)"><i></i></span>
- <span class="dipsw on" onclick="dip(this)"><i></i></span><span class="dipsw on" onclick="dip(this)"><i></i></span>
- <span class="dipsw" onclick="dip(this)"><i></i></span><span class="dipsw" onclick="dip(this)"><i></i></span>
- </span>`,
+ (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',
- `<span class="jog" id="jog10"><span class="shuttle" style="--sh:40deg"></span><span class="inner" id="jogi"><span class="dimple"></span></span></span>`,
+ (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',
- `<svg id="vern" class="rsvg drag" viewBox="0 0 150 150" width="150" height="150"></svg>`,
+ (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',
- `<svg id="batsw" class="rsvg press" viewBox="0 0 70 90" width="70" height="90"></svg>`,
+ (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',
- `<svg id="bakek" class="rsvg drag" viewBox="0 0 110 110" width="110" height="110"></svg>`,
+ (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',
- `<svg id="fbank" class="rsvg" viewBox="0 0 190 96" width="190" height="96"></svg>`,
+ (st,rd)=>GW.filterBank(st,{onChange:(v,t)=>rd(t)}),
'<b>a wall of band faders.</b> One slider per band, arrow-head caps on black tracks, dB rail at the side. Drag any cap; readout names band and level. After a variable multi-band filter.');
card(C,'R06','Chicken-head selector',
- `<svg id="chick" class="rsvg press" viewBox="0 0 110 96" width="110" height="96"></svg>`,
+ (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',
- `<svg id="slotf" class="rsvg drag" viewBox="0 0 90 150" width="90" height="150"></svg>`,
+ (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',
- `<svg id="spade" class="rsvg drag" viewBox="0 0 140 124" width="140" height="124"></svg>`,
+ (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',
- `<svg id="mband" class="rsvg" viewBox="0 0 190 110" width="190" height="110"></svg>`,
+ (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',
- `<svg id="keypad" class="rsvg" viewBox="0 0 150 190" width="150" height="190"></svg>`,
+ (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',
- `<svg id="thumbsl" class="rsvg" viewBox="0 0 130 150" width="130" height="150"></svg>`,
+ (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',
- `<svg id="wregion" class="rsvg" viewBox="0 0 190 100" width="190" height="100"></svg>`,
+ (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',
- `<svg id="drumr" class="rsvg" viewBox="0 0 130 140" width="130" height="140"></svg>`,
+ (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',
- `<svg id="ledrow" class="rsvg" viewBox="0 0 190 58" width="190" height="58"></svg>`,
+ (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',
- `<svg id="varslide" class="rsvg press" viewBox="0 0 110 64" width="110" height="64"></svg>`,
+ (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',
- `<svg id="spunk" class="rsvg drag" viewBox="0 0 110 110" width="110" height="110"></svg>`,
+ (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',
- `<svg id="stomp" class="rsvg press" viewBox="0 0 110 110" width="110" height="110"></svg>`,
+ (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',
- `<svg id="wingk" class="rsvg drag" viewBox="0 0 110 110" width="110" height="110"></svg>`,
+ (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',
- `<svg id="discsw" class="rsvg press" viewBox="0 0 110 116" width="110" height="116"></svg>`,
+ (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',
- `<svg id="guardsw" class="rsvg press" viewBox="0 0 90 100" width="90" height="100"></svg>`,
+ (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',
- `<svg id="mtimer" class="rsvg" viewBox="0 0 158 132" width="158" height="132"></svg>`,
+ (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',
- `<svg id="rocker4" class="rsvg press" viewBox="0 0 110 110" width="110" height="110"></svg>`,
+ (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',
- `<svg id="fourway" class="rsvg press" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (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',
- `<svg id="pinmx" class="rsvg press" viewBox="0 0 160 110" width="160" height="110"></svg>`,
+ (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',
- `<svg id="deadman" class="rsvg" viewBox="0 0 120 110" width="120" height="110"></svg>`,
+ (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',
- `<svg id="teldial" class="rsvg press" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (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',
- `<svg id="brkpanel" class="rsvg press" viewBox="0 0 160 110" width="160" height="110"></svg>`,
+ (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',
- `<div class="dsky" id="dsky"></div>`,
+ (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',
- `<svg id="camtimer" class="rsvg press" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (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',
- `<svg id="knifesw" class="rsvg press" viewBox="0 0 130 110" width="130" height="110"></svg>`,
+ (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',
- `<svg id="decbox" class="rsvg" viewBox="0 0 160 100" width="160" height="100"></svg>`,
+ (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',
- `<svg id="twohand" class="rsvg press" viewBox="0 0 160 100" width="160" height="100"></svg>`,
+ (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',
- `<div class="vloop" id="vloop"></div>`,
+ (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',
- `<div class="gauge" id="gauge10"><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" id="g1"></div><div class="hub"></div></div><div class="gv"><span id="g1v">0</span>%</div></div>`,
+ (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)',
- `<div class="vu"><div class="vurow"><span class="ch">L</span><span class="vubar" id="vuL"></span></div>
- <div class="vurow"><span class="ch">R</span><span class="vubar" id="vuR"></span></div></div>`,
+ (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)',
- `<span class="sig" id="mini"></span>`,
+ (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',
- `<span class="ladder" id="ladder"><i></i><i></i><i></i><i></i></span>`,
+ (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',
- `<div class="bar" id="bar14"><span style="width:72%"></span></div><div class="bar" id="bar14b"><span style="width:40%"></span></div>`,
+ (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',
- `<span class="ring" id="ring15" style="--p:68"><b>68</b></span>`,
+ (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',
- `<span class="spark" id="spark"><svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline id="sparkp" fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg></span>`,
+ (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',
- `<span class="wave" id="wave"><svg viewBox="0 0 170 38" preserveAspectRatio="none"><path id="wavep" fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg></span>`,
+ (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',
- `<span class="scope"><span class="grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline id="scopep"/></svg></span>`,
+ (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',
- `<span class="eq" id="eq"></span>`,
+ (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',
- `<div class="crossm"><div class="face" id="crossm13"><div class="arc"></div><div class="nA" id="cnA"></div><div class="nB" id="cnB"></div></div>
- <div class="lbl"><span>FWD</span><span>RFL</span></div></div>`,
+ (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',
- `<div class="thermo" id="thermo14"><div class="scale"><span>90</span><span>60</span><span>30</span></div>
- <div class="wrapcol"><div class="tube"><div class="fill" id="thermf" style="height:58%"></div></div><div class="bulb"></div></div></div>`,
+ (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',
- `<div class="bourdon" id="bourdon15">
- <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" id="bourd" style="transform:rotate(30deg)"></div><div class="hub"></div><div class="cap">PSI</div></div>`,
+ (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',
- `<span class="strip"><span class="rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline id="stripp"/></svg><span class="pen" id="strippen" style="top:31px"></span></span>`,
+ (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 (±)',
- `<div class="corr" id="corr17"><div class="face"><div class="arc"></div><div class="zero"></div><div class="ndl" id="corrn" style="transform:rotate(8deg)"></div></div>
- <div class="lbl"><span>−1</span><span>0</span><span>+1</span></div></div>`,
+ (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',
- `<div class="batt" id="batt18"><div class="cells" id="battc"></div><span class="nub"></span></div>`,
+ (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',
- `<svg id="mcvu" class="rsvg" viewBox="0 0 150 96" width="150" height="96"></svg>`,
+ (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',
- `<svg id="rndm" class="rsvg drag" viewBox="0 0 110 104" width="110" height="104"></svg>`,
+ (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',
- `<svg id="chrmm" class="rsvg drag" viewBox="0 0 110 104" width="110" height="104"></svg>`,
+ (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',
- `<svg id="avgauge" class="rsvg drag" viewBox="0 0 110 104" width="110" height="104"></svg>`,
+ (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',
- `<svg id="edgem" class="rsvg drag" viewBox="0 0 70 130" width="70" height="130"></svg>`,
+ (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',
- `<svg id="attind" class="rsvg" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (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',
- `<svg id="hdgbug" class="rsvg" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (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',
- `<svg id="vtape" class="rsvg" viewBox="0 0 70 130" width="70" height="130"></svg>`,
+ (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',
- `<svg id="twinng" class="rsvg" viewBox="0 0 120 110" width="120" height="110"></svg>`,
+ (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',
- `<svg id="comfort" class="rsvg" viewBox="0 0 130 122" width="130" height="122"></svg>`,
+ (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',
- `<svg id="circhart" class="rsvg press" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (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',
- `<svg id="rcrt" class="rsvg" viewBox="0 0 110 104" width="110" height="104"></svg>`,
+ (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',
- `<span id="lamp18" style="display:inline-flex;gap:9px;align-items:center"><span class="lamp"></span><span class="lamp gold"></span><span class="lamp red"></span><span class="lamp off"></span><span class="lamp busy"></span></span>`,
+ (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',
- `<span id="badge19"><span class="badge">TUNNEL</span> <span class="badge red">LOW BATT</span> <span class="badge ghost">2.4G</span></span>`,
+ (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',
- `<div style="text-align:center"><div class="readout" id="treadout">24:10</div><span class="u">timer</span></div>`,
+ (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',
- `<span class="engrave" id="engrave">outputs<span class="cnt" id="engc">· 3</span></span>`,
+ (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',
- `<div class="owell" id="owell"><div class="ostep"><span class="lamp"></span><span><b>Link</b><span class="ev">wlp170s0 · @Hyatt</span></span></div>
- <div class="ostep"><span class="lamp gold"></span><span><b>DNS</b><span class="ev">resolving…</span></span></div></div>`,
+ (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',
- `<span class="toastw" id="toast">joined @Hyatt_WiFi — saved</span>`,
+ (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',
- `<span class="nixie" id="nixie"><span class="tube off"><b>0</b></span><span class="tube"><b>8</b></span></span>`,
+ (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',
- `<span class="flap" id="flap"><span class="flapd"><b>D</b></span><span class="flapd"><b>N</b></span><span class="flapd"><b>S</b></span></span>`,
+ (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',
- `<span class="seven" id="seven"></span>`,
+ (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',
- `<span class="vfdm" id="vfdm"><span class="txt" id="vfdt">ARCHSETUP · NET OK · BT 2 · SND 62%</span><span class="mesh"></span></span>`,
+ (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',
- `<div class="annwrap"><div class="annun" id="annun">
- <span class="acell">SYNC</span><span class="acell warn">LOW BATT</span><span class="acell">LINK</span>
- <span class="acell fault">NO DNS</span><span class="acell">VPN</span><span class="acell">DISK</span>
- </div><div class="annbar" id="annbar"><span class="mc" id="anmc">MSTR CAUTION</span>
- <button class="key" data-k="ACK">ACK</button><button class="key" data-k="TEST">TEST</button>
- <button class="key" data-k="RESET">RESET</button></div></div>`,
+ (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',
- `<span id="jewels" style="display:inline-flex;gap:10px;align-items:center"><span class="jewel" style="--jc:var(--jewel-r)"></span><span class="jewel" style="--jc:var(--jewel-a)"></span>
- <span class="jewel" style="--jc:var(--jewel-g)"></span><span class="jewel dim"></span></span>`,
+ (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',
- `<span class="counter" id="counter"></span>`,
+ (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',
- `<span class="clock" id="clock"><span class="hh" id="clkh"></span><span class="mh" id="clkm"></span><span class="sh" id="clks"></span><span class="pin"></span></span>`,
+ (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',
- `<span class="freqscale" id="fscale"><span class="band">MHz</span></span>`,
+ (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',
- `<div class="patch" id="patch"></div>`,
+ (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',
- `<svg id="dmx" class="rsvg press" viewBox="0 0 190 74" width="190" height="74"></svg>`,
+ (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',
- `<svg id="wflag" class="rsvg press" viewBox="0 0 120 64" width="120" height="64"></svg>`,
+ (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',
- `<svg id="seg14" class="rsvg press" viewBox="0 0 130 54" width="130" height="54"></svg>`,
+ (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',
- `<svg id="rgraph" class="rsvg" viewBox="0 0 190 110" width="190" height="110"></svg>`,
+ (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',
- `<svg id="telind" class="rsvg press" viewBox="0 0 110 110" width="110" height="110"></svg>`,
+ (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',
- `<svg id="radar" class="rsvg press" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (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',
- `<svg id="daydate" class="rsvg press" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (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',
- `<svg id="dotmx" class="rsvg press" viewBox="0 0 108 108" width="108" height="108"></svg>`,
+ (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',
- `<svg id="flipdisc" class="rsvg press" viewBox="0 0 120 92" width="120" height="92"></svg>`,
+ (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',
- `<svg id="dekatron" class="rsvg press" viewBox="0 0 120 120" width="120" height="120"></svg>`,
+ (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',
- `<svg id="gearind" class="rsvg press" viewBox="0 0 140 110" width="140" height="110"></svg>`,
+ (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',
- `<svg id="blinken" class="rsvg press" viewBox="0 0 170 100" width="170" height="100"></svg>`,
+ (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 */
-(function(){const sw=$('toggle01').querySelector('.switch');
- function upd(){setRd('01',sw.classList.contains('on')?'ON':'OFF');}
- sw.onclick=()=>{sw.classList.toggle('on');upd();};upd();})();
-/* 02 console key */
-$('keys02').querySelectorAll('.key').forEach(k=>k.onclick=()=>{
- $('keys02').querySelectorAll('.key').forEach(x=>x.classList.remove('on','red'));
- k.classList.add(k.textContent==='MUTED'?'red':'on');setRd('02',k.textContent);});
-setRd('02','LIVE');
-/* 03 horizontal fader */
-function setF1(p){const f=$('f1');f.querySelector('.fill').style.width=p+'%';f.querySelector('.cap').style.left=p+'%';setRd('03','level '+Math.round(p));}
-dragX($('f1'),setF1);setF1(68);
-/* 04 vertical faders */
-let vfA=60,vfB=35;
-function vfUpd(){setRd('04','A '+Math.round(vfA)+' · B '+Math.round(vfB));}
-function setVf(el,p){el.querySelector('.fill').style.height=p+'%';el.querySelector('.cap').style.bottom=p+'%';}
-dragY($('v1'),p=>{vfA=p;setVf($('v1'),p);vfUpd();});
-dragY($('v2'),p=>{vfB=p;setVf($('v2'),p);vfUpd();});
-setVf($('v1'),60);setVf($('v2'),35);vfUpd();
-/* 05 knob */
-let knobV=53;
-function setKnob(v){knobV=v;$('kind').style.transform=`rotate(${-150+v/100*300}deg)`;setRd('05','gain '+Math.round(v));}
-dragDelta($('knob'),()=>knobV,setKnob,{min:0,max:100});setKnob(53);
-/* 06 segmented */
-$('seg06').querySelectorAll('button').forEach(b=>b.onclick=()=>{
- $('seg06').querySelectorAll('button').forEach(x=>x.classList.remove('on'));b.classList.add('on');setRd('06',b.textContent);});
-setRd('06','TIMER');
-/* 07 chip */
-$('chip07').onclick=()=>{$('chip07').classList.toggle('on');setRd('07',$('chip07').classList.contains('on')?'ON':'OFF');};
-setRd('07','ON');
-/* 08 arm */
-let armed=false;
-$('arm').onclick=()=>{const a=$('arm');if(!armed){armed=true;a.classList.add('armed');a.textContent='forget? again';setRd('08','ARMED');}
- else{armed=false;a.classList.remove('armed');a.textContent='forget';setRd('08','FIRED · reset');}};
-setRd('08','SAFE');
-/* 09 lamp row */
-const lrStates=[['gold','tap to connect'],['busy','connecting…'],['','connected']];let lrI=0;
-$('lrow9').onclick=()=>{lrI=(lrI+1)%3;const s=lrStates[lrI];$('lrow9').querySelector('.lamp').className='lamp '+s[0];
- $('lrow9').querySelector('.what').textContent=s[1];setRd('09',s[1]);};
-setRd('09','tap to connect');
-/* 24 rotary selector */
-const rotVals=[4,6,8,10,12];let rotIdx=2;const rotAng=[-70,-35,0,35,70];
-$('rotsel24').querySelector('.knob').onclick=()=>{rotIdx=(rotIdx+1)%5;
- $('rotsel24').querySelector('.ind').style.transform=`rotate(${rotAng[rotIdx]}deg)`;
- $('rotsel24').querySelectorAll('.pos').forEach((p,i)=>p.classList.toggle('on',i===rotIdx));setRd('24','size '+rotVals[rotIdx]);};
-setRd('24','size 8');
-/* 25 slide-rule */
-const tunVals=[4,6,8,10,12];let tunIdx=2;const tunX=[12,51,90,129,168];
-function setTun(i){tunIdx=Math.max(0,Math.min(tunVals.length-1,i));$('tundl').style.left=tunX[tunIdx]+'px';
- $('tuner').querySelectorAll('.mk').forEach((m,j)=>m.classList.toggle('on',j===tunIdx));setRd('25','pos '+tunVals[tunIdx]);}
-$('tuner').onclick=(e)=>{const r=$('tuner').getBoundingClientRect(),x=e.clientX-r.left;
- let best=0,bd=1e9;tunX.forEach((tx,j)=>{const d=Math.abs(tx-x);if(d<bd){bd=d;best=j;}});setTun(best);$('tuner').focus();};
-$('tuner').onkeydown=(e)=>{if(e.key==='ArrowLeft'||e.key==='ArrowDown'){e.preventDefault();setTun(tunIdx-1);}
- else if(e.key==='ArrowRight'||e.key==='ArrowUp'){e.preventDefault();setTun(tunIdx+1);}};
-setRd('25','pos 8');
-/* N01 rocker */
-$('rocker').onclick=()=>{$('rocker').classList.toggle('on');setRd('N01',$('rocker').classList.contains('on')?'ON':'OFF');};
-setRd('N01','ON');
-/* N02 transport */
-function transport(b){b.parentElement.querySelectorAll('.tbtn').forEach(x=>x.classList.remove('on'));b.classList.add('on');
- const playing=b.dataset.t==='play';document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState=playing&&!reduced?'running':'paused');
- setRd('N02',{rew:'REW',play:'PLAY',stop:'STOP',rec:'REC'}[b.dataset.t]);}
-setRd('N02','PLAY');
-/* N03 radio preset */
-function preset(b){b.parentElement.querySelectorAll('.preset').forEach(x=>x.classList.remove('on'));b.classList.add('on');setRd('N03',b.textContent);}
-setRd('N03','WIFI');
-/* N04 concentric dual knob */
-let dkoV=50,dkiV=50;
-function setDual(){$('dko').style.transform=`rotate(${-150+dkoV/100*300}deg)`;$('dki').style.transform=`rotate(${-150+dkiV/100*300}deg)`;
- setRd('N04','bass '+Math.round(dkoV)+' · treble '+Math.round(dkiV));}
-dragDelta($('dko'),()=>dkoV,v=>{dkoV=v;setDual();},{min:0,max:100});
-dragDelta($('dki'),()=>dkiV,v=>{dkiV=v;setDual();},{min:0,max:100,stop:true});
-setDual();
-/* N05 encoder */
-let encV=7;
-function paintEnc(){const leds=$('enc').querySelectorAll('.led');const lit=((Math.floor(encV)%12)+12)%12;
- leds.forEach((l,i)=>l.classList.toggle('on',i<=lit));}
-function setEnc(v){encV=v;paintEnc();const ind=$('enc').querySelector('.knob .ind');if(ind)ind.parentElement.style.transform=`rotate(${encV*30}deg)`;
- setRd('N05','pos '+Math.round(encV));}
-(function(){const enc=$('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);})();
-dragDelta($('enc'),()=>encV,setEnc,{min:0,max:100000,sens:0.25});setEnc(7);
-/* N06 keylock */
-let keyIdx=1;const keyAng=[-70,0,70];const keyNm=['OFF','ON','RUN'];
-function keyTurn(){keyIdx=(keyIdx+1)%3;$('kbit').style.transform=`rotate(${keyAng[keyIdx]}deg)`;
- $('keylock6').querySelectorAll('.kpos').forEach((p,i)=>p.classList.toggle('on',i===keyIdx));setRd('N06',keyNm[keyIdx]);}
-setRd('N06','ON');
-/* N07 crossfader */
-function setXf(p){$('xfcap').style.left=p+'%';const v=Math.round(p-50);setRd('N07',(v>0?'+':'')+v);}
-dragX($('xf'),setXf);setXf(50);
-/* N08 thumbwheel */
-let thumbV=42;
-function setThumb(v){thumbV=Math.round(v);const s=(((thumbV%100)+100)%100);$('thval').textContent=String(s).padStart(2,'0');setRd('N08','value '+s);}
-dragDelta($('thumbwheel'),()=>thumbV,setThumb,{min:0,max:99,sens:0.2});setThumb(42);
-/* N09 DIP */
-function dipUpd(){const bits=[...$('dips').querySelectorAll('.dipsw')].map(x=>x.classList.contains('on')?'1':'0').join('');setRd('N09',bits);}
-function dip(s){s.classList.toggle('on');dipUpd();}
-dipUpd();
-/* N10 jog */
-let jogV=0;
-function setJog(v){jogV=v;$('jogi').style.transform=`rotate(${jogV*4}deg)`;setRd('N10','pos '+Math.round(jogV));}
-dragDelta($('jog10'),()=>jogV,setJog,{min:-100000,max:100000,sens:0.5});setJog(0);
+/* 01-09, 24, 25, N01-N10 extracted to widgets.js (GW.*) — wired at their card records */
/* ============ METER WIRING (drag-driven) ============ */
-/* 10 needle gauge */
-let gV=50;
-function setGauge(v){gV=v;$('g1').style.transform=`rotate(${-60+v/100*120}deg)`;$('g1v').textContent=Math.round(v);setRd('10','value '+Math.round(v)+'%');}
-dragDelta($('gauge10'),()=>gV,setGauge,{min:0,max:100});setGauge(50);
-/* 13 signal ladder */
-let ladV=3;
-function setLadder(v){ladV=v;const bars=$('ladder').children;
- for(let i=0;i<bars.length;i++)bars[i].style.background=i<v?'var(--gold)':'var(--wash)';setRd('13',v+'/4');}
-$('ladder').onclick=()=>setLadder((ladV+1)%5);setLadder(3);
-/* 14 fuel bars */
-function setBar(id,no,p){const b=$(id);b.querySelector('span').style.width=p+'%';b.classList.toggle('warn',p<20);setRd(no,Math.round(p)+'%');}
-dragX($('bar14'),p=>setBar('bar14','14',p));dragX($('bar14b'),p=>setBar('bar14b','14',p));
-setBar('bar14','14',72);
-/* 15 radial ring */
-let ringV=68;
-function setRing(v){ringV=v;const r=$('ring15');r.style.setProperty('--p',v);r.querySelector('b').textContent=Math.round(v);setRd('15',Math.round(v)+'%');}
-dragDelta($('ring15'),()=>ringV,setRing,{min:0,max:100});setRing(68);
-/* N13 crossed-needle */
-let cnPow=55;
-function setCross(v){cnPow=v;const fwd=v,rfl=v*0.68;$('cnA').style.transform=`rotate(${-42+fwd/100*84}deg)`;
- $('cnB').style.transform=`rotate(${42-rfl/100*84}deg)`;setRd('N13','FWD '+Math.round(fwd)+' · RFL '+Math.round(rfl));}
-dragDelta($('crossm13'),()=>cnPow,setCross,{min:0,max:100});setCross(55);
-/* N14 thermometer */
-let thV=58;
-function setThermo(v){thV=v;$('thermf').style.height=v+'%';setRd('N14',Math.round(30+v/100*60)+'°');}
-dragDelta($('thermo14'),()=>thV,setThermo,{min:0,max:100});setThermo(58);
-/* N15 bourdon */
-let bV=45;
-function setBour(v){bV=v;$('bourd').style.transform=`rotate(${-60+v/100*120}deg)`;setRd('N15',Math.round(v/100*160)+' PSI');}
-dragDelta($('bourdon15'),()=>bV,setBour,{min:0,max:100});setBour(45);
-/* N17 correlation */
-function setCorr(p){const v=(p-50)/50;$('corrn').style.transform=`rotate(${v*38}deg)`;setRd('N17',(v>=0?'+':'')+v.toFixed(2));}
-dragX($('corr17'),setCorr);setCorr(58);
-/* N18 battery */
-(function(){const bc=$('battc');for(let i=0;i<8;i++){const c=document.createElement('span');c.className='cell';bc.appendChild(c);}})();
-function setBatt(p){const cells=$('battc').children,lit=Math.round(p/100*cells.length);
- for(let i=0;i<cells.length;i++)cells[i].className='cell'+(i<lit?' on':'')+((p<=25&&i<lit)?' warn':'');setRd('N18',Math.round(p)+'%');}
-dragX($('batt18'),setBatt);setBatt(62);
+/* 10-17, N11-N18 extracted to widgets.js (GW.*) — wired at their card records */
/* ============ INDICATOR WIRING ============ */
-/* 18 status lamp */
-(function(){const lst=['lamp','lamp gold','lamp red','lamp off','lamp busy'];const nm=['ok','engaged','fault','off','busy'];
- $('lamp18').querySelectorAll('.lamp').forEach(l=>{l.style.cursor='pointer';l.onclick=()=>{
- let i=lst.indexOf(l.className);if(i<0)i=0;i=(i+1)%lst.length;l.className=lst[i];setRd('18',nm[i]);};});})();
-setRd('18','five states');
-/* 19 badge */
-(function(){const bv=['badge','badge red','badge ghost'];
- $('badge19').querySelectorAll('.badge').forEach(b=>{b.style.cursor='pointer';b.onclick=()=>{
- let i=bv.indexOf(b.className);if(i<0)i=0;i=(i+1)%bv.length;b.className=bv[i];setRd('19',b.textContent);};});})();
-setRd('19','three variants');
-/* 21 engraved */
-let engN=3;$('engrave').onclick=()=>{engN=engN%9+1;$('engc').textContent='· '+engN;setRd('21','count '+engN);};
-setRd('21','count 3');
-/* 22 output well */
-const owSteps=[['gold','Probe','8.8.8.8 …'],['','Gateway','10.0.0.1 ok'],['','DNS','1.1.1.1 ok'],['red','Retry','timeout']];let owI=0;
-$('owell').onclick=()=>{owI=(owI+1)%owSteps.length;const s=owSteps[owI];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>`;
- $('owell').appendChild(d);while($('owell').children.length>5)$('owell').removeChild($('owell').firstChild);setRd('22','+ '+s[1]);};
-setRd('22','click to stream');
-/* 23 toast */
-const toastMsgs=['link up · 300 Mbps','bt paired — WH-1000XM4','profile switched','joined @Hyatt_WiFi — saved'];let toastN=0;
-$('toast').onclick=()=>{toastN++;$('toast').textContent=toastMsgs[toastN%toastMsgs.length];
- $('toast').style.opacity='0';requestAnimationFrame(()=>{$('toast').style.transition='opacity .3s';$('toast').style.opacity='1';});setRd('23','fired '+toastN);};
-setRd('23','click to fire');
-/* 26 nixie */
-let nixV=8;
-function setNix(v){nixV=v;const t=$('nixie').children,s=String(v).padStart(2,'0');
- t[0].classList.toggle('off',v<10);t[0].querySelector('b').textContent=s[0];t[1].querySelector('b').textContent=s[1];setRd('26',s);}
-$('nixie').onclick=()=>setNix((nixV+1)%100);setNix(8);
-/* N23 annunciator with alarm lifecycle: raise -> MSTR CAUTION flashes -> ACK steadies -> RESET clears */
-(function(){const st=['acell','acell warn','acell fault'];const nm=['ok','warn','fault'];
- const mc=$('anmc');let acked=false;
- const active=()=>$('annun').querySelectorAll('.warn,.fault').length;
- const refresh=()=>{const n=active();
- mc.classList.toggle('on',n>0);
- mc.classList.toggle('fl',n>0&&!acked);
- setRd('N23',n===0?'clear':n+' active · '+(acked?'ACK':'UNACK'));};
- $('annun').querySelectorAll('.acell').forEach(cell=>{const label=cell.textContent;cell.onclick=()=>{
- let cur=cell.className.trim();let i=st.indexOf(cur);if(i<0)i=0;i=(i+1)%st.length;cell.className=st[i];
- if(i>0)acked=false; // a new alarm re-arms the flasher
- refresh();setRd('N23',label+' → '+nm[i]);};});
- $('annbar').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'){$('annun').querySelectorAll('.acell').forEach(c=>c.className='acell');acked=false;refresh();}
- else if(a==='TEST'){const prev=[...$('annun').querySelectorAll('.acell')].map(c=>c.className);
- $('annun').querySelectorAll('.acell').forEach(c=>c.className='acell fault');
- mc.classList.add('on','fl');setRd('N23','lamp test');
- setTimeout(()=>{[...$('annun').querySelectorAll('.acell')].forEach((c,i)=>c.className=prev[i]);refresh();},1100);}}));
- refresh();})();
-/* N24 jewel */
-(function(){const cols=['var(--jewel-r)','var(--jewel-a)','var(--jewel-g)'];const nm=['red','amber','green'];
- $('jewels').querySelectorAll('.jewel').forEach(j=>{j.onclick=()=>{
- if(j.classList.contains('dim')){j.classList.remove('dim');j.style.setProperty('--jc',cols[0]);setRd('N24','red');return;}
- let cur=j.style.getPropertyValue('--jc').trim();let i=cols.indexOf(cur);
- if(i>=cols.length-1||i<0){j.classList.add('dim');setRd('N24','dark');}else{j.style.setProperty('--jc',cols[i+1]);setRd('N24',nm[i+1]);}};});})();
-setRd('N24','click to cycle');
-/* N27 frequency-dial + pointer */
-(function(){const fs=$('fscale');const W=184;const marks=[0.5,1,2,3,5,7,10,14,20];const lo=Math.log10(0.5),hi=Math.log10(20);
- marks.forEach(m=>{const x=6+((Math.log10(m)-lo)/(hi-lo))*(W-12);
- const tk=document.createElement('span');tk.className='tick';tk.style.left=x+'px';tk.style.height='11px';fs.appendChild(tk);
- const mk=document.createElement('span');mk.className='mk';mk.style.left=x+'px';mk.textContent=m;fs.appendChild(mk);});
- for(let m=0.5;m<=20;m+=(m<2?0.25:m<10?1:2)){const x=6+((Math.log10(m)-lo)/(hi-lo))*(W-12);
- const tk=document.createElement('span');tk.className='tick';tk.style.left=x+'px';tk.style.height='6px';tk.style.opacity='.5';fs.appendChild(tk);}
- const ptr=document.createElement('span');ptr.className='fptr';fs.appendChild(ptr);
- function freqAt(p){return Math.pow(10,lo+(p/100)*(hi-lo));}
- window.setFreq=function(p){ptr.style.left=(6+p/100*(W-12))+'px';const f=freqAt(p);setRd('N27',(f<10?f.toFixed(1):Math.round(f))+' MHz');};
- dragX(fs,window.setFreq);window.setFreq(45);})();
-/* N28 patch-bay */
-(function(){const patch=$('patch');const rows=2,cols=4;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('http://www.w3.org/2000/svg','svg');patch.appendChild(svg);
- let conns=[];let pending=null;
- function key(a,b){return Math.min(a,b)+'-'+Math.max(a,b);}
- function 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('http://www.w3.org/2000/svg','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');});
- setRd('N28',conns.length?conns.map(c=>{const[a,b]=c.split('-');return a+'↔'+b;}).join(' '):'no cables');}
- jacks.forEach(j=>{j.onclick=()=>{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();};});
- conns=['0-5','2-7'];draw();window.addEventListener('resize',draw);})();
+/* 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 ============ */
-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)];}
+/* svgEl, polar, VUDB live in widgets.js (GW) */
-/* R01 moving-coil VU: pivot below the face, authentic nonlinear dB scale */
-const VUDB=[[-20,0],[-10,.20],[-7,.31],[-5,.40],[-3,.50],[-2,.58],[-1,.66],[0,.75],[1,.84],[2,.91],[3,1]];
-const mcvu={t:.35,needle:null};
-(function(){const s=$('mcvu'),cx=75,cy=112,sweep=t=>-43+t*86;
- const defs=svgEl(s,'defs',{});
- const g1=svgEl(defs,'linearGradient',{id:'vuGlass',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:'vuFace'});
- 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(#vuFace)'});
- 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';
- mcvu.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(#vuGlass)'});
- mcvu.sweep=sweep;})();
-function setMcVu(t){t=Math.max(0,Math.min(1.02,t));mcvu.t=t;
- mcvu.needle.setAttribute('transform',`rotate(${mcvu.sweep(Math.min(1,t))},75,112)`);
- const db=vuDb(Math.min(1,t));
- setRd('R01',(db>0?'+':'')+db.toFixed(1)+' VU');}
+/* 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);
- setMcVu(mcvu.t+(target-mcvu.t)*(target>mcvu.t?0.32:0.10));}
-
-/* R02 calibrated vernier dial: the disc turns under a fixed hairline */
-let vernV=42.0,vernDisc=null;
-(function(){const s=$('vern'),cx=75,cy=75;
- const defs=svgEl(s,'defs',{});
- const fg=svgEl(defs,'radialGradient',{id:'vernFace',cx:'50%',cy:'45%',r:'60%'});
- svgEl(fg,'stop',{offset:'0','stop-color':'#f7f2da'});
- svgEl(fg,'stop',{offset:'1','stop-color':'#e4dfc2'});
- const bk=svgEl(defs,'radialGradient',{id:'bakeKnob',cx:'42%',cy:'36%',r:'75%'});
- svgEl(bk,'stop',{offset:'0','stop-color':'#37332c'});
- svgEl(bk,'stop',{offset:'1','stop-color':'#060505'});
- svgEl(s,'circle',{cx,cy,r:72,fill:'#0c0b0a',stroke:'#262320'});
- vernDisc=svgEl(s,'g',{});
- svgEl(vernDisc,'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(vernDisc,'line',{x1,y1,x2,y2,stroke:'#3a3128','stroke-width':major?1.3:half?1:.7});
- if(major){const g=svgEl(vernDisc,'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'});})();
-function setVern(v){vernV=v;vernDisc.setAttribute('transform',`rotate(${-v*3},75,75)`);setRd('R02',v.toFixed(1));}
-dragDelta($('vern'),()=>vernV,setVern,{min:0,max:100,sens:0.15});setVern(42.0);
-
-/* R03 bat-handle toggle */
-let batOn=true,batLever=null,batLblOn=null,batLblOff=null;
-(function(){const s=$('batsw'),cx=35,cy=44;
- const defs=svgEl(s,'defs',{});
- const ng=svgEl(defs,'linearGradient',{id:'nutG',x1:0,y1:0,x2:0,y2:1});
- svgEl(ng,'stop',{offset:'0','stop-color':'#8a8578'});
- svgEl(ng,'stop',{offset:'1','stop-color':'#4e4a42'});
- const cg=svgEl(defs,'linearGradient',{id:'chromeG',x1:0,y1:0,x2:1,y2:0});
- svgEl(cg,'stop',{offset:'0','stop-color':'#7e7a70'});
- svgEl(cg,'stop',{offset:'.45','stop-color':'#e8e5db'});
- svgEl(cg,'stop',{offset:'1','stop-color':'#8a867c'});
- const tg=svgEl(defs,'radialGradient',{id:'tipG',cx:'40%',cy:'32%',r:'75%'});
- svgEl(tg,'stop',{offset:'0','stop-color':'#f2efe8'});
- svgEl(tg,'stop',{offset:'1','stop-color':'#7e7a70'});
- batLblOn=svgEl(s,'text',{x:cx,y:9,'text-anchor':'middle','font-size':7,'letter-spacing':'.1em',
- 'font-family':'var(--mono)',fill:'var(--gold-hi)'});batLblOn.textContent='ON';
- batLblOff=svgEl(s,'text',{x:cx,y:87,'text-anchor':'middle','font-size':7,'letter-spacing':'.1em',
- 'font-family':'var(--mono)',fill:'var(--dim)'});batLblOff.textContent='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'});
- batLever=svgEl(s,'g',{});
- svgEl(batLever,'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(batLever,'ellipse',{cx,cy:17,rx:6.5,ry:8,fill:'url(#tipG)',stroke:'#5e5a52','stroke-width':.6});
- batLever.style.transformOrigin=`${cx}px ${cy}px`;batLever.style.transition='transform .12s';
- $('batsw').onclick=()=>setBat(!batOn);})();
-function setBat(on){batOn=on;batLever.style.transform=on?'rotate(0deg)':'rotate(180deg)';
- batLblOn.setAttribute('fill',on?'var(--gold-hi)':'var(--dim)');
- batLblOff.setAttribute('fill',on?'var(--dim)':'var(--gold-hi)');setRd('R03',on?'ON':'OFF');}
-setBat(true);
-
-/* R04 bakelite fluted knob over a printed 0-10 scale */
-let bakeV=63,bakeIdx=null;
-(function(){const s=$('bakek'),cx=55,cy=54;
- const defs=svgEl(s,'defs',{});
- const sk=svgEl(defs,'radialGradient',{id:'bakeSk',cx:'42%',cy:'36%',r:'80%'});
- svgEl(sk,'stop',{offset:'0','stop-color':'#312d27'});
- svgEl(sk,'stop',{offset:'1','stop-color':'#050404'});
- const dm=svgEl(defs,'radialGradient',{id:'bakeDome',cx:'40%',cy:'32%',r:'80%'});
- svgEl(dm,'stop',{offset:'0','stop-color':'#3a362f'});
- svgEl(dm,'stop',{offset:'1','stop-color':'#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});
- bakeIdx=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)'});})();
-function setBake(v){bakeV=v;bakeIdx.setAttribute('transform',`rotate(${v*2.7-135},55,54)`);
- setRd('R04',(v/10).toFixed(1)+' / 10');}
-dragDelta($('bakek'),()=>bakeV,setBake,{min:0,max:100,sens:0.25});setBake(63);
-
-/* shared: interpolate a dB reading from a 0..1 sweep position (VU law) */
-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)));}
-
-/* R05 filter slider bank: one fader per band, arrow caps on black tracks */
-const FB_FREQ=[78,136,235,406,701,1210,2090,3620];
-const fbank={vals:[18,30,42,25,35,55,20,48],caps:[]};
-(function(){const s=$('fbank'),y0=14,y1=84,x0=30,dx=19.5;
- const yOf=v=>y0+v/60*(y1-y0);
- for(const [lbl,y] of [['0',y0],['20',y0+(y1-y0)/3],['40',y0+2*(y1-y0)/3],['60',y1]]){
- svgEl(s,'text',{x:14,y:y+2,'text-anchor':'end','font-size':6,'font-family':'var(--mono)',
- fill:'var(--steel)'}).textContent=lbl;}
- svgEl(s,'text',{x:14,y:94,'text-anchor':'end','font-size':5.5,'font-family':'var(--mono)',
- fill:'var(--dim)'}).textContent='dB';
- FB_FREQ.forEach((f,i)=>{const x=x0+i*dx;
- svgEl(s,'text',{x,y:8,'text-anchor':'middle','font-size':5.2,'font-family':'var(--mono)',
- fill:'var(--steel)'}).textContent=f<1000?f:(f/1000)+'k';
- svgEl(s,'line',{x1:x,y1:y0,x2:x,y2:y1,stroke:'#0a0908','stroke-width':3.4,'stroke-linecap':'round'});
- svgEl(s,'line',{x1:x-.7,y1:y0,x2:x-.7,y2:y1,stroke:'rgba(255,255,255,.05)','stroke-width':.7});
- const cap=svgEl(s,'g',{});
- svgEl(cap,'polygon',{points:`${x-8.5},${-4.5} ${x-2.5},0 ${x-8.5},${4.5}`,fill:'var(--vfd)',stroke:'#123028','stroke-width':.6});
- svgEl(cap,'polygon',{points:`${x+8.5},${-4.5} ${x+2.5},0 ${x+8.5},${4.5}`,fill:'var(--vfd)',stroke:'#123028','stroke-width':.6});
- svgEl(cap,'circle',{cx:x,cy:0,r:1.5,fill:'#123028'});
- fbank.caps.push(cap);
- const hit=svgEl(s,'rect',{x:x-9.5,y:y0-6,width:19,height:y1-y0+12,fill:'transparent'});
- hit.style.cursor='ns-resize';
- dragY(hit,pct=>setFbank(i,(100-pct)/100*60));});
- })();
-function setFbank(i,db){db=Math.max(0,Math.min(60,db));fbank.vals[i]=db;
- fbank.caps[i].setAttribute('transform',`translate(0,${14+db/60*70})`);
- setRd('R05',`${FB_FREQ[i]<1000?FB_FREQ[i]:(FB_FREQ[i]/1000)+'k'} Hz · −${Math.round(db)} dB`);}
-fbank.vals.forEach((v,i)=>setFbank(i,v));setRd('R05','drag a band');
-
-/* R06 chicken-head selector: the tapered lever aims at the position */
-const CHICK_POS=[['OFF',-60],['LO',-20],['MID',20],['HI',60]];
-let chickI=2,chickLever=null,chickLbls=[];
-(function(){const s=$('chick'),cx=55,cy=58;
- const defs=svgEl(s,'defs',{});
- const bg=svgEl(defs,'radialGradient',{id:'chickG',cx:'42%',cy:'34%',r:'80%'});
- svgEl(bg,'stop',{offset:'0','stop-color':'#35312b'});
- svgEl(bg,'stop',{offset:'1','stop-color':'#070606'});
- CHICK_POS.forEach(([lbl,a],i)=>{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;chickLbls.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'});
- chickLever=svgEl(s,'g',{});
- svgEl(chickLever,'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(chickLever,'line',{x1:cx,y1:cy-26,x2:cx,y2:cy+6,stroke:'#4a463e','stroke-width':1.4,'stroke-linecap':'round'});
- chickLever.style.transformOrigin=`${cx}px ${cy}px`;chickLever.style.transition='transform .12s';
- $('chick').onclick=()=>setChick((chickI+1)%CHICK_POS.length);})();
-function setChick(i){chickI=i;chickLever.style.transform=`rotate(${CHICK_POS[i][1]}deg)`;
- chickLbls.forEach((t,k)=>t.setAttribute('fill',k===i?'var(--gold-hi)':'var(--dim)'));
- setRd('R06',CHICK_POS[i][0]);}
-setChick(2);
-
-/* R07 round panel meter: porthole bezel, same VU law as R01 */
-let rndT=.5,rndNeedle=null;
-(function(){const s=$('rndm'),cx=55,fy=54,py=74,sweep=t=>-40+t*80;
- const defs=svgEl(s,'defs',{});
- const clip=svgEl(defs,'clipPath',{id:'rndFace'});
- 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(#rndFace)'});
- 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';
- rndNeedle=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})`});})();
-function setRnd(v){const t=Math.max(0,Math.min(1,v/100));rndT=t;
- rndNeedle.setAttribute('transform',`rotate(${-40+t*80},55,74)`);
- const db=vuDb(t);setRd('R07',(db>0?'+':'')+db.toFixed(1)+' dB');}
-dragDelta($('rndm'),()=>rndT*100,setRnd,{min:0,max:100});setRnd(50);
+ const t=MH.mcvu.get();MH.mcvu.set(t+(target-t)*(target>t?0.32:0.10));}
-/* R08 chrome MIN/MAX indicator */
-let chrV=50,chrPtr=null;
-(function(){const s=$('chrmm'),cx=55,cy=58;
- const defs=svgEl(s,'defs',{});
- const ring=svgEl(defs,'linearGradient',{id:'chrRing',x1:0,y1:0,x2:0,y2:1});
- svgEl(ring,'stop',{offset:'0','stop-color':'#eceef0'});
- svgEl(ring,'stop',{offset:'.5','stop-color':'#9aa0a8'});
- svgEl(ring,'stop',{offset:'1','stop-color':'#54585e'});
- const dome=svgEl(defs,'radialGradient',{id:'chrDome',cx:'48%',cy:'20%',r:'85%'});
- svgEl(dome,'stop',{offset:'0','stop-color':'#e6e8ec'});
- svgEl(dome,'stop',{offset:'1','stop-color':'#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});
- chrPtr=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)'});})();
-function setChr(v){chrV=Math.max(0,Math.min(100,v));
- chrPtr.setAttribute('transform',`rotate(${-55+chrV/100*110},55,58)`);
- setRd('R08',chrV<=2?'MIN':chrV>=98?'MAX':Math.round(chrV)+'%');}
-dragDelta($('chrmm'),()=>chrV,setChr,{min:0,max:100});setChr(50);
+/* R02-R06, R12, R14, R15 extracted to widgets.js (GW.*) */
-/* R09 black-face aviation gauge: zone arcs per the airspeed-indicator scheme */
-let avV=52,avNeedle=null;
-(function(){const s=$('avgauge'),cx=55,cy=52,sweep=t=>-135+t*270;
- const defs=svgEl(s,'defs',{});
- const fl=svgEl(defs,'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';
- avNeedle=svgEl(s,'g',{});
- svgEl(avNeedle,'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(avNeedle,'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})`});})();
-function setAv(v){avV=Math.max(0,Math.min(100,v));
- avNeedle.setAttribute('transform',`rotate(${-135+avV/100*270},55,52)`);
- setRd('R09',(avV/100*8).toFixed(1)+' ×100 rpm');}
-dragDelta($('avgauge'),()=>avV,setAv,{min:0,max:100});setAv(52);
-/* R10 data matrix readout */
-const DMX_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'];}]];
-let dmxI=0,dmxLines=[];
-(function(){const s=$('dmx');
- const defs=svgEl(s,'defs',{});
- const fl=svgEl(defs,'filter',{id:'dmxGlow',x:'-30%',y:'-30%',width:'160%',height:'160%'});
- svgEl(fl,'feGaussianBlur',{in:'SourceGraphic',stdDeviation:1.1});
- const bg=svgEl(defs,'linearGradient',{id:'dmxBg',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(#dmxBg)',stroke:'#2c261d'});
- 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))'});
- dmxLines.push([glow,crisp]);}
- $('dmx').onclick=()=>setDmx((dmxI+1)%DMX_PAGES.length);})();
-function setDmx(i){dmxI=i;const [name,rowsFn]=DMX_PAGES[i];
- rowsFn().forEach((txt,r)=>{dmxLines[r][0].textContent=txt;dmxLines[r][1].textContent=txt;});
- setRd('R10','page '+name);}
-setDmx(0);
-if(!matchMedia('(prefers-reduced-motion: reduce)').matches)
- setInterval(()=>{if(DMX_PAGES[dmxI][0]==='TIME')setDmx(dmxI);},1000);
-/* R11 warning flag window */
-let flagOn=false,flagStripes=null;
-(function(){const s=$('wflag');
- const defs=svgEl(s,'defs',{});
- const pat=svgEl(defs,'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 clip=svgEl(defs,'clipPath',{id:'flagWin'});
- 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(#flagWin)'});
- flagStripes=svgEl(g,'rect',{x:14,y:20,width:56,height:24,fill:'url(#barber)'});
- flagStripes.style.transition='transform .18s';flagStripes.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='VIB';
- $('wflag').onclick=()=>setFlag(!flagOn);})();
-function setFlag(on){flagOn=on;flagStripes.style.transform=on?'translateX(0)':'translateX(-58px)';
- setRd('R11',on?'FLAG':'clear');}
-setFlag(false);
-/* R12 chrome slot fader: engraved dB scale, chrome T-handle */
-let slotDb=-4,slotHandle=null;
-(function(){const s=$('slotf'),cx=45,yTop=30,yBot=120;
- const yOf=db=>yTop+(12-db)/36*(yBot-yTop);
- const defs=svgEl(s,'defs',{});
- const pl=svgEl(defs,'linearGradient',{id:'sfPlate',x1:0,y1:0,x2:1,y2:1});
- svgEl(pl,'stop',{offset:'0','stop-color':'#242019'});
- svgEl(pl,'stop',{offset:'1','stop-color':'#131110'});
- const ch=svgEl(defs,'linearGradient',{id:'sfChrome',x1:0,y1:0,x2:0,y2:1});
- svgEl(ch,'stop',{offset:'0','stop-color':'#f0efec'});
- svgEl(ch,'stop',{offset:'.45','stop-color':'#c4c1b9'});
- svgEl(ch,'stop',{offset:'1','stop-color':'#75726a'});
- const sc=svgEl(defs,'radialGradient',{id:'sfScrew',cx:'40%',cy:'35%',r:'75%'});
- svgEl(sc,'stop',{offset:'0','stop-color':'#9b968a'});
- svgEl(sc,'stop',{offset:'1','stop-color':'#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);}
- slotHandle=svgEl(s,'g',{});
- svgEl(slotHandle,'polygon',{points:'45,0 58,-5 58,5',fill:'#b9b6ae',stroke:'#5e5a52','stroke-width':.5});
- svgEl(slotHandle,'rect',{x:58,y:-7,width:22,height:14,rx:3.5,fill:'url(#sfChrome)',stroke:'#4e4a42','stroke-width':.6});
- svgEl(slotHandle,'rect',{x:60,y:-5.2,width:18,height:2.2,rx:1,fill:'rgba(255,255,255,.4)'});
- dragY(s,pct=>setSlot(-24+pct/100*36));})();
-function setSlot(db){slotDb=Math.max(-24,Math.min(12,db));
- slotHandle.setAttribute('transform',`translate(0,${30+(12-slotDb)/36*90})`);
- setRd('R12',(slotDb>0?'+':slotDb<0?'−':'')+Math.abs(slotDb).toFixed(1)+' dB');}
-setSlot(-4);
-/* R13 edgewise strip meter: compressed log scale, bar rides the edge */
-const EDGE_SCALE=[[0,.08],[3,.26],[6,.44],[12,.62],[20,.78],[40,.94]];
-let edgeFrac=.62,edgeBar=null;
-(function(){const s=$('edgem'),wy0=12,wy1=118;
- const defs=svgEl(s,'defs',{});
- const pg=svgEl(defs,'linearGradient',{id:'edgeFace',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(#edgeFace)',stroke:'#0a0908'});
- const yOf=f=>wy0+f*(wy1-wy0);
- for(const [db,f] of EDGE_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<EDGE_SCALE.length-1;i++){const y=yOf((EDGE_SCALE[i][1]+EDGE_SCALE[i+1][1])/2);
- svgEl(s,'line',{x1:19,y1:y,x2:32,y2:y,stroke:'#3a3128','stroke-width':.6,opacity:.7});}
- edgeBar=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)'});
- dragY(s,pct=>setEdge(.08+(100-pct)/100*.86));})();
-function setEdge(f){edgeFrac=Math.max(.08,Math.min(.94,f));
- edgeBar.setAttribute('transform',`translate(0,${12+edgeFrac*106})`);
- let i=0;while(i<EDGE_SCALE.length-2&&EDGE_SCALE[i+1][1]<edgeFrac)i++;
- const [d1,f1]=EDGE_SCALE[i],[d2,f2]=EDGE_SCALE[i+1];
- const db=d1+(d2-d1)*Math.max(0,Math.min(1,(edgeFrac-f1)/(f2-f1)));
- setRd('R13','−'+db.toFixed(1)+' dB');}
-setEdge(.62);
-/* R14 spade-pointer tuning knob: engraved panel arc, pointer rides the knob */
-let spadeV=8.3,spadeGrp=null;
-(function(){const s=$('spade'),cx=70,cy=82,sweep=v=>-80+v/10*160;
- const defs=svgEl(s,'defs',{});
- const bk=svgEl(defs,'radialGradient',{id:'spadeKnob',cx:'42%',cy:'34%',r:'80%'});
- svgEl(bk,'stop',{offset:'0','stop-color':'#33302a'});
- svgEl(bk,'stop',{offset:'1','stop-color':'#080706'});
- const mt=svgEl(defs,'linearGradient',{id:'spadeMetal',x1:0,y1:0,x2:1,y2:1});
- svgEl(mt,'stop',{offset:'0','stop-color':'#e9e7e0'});
- svgEl(mt,'stop',{offset:'1','stop-color':'#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;}}
- spadeGrp=svgEl(s,'g',{});
- svgEl(spadeGrp,'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(spadeGrp,'circle',{cx,cy,r:30,fill:'url(#spadeKnob)',stroke:'#000','stroke-width':.8});
- svgEl(spadeGrp,'circle',{cx,cy,r:28.5,fill:'none',stroke:'#0a0908','stroke-width':3,'stroke-dasharray':'2.2 2.2'});
- svgEl(spadeGrp,'circle',{cx,cy,r:22,fill:'url(#spadeKnob)'});
- svgEl(spadeGrp,'ellipse',{cx:cx-8,cy:cy-9,rx:8,ry:5,fill:'rgba(255,255,255,.08)',transform:`rotate(-30,${cx-8},${cy-9})`});})();
-function setSpade(v){spadeV=Math.max(0,Math.min(10,v));
- spadeGrp.setAttribute('transform',`rotate(${-80+spadeV/10*160},70,82)`);
- setRd('R14',spadeV.toFixed(1));}
-dragDelta($('spade'),()=>spadeV,setSpade,{min:0,max:10,sens:.05});setSpade(8.3);
-/* R15 multi-band dial: nested arcs, one needle; bandspread selects the ring */
-const MBAND_RANGES=[[0.54,1.6],[1.6,5.1],[5.1,15.5],[15.5,30.5]];
-let mbandV=45,mbandB=2,mbNeedle=null,mbRings=[],mbSpread=null;
-(function(){const s=$('mband'),cx=62,cy=62,sweep=t=>-70+t*140;
- const defs=svgEl(s,'defs',{});
- const pf=svgEl(defs,'linearGradient',{id:'mbFace',x1:0,y1:0,x2:1,y2:1});
- svgEl(pf,'stop',{offset:'0','stop-color':'#eee5c8'});
- svgEl(pf,'stop',{offset:'1','stop-color':'#dcd2ae'});
- 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';
- [18,26,34,42].forEach((r,bi)=>{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});}
- mbRings.push(ring);});
- mbNeedle=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});
- mbSpread=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';
- const dragHit=svgEl(s,'rect',{x:9,y:9,width:100,height:92,fill:'transparent'});
- dragHit.style.cursor='ns-resize';
- dragDelta(dragHit,()=>mbandV,v=>setMband(v,mbandB),{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',()=>setMband(mbandV,(mbandB+1)%4));})();
-function setMband(v,b){mbandV=Math.max(0,Math.min(100,v));mbandB=b;
- mbNeedle.setAttribute('transform',`rotate(${-70+mbandV/100*140},62,62)`);
- mbSpread.setAttribute('transform',`rotate(${-45+b*30},142,56)`);
- mbRings.forEach((g,i)=>g.setAttribute('opacity',i===b?'1':'.4'));
- const [lo,hi]=MBAND_RANGES[b];const mc=lo+mbandV/100*(hi-lo);
- setRd('R15',`B${b+1} · ${mc.toFixed(2)} Mc`);}
-setMband(45,2);
-/* R16 entry keypad: worn keys feed the amber display; lamps watch state */
-let kpBuf='',kpDisp=null,kpLampTop=null,kpLampBot=null;
-(function(){const s=$('keypad');
- const defs=svgEl(s,'defs',{});
- const kg=svgEl(defs,'linearGradient',{id:'kpKey',x1:0,y1:0,x2:0,y2:1});
- svgEl(kg,'stop',{offset:'0','stop-color':'#dbd8cf'});
- svgEl(kg,'stop',{offset:'1','stop-color':'#a29d92'});
- const ag=svgEl(defs,'linearGradient',{id:'kpAmber',x1:0,y1:0,x2:0,y2:1});
- svgEl(ag,'stop',{offset:'0','stop-color':'var(--amber-grad-top)'});
- svgEl(ag,'stop',{offset:'1','stop-color':'var(--amber-grad-mid)'});
- // lamp pod
- const lamp=cy=>{const l=svgEl(s,'circle',{cx:13,cy,r:6.5,fill:'#3a0f0a',stroke:'#14100e','stroke-width':1.5});return l;};
- kpLampTop=lamp(20);kpLampBot=lamp(38);
- // display window
- svgEl(s,'rect',{x:26,y:8,width:116,height:32,rx:5,fill:'#0a0806',stroke:'#2c261d','stroke-width':2});
- kpDisp=svgEl(s,'text',{x:84,y:31,'text-anchor':'middle','font-size':16,'letter-spacing':'.22em',
- 'font-family':'var(--mono)',fill:'var(--gold-hi)'});
- // key plate
- svgEl(s,'rect',{x:24,y:48,width:118,height:138,rx:8,fill:'#1a1714',stroke:'#0a0908','stroke-width':1.5});
- 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);kpPress(k);});}));
- })();
-function kpRender(){kpDisp.textContent=kpBuf.padEnd(6,'–');
- kpLampTop.setAttribute('fill',kpBuf?'var(--jewel-r)':'#3a0f0a');}
-function kpPress(k){
- if(k==='✓'){setRd('R16',kpBuf?'OK · '+kpBuf:'empty');kpBuf='';kpRender();return;}
- if(k==='✗'){kpBuf='';kpRender();setRd('R16','cleared');
- kpLampBot.setAttribute('fill','var(--jewel-r)');
- setTimeout(()=>kpLampBot.setAttribute('fill','#3a0f0a'),350);return;}
- if(kpBuf.length<6){kpBuf+=k;kpRender();setRd('R16',kpBuf);}}
-kpRender();setRd('R16','enter a code');
-/* R17 round CRT scope: pale phosphor face, live trace */
-let rcrtPh=0,rcrtTrace=null;
-(function(){const s=$('rcrt'),cx=55,cy=52;
- const defs=svgEl(s,'defs',{});
- const ph=svgEl(defs,'radialGradient',{id:'rcrtFace',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:'rcrtClip'});
- 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(#rcrtFace)'});
- const g=svgEl(s,'g',{'clip-path':'url(#rcrtClip)'});
- 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});}
- svgEl(g,'polyline',{id:'rcrtGlow',points:'',fill:'none',stroke:'var(--crt-glow,#eef7ee)','stroke-width':3,opacity:.35,filter:'url(#avGlow)'});
- rcrtTrace=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})`});})();
-function tickRcrt(){rcrtPh+=0.12;const amp=8+3*Math.sin(rcrtPh*0.35);let pts='';
- for(let x=-38;x<=38;x+=2){const y=Math.sin(x*0.55+rcrtPh*2.5)*amp;
- pts+=`${55+x},${(52+y).toFixed(1)} `;}
- rcrtTrace.setAttribute('points',pts.trim());
- document.getElementById('rcrtGlow').setAttribute('points',pts.trim());
- setRd('R17','Vpp '+Math.round(amp*2*4.5));}
-tickRcrt();
-if(!matchMedia('(prefers-reduced-motion: reduce)').matches)setInterval(tickRcrt,80);
+/* R07-R09 extracted to widgets.js (GW.*) */
-/* R18 thumb-slide attenuator pair: lit numeral strip, side thumb tab */
-const THSL=[{name:'BLEND',v:74},{name:'MIX',v:92}];
-const thslParts=[];
-(function(){const s=$('thumbsl'),y0=22,y1=122;
- const defs=svgEl(s,'defs',{});
- const rg=svgEl(defs,'linearGradient',{id:'thRail',x1:0,y1:0,x2:1,y2:0});
- svgEl(rg,'stop',{offset:'0','stop-color':'#3a3733'});
- svgEl(rg,'stop',{offset:'.5','stop-color':'#211f1c'});
- svgEl(rg,'stop',{offset:'1','stop-color':'#161412'});
- const tg=svgEl(defs,'linearGradient',{id:'thTab',x1:0,y1:0,x2:0,y2:1});
- svgEl(tg,'stop',{offset:'0','stop-color':'#f2efe6'});
- svgEl(tg,'stop',{offset:'1','stop-color':'#c6c1b3'});
- THSL.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'});
- for(let n=0;n<=10;n++){const val=100-n*10,y=y0+6+n*(y1-y0-12)/10;
- svgEl(s,'text',{x,y:y+2,'text-anchor':'middle','font-size':5.5,'font-family':'var(--mono)',
- fill:'var(--gold)',opacity:.55}).textContent=val;}
- 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';
- thslParts.push({tab,nums:[...s.querySelectorAll('text')].filter(t=>t.getAttribute('x')==String(x)&&t.textContent!==ch.name)});
- dragY(hit,pct=>setThsl(i,pct));});
- })();
-function setThsl(i,v){v=Math.max(0,Math.min(100,Math.round(v)));THSL[i].v=v;
- const p=thslParts[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');});
- setRd('R18',`${THSL[0].name} ${THSL[0].v} · ${THSL[1].name} ${THSL[1].v}`);}
-setThsl(0,74);setThsl(1,92);
+/* R10, R11 extracted to widgets.js (GW.*) */
-/* R19 waveform region editor: monochrome LCD, draggable S/E flags */
-let wrS=22,wrE=76,wrBars=[],wrFlagS=null,wrFlagE=null;
-(function(){const s=$('wregion'),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'});
- 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);
- wrBars.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;};
- wrFlagS=flag('S');wrFlagE=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';
- dragX(s,pct=>{
- // move whichever flag is nearer the pointer
- if(Math.abs(pct-wrS)<=Math.abs(pct-wrE))setWregion(Math.min(pct,wrE-4),wrE);
- else setWregion(wrS,Math.max(pct,wrS+4));});})();
-function setWregion(sv,ev){wrS=Math.max(0,Math.min(96,sv));wrE=Math.max(4,Math.min(100,ev));
- const x0=10,x1=180,xOf=p=>x0+p/100*(x1-x0);
- wrFlagS.setAttribute('transform',`translate(${xOf(wrS)},0)`);
- wrFlagE.setAttribute('transform',`translate(${xOf(wrE)},0)`);
- wrBars.forEach((b,i)=>{const p=i/(wrBars.length-1)*100;
- b.setAttribute('fill',(p>=wrS&&p<=wrE)?'var(--scr-hi,#f2f4f2)':'var(--scr-dim,#5a5c5a)');});
- setRd('R19',`S ${Math.round(wrS)}% · E ${Math.round(wrE)}%`);}
-setWregion(22,76);
-/* R20 drum roller selector: numbered drum in a window, center chip reads it */
-const DRUMR=[{name:'HIGH',v:6},{name:'LOW',v:8}];
-const drumrGrps=[];
-(function(){const s=$('drumr'),cy=76,step=17;
- const defs=svgEl(s,'defs',{});
- const paper=svgEl(defs,'linearGradient',{id:'drPaper',x1:0,y1:0,x2:1,y2:0});
- svgEl(paper,'stop',{offset:'0','stop-color':'#cfc6a8'});
- svgEl(paper,'stop',{offset:'.5','stop-color':'#ece4c8'});
- svgEl(paper,'stop',{offset:'1','stop-color':'#c6bd9e'});
- const curve=svgEl(defs,'linearGradient',{id:'drCurve',x1:0,y1:0,x2:0,y2:1});
- svgEl(curve,'stop',{offset:'0','stop-color':'rgba(0,0,0,.4)'});
- svgEl(curve,'stop',{offset:'.28','stop-color':'rgba(0,0,0,0)'});
- svgEl(curve,'stop',{offset:'.72','stop-color':'rgba(0,0,0,0)'});
- svgEl(curve,'stop',{offset:'1','stop-color':'rgba(0,0,0,.4)'});
- svgEl(s,'text',{x:65,y:12,'text-anchor':'middle','font-size':7,'letter-spacing':'.18em',
- 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='EQUALIZER';
- DRUMR.forEach((ch,i)=>{const x=45+i*50;
- 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(defs,'clipPath',{id:'drClip'+i});
- 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(#drClip${i})`});
- 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';
- drumrGrps.push({nums,chip,base:ch.v});
- dragDelta(hit,()=>DRUMR[i].v,v=>setDrumr(i,v),{min:1,max:10,sens:.08});});
- })();
-function setDrumr(i,v){v=Math.max(1,Math.min(10,v));DRUMR[i].v=v;
- const p=drumrGrps[i];
- p.nums.style.transform=`translateY(${(v-p.base)*17}px)`;
- p.chip.textContent=Math.round(v);
- setRd('R20',`${DRUMR[0].name} ${Math.round(DRUMR[0].v)} · ${DRUMR[1].name} ${Math.round(DRUMR[1].v)}`);}
-setDrumr(0,6);setDrumr(1,8);
-/* R21 LED program row: exclusive select, the LED carries the state */
-const LEDROW_PROGS=['Sm Hall B','VocPlate','Lg Hall B','Chamber','ParcPlate','Sm Hall A','Room A','Const Plate'];
-let ledrowI=5,ledrowLeds=[];
-(function(){const s=$('ledrow');
- const defs=svgEl(s,'defs',{});
- const kb=svgEl(defs,'linearGradient',{id:'lrKey',x1:0,y1:0,x2:0,y2:1});
- svgEl(kb,'stop',{offset:'0','stop-color':'#2b2823'});
- svgEl(kb,'stop',{offset:'1','stop-color':'#171512'});
- LEDROW_PROGS.forEach((name,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;
- ledrowLeds.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);setLedrow(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='PROGRAM';})();
-function setLedrow(i){ledrowI=i;
- ledrowLeds.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))':'');});
- setRd('R21',`${ledrowI+1} · ${LEDROW_PROGS[ledrowI]}`);}
-setLedrow(5);
-/* R22 three-position slide: chrome pill, detents, honest LED pair */
-const VARSL_POS=['A','AB','B'];
-let varslI=1,varslPill=null,varslLbls=[],varslLeds=[];
-(function(){const s=$('varslide');const detX=[34,55,76];
- 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='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)'});
- varslPill=svgEl(s,'rect',{x:-13,y:18.5,width:26,height:9,rx:4.5,fill:'url(#sfChrome)',stroke:'#4e4a42','stroke-width':.6});
- varslPill.style.transition='transform .12s';
- VARSL_POS.forEach((lbl,i)=>{
- varslLbls.push(svgEl(s,'text',{x:detX[i],y:40,'text-anchor':'middle','font-size':6.5,
- 'font-family':'var(--mono)',fill:'var(--dim)'}));
- varslLbls[i].textContent=lbl;});
- svgEl(s,'rect',{x:32,y:45,width:46,height:11,rx:2,fill:'#141210',stroke:'#060505'});
- [44,66].forEach(x=>varslLeds.push(svgEl(s,'circle',{cx:x,cy:50.5,r:2.4,fill:'#3a0f0a'})));
- s.addEventListener('click',e=>{const r=s.getBoundingClientRect();
- const x=(e.clientX-r.left)/r.width*110;
- setVarsl(x<45?0:x<66?1:2);});})();
-function setVarsl(i){varslI=i;const detX=[34,55,76];
- varslPill.setAttribute('transform',`translate(${detX[i]},0)`);
- varslLbls.forEach((t,k)=>t.setAttribute('fill',k===i?'var(--gold-hi)':'var(--dim)'));
- const lit=[i===0||i===1,i===2||i===1];
- varslLeds.forEach((l,k)=>l.setAttribute('fill',lit[k]?'var(--jewel-r)':'#3a0f0a'));
- setRd('R22',VARSL_POS[i]);}
-setVarsl(1);
+/* R13, R17 extracted to widgets.js (GW.*) */
-/* R23 spun-aluminum knob: machined rings in a knurled grip */
-let spunV=62,spunIdx=null;
-(function(){const s=$('spunk'),cx=55,cy=52;
- const defs=svgEl(s,'defs',{});
- const al=svgEl(defs,'radialGradient',{id:'spunFace',cx:'42%',cy:'34%',r:'85%'});
- svgEl(al,'stop',{offset:'0','stop-color':'#e8e6e0'});
- svgEl(al,'stop',{offset:'.7','stop-color':'#b3b0a8'});
- svgEl(al,'stop',{offset:'1','stop-color':'#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});
- spunIdx=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='SPEED';})();
-function setSpun(v){spunV=Math.max(0,Math.min(100,v));
- spunIdx.setAttribute('transform',`rotate(${-135+spunV/100*270},55,52)`);
- setRd('R23',Math.round(spunV)+'%');}
-dragDelta($('spunk'),()=>spunV,setSpun,{min:0,max:100});setSpun(62);
+/* R25, R26, R30, R31 extracted to widgets.js (GW.*) */
-/* R24 stomp switch + jewel: press to engage, the jewel reports */
-let stompOn=false,stompDome=null,stompJewel=null,stompGlow=null;
-(function(){const s=$('stomp'),cx=55;
- const defs=svgEl(s,'defs',{});
- const dg=svgEl(defs,'radialGradient',{id:'stompDome',cx:'40%',cy:'30%',r:'80%'});
- svgEl(dg,'stop',{offset:'0','stop-color':'#f4f2ec'});
- svgEl(dg,'stop',{offset:'.55','stop-color':'#b9b6ae'});
- svgEl(dg,'stop',{offset:'1','stop-color':'#6e6b63'});
- const jg=svgEl(defs,'radialGradient',{id:'stompJewel',cx:'38%',cy:'30%',r:'80%'});
- svgEl(jg,'stop',{offset:'0','stop-color':'#ffe9b0'});
- svgEl(jg,'stop',{offset:'.45','stop-color':'var(--jewel-a)'});
- svgEl(jg,'stop',{offset:'1','stop-color':'#5c3d0c'});
- stompGlow=svgEl(s,'circle',{cx,cy:22,r:14,fill:'rgba(255,180,58,.35)',filter:'url(#avGlow)',opacity:0});
- stompJewel=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});
- stompDome=svgEl(s,'g',{});stompDome.style.transition='transform .08s';
- svgEl(stompDome,'circle',{cx,cy:72,r:19,fill:'url(#stompDome)',stroke:'#55524a','stroke-width':.8});
- svgEl(stompDome,'ellipse',{cx:cx-6,cy:64,rx:8,ry:4.5,fill:'rgba(255,255,255,.5)',transform:`rotate(-24,${cx-6},64)`});
- $('stomp').onclick=()=>{stompDome.style.transform='translateY(1.5px)';
- setTimeout(()=>stompDome.style.transform='',90);setStomp(!stompOn);};})();
-function setStomp(on){stompOn=on;
- stompJewel.setAttribute('fill',on?'url(#stompJewel)':'#241f1b');
- stompGlow.setAttribute('opacity',on?'1':'0');
- setRd('R24',on?'ENGAGED':'bypass');}
-setStomp(false);
-/* R27 winged gain selector: red T-bar over a dot ring, stepped detents */
-const WINGK_STEPS=[-80,-70,-60,-50,-40,-30,-20,-10,-5,0,5,10];
-let wingI=7,wingGrp=null;
-(function(){const s=$('wingk'),cx=55,cy=52;
- const defs=svgEl(s,'defs',{});
- const rg2=svgEl(defs,'radialGradient',{id:'wingRed',cx:'40%',cy:'32%',r:'80%'});
- svgEl(rg2,'stop',{offset:'0','stop-color':'#d98a6f'});
- svgEl(rg2,'stop',{offset:'.6','stop-color':'var(--fail)'});
- svgEl(rg2,'stop',{offset:'1','stop-color':'#6e2415'});
- WINGK_STEPS.forEach((db,i)=>{const a=-135+i/(WINGK_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;}});
- wingGrp=svgEl(s,'g',{});wingGrp.style.transition='transform .09s';
- svgEl(wingGrp,'rect',{x:cx-6.5,y:cy-30,width:13,height:60,rx:6,fill:'url(#wingRed)',stroke:'#4e150a','stroke-width':.8});
- svgEl(wingGrp,'circle',{cx,cy,r:15,fill:'url(#wingRed)',stroke:'#4e150a','stroke-width':.8});
- svgEl(wingGrp,'line',{x1:cx,y1:cy-28,x2:cx,y2:cy-18,stroke:'var(--cream)','stroke-width':2,'stroke-linecap':'round'});
- svgEl(wingGrp,'ellipse',{cx:cx-4,cy:cy-8,rx:5,ry:3,fill:'rgba(255,255,255,.18)',transform:`rotate(-30,${cx-4},${cy-8})`});})();
-function setWing(v){const i=Math.max(0,Math.min(WINGK_STEPS.length-1,Math.round(v)));wingI=i;
- wingGrp.style.transform=`rotate(${-135+i/(WINGK_STEPS.length-1)*270}deg)`;
- wingGrp.style.transformOrigin='55px 52px';
- const db=WINGK_STEPS[i];
- setRd('R27',(db>0?'+':'')+db+' dB');}
-dragDelta($('wingk'),()=>wingI,setWing,{min:0,max:WINGK_STEPS.length-1,sens:.05});setWing(7);
-/* R28 rotary disc switch: the whole disc turns between heavy positions */
-const DISCSW_POS=[['OFF',-45],['ON',45],['COMBINE',135]];
-let discI=1,discGrp=null,discLbls=[];
-(function(){const s=$('discsw'),cx=55,cy=58;
- const defs=svgEl(s,'defs',{});
- const rr=svgEl(defs,'radialGradient',{id:'discRed',cx:'42%',cy:'34%',r:'85%'});
- svgEl(rr,'stop',{offset:'0','stop-color':'#d98a6f'});
- svgEl(rr,'stop',{offset:'.65','stop-color':'var(--fail)'});
- svgEl(rr,'stop',{offset:'1','stop-color':'#7a2a1a'});
- 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});}
- DISCSW_POS.forEach(([lbl,a],i)=>{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;discLbls.push(t);});
- svgEl(s,'circle',{cx,cy,r:36,fill:'url(#discRed)',stroke:'#4e150a','stroke-width':1});
- discGrp=svgEl(s,'g',{});discGrp.style.transition='transform .15s';discGrp.style.transformOrigin=`${cx}px ${cy}px`;
- svgEl(discGrp,'rect',{x:cx-27,y:cy-8,width:54,height:16,rx:8,fill:'url(#discRed)',stroke:'#8f3520','stroke-width':1});
- svgEl(discGrp,'rect',{x:cx-25,y:cy-6.5,width:50,height:4,rx:2,fill:'rgba(255,255,255,.16)'});
- svgEl(discGrp,'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})`});
- $('discsw').onclick=()=>setDisc((discI+1)%DISCSW_POS.length);})();
-function setDisc(i){discI=i;
- discGrp.style.transform=`rotate(${DISCSW_POS[i][1]}deg)`;
- discLbls.forEach((t,k)=>t.setAttribute('fill',k===i?'var(--gold-hi)':'var(--dim)'));
- setRd('R28',DISCSW_POS[i][0]);}
-setDisc(1);
-
-/* R29 guarded toggle: guard posts + red collar mark the critical throw */
-let guardOn=true,guardLever=null,guardLblOn=null,guardLblOff=null;
-(function(){const s=$('guardsw'),cx=45,cy=52;
- guardLblOn=svgEl(s,'text',{x:cx,y:12,'text-anchor':'middle','font-size':7,'letter-spacing':'.1em',
- 'font-family':'var(--mono)',fill:'var(--gold-hi)'});guardLblOn.textContent='ON';
- guardLblOff=svgEl(s,'text',{x:cx,y:96,'text-anchor':'middle','font-size':7,'letter-spacing':'.1em',
- 'font-family':'var(--mono)',fill:'var(--dim)'});guardLblOff.textContent='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)'});}
- guardLever=svgEl(s,'g',{});
- svgEl(guardLever,'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(guardLever,'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(guardLever,'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});
- guardLever.style.transformOrigin=`${cx}px ${cy}px`;guardLever.style.transition='transform .12s';
- $('guardsw').onclick=()=>setGuard(!guardOn);})();
-function setGuard(on){guardOn=on;guardLever.style.transform=on?'rotate(0deg)':'rotate(180deg)';
- guardLblOn.setAttribute('fill',on?'var(--gold-hi)':'var(--dim)');
- guardLblOff.setAttribute('fill',on?'var(--dim)':'var(--gold-hi)');
- setRd('R29',on?'ON':'OFF');}
-setGuard(true);
-
-/* R30 telegraph indicator: the pointer names the state on labeled sectors */
-const TELIND_STATES=['OK','TEST','BUSY','STOP','ATTN','FAULT'];
-let telI=0,telPtr=null;
-(function(){const s=$('telind'),cx=55,cy=55,N=TELIND_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});
- TELIND_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;});
- telPtr=svgEl(s,'g',{});telPtr.style.transition='transform .18s';telPtr.style.transformOrigin=`${cx}px ${cy}px`;
- svgEl(telPtr,'polygon',{points:`${cx-2.5},${cy} ${cx},${cy-38} ${cx+2.5},${cy}`,fill:'var(--fail)',stroke:'#7a2a1a','stroke-width':.5});
- svgEl(telPtr,'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'});
- $('telind').onclick=()=>setTelind((telI+1)%TELIND_STATES.length);})();
-function setTelind(i){telI=i;const N=TELIND_STATES.length;
- telPtr.style.transform=`rotate(${i*360/N+180/N}deg)`;
- setRd('R30',TELIND_STATES[i]);}
-setTelind(0);
-
-/* R31 radar sweep: rotating beam, blooming contacts, click to mark */
-let radarA=210,radarSweep=null,radarBlips=[],radarMark=null;
-const RADAR_CONTACTS=[[60,.55],[205,.7],[318,.4]];
-(function(){const s=$('radar'),cx=65,cy=65;
- const defs=svgEl(s,'defs',{});
- const clip=svgEl(defs,'clipPath',{id:'radarClip'});
- 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 a=d;
- const [x1,y1]=polar(cx,cy,51,a),[x2,y2]=polar(cx,cy,47,a);
- 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(${a},${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(#radarClip)'});
- 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});
- radarSweep=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(radarSweep,'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(radarSweep,'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});
- RADAR_CONTACTS.forEach(([bd,rf])=>{const [x,y]=polar(cx,cy,46*rf,bd);
- radarBlips.push(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});
- radarMark=svgEl(s,'text',{x:cx,y:124,'text-anchor':'middle','font-size':6,'letter-spacing':'.12em',
- 'font-family':'var(--mono)',fill:'var(--steel)'});
- radarMark.textContent='BEARING —';
- $('radar').onclick=()=>{radarMark.textContent='MARK '+String(Math.round(radarA)%360).padStart(3,'0');
- setRd('R31','mark '+String(Math.round(radarA)%360).padStart(3,'0'));};})();
-function tickRadar(){radarA=(radarA+2.6)%360;
- radarSweep.setAttribute('transform',`rotate(${radarA},65,65)`);
- RADAR_CONTACTS.forEach(([bd],i)=>{
- let d=(radarA-bd+360)%360; // degrees since beam passed
- radarBlips[i].setAttribute('opacity',d<200?Math.max(0,.95-d/200):0);});
- radarMark.textContent=radarMark.textContent.startsWith('MARK')?radarMark.textContent:
- 'BEARING '+String(Math.round(radarA)%360).padStart(3,'0');}
-tickRadar();setRd('R31','sweeping');
-if(!matchMedia('(prefers-reduced-motion: reduce)').matches)setInterval(tickRadar,50);
-
-/* R25 fourteen-segment display: the starburst that spells */
-const SEG14_WORDS=['ZOOM','ECHO','TAPE','MOOD','HALL','COMP'];
-const SEG14_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']};
-let seg14I=0,seg14Cells=[];
-(function(){const s=$('seg14');
- 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 (x0,y0 top-left)
- 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]]};
- 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)'});}
- seg14Cells.push(cell);}
- $('seg14').onclick=()=>setSeg14((seg14I+1)%SEG14_WORDS.length);})();
-function setSeg14(i){seg14I=i;const word=SEG14_WORDS[i].padEnd(4,' ');
- seg14Cells.forEach((cell,d)=>{const lit=SEG14_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))':'');}});
- setRd('R25',SEG14_WORDS[i]);}
-setSeg14(0);
-
-/* R26 response graph: log-frequency axes, a draggable amber peak */
-const RG={x0:30,x1:182,y0:8,y1:86,fLo:Math.log10(32),fHi:Math.log10(16000),db:12};
-let rgFc=1200,rgGain=6.5,rgCurve=null,rgGlowLine=null,rgHandle=null;
-(function(){const s=$('rgraph');
- 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];
- FREQS.forEach(f=>{const x=RG.x0+(Math.log10(f)-RG.fLo)/(RG.fHi-RG.fLo)*(RG.x1-RG.x0);
- 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=RG.y0+(RG.db-db)/(2*RG.db)*(RG.y1-RG.y0);
- 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;}
- rgGlowLine=svgEl(s,'polyline',{points:'',fill:'none',stroke:'var(--gold-hi)','stroke-width':3,
- opacity:.35,filter:'url(#avGlow)'});
- rgCurve=svgEl(s,'polyline',{points:'',fill:'none',stroke:'var(--gold)','stroke-width':1.6});
- rgHandle=svgEl(s,'circle',{r:4,fill:'var(--gold-hi)',stroke:'#7d5c16','stroke-width':1});
- rgHandle.style.cursor='move';
- // 2D drag: the handle (and the plot) place 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;
- setRgraph(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();});})();
-function setRgraph(fc,gain){rgFc=Math.max(32,Math.min(16000,fc));rgGain=Math.max(-12,Math.min(12,gain));
- 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);
- 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=rgGain*Math.exp(-Math.pow(lf-Math.log10(rgFc),2)/(2*sigma*sigma));
- pts+=`${xOf(Math.pow(10,lf)).toFixed(1)},${yOf(db).toFixed(1)} `;}
- rgCurve.setAttribute('points',pts.trim());rgGlowLine.setAttribute('points',pts.trim());
- rgHandle.setAttribute('cx',xOf(rgFc));rgHandle.setAttribute('cy',yOf(rgGain));
- const fLbl=rgFc<1000?Math.round(rgFc)+' Hz':(rgFc/1000).toFixed(1)+' kHz';
- setRd('R26',`${fLbl} · ${rgGain>=0?'+':''}${rgGain.toFixed(1)} dB`);}
-setRgraph(1200,6.5);
/* ============ LIVE SIGNAL LOOPS ============ */
-buildBars($('vuL'),16);buildBars($('vuR'),16);$('mini').innerHTML='<i></i><i></i><i></i><i></i>';
-(function(){const eq=$('eq');for(let b=0;b<11;b++){const band=document.createElement('span');band.className='band';
- for(let s=0;s<9;s++)band.appendChild(document.createElement('i'));eq.appendChild(band);}})();
-(function(){const cl=$('clock');for(let i=0;i<12;i++){const t=document.createElement('span');t.className='tk';
- t.style.transform=`rotate(${i*30}deg)`;cl.insertBefore(t,cl.firstChild);}})();
-const counterDigits=[4,7,1,3,0,0];
-(function(){const ct=$('counter');counterDigits.forEach((d,idx)=>{const w=document.createElement('span');
- w.className='cwheel'+(idx>=4?' redw':'');const col=document.createElement('span');col.className='col';
- for(let n=-1;n<=10;n++){const s=document.createElement('span');s.textContent=((n+10)%10);col.appendChild(s);}
- w.appendChild(col);ct.appendChild(w);setWheel(w,d);});})();
-function setWheel(w,d){w.querySelector('.col').style.top=(-(d+1)*34)+'px';}
+/* 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 hist=Array.from({length:40},()=>0.5);
const eqBands=Array.from({length:11},(_,i)=>({v:0.4,ph:i*0.6}));
-const stripHist=Array.from({length:60},()=>31);
-const pkL={v:0},pkR={v:0};
-function paintVU(el,l,pk){const b=el.children,n=b.length,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;}}
-function paintMini(el,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'):'';}
-function paintEq(){const bands=$('eq').children;let peak=0;
- for(let b=0;b<bands.length;b++){const cells=bands[b].children,n=cells.length,val=eqBands[b].v;const lit=Math.round(val*n);peak=Math.max(peak,val);
- for(let k=0;k<n;k++){let c='';if(k<lit)c=(k>=n-1?'clip':k>=n-3?'hot':'on');cells[k].className=c?c:'';}}return peak;}
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();
- paintVU($('vuL'),a,pkL);paintVU($('vuR'),b,pkR);paintMini($('mini'),a);
- setRd('11','L '+Math.round(a*100)+' · R '+Math.round(b*100));
- setRd('12',Math.round(a*100)+'%');
- hist.push(0.5+0.42*Math.sin(ph*0.9)+(Math.random()-0.5)*0.25);hist.shift();
- $('sparkp').setAttribute('points',hist.map((v,i)=>`${i/(hist.length-1)*170},${44-Math.max(0,Math.min(1,v))*40-2}`).join(' '));
- setRd('16',Math.round(Math.max(0,Math.min(1,hist[hist.length-1]))*100));
- let d='M0 19';const env=Math.min(1,0.6+0.3*Math.sin(ph*0.4));
- for(let x=0;x<=170;x+=3){const y=19+Math.sin(x*0.18+ph*3)*Math.sin(x*0.05)*14;d+=` L${x} ${y.toFixed(1)}`;}
- $('wavep').setAttribute('d',d);setRd('17','amp '+Math.round(env*100)+'%');
- let sp='';for(let x=0;x<=176;x+=3){const y=39+Math.sin(x*0.16+ph*3.2)*Math.min(1,0.6+0.3*Math.sin(ph*0.4))*22;sp+=`${x},${y.toFixed(1)} `;}
- $('scopep').setAttribute('points',sp.trim());setRd('N11','Vpp '+Math.round((0.6+0.3*Math.sin(ph*0.4))*100));
+ 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));});
- const peak=paintEq();setRd('N12','peak '+Math.round(peak*100)+'%');
- stripHist.push(31-24*Math.sin(ph*0.7)*(0.6+0.4*Math.sin(ph*0.13))+(Math.random()-0.5)*4);stripHist.shift();
- const last=Math.max(3,Math.min(59,stripHist[stripHist.length-1]));
- $('stripp').setAttribute('points',stripHist.map((v,i)=>`${i/(stripHist.length-1)*176},${Math.max(3,Math.min(59,v)).toFixed(1)}`).join(' '));
- $('strippen').style.top=last+'px';setRd('N16',Math.round((59-last)/56*100));
+ 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);
}
-/* clock */
-function tickClock(){const d=new Date();const s=d.getSeconds(),m=d.getMinutes(),h=d.getHours()%12;
- $('clks').style.transform=`rotate(${s*6}deg)`;$('clkm').style.transform=`rotate(${m*6+s*0.1}deg)`;$('clkh').style.transform=`rotate(${h*30+m*0.5}deg)`;
- setRd('N26',String(d.getHours()).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0'));}
-/* split-flap */
-const flapWords=['DNS ','LINK','SYNC','WIFI','SCAN'];let flapI=0;
-function tickFlap(){flapI=(flapI+1)%flapWords.length;const w=flapWords[flapI].padEnd(4,' ');
- $('flap').querySelectorAll('.flapd').forEach((dd,i)=>{const ch=w[i]||' ';const bel=dd.querySelector('b');
- if(bel.textContent!==ch){bel.textContent=ch;if(!reduced){dd.classList.remove('flip');void dd.offsetWidth;dd.classList.add('flip');}}});
- setRd('N20',flapWords[flapI].trim());}
-$('flap').onclick=tickFlap;
-/* seven-segment countdown */
-let sevenSecs=24*60+10;
-function renderSeven(){const mm=String(Math.floor(sevenSecs/60)).padStart(2,'0'),ss=String(sevenSecs%60).padStart(2,'0');
- $('seven').innerHTML=seg7(mm[0])+seg7(mm[1])+'<span class="colon"><i></i><i></i></span>'+seg7(ss[0])+seg7(ss[1]);setRd('N21',mm+':'+ss);}
-function tickSeven(){sevenSecs=(sevenSecs-1+3600)%3600;renderSeven();}
-$('seven').onclick=()=>{sevenSecs=(sevenSecs+60)%3600;renderSeven();};
-renderSeven();
-/* tabular timer */
-let tSecs=24*60+10,tRun=true;
-function renderT(){$('treadout').textContent=String(Math.floor(tSecs/60)).padStart(2,'0')+':'+String(tSecs%60).padStart(2,'0');
- setRd('20',tRun?'running':'paused');}
-function tickT(){if(tRun){tSecs=(tSecs-1+3600)%3600;renderT();}}
-$('treadout').onclick=()=>{tRun=!tRun;renderT();};renderT();
-/* VFD marquee */
-const vfdMsgs=['ARCHSETUP · NET OK · BT 2 · SND 62%','WIFI @Hyatt · 300 Mbps · VPN UP','BATTERY 84% · DISK 61% · TEMP 47C'];let vfdMi=0,vfdX=176;
-function tickVfd(){const t=$('vfdt');vfdX-=1.1;if(vfdX< -t.offsetWidth)vfdX=176;t.style.left=vfdX+'px';}
-$('vfdm').onclick=()=>{vfdMi=(vfdMi+1)%vfdMsgs.length;$('vfdt').textContent=vfdMsgs[vfdMi];vfdX=176;setRd('N22','msg '+(vfdMi+1)+'/'+vfdMsgs.length);};
-setRd('N22','scrolling');
-/* tape counter */
-let ctrVal=471300;
-function tickCounter(){ctrVal+=137;const s=String(ctrVal).padStart(6,'0');
- $('counter').querySelectorAll('.cwheel').forEach((w,i)=>setWheel(w,+s[i]));setRd('N25',s);}
-setRd('N25',String(ctrVal).padStart(6,'0'));
-
-/* reels spin keyframe */
-(function(){const st=document.createElement('style');st.textContent='@keyframes reelspin{to{transform:rotate(360deg)}}';document.head.appendChild(st);})();
-document.querySelectorAll('.reel.spin').forEach(r=>{r.style.animation='reelspin 2.6s linear infinite';});
+/* 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(){
- paintVU($('vuL'),0.55,pkL);paintVU($('vuR'),0.5,pkR);paintMini($('mini'),0.55);
- setRd('11','L 55 · R 50');setRd('12','55%');
- $('sparkp').setAttribute('points',hist.map((v,i)=>`${i/(hist.length-1)*170},${44-0.5*40-2}`).join(' '));setRd('16','50');
- $('wavep').setAttribute('d','M0 19 L170 19');setRd('17','amp 60%');
- eqBands.forEach((bd,i)=>bd.v=0.3+0.5*Math.abs(Math.sin(i*0.9)));setRd('N12','peak '+Math.round(paintEq()*100)+'%');
- $('scopep').setAttribute('points',Array.from({length:60},(_,i)=>`${i*3},${39+Math.sin(i*0.5)*18}`).join(' '));setRd('N11','Vpp 74');
- $('stripp').setAttribute('points',stripHist.map((v,i)=>`${i/(stripHist.length-1)*176},${31-Math.sin(i*0.3)*16}`).join(' '));setRd('N16','50');
- tickClock();renderSeven();setRd('N20','DNS');setRd('N22','scrolling');setRd('N25',String(ctrVal).padStart(6,'0'));
- setMcVu(.5);
+ 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){
@@ -3236,900 +1493,69 @@ if(!reduced){
document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState='paused');
}
-/* R32 mechanical timer dial: dial rotates under a fixed index; wind by drag, stop by the red knob */
-let mtMin=0,mtRot=null;
-(function(){const s=$('mtimer'),cx=62,cy=58;
- const defs=svgEl(s,'defs',{});
- const fc=svgEl(defs,'radialGradient',{id:'mtFace',cx:'50%',cy:'42%',r:'75%'});
- svgEl(fc,'stop',{offset:'0','stop-color':'#282320'});
- svgEl(fc,'stop',{offset:'1','stop-color':'#14110e'});
- const rd=svgEl(defs,'radialGradient',{id:'mtRed',cx:'38%',cy:'32%',r:'80%'});
- svgEl(rd,'stop',{offset:'0','stop-color':'#e0523a'});
- svgEl(rd,'stop',{offset:'1','stop-color':'#8f2416'});
- const hb=svgEl(defs,'linearGradient',{id:'mtHub',x1:0,y1:0,x2:0,y2:1});
- svgEl(hb,'stop',{offset:'0','stop-color':'#c9c4b8'});
- svgEl(hb,'stop',{offset:'1','stop-color':'#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)
- mtRot=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(mtRot,'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(mtRot,'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(mtRot,'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(mtRot,'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);
- // 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,()=>mtMin,v=>setMtimer(v),{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',()=>{setMtimer(0);setRd('R32','STOP · OFF');});})();
-function setMtimer(m){mtMin=Math.max(0,Math.min(60,m));
- mtRot.setAttribute('transform',`rotate(${(-mtMin*4.5).toFixed(2)},62,58)`);
- setRd('R32',mtMin>0.5?'T-'+Math.round(mtMin)+' MIN':'OFF');}
-setMtimer(0);
-if(!matchMedia('(prefers-reduced-motion: reduce)').matches)
- setInterval(()=>{if(mtMin>0){setMtimer(mtMin-1);if(mtMin===0)setRd('R32','DING · OFF');}},1000);
-
-/* R33 four-way rocker: quadrant clicks step a tracked cursor; arrows flash on press */
-(function(){const s=$('rocker4'),cx=55,cy=55;
- const defs=svgEl(s,'defs',{});
- const pd=svgEl(defs,'radialGradient',{id:'rk4Pad',cx:'42%',cy:'34%',r:'85%'});
- svgEl(pd,'stop',{offset:'0','stop-color':'#33302b'});
- svgEl(pd,'stop',{offset:'1','stop-color':'#141210'});
- const ar=svgEl(defs,'linearGradient',{id:'rk4Arr',x1:0,y1:0,x2:0,y2:1});
- svgEl(ar,'stop',{offset:'0','stop-color':'#e9e4d6'});
- svgEl(ar,'stop',{offset:'1','stop-color':'#9d988b'});
- const nb=svgEl(defs,'radialGradient',{id:'rk4Nub',cx:'38%',cy:'30%',r:'85%'});
- svgEl(nb,'stop',{offset:'0','stop-color':'#4a453e'});
- svgEl(nb,'stop',{offset:'1','stop-color':'#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;
- setRd('R33',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);
- setRd('R33','x 0 y 0');})();
+/* R35, R36 extracted to widgets.js (GW.*) */
-/* R34 four-way toggle selector: ball lever throws to a diagonal; corner lamps show the state */
-(function(){const s=$('fourway'),cx=65,cy=65;
- const defs=svgEl(s,'defs',{});
- const ch=svgEl(defs,'linearGradient',{id:'fw4Chrome',x1:0,y1:0,x2:1,y2:1});
- svgEl(ch,'stop',{offset:'0','stop-color':'#e8e6e0'});
- svgEl(ch,'stop',{offset:'.5','stop-color':'#9a968c'});
- svgEl(ch,'stop',{offset:'1','stop-color':'#55524a'});
- const bl=svgEl(defs,'radialGradient',{id:'fw4Ball',cx:'36%',cy:'30%',r:'80%'});
- svgEl(bl,'stop',{offset:'0','stop-color':'#f2f0ea'});
- svgEl(bl,'stop',{offset:'.55','stop-color':'#8e8a80'});
- svgEl(bl,'stop',{offset:'1','stop-color':'#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 fwPos=null;
- const setFw=q=>{fwPos=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');}
- setRd('R34','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',()=>setFw(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}`);
- setFw('C');})();
-/* R35 day-date disc calendar: coaxial printed discs rotate so today reads under the fixed hand */
-const DD_DAYS=['SUN','MON','TUE','WED','THU','FRI','SAT'];
-let ddDate=1,ddDay=0,ddDateG=null,ddDayG=null;
-(function(){const s=$('daydate'),cx=65,cy=65,HAND=135;
- const defs=svgEl(s,'defs',{});
- const fc=svgEl(defs,'radialGradient',{id:'ddFace',cx:'46%',cy:'40%',r:'75%'});
- svgEl(fc,'stop',{offset:'0','stop-color':'#efe9da'});
- svgEl(fc,'stop',{offset:'1','stop-color':'#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
- ddDateG=svgEl(s,'g',{});
- for(let d=1;d<=31;d++){const a=(d-1)*(360/31);
- const g=svgEl(ddDateG,'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});
- ddDayG=svgEl(s,'g',{});
- for(let i=0;i<14;i++){const a=i*(360/14);
- const g=svgEl(ddDayG,'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=DD_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';
- s.addEventListener('click',()=>{setDdcal(ddDate%31+1,(ddDay+1)%7);});})();
-function setDdcal(d,w){ddDate=d;ddDay=w;const HAND=135;
- ddDateG.setAttribute('transform',`rotate(${(HAND-(d-1)*(360/31)).toFixed(2)},65,65)`);
- ddDayG.setAttribute('transform',`rotate(${(HAND-w*(360/14)).toFixed(2)},65,65)`);
- setRd('R35',DD_DAYS[w]+' '+d);}
-(function(){const now=new Date();setDdcal(now.getDate(),now.getDay());})();
+/* R43, R44 extracted to widgets.js (GW.*) */
-/* R36 LED dot matrix: 8x8 paintable bitmap, starts on the reference K */
-(function(){const s=$('dotmx'),N=8,STEP=11,X0=10.5,Y0=10.5;
- const K8=['10000100','10001000','10010000','11100000','10100000','10010000','10001000','10000100'];
- 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});
- const dots=[];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};dots.push(d);
- paint(d,K8[r][c]==='1');if(d.on)lit++;
- el.addEventListener('click',()=>{paint(d,!d.on);lit+=d.on?1:-1;
- setRd('R36',lit+' / 64 lit');});}
- setRd('R36',lit+' / 64 lit');})();
-
-/* R37 pin routing matrix: click an intersection to seat/pull a pin; many-to-many */
-(function(){const s=$('pinmx');
- const ROWS=['OSC1','OSC2','LFO','NOIS','ENV'],COLS=['VCF','VCA','PAN','DLY','OUT','MOD'];
- 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);
- const pins=new Set(['OSC1>VCF','LFO>PAN','ENV>VCA']); // a legible default patch
- 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);
- setRd('R37',(on?r+' → '+c:'pulled '+r+' → '+c)+' · '+pins.size+' routes');});}));
- setRd('R37',pins.size+' routes');})();
-
-/* R38 dead-man button: state exists only while the pointer holds it down */
-(function(){const s=$('deadman'),cx=60,cy=58;
- const defs=svgEl(s,'defs',{});
- const rb=svgEl(defs,'radialGradient',{id:'dmBtn',cx:'40%',cy:'32%',r:'85%'});
- svgEl(rb,'stop',{offset:'0','stop-color':'#3a3631'});
- svgEl(rb,'stop',{offset:'1','stop-color':'#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='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='RUN';
- btn.style.cursor='pointer';
- let dmT0=null,dmIv=null;
- const stop=()=>{if(dmT0===null)return;
- const held=((Date.now()-dmT0)/1000).toFixed(1);
- dmT0=null;clearInterval(dmIv);
- btn.removeAttribute('transform');
- ring.setAttribute('stroke','#2c2820');ring.removeAttribute('filter');
- lamp.setAttribute('fill','#1e2a12');lamp.removeAttribute('filter');
- setRd('R38','SAFE (held '+held+'s)');};
- btn.addEventListener('pointerdown',e=>{btn.setPointerCapture(e.pointerId);
- dmT0=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))');
- setRd('R38','RUNNING 0.0s');
- dmIv=setInterval(()=>{if(dmT0!==null)setRd('R38','RUNNING '+((Date.now()-dmT0)/1000).toFixed(1)+'s');},100);
- e.preventDefault();});
- btn.addEventListener('pointerup',stop);
- btn.addEventListener('pointercancel',stop);
- setRd('R38','SAFE');})();
-
-/* R39 rotary telephone dial: click a hole; the wheel winds to the stop and returns */
-(function(){const s=$('teldial'),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;
- const defs=svgEl(s,'defs',{});
- const mt=svgEl(defs,'radialGradient',{id:'tdWheel',cx:'42%',cy:'36%',r:'85%'});
- svgEl(mt,'stop',{offset:'0','stop-color':'#8f8a7e'});
- svgEl(mt,'stop',{offset:'1','stop-color':'#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((d,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);setRd('R39',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);});});
- setRd('R39','dial a number');})();
-
-/* R40 circuit breaker panel: on/off by click, TRIP pops one to the amber mid-state, reset is two-step */
-(function(){const s=$('brkpanel');
- const NAMES=['MAIN','PUMP','LAMP','AUX'];
- 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);});
- setRd('R40',closed+'/4 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();})();
-
-/* R41 DSKY: verb/noun command grammar with status lamps and a lamp-test verb */
-(function(){const host=$('dsky');
- const LAMPS=['UPLINK','TEMP','GIMBAL','PROG','RESTART','OPR ERR'];
- host.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=>host.querySelector(`[data-w="${w}"]`);
- const setWin=(w,txt)=>{win(w).querySelector('.wd').innerHTML=seg7(txt[0]||' ')+seg7(txt[1]||' ');};
- const lampEl=l=>host.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=host.querySelector('.pad');
- const hot=()=>{host.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);setRd('R41','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);
- setRd('R41','V35 · lamp test');}
- else if(v==='16'&&n==='36'){vals.prog='16';setWin('prog',vals.prog);setRd('R41','V16 N36 · monitor clock');}
- else setRd('R41','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'));setRd('R41','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',' ');
- setRd('R41','VERB ## ENTR');})();
+/* R45-R47, R52 extracted to widgets.js (GW.*) */
-/* R42 cam-timer program drum: the program is a ring; the pointer self-advances through it */
-const CAM_STEPS=['OFF','FILL','WASH','WASH','WASH','RINSE','RINSE','DRAIN','SPIN','SPIN','FLUFF','COOL'];
-const CAM_COLS={OFF:'#3a3426',FILL:'#54677d',WASH:'#d29638',RINSE:'#46b89e',DRAIN:'#969385',SPIN:'#cb6b4d',FLUFF:'#c9b98a',COOL:'#7c99b0'};
-let camPos=0,camPtr=null;
-(function(){const s=$('camtimer'),cx=65,cy=65;
- svgEl(s,'circle',{cx,cy,r:60,fill:'#17140f',stroke:'#060505','stroke-width':2});
- CAM_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:CAM_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});
- CAM_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});
- camPtr=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});
- s.style.cursor='pointer';
- s.addEventListener('click',()=>setCam((camPos+1)%12));})();
-function setCam(i){camPos=i;
- camPtr.setAttribute('transform',`rotate(${i*30+15},65,65)`);
- setRd('R42',camPos===0?'OFF':'STEP '+camPos+' · '+CAM_STEPS[camPos]);}
-setCam(0);
-if(!matchMedia('(prefers-reduced-motion: reduce)').matches)
- setInterval(()=>{if(camPos>0){const n=(camPos+1)%12;setCam(n);if(n===0)setRd('R42','CYCLE DONE · OFF');}},2000);
-/* R43 attitude indicator: sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */
-(function(){const s=$('attind'),cx=65,cy=65;
- const defs=svgEl(s,'defs',{});
- const clip=svgEl(defs,'clipPath',{id:'aiClip'});
- svgEl(clip,'circle',{cx,cy,r:48});
- const sky=svgEl(defs,'linearGradient',{id:'aiSky',x1:0,y1:0,x2:0,y2:1});
- svgEl(sky,'stop',{offset:'0','stop-color':'#54677d'});
- svgEl(sky,'stop',{offset:'1','stop-color':'#3a4c60'});
- const gnd=svgEl(defs,'linearGradient',{id:'aiGnd',x1:0,y1:0,x2:0,y2:1});
- svgEl(gnd,'stop',{offset:'0','stop-color':'#5c4630'});
- svgEl(gnd,'stop',{offset:'1','stop-color':'#3c2e1e'});
- svgEl(s,'circle',{cx,cy,r:56,fill:'#14110e',stroke:'#060505','stroke-width':2});
- const g=svgEl(s,'g',{'clip-path':'url(#aiClip)'});
- 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 aiBank=0,aiPitch=0;
- const setAi=(b,p)=>{aiBank=Math.max(-60,Math.min(60,b));aiPitch=Math.max(-20,Math.min(20,p));
- ball.setAttribute('transform',`rotate(${(-aiBank).toFixed(1)},${cx},${cy}) translate(0,${(aiPitch*1.6).toFixed(1)})`);
- setRd('R43','BANK '+(aiBank<0?'L':aiBank>0?'R':'')+Math.abs(Math.round(aiBank))+' · PITCH '+(aiPitch>=0?'+':'-')+String(Math.abs(Math.round(aiPitch))).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=aiBank,p0=aiPitch;
- const mv=ev=>setAi(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();});
- setAi(0,0);})();
-/* R44 heading bug + servo needle: drag sets the command; the needle chases with lag */
-let hbCmd=90,hbAct=90,hbBug=null,hbNeedle=null;
-(function(){const s=$('hdgbug'),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});
- hbBug=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});
- hbNeedle=svgEl(s,'g',{});
- svgEl(hbNeedle,'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});
- const draw=()=>{hbBug.setAttribute('transform',`rotate(${hbCmd.toFixed(1)},${cx},${cy})`);
- hbNeedle.setAttribute('transform',`rotate(${hbAct.toFixed(1)},${cx},${cy})`);
- setRd('R44','CMD '+String(Math.round((hbCmd%360+360)%360)).padStart(3,'0')+' · ACT '+String(Math.round((hbAct%360+360)%360)).padStart(3,'0'));};
- dragDelta(s,()=>hbCmd,v=>{hbCmd=(v%360+360)%360;draw();},{min:-100000,max:100000,sens:1.2});
- draw();
- if(!matchMedia('(prefers-reduced-motion: reduce)').matches)
- setInterval(()=>{const diff=((hbCmd-hbAct+540)%360)-180;
- if(Math.abs(diff)<0.4){if(hbAct!==hbCmd){hbAct=hbCmd;draw();}return;}
- hbAct=(hbAct+diff*0.07+360)%360;draw();},80);
- else{hbAct=hbCmd;}})();
-/* R45 flip-disc: bistable mechanical pixels; scaleX flip with color swap at the midpoint */
-(function(){const s=$('flipdisc'),COLS=7,ROWS=5,STEP=14,X0=13,Y0=13;
- const GLYPH=['0000100','0000010','1111111','0000010','0000100']; // arrow right
- svgEl(s,'rect',{x:2,y:2,width:116,height:88,rx:7,fill:'#17140f',stroke:'#060505','stroke-width':1.5});
- let lit=0;
- const discs=[];
- 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);}
- setRd('R45',lit+' discs showing');});
- discs.push(d);}
- setRd('R45',lit+' discs showing');})();
-
-/* R46 dekatron: click pulses the neon glow one cathode around; wrap = carry */
-let dkPos=0,dkCarry=0;
-(function(){const s=$('dekatron'),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';
- const draw=()=>{dots.forEach((d,i)=>{const on=i===dkPos;
- d.setAttribute('fill',on?'#ff9a4c':'#3a2a20');
- d.setAttribute('filter',on?'drop-shadow(0 0 5px rgba(255,154,76,.9))':'none');});
- setRd('R46','COUNT '+dkPos+(dkCarry?' · CARRY x'+dkCarry:''));};
- s.style.cursor='pointer';
- s.addEventListener('click',()=>{dkPos=(dkPos+1)%10;
- if(dkPos===0){dkCarry++;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();})();
-
-/* R47 landing gear: three greens or nothing; lever cycles down -> transit -> up */
-(function(){const s=$('gearind');
- 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='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)':'');
- setRd('R47',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();})();
-
-/* R48 knife switch (side view): the blade hinges at the left post and lands in the right jaw */
-(function(){const s=$('knifesw');
- const defs=svgEl(s,'defs',{});
- const cu=svgEl(defs,'linearGradient',{id:'ksCu',x1:0,y1:0,x2:0,y2:1});
- svgEl(cu,'stop',{offset:'0','stop-color':'#d09a5c'});
- svgEl(cu,'stop',{offset:'1','stop-color':'#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=true;
- const draw=()=>{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');
- setRd('R48',closed?'CLOSED · live':'OPEN · visibly dead');};
- s.style.cursor='pointer';
- s.addEventListener('click',()=>{closed=!closed;draw();});
- draw();})();
-
-/* R49 decade box: four skirted knobs, one digit each; the value is their sum */
-(function(){const s=$('decbox');
- const MUL=[1000,100,10,1],LBL=['x1000','x100','x10','x1'];
- const digs=[3,5,0,0];
- 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)`));
- setRd('R49',total().toLocaleString('en-US')+' Ω');};
- MUL.forEach((m,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();})();
-
-/* R50 two-hand safety: one palm button arms a 500ms window; the other completes the cycle */
-(function(){const s=$('twohand');
- 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);
- setRd('R50','TIE-DOWN FAULT · release and retry');};
- const press=side=>{if(busy)return;
- if(armed===null){armed=side;flash(side==='L'?L.cap:R.cap);
- setRd('R50',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))');
- setRd('R50','CYCLE RUNNING');
- setTimeout(()=>{runLamp.setAttribute('fill','#1e2a12');runLamp.removeAttribute('filter');
- busy=false;setRd('R50','ready');},1500);};
- L.g.addEventListener('click',()=>press('L'));
- R.g.addEventListener('click',()=>press('R'));
- setRd('R50','ready');})();
-
-/* R51 voice-loop keyset: independent monitor states, exclusive talk, activity flicker */
-(function(){const host=$('vloop');
- const LOOPS=['FD','GNC','ECOM','SURG','A/G','NET1','NET2','PAO'];
- const keys=LOOPS.map((l,i)=>{const k=document.createElement('div');k.className='vk';
- k.innerHTML=l+'<span class="bar"></span>';k.dataset.state='0';host.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');
- setRd('R51',mon===0?'all loops dropped':mon+' monitored'+(tlk>=0?' · talk '+LOOPS[tlk]:''));};
- keys.forEach((k,i)=>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();})();
-
-/* R52 blinkenlights: live register lamps driven by a pseudo-PC that folds in the switch register */
-(function(){const s=$('blinken');
- 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=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';
- const flip=()=>{sr^=(1<<(7-i));draw();};
- hit.addEventListener('click',flip);
- 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));
- setRd('R52','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);})();
-
-/* R53 circular chart recorder: a day per revolution; the pen draws the cycle */
-(function(){const s=$('circhart'),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);
- setRd('R53',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);}})();
-
-/* R54 vertical tape: the scale scrolls behind a fixed index; drag to drive the value */
-(function(){const s=$('vtape'),W=70,H=130,CX=38,CY=65;
- const MIN=5,MAX=35,PPU=5.5; // rpm x100, px per unit
- const defs=svgEl(s,'defs',{});
- const clip=svgEl(defs,'clipPath',{id:'vtClip'});
- 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(#vtClip)'});
- 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 vt=24;
- const draw=()=>{tape.setAttribute('transform',`translate(0,${(CY+vt*PPU).toFixed(1)})`);
- setRd('R54','RPM '+Math.round(vt*100));};
- s.style.cursor='ns-resize';
- dragDelta(s,()=>vt,v=>{vt=Math.max(MIN,Math.min(MAX,v));draw();},{min:MIN,max:MAX,sens:(MAX-MIN)/140});
- draw();})();
-
-/* R55 twin-needle gauge: mirrored half-scales, one hub, two independent needles */
-(function(){const s=$('twinng'),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,anchor] of [[angL(v),'end'],[angR(v),'start']]){
- 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=2.4,vO=3.1;
- const draw=()=>{nF.setAttribute('transform',`rotate(${angL(vF).toFixed(1)},${cx},${cy})`);
- nO.setAttribute('transform',`rotate(${angR(vO).toFixed(1)},${cx},${cy})`);
- setRd('R55','FUEL '+vF.toFixed(1)+' · OIL '+vO.toFixed(1));};
- // each half is its own drag surface
- const half=(x,get,set)=>{const hit=svgEl(s,'rect',{x,y:10,width:60,height:104,fill:'transparent'});
- hit.style.cursor='ns-resize';
- dragDelta(hit,get,v=>{set(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();})();
-
-/* R56 comfort meter: temp and humidity needles cross over printed zone verdicts */
-(function(){const s=$('comfort'),cx=65,cy=60;
- const defs=svgEl(s,'defs',{});
- const br=svgEl(defs,'linearGradient',{id:'cmBrass',x1:0,y1:0,x2:0,y2:1});
- svgEl(br,'stop',{offset:'0','stop-color':'#d8b25c'});
- svgEl(br,'stop',{offset:'.5','stop-color':'#a07c30'});
- svgEl(br,'stop',{offset:'1','stop-color':'#6e5218'});
- 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:'cmClip'});
- svgEl(nclip,'circle',{cx,cy,r:49});
- const ng=svgEl(s,'g',{'clip-path':'url(#cmClip)'});
- 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=72,vH=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');
- setRd('R56',Math.round(vT)+'F · '+Math.round(vH)+'% RH · '+zones[v].textContent);};
- const half=(x,get,set,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=>{set(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();})();
+/* 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',$('dmx'),['amber','green','red','blue','vfd'],'amber');
-screenChips('R17',$('rcrt'),['green','amber','red','blue','vfd'],'green');
-screenChips('R19',$('wregion'),['white','green','amber','blue','vfd'],'white');
-screenChips('R31',$('radar'),['amber','green','red','blue','vfd'],'amber');
-screenChips('N11',$('scopep').closest('.scope'),['green','amber','red','blue','vfd'],'green');
+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);});})();
+
+/* slide-toggle color choices: track color per state, thumb never changes */
+(function(){const sw=document.querySelector('.switch');if(!sw)return;
+ const cardEl=sw.closest('.card');
+ const ON_OPTS={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)'}}};
+ const OFF_OPTS={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)'}}};
+ const OFFTXT_OPTS={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'}}};
+ const THUMB_OPTS={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)'}}};
+ const row=document.createElement('div'); row.className='famchips';
+ const group=(label,opts,def)=>{
+ 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(opts)){
+ 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',()=>{
+ for(const [k,v] of Object.entries(o.vars)) sw.style.setProperty(k,v);
+ chips.forEach(x=>x.classList.toggle('on',x===b));});
+ g.appendChild(b);}
+ row.appendChild(g);};
+ group('on',ON_OPTS,'amber');
+ group('off',OFF_OPTS,'dark');
+ group('off text',OFFTXT_OPTS,'white');
+ group('thumb',THUMB_OPTS,'light');
+ cardEl.querySelector('.opts').appendChild(row);})();
</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/widgets.js b/docs/prototypes/widgets.js
new file mode 100644
index 0000000..72bb107
--- /dev/null
+++ b/docs/prototypes/widgets.js
@@ -0,0 +1,4298 @@
+/* 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-*). */
+GW.slideToggle = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const sw = document.createElement('span'); sw.className = 'switch'; host.appendChild(sw);
+ 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 };
+};
+
+/* 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 */
+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 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 };
+};
+
+/* 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; click a mark or arrow-key */
+GW.slideRule = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const values = opts.values || [4, 6, 8, 10, 12];
+ const fmt = opts.fmt || (v => 'pos ' + v);
+ const X = [12, 51, 90, 129, 168];
+ const t = document.createElement('span'); t.className = 'tuner';
+ t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value');
+ values.forEach((v, i) => {
+ t.insertAdjacentHTML('beforeend', `<span class="tick" style="left:${X[i]}px"></span><span class="mk" style="left:${X[i]}px">${v}</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(values.length - 1, i));
+ ndl.style.left = X[idx] + 'px';
+ t.querySelectorAll('.mk').forEach((m, j) => m.classList.toggle('on', j === idx));
+ onChange(values[idx], fmt(values[idx]));
+ };
+ t.addEventListener('click', e => {
+ const r = t.getBoundingClientRect(), x = e.clientX - r.left;
+ let best = 0, bd = 1e9; X.slice(0, values.length).forEach((tx, j) => { const d = Math.abs(tx - 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); }
+ });
+ set(opts.index !== undefined ? opts.index : 2);
+ return { el: t, get: () => values[idx], set };
+};
+
+/* 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 — one fader per band, teal arrow caps on black tracks */
+GW.filterBank = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const freqs = opts.freqs || [78, 136, 235, 406, 701, 1210, 2090, 3620];
+ const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48]).slice();
+ const fmtHz = f => f < 1000 ? f : (f / 1000) + 'k';
+ const s = stageSvg(host, 'rsvg', 190, 96);
+ const y0 = 14, y1 = 84, x0 = 30, dx = 19.5;
+ for (const [lbl, y] of [['0', y0], ['20', y0 + (y1 - y0) / 3], ['40', y0 + 2 * (y1 - y0) / 3], ['60', y1]])
+ svgEl(s, 'text', { x: 14, y: y + 2, 'text-anchor': 'end', 'font-size': 6, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = lbl;
+ svgEl(s, 'text', { x: 14, y: 94, 'text-anchor': 'end', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--dim)' }).textContent = 'dB';
+ const caps = [];
+ const set = (i, db) => {
+ db = Math.max(0, Math.min(60, db)); vals[i] = db;
+ caps[i].setAttribute('transform', `translate(0,${14 + db / 60 * 70})`);
+ onChange({ band: i, hz: freqs[i], db }, `${fmtHz(freqs[i])} Hz · −${Math.round(db)} dB`);
+ };
+ freqs.forEach((f, i) => {
+ const x = x0 + i * dx;
+ svgEl(s, 'text', { x, y: 8, 'text-anchor': 'middle', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = fmtHz(f);
+ svgEl(s, 'line', { x1: x, y1: y0, x2: x, y2: y1, stroke: '#0a0908', 'stroke-width': 3.4, 'stroke-linecap': 'round' });
+ svgEl(s, 'line', { x1: x - .7, y1: y0, x2: x - .7, y2: y1, stroke: 'rgba(255,255,255,.05)', 'stroke-width': .7 });
+ const cap = svgEl(s, 'g', {});
+ svgEl(cap, 'polygon', { points: `${x - 8.5},${-4.5} ${x - 2.5},0 ${x - 8.5},${4.5}`, fill: 'var(--vfd)', stroke: '#123028', 'stroke-width': .6 });
+ svgEl(cap, 'polygon', { points: `${x + 8.5},${-4.5} ${x + 2.5},0 ${x + 8.5},${4.5}`, fill: 'var(--vfd)', stroke: '#123028', 'stroke-width': .6 });
+ svgEl(cap, 'circle', { cx: x, cy: 0, r: 1.5, fill: '#123028' });
+ caps.push(cap);
+ /* paint the initial position without announcing it */
+ cap.setAttribute('transform', `translate(0,${14 + vals[i] / 60 * 70})`);
+ const hit = svgEl(s, 'rect', { x: x - 9.5, y: y0 - 6, width: 19, height: y1 - y0 + 12, fill: 'transparent' });
+ hit.style.cursor = 'ns-resize';
+ dragY(hit, pct => set(i, (100 - pct) / 100 * 60));
+ });
+ onChange(null, 'drag a band');
+ return { el: s, get: () => vals.slice(), set };
+};
+
+/* 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:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color: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:linear-gradient(180deg,#191510,#0b0908);border:1px solid #2a251c;
+ box-shadow:inset 0 0 20px 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(--steel);transform:translateX(-50%)}
+.tuner .mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--steel);font-size:10px}
+.tuner .mk.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.6)}
+.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(--fail);box-shadow:0 0 7px 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;
+})();
diff --git a/docs/specs/2026-07-12-component-generation-spec.org b/docs/specs/2026-07-12-component-generation-spec.org
index 4078d1c..95c4712 100644
--- a/docs/specs/2026-07-12-component-generation-spec.org
+++ b/docs/specs/2026-07-12-component-generation-spec.org
@@ -4,34 +4,36 @@
#+TODO: TODO | DONE
#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
-* DRAFT Widget component generation
+* DOING Widget component generation
:PROPERTIES:
:ID: 3ac0d42c-db1a-4d21-bce4-e63785fef0ba
:END:
+- 2026-07-12 Sun @ 20:57:50 -0500 — READY → DOING: phases decomposed into todo.org tasks under "Retro widget catalogue" (=:SPEC_ID:= stamped); Phase 1 extraction starts now per the option-1 approval.
+- 2026-07-12 Sun @ 20:57:50 -0500 — DRAFT → READY: spec-review passed (Ready; 3 findings — the option-1 supersession, the card-record refactor, stale counts — all accepted and folded same pass).
- 2026-07-12 Sun @ 10:12:06 -0500 — drafted.
** Prototype iterations
Per =claude-rules/ui-prototyping.md=, a non-trivial-UI spec carries its prototype evidence here. This spec's UI evidence is the [[file:../prototypes/panel-widget-gallery.html][panel widget gallery]] itself — the living catalogue is the prototype set, not a numbered iteration series:
- *Research:* reference photos of period hardware (Berna 3 studio suite, aviation clusters, broadcast consoles, pedals, marine/telegraph instruments) filed in [[file:../../working/retro-stereo-widgets/references/][working/retro-stereo-widgets/references/]]; in-chat-pasted references have provenance noted in card notes and the 2026-07-11/12 session archive.
-- *Iterations:* the gallery's git history is the iteration record — N-series base widgets, then reference-derived R01–R31 built and behaviorally verified card by card (commits 7fff507 through 52a43ec, 2026-07-12). 84 cards total, every one CDP-verified (drag tracking, click response, animation, zero console errors).
-- *Final:* the gallery as of 52a43ec is the settled visual + behavioral spec each component port is judged against.
+- *Iterations:* the gallery's git history is the iteration record — N-series base widgets, then reference-derived R01–R56 built and behaviorally verified card by card (7fff507 through the gallery-upgrades squash merge bc93388 and beyond, 2026-07-12). 109 cards as of the 2026-07-12 review, every one CDP-verified (drag tracking, click response, animation, zero console errors); the committed probe suite lives in =tests/gallery-probes/=.
+- *Final:* the gallery at current =main= is the settled visual + behavioral spec each component port is judged against — it keeps growing, so a port is judged against its card as it stands, not a frozen SHA.
* Metadata
-| Status | draft |
+| Status | doing |
|----------+-----------------------------------|
| Owner | Craig Jennings |
|----------+-----------------------------------|
-| Reviewer | (spec-review pending) |
+| Reviewer | Claude Code (archsetup) |
|----------+-----------------------------------|
| Related | [[file:../../todo.org][todo.org — Retro widget catalogue]] |
|----------+-----------------------------------|
* Summary
-The panel widget gallery (84 behaviorally-verified retro-instrument cards) is a spec, not yet a library. This document defines how gallery cards become reusable components for three real targets — web (vanilla JS, the gallery itself as first consumer), Emacs (svg.el), and waybar (GTK CSS) — driven by actual demand rather than porting all 84.
+The panel widget gallery (109 behaviorally-verified retro-instrument cards as of the 2026-07-12 review) is a spec, not yet a library. This document defines how gallery cards become reusable components for three real targets — web (vanilla JS, the gallery itself as first consumer), Emacs (svg.el), and waybar (GTK CSS). The web library is extracted whole (a lossless transform of code that already exists); the Emacs and waybar ports are driven by actual demand rather than porting the full catalogue.
* Problem / Context
-The gallery grew card by card as a visual and behavioral catalogue: each card is a self-contained inline builder function inside one 2258-line HTML file. That was right for the collection phase — fast iteration, one canvas, one review surface. It is wrong for consumption: a waybar panel, an Emacs game, or a web UI that wants a VU meter today would have to copy-paste from the gallery's internals and hand-fork the styling.
+The gallery grew card by card as a visual and behavioral catalogue: each card is a self-contained inline builder function inside one ~4300-line HTML file (as of the 2026-07-12 review; it still grows). That was right for the collection phase — fast iteration, one canvas, one review surface. It is wrong for consumption: a waybar panel, an Emacs game, or a web UI that wants a VU meter today would have to copy-paste from the gallery's internals and hand-fork the styling.
The groundwork is already laid. Tokens are single-sourced in [[file:../prototypes/tokens.json][tokens.json]]; =gen_tokens.py= emits the gallery's =:root= CSS block, [[file:../prototypes/tokens-waybar.css][tokens-waybar.css]], and [[file:../prototypes/gallery-tokens.el][gallery-tokens.el]] (27 tests, 100% coverage). An svg.el proof widget ([[file:../prototypes/gallery-widget.el][gallery-widget.el]], the needle gauge, 10 ERT tests plus an rsvg-convert side-by-side render) proved the Emacs pipeline — the riskiest unknown — end to end. What's missing is the middle layer: parameterized, importable widgets per target, and a decision procedure for which widgets earn a port.
@@ -43,14 +45,14 @@ The groundwork is already laid. Tokens are single-sourced in [[file:../prototype
- Every port carries tests in its target's idiom (CDP behavioral for web, ERT + visual harness for Emacs, panel test suite for waybar).
** Non-Goals
-- Porting all 84 cards. Most cards are catalogue entries; a port with no consumer is inventory nobody asked for.
+- Emacs/waybar ports of the full catalogue. Most cards are catalogue entries; a scripted port with no consumer is inventory nobody asked for. (The web extraction is exempt — it transforms code that already exists.)
- Shared rendering code across targets. Reuse lives in shared tokens and shared SVG geometry math, not shared code — each target renders in its own idiom (recorded architecture decision).
- Framework wrappers (React or otherwise) ahead of a real framework consumer.
- Level-2/3 codegen (widget-spec → renderer generation) in this spec's v1 — that's the Phase 5 decision point, deliberately after hand-porting experience exists.
** Scope tiers
-- v1: demand inventory, web library extraction, Emacs ports of the demanded widgets, one waybar pilot panel, the Level-2 generator go/no-go.
-- Out of scope: new widget collection (R-series closed), shared cross-target rendering code, interaction parity beyond each target's native idiom.
+- v1: full web library extraction (with the card-record refactor), demand inventory for the scripted targets, Emacs ports of the demanded widgets, one waybar pilot panel, the Level-2 generator go/no-go.
+- Out of scope: new widget collection as part of this work (the catalogue keeps growing on its own track — new cards land as =GW.*= calls once extracted), shared cross-target rendering code, interaction parity beyond each target's native idiom.
- vNext (log to todo.org at hand-off): React/framework wrappers, Level-2/3 codegen if Phase 5 says go, ports beyond the demand matrix as consumers appear.
* Design
@@ -64,12 +66,12 @@ Waybar: no widget functions — waybar consumes =tokens-waybar.css= plus the ban
** For an implementer
Three layers, already agreed: tokens (values) → widget spec (the gallery card: geometry, params, behavior) → per-target renderers. The card is the contract. A port re-reads the card's builder for geometry constants and behavioral rules (detent snapping, VU ballistics via the =vuDb()= law, exclusive-select), then re-expresses them natively. Geometry math (polar helpers, scale interpolation tables like =EDGE_SCALE=, =SEG14_MAP=) is copied deliberately per target and cross-checked by tests, not shared as a runtime dependency — the false-coupling judgment from the token work applies to code too.
-Extraction order inside Phase 2: the card builders already share =svgEl=/=polar= helpers and document-scoped gradients/filters; lifting them means (a) moving each demanded builder into =widgets.js= behind a parameter object, (b) replacing its gallery card with a call, (c) re-running the existing CDP behavioral probes unchanged — the probes drive the rendered DOM, so a green re-run is the no-regression proof.
+Extraction order inside Phase 1: the card builders already share =svgEl=/=polar=/=vuDb=/drag helpers and document-scoped gradients/filters; lifting them means (a) moving the shared engine and each builder into =widgets.js= behind a parameter object, (b) replacing its gallery card with a declarative card record (no / title / stage builder / options / readout / note / spec sheet incl. reference link / validation lamp) rendered by one =card()= path, (c) re-running the existing CDP behavioral probes unchanged — the probes drive the rendered DOM, so a green re-run is the no-regression proof. Batches of roughly 10-15 widgets keep each commit reviewable and the gallery working throughout.
* Alternatives Considered
** Port everything to every target up front
- Good, because the catalogue would be uniformly consumable.
-- Bad, because ~84 × 3 ports is months of work with no consumer for most of it, and unconsumed ports rot silently.
+- Bad, because ~109 × 3 ports is months of work with no consumer for most of it, and unconsumed ports rot silently.
- Bad, because it front-loads the Level-2 codegen question before hand-port experience exists to answer it.
** Shared renderer core (one geometry engine, thin target adapters)
@@ -82,7 +84,7 @@ Extraction order inside Phase 2: the card builders already share =svgEl=/=polar=
- Bad, because the gallery-as-demo property is lost: web consumers copy-paste internals, and the spec and any web usage drift apart immediately.
- Bad, because the extraction is cheap insurance — the CDP tests already exist to catch regressions.
-* Decisions [7/8]
+* Decisions [8/8]
** DONE SVG-first substrate
- Context: curved dials, needles, glow filters must render identically enough across web and Emacs; librsvg reproduces gradients and blur filters.
- Decision: widgets with curved/needle geometry are authored as SVG in every scripted target; plain-CSS forms stay CSS only where the target is CSS-native (waybar).
@@ -101,11 +103,11 @@ Extraction order inside Phase 2: the card builders already share =svgEl=/=polar=
- Consequences: easier — each target feels native; harder — behavioral parity is a per-port judgment, tested per target rather than shared.
- Resolved live with Craig (2026-07-11).
-** DONE Demand-driven porting
-- Context: 84 cards, three targets, most cards have no consumer today.
-- Decision: a widget is ported when the demand matrix names a real consumer (a waybar panel, an Emacs game/UI, a web surface); the matrix is Phase 1's deliverable and Craig approves it.
-- Consequences: easier — effort lands where it's used; harder — the catalogue stays partially ported indefinitely, by design.
-- Agreed in the 2026-07-12 outline.
+** DONE Demand-driven porting (Emacs and waybar targets)
+- Context: 109 cards (as of 2026-07-12 review), three targets, most cards have no Emacs or waybar consumer today.
+- Decision: an *Emacs or waybar* port happens when the demand matrix names a real consumer (a waybar panel, an Emacs game/UI); the matrix is Phase 1's deliverable and Craig approves it. The *web* extraction is exempt: it is a lossless transform of code that already exists, so it covers the full catalogue (option-1 approval, 2026-07-12).
+- Consequences: easier — port effort lands where it's used, and the web library is complete from day one; harder — the Emacs/waybar surface stays partially ported indefinitely, by design.
+- Agreed in the 2026-07-12 outline; web-extraction exemption approved with the option-1 go-ahead (2026-07-12).
** DONE Gallery-as-spec
- Context: the ui-prototyping rule wants working prototypes as design evidence.
@@ -125,18 +127,29 @@ Extraction order inside Phase 2: the card builders already share =svgEl=/=polar=
- Consequences: easier — freshest panel, known suite; harder — visual changes to a daily-driver panel need the manual-testing checklist.
- Agreed in the 2026-07-12 outline.
-** TODO Web library packaging
-- Owner / by-when: Craig / at spec-review.
+** DONE Web library packaging
- Context: the gallery is a single self-contained =file://= HTML page; ES modules don't load over =file://=, and a React consumer doesn't exist yet.
-- Decision (proposed): =widgets.js= ships as a classic script exposing one namespace object (working name =GW=), loaded by the gallery via a relative =<script src>=; the shared helpers (=svgEl=, =polar=, =vuDb=, scale tables) move inside it. Framework wrappers are vNext, demand-gated.
+- Decision: =widgets.js= ships as a classic script exposing one namespace object (working name =GW=), loaded by the gallery via a relative =<script src>=; the shared helpers (=svgEl=, =polar=, =vuDb=, scale tables) move inside it. Framework wrappers are vNext, demand-gated.
- Consequences: easier — gallery keeps working from =file://=, zero build tooling; harder — no tree-shaking or import isolation (acceptable at this scale).
+- Approved by Craig 2026-07-12 (componentization go-ahead, option 1). The extraction itself is ungated — a lossless transform verified by the CDP probes; Craig's per-card validation pass (the status lamps) gates only the per-widget Emacs ports and the final blessing.
+
+* Review findings [3/3]
+** DONE Extraction scope and gate superseded by the option-1 approval :blocking:
+The spec's Phase 1 → Phase 2 order gates the web extraction on the demand matrix, and the Web-library-packaging decision says the build is "gated on his per-card validation pass." Craig's componentization go-ahead (option 1, 2026-07-12 ~21:30) superseded both: the web extraction covers the *full* catalogue as a lossless transform and starts immediately; the demand matrix and the validation lamps gate only the per-widget Emacs ports and the final blessing. An implementer reading the spec as written would follow the retired order. Recommended change: re-scope the Demand-driven-porting decision to the Emacs/waybar targets, drop the extraction gate from the packaging decision, and reorder/reword Phases 1-2.
+Disposition: accepted — pre-agreed by Craig (recorded in todo.org and the 2026-07-12 session log). Folded into Decisions 4 and 8 and Phases 1-2.
+** DONE Card-as-component refactor absent from Phase 2
+The approved build includes restructuring each gallery card into a declarative record (no/title/stage builder/options/readout/note/spec-sheet incl. reference link/validation lamp) so the page becomes data plus =GW.*= calls, but Phase 2 as written only covers lifting builders into =widgets.js=. Without it the "gallery becomes calls into the library" claim leaves the card chrome hand-rolled per card. Recommended change: name the card-record refactor as part of Phase 2's deliverable.
+Disposition: accepted — folded into the Design section and Phase 2.
+** DONE Stale gallery facts
+The spec states 84 cards, a 2258-line file, the R-series closed at R31, and 52a43ec as the final SHA. The gallery has since grown to 109 cards (R-series through R56, ~4300 lines) across the gallery-upgrades merge and the takuzu-survey builds. Not a design problem, but stale numbers in the contract mislead the implementer sizing the extraction. Recommended change: refresh the counts and phrase them as of the review date, since the catalogue still grows.
+Disposition: accepted — counts refreshed and dated throughout.
* Implementation phases
-** Phase 1 — Demand inventory
-Build the widget-to-target matrix from real consumers: walk the live waybar panels (net/bt/audio/maint), the Emacs surfaces Craig names (games, dashboards), and any web UI, and record which gallery cards each actually wants. Deliverable: a short matrix table in this spec's appendix, approved by Craig. Tree untouched.
+** Phase 1 — Web library extraction
+Lift *all* card builders into =docs/prototypes/widgets.js= behind parameter objects (classic script, =GW= namespace, shared helpers inside), and restructure each gallery card as a declarative record (no / title / stage builder / options / readout / note / spec sheet incl. reference link / validation lamp) so the page becomes data plus =GW.*= calls. Batched (roughly 10-15 widgets per batch); a green re-run of the CDP behavioral probes (=tests/gallery-probes/=) is the per-batch no-regression gate, and each batch leaves the gallery fully working from =file://=. Ungated by the validation lamps (option-1 approval — lossless transform).
-** Phase 2 — Web library extraction
-Lift the demanded cards' builders into =docs/prototypes/widgets.js= behind parameter objects; the gallery becomes calls into the library. Green re-run of the existing CDP behavioral probes is the no-regression gate. Each extraction batch leaves the gallery fully working.
+** Phase 2 — Demand inventory (Emacs / waybar)
+Build the widget-to-target matrix for the *scripted-port* targets: walk the live waybar panels (net/bt/audio/maint) and the Emacs surfaces Craig names (games, dashboards), and record which gallery cards each actually wants. Deliverable: a short matrix table in this spec's appendix, approved by Craig. Tree untouched. Runs in parallel with or after Phase 1 — the extraction does not wait on it.
** Phase 3 — Emacs ports
Extend the =gallery-widget.el= pattern for the demanded widgets: per-widget ERT (token resolution, geometry math normal/boundary/error, SVG structure, state-tracks-value) plus the rsvg-convert side-by-side visual harness against the gallery card. Keymap/click-region interaction designed per widget. Wired into =make test-elisp=.
@@ -149,7 +162,7 @@ After ~5 hand ports, revisit Level-2 codegen with evidence: how much of each por
* Acceptance criteria
- [ ] Demand matrix exists in the appendix and Craig has approved it.
-- [ ] Every demanded web widget is a =widgets.js= call in the gallery; CDP probes green; gallery renders unchanged from =file://=.
+- [ ] Every gallery card is a declarative record calling into =widgets.js=; CDP probes green; gallery renders unchanged from =file://=.
- [ ] Every demanded Emacs widget has ERT coverage and an rsvg side-by-side render matching its card; =make test-unit= green.
- [ ] The audio panel reads its palette from =tokens-waybar.css=; panel suite green; manual-testing checklist entry filed.
- [ ] Phase 5 go/no-go recorded as a dated decision.
@@ -160,7 +173,7 @@ Answer each, or write "N/A because…".
- Errors, empty states & failure: builders validate parameter ranges and clamp (the gallery's existing behavior — e.g. no-cross flag clamps, detent wrap); Emacs widgets signal =user-error= on out-of-range tokens/params (the =gallery-widget-token= missing-token error is the pattern).
- Security & privacy: N/A because everything is local rendering of design assets; no credentials, no network.
- Observability: web — CDP probes plus zero-console-error checks; Emacs — ERT output; waybar — the panel's existing smoke/AT-SPI harness.
-- Performance & scale: N/A beyond the existing gallery scale (84 cards render fine); ports are per-widget, no aggregate load.
+- Performance & scale: N/A beyond the existing gallery scale (109 cards render fine); ports are per-widget, no aggregate load.
- Reuse & lost opportunities: tokens and geometry constants reused by value with cross-checking tests; svg.el, librsvg, and GTK CSS used as the platforms provide them; deliberate non-reuse of cross-target code recorded above.
- Architecture fit & weak points: fits the shipped 3-layer architecture; weak point is spec drift between a card and its ports — mitigated by the side-by-side harness and by the gallery-as-demo property on web.
- Config surface: no new knobs; token values remain the only tuning surface.
@@ -171,7 +184,7 @@ Answer each, or write "N/A because…".
* Risks, Rabbit Holes, and Drawbacks
- Pure-CSS cards demanded into Emacs need SVG re-expression — sized per widget in Phase 3; if one balloons, it goes back to the demand matrix rather than stalling the phase.
-- The gallery extraction could tempt a rewrite-everything pass; the phase is scoped to *demanded* builders only, monolith remainder stays put.
+- The gallery extraction could tempt a redesign pass; the phase is a *lossless transform* only — builders move and cards become records, but no widget's look or behavior changes (the probes enforce it).
- GTK CSS renders differently from the browser (no SVG filters, different gradient behavior); the pilot may reveal idioms that don't translate — acceptable, the pilot exists to find them, and findings feed the composition-idiom notes rather than blocking.
- Hand-duplicated geometry constants can drift before Phase 5; each port's tests pin its constants against the card's values.
@@ -182,6 +195,11 @@ Answer each, or write "N/A because…".
- Demand matrix: (Phase 1 deliverable — lands here.)
* Review and iteration history
+** 2026-07-12 Sun @ 20:57:50 -0500 — Claude Code (archsetup) — reviewer + responder
+- What: full spec-review (code read against the live gallery, probes re-baselined green at 753380e). Three findings, all accepted and folded: the option-1 approval superseded the demand-gated extraction order (Phases 1-2 swapped and re-scoped, Decisions 4 and 8 reworded), the card-record refactor named as part of the extraction phase, stale counts refreshed (84→109 cards, R31→R56). Rubric: Ready. Flipped READY, then DOING with the phase decomposition into todo.org.
+- Why: Craig's componentization go-ahead (option 1, 2026-07-12 ~21:30) landed after drafting — extraction is a lossless transform and proceeds ungated; the validation lamps gate only Emacs ports and the final blessing. The spec had to match the approved build before tasks decomposed from it.
+- Artifacts: the three completed findings under =* Review findings=; probe repair commit 753380e; todo.org build tasks under "Retro widget catalogue" (=:SPEC_ID:= 3ac0d42c-db1a-4d21-bce4-e63785fef0ba).
+
** 2026-07-12 Sun @ 10:12:06 -0500 — Craig Jennings — author
- What: initial draft.
- Why: collection phase closed at R01–R31; porting needs a decision procedure and per-target shape before any consumer work starts.
diff --git a/tests/gallery-probes/README.md b/tests/gallery-probes/README.md
new file mode 100644
index 0000000..a062bfc
--- /dev/null
+++ b/tests/gallery-probes/README.md
@@ -0,0 +1,13 @@
+# Gallery CDP probes
+
+Behavioral verification for docs/prototypes/panel-widget-gallery.html — headless Chrome
+driven over the DevTools protocol (Node global WebSocket/fetch, no playwright).
+
+- probe.mjs — full regression: card count, size toggle, drag + click behavior under zoom,
+ zero-exception gate. Run: `node probe.mjs [--shot out.png]`. Update the card-count
+ assertion when cards are added.
+- probe-fams.mjs — screen-color families: default fallbacks pixel-identical, chip clicks
+ recolor ink/face/trace, dynamic fills stay var-based.
+
+Both exit nonzero on failure. The gallery's componentization work (widgets.js extraction)
+uses a green run of these as its no-regression gate per batch.
diff --git a/tests/gallery-probes/probe-fams.mjs b/tests/gallery-probes/probe-fams.mjs
new file mode 100644
index 0000000..99b4e92
--- /dev/null
+++ b/tests/gallery-probes/probe-fams.mjs
@@ -0,0 +1,64 @@
+// Verify screen-family chips: presence, default fallbacks intact, live recolor on click.
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+const PORT = 9335;
+const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html';
+const chrome = spawn('google-chrome-stable', ['--headless=new', `--remote-debugging-port=${PORT}`, '--no-first-run', '--window-size=1600,1200', URL], { stdio: 'ignore' });
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+let ws, id = 0; const pending = new Map(); const events = [];
+for (let i = 0; i < 40; i++) { try { const l = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); const p = l.find(t => t.type === 'page' && t.url.startsWith('file')); if (p) { ws = new WebSocket(p.webSocketDebuggerUrl); break; } } catch {} await sleep(250); }
+await new Promise(r => ws.onopen = r);
+ws.onmessage = m => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } else if (d.method) events.push(d); };
+const send = (method, params = {}) => new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
+const evl = async e => { const r = await send('Runtime.evaluate', { expression: e, returnByValue: true }); if (r.result.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails)); return r.result.result.value; };
+const fails = [];
+const ok = (n, c, d = '') => { console.log(`${c ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`); if (!c) fails.push(n); };
+await send('Runtime.enable'); await sleep(1500);
+
+ok('no exceptions on load', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0);
+// count screen-family rows only — card option groups (e.g. card 01) reuse .famchips
+ok('5 screen chip rows', await evl(`[...document.querySelectorAll('.famchips .lab')].filter(l=>l.textContent==='screen').length`) === 5);
+
+// default fallback: dmx ink computes to gold-hi rgb(255,190,84)
+const dmxFill0 = await evl(`getComputedStyle(document.querySelector('#card-R10 svg text')).fill`);
+ok('R10 default ink = gold-hi', dmxFill0 === 'rgb(255, 190, 84)', dmxFill0);
+
+// click green chip on R10 -> ink becomes phos rgb(127,224,160)
+await evl(`document.querySelector('#card-R10').querySelector('.fc[title="green"]').click()`);
+const dmxFill1 = await evl(`getComputedStyle(document.querySelector('#card-R10 svg text')).fill`);
+ok('R10 green chip recolors ink', dmxFill1 === 'rgb(127, 224, 160)', dmxFill1);
+
+// radar: default sweep line = gold-hi; click green -> phos
+const swSel = `document.querySelector('#card-R31')`;
+const line0 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`);
+await evl(`${swSel}.querySelector('.fc[title="green"]').click()`);
+const line1 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`);
+ok('R31 green chip recolors furniture', line0 !== line1 && line1 === 'rgb(88, 184, 126)', `${line0} -> ${line1}`);
+
+// scope (CSS widget): trace stroke changes on amber chip
+const tr0 = await evl(`getComputedStyle(document.querySelector('.scope polyline')).stroke`);
+await evl(`document.querySelector('.scope').closest('.card').querySelector('.fc[title="amber"]').click()`);
+const tr1 = await evl(`getComputedStyle(document.querySelector('.scope polyline')).stroke`);
+ok('N11 amber chip recolors trace', tr0 !== tr1 && tr1 === 'rgb(255, 190, 84)', `${tr0} -> ${tr1}`);
+
+// R19: drag still works after family switch (click vfd, then check the handle's set() writes var-based fills)
+await evl(`document.querySelector('#card-R19 .fc[title="vfd"]').click()`);
+await evl(`document.getElementById('card-R19').gw.set(30,70)`);
+const barFill = await evl(`(()=>{const b=document.querySelectorAll('#card-R19 svg rect')[10];return b.getAttribute('fill');})()`);
+ok('R19 dynamic fills use vars', barFill.startsWith('var(--scr-'), barFill);
+
+// R17: face gradient stop resolves to amber face after chip
+await evl(`document.querySelector('#card-R17 .fc[title="amber"]').click()`);
+const face = await evl(`getComputedStyle(document.querySelector('#card-R17 svg radialGradient stop')).stopColor`);
+ok('R17 amber chip retints face', face === 'rgb(216, 203, 166)', face);
+
+ok('no exceptions after chip clicks', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0);
+
+// screenshot the recolored screens region (scroll R31 into view)
+await evl(`document.querySelector('#card-R31 svg').scrollIntoView({block:'center'})`);
+await sleep(400);
+const shot = await send('Page.captureScreenshot', { format: 'png' });
+writeFileSync('fams.png', Buffer.from(shot.result.data, 'base64'));
+console.log('shot: fams.png');
+chrome.kill();
+process.exit(fails.length ? 1 : 0);
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
new file mode 100644
index 0000000..17b3d73
--- /dev/null
+++ b/tests/gallery-probes/probe.mjs
@@ -0,0 +1,120 @@
+// CDP probe for the widget gallery — no playwright, Node global WebSocket/fetch.
+// Usage: node probe.mjs [--shot out.png] [--size N]
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+
+const PORT = 9333;
+const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html';
+const shotIdx = process.argv.indexOf('--shot');
+const shotPath = shotIdx > -1 ? process.argv[shotIdx + 1] : null;
+
+const chrome = spawn('google-chrome-stable', [
+ '--headless=new', `--remote-debugging-port=${PORT}`,
+ '--no-first-run', '--no-default-browser-check',
+ '--window-size=1600,1200', URL,
+], { stdio: 'ignore' });
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+let ws, id = 0;
+const pending = new Map();
+const events = [];
+
+async function connect() {
+ for (let i = 0; i < 40; i++) {
+ try {
+ const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
+ const page = list.find(t => t.type === 'page' && t.url.startsWith('file'));
+ if (page) { ws = new WebSocket(page.webSocketDebuggerUrl); break; }
+ } catch { /* retry */ }
+ await sleep(250);
+ }
+ if (!ws) throw new Error('no page target');
+ await new Promise(r => ws.onopen = r);
+ ws.onmessage = m => {
+ const d = JSON.parse(m.data);
+ if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); }
+ else if (d.method) events.push(d);
+ };
+}
+function send(method, params = {}) {
+ return new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
+}
+async function evl(expr) {
+ const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true });
+ if (r.result.exceptionDetails) throw new Error('eval failed: ' + JSON.stringify(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text));
+ return r.result.result.value;
+}
+async function drag(x1, y1, x2, y2) {
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: x1, y: y1, button: 'left', clickCount: 1 });
+ const steps = 8;
+ for (let i = 1; i <= steps; i++)
+ await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: x1 + (x2 - x1) * i / steps, y: y1 + (y2 - y1) * i / steps, button: 'left' });
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: x2, y: y2, button: 'left', clickCount: 1 });
+}
+
+const fails = [];
+const ok = (name, cond, detail = '') => { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) fails.push(name); };
+
+try {
+ await connect();
+ await send('Runtime.enable');
+ await send('Page.enable');
+ await sleep(1500);
+
+ // 0. console errors / exceptions
+ const errs = events.filter(e => e.method === 'Runtime.exceptionThrown');
+ ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200));
+
+ // 1. defaults: size=2 (M), card count
+ ok('default size 2', await evl(`document.body.dataset.size`) === '2');
+ const cards = await evl(`document.querySelectorAll('.card').length`);
+ ok('109 cards', cards === 109, `got ${cards}`);
+
+ // 2. zoom actually scales: card visual width at 3x vs 1x
+ await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`);
+ const w3 = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
+ await evl(`document.querySelector('.szbar .key[data-sz="1"]').click()`);
+ const w1 = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
+ ok('3x wider than 1x', w3 > w1 * 1.8, `w3=${Math.round(w3)} w1=${Math.round(w1)}`);
+ ok('size chip flips state', await evl(`document.body.dataset.size`) === '1');
+
+ // 3. behavioral at 3x: fader drag on card 03 (horizontal fader) changes readout
+ await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`);
+ await evl(`document.querySelectorAll('.card')[2].scrollIntoView({block:'center'}); ''`);
+ await sleep(200);
+ const fr = await evl(`(()=>{const c=document.querySelectorAll('.card')[2];const f=c.querySelector('.fader');const r=f.getBoundingClientRect();return [r.left,r.top,r.width,r.height];})()`);
+ const before = await evl(`document.getElementById('rd-03').textContent`);
+ await drag(fr[0] + fr[2] * 0.2, fr[1] + fr[3] / 2, fr[0] + fr[2] * 0.9, fr[1] + fr[3] / 2);
+ await sleep(150);
+ const after = await evl(`document.getElementById('rd-03').textContent`);
+ ok('fader drag tracks at 3x', before !== after && after !== '—', `'${before}' -> '${after}'`);
+
+ // 4. behavioral at 3x: toggle click on card 01
+ await evl(`document.querySelectorAll('.card')[0].scrollIntoView({block:'center'}); ''`);
+ await sleep(200);
+ const sw = await evl(`(()=>{const c=document.querySelectorAll('.card')[0];const s=c.querySelector('.switch')||c.querySelector('.stagew > *');const r=s.getBoundingClientRect();return [r.left+r.width/2,r.top+r.height/2];})()`);
+ const t0 = await evl(`document.getElementById('rd-01').textContent`);
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: sw[0], y: sw[1], button: 'left', clickCount: 1 });
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sw[0], y: sw[1], button: 'left', clickCount: 1 });
+ await sleep(150);
+ const t1 = await evl(`document.getElementById('rd-01').textContent`);
+ ok('toggle click responds at 3x', t0 !== t1, `'${t0}' -> '${t1}'`);
+
+ // late exceptions from interactions
+ const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
+ ok('no exceptions after interaction', errs2.length === 0);
+
+ if (shotPath) {
+ await evl(`window.scrollTo(0,0)`);
+ await sleep(300);
+ const shot = await send('Page.captureScreenshot', { format: 'png' });
+ writeFileSync(shotPath, Buffer.from(shot.result.data, 'base64'));
+ console.log('shot: ' + shotPath);
+ }
+} catch (e) {
+ console.error('PROBE ERROR: ' + e.message);
+ fails.push('probe-error');
+} finally {
+ chrome.kill();
+}
+process.exit(fails.length ? 1 : 0);
diff --git a/todo.org b/todo.org
index b4aa1d6..d08d48c 100644
--- a/todo.org
+++ b/todo.org
@@ -49,22 +49,45 @@ Craig picked option 3 (note first, then build) and asked for the reading list in
Craig fed a Mitsubishi Ki-57 right-panel photo (instruments identified card by card, labels read from the Japanese: 速度計 airspeed, 遠方回転計 remote tach, 昇降計 VSI, 人工水平儀 artificial horizon, 高度計 altimeter, 気筒温度計 CHT, etc.). Two genuine gaps built: R54 vertical tape instrument (scrolling scale behind a fixed amber index — the Ki-57 remote tach's form, universal in glass cockpits; drag spins the tape) and R55 twin-needle gauge (mirrored FUEL/OIL half-scales, two needles one hub, per-half drag surfaces — the banked "twin mirrored gauge" from the French jet panel, distinct from N13 whose needles are read at their crossing). Cleanup pass earlier (c4fcee6): rotary-dial digits were painted under the finger wheel — now on top; legible demo defaults for pin matrix / voice loop / DSKY PROG; caption color unified; cam labels bumped. Reference filed (2026-07-12-ki57-right-panel.jpg). CDP-verified: tape drags (2400→1329), needles independent (fuel up leaves oil, oil down leaves fuel), zero exceptions.
*** 2026-07-12 Sun @ 18:59:08 -0500 Added R56 comfort-zone crossed needles (gallery at 109)
Craig fed a brass weather-station comfort meter and correctly picked it as different from both dual meters: N13 derives a NUMBER from the crossing (iso-curves), R55 reads two needles separately; this one lands the crossing in a printed categorical VERDICT (TOO WARM / TOO DRY / JUST RIGHT — active zone goes red, a digital advantage the paper face lacks). Brass knurled bezel, cream face, temp needle from bottom-left, humidity from bottom-right, per-half drag surfaces, needles clipped to the face (probe caught tails poking past the bezel at low values — fixed). Reference filed (2026-07-12-comfort-meter.png). CDP-verified: default 72F/45%/JUST RIGHT, warm and dry verdicts flip with the active label, zero exceptions.
+*** 2026-07-12 Sun @ 19:13:41 -0500 Squash-merged to main (bc93388) + cleanup round 2
+Squash merge of the 25-commit branch landed on main; branch deleted local+remote. Then a programmatic defect sweep (per-card overflow/empty/dead-readout/sparse bounding-box audit) caught R51's monitor bars streaking across the card — Chrome's CSS zoom miscomputes absolute left/right insets, fr tracks, AND stretched widths inside zoomed stages, so the keyset now uses fixed 52px grid tracks and fixed 40px flow bars (fixed px scale correctly under zoom; this is the third zoom-layout trap after the two in the same widget — noted for future stage-internal CSS: prefer fixed px inside .stagew). Seven intrinsically tiny widgets (toggle, chip, arm-to-fire, mini signal, ladder, thermometer, status lamp) got a .boost stage zoom (1.5x, compounds with the size toggle) so they stop floating in empty stages. Sweep false positives understood: clipped content (R43 horizon, R54 tape, N22 marquee, N25 counter) reports as overflow because getBoundingClientRect ignores clip; N13/N17/N28/R20/10 verified clean visually. Full regression green at 109 cards.
+*** 2026-07-12 Sun @ 19:25:04 -0500 Page iteration: palette rebuilt as named instrument colors, moved to bottom; default size M
+Per Craig: the palette left the top (now below Indicators) and stopped being a token dump — it is now a curated card of 36 NAMED colors grouped by role (Materials / Faces & inks / Lamps, LEDs & jewels / Screens & phosphors / Needles & controls), each with a name (Brass, Chart paper, Neon orange, Graticule green...) and where it appears on the instruments. No hex codes visible anywhere on the page (verified by innerText scan); page chrome (background, body text) deliberately excluded. Default widget size dropped 3x → M (1.7x). Data lives in a PALETTE array in gallery JS — the curation IS the data, since most instrument colors are local literals, not tokens.
*** 2026-07-12 Sun @ 13:01:27 -0500 Added the palette section
New "Palette — design tokens" section above Controls: 33 tiles read live from the generated =:root= rule via document.styleSheets (zero drift possible — what renders is what the widgets use). Hex tokens and glow rgb triples get swatches; mono/pulse-rate render as text tiles. Scales with the size toggle.
*** 2026-07-12 Sun @ 13:08:42 -0500 Added screen-color families + chips (first round)
Six period families defined (green = P1 phosphor built on =phos=, amber = P3 on the gold family, red = the nixie neon pulled out, blue = P4 white-blue, vfd = the marquee's =--vfd=, white = mono LCD), applied via scoped CSS vars with the shipped color as fallback — defaults are pixel-identical until a chip is clicked. Chips on the five clearly-screen widgets: R10 data matrix (amber), R17 CRT scope (green face), R19 waveform LCD (white), R31 radar (amber — the "in amber" one; green chip gives the classic PPI), N11 oscilloscope (green). Answers to the green question: the kit deliberately has four greens — phos #7fe0a0 (CRT trace), vfd #63e6c8 (marquee, blue-leaning), sevgrn #57d357 (LED), jewel-g #6fce33 (jewel lens); the phosphor-screen green is phos, not the others. Nixie left chipless: neon is only ever orange. Families live in gallery JS, not tokens.json — tokenize the winners once picked, the way amber earned its tokens. CDP-verified: recolors land on all five, interactions intact, zero exceptions.
*** 2026-07-12 Sun @ 13:15:19 -0500 Added spec sheets to all 84 cards
Every card now carries a collapsible "spec sheet" (a details element under the note) with up to 8 fields: input (always present, names the model — click ports everywhere including Emacs click-regions, drag needs a click/key idiom there, display widgets take no real input), solves, use (common vs specialty + where it shines), limits, origin, difficulty, prefer-when, and period (present on 41 of 84 — only where the component is clearly not timeless, e.g. nixie 1955-75 sits with keypads/telegraphs and clashes with VFD and LED seven-seg). Content lives in one INFO object keyed by card number; card() renders it. CDP-verified: 84/84 sheets present (missing-key check), fields render, zero exceptions.
+*** TODO Widget validation pass
+Craig validates every control via the card lamps (his worklist, not agent-checkable): click cycles off → amber → green, state persists per card in localStorage (key =gv-<no>=). Gate change with the option-1 approval (2026-07-12): the lamps no longer gate the widgets.js extraction (lossless, proceeds now) — they gate per-widget Emacs ports and the final catalogue blessing. When the pass completes, bake the final statuses into the gallery source.
*** TODO Component classification taxonomy
Brainstorm WITH Craig (after the squash merge, not solo): break the components down by use — freeform output, binary selection, multi-selection, fine-tuning, etc. The taxonomy identifies use-cases with no appropriate component, which fills out the gallery with some confidence of completeness.
** DOING [#B] Retro widget catalogue :feature:design:
-The panel widget gallery ([[file:docs/prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]]) grows into a retro-instrument component catalogue: reference photos of period hardware → gallery cards (the visual + behavioral spec) → reusable components for three targets (emacs svg.el, web/React, waybar). Tokens single-sourced in [[file:docs/prototypes/tokens.json][tokens.json]] (gen_tokens.py emits web/waybar/elisp); svg.el proof widget shipped (gallery-widget.el, needle gauge). Reference photos live in [[file:working/retro-stereo-widgets/][working/retro-stereo-widgets/]]. R-series cards (R01-R31) are the reference-derived widgets — collection phase COMPLETE 2026-07-12 (84 cards, all behaviorally verified).
+:PROPERTIES:
+:SPEC_ID: 3ac0d42c-db1a-4d21-bce4-e63785fef0ba
+:END:
+The panel widget gallery ([[file:docs/prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]]) grows into a retro-instrument component catalogue: reference photos of period hardware → gallery cards (the visual + behavioral spec) → reusable components for three targets (emacs svg.el, web/React, waybar). Tokens single-sourced in [[file:docs/prototypes/tokens.json][tokens.json]] (gen_tokens.py emits web/waybar/elisp); svg.el proof widget shipped (gallery-widget.el, needle gauge). Reference photos live in [[file:working/retro-stereo-widgets/][working/retro-stereo-widgets/]]. Collection converged at R56 (109 cards, all behaviorally verified; probes in [[file:tests/gallery-probes/][tests/gallery-probes/]]).
-Next: spec-review of the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DRAFT), then decompose via spec-response. The spec carries the agreed 5-phase plan (demand inventory → web library extraction → emacs ports → waybar audio pilot → Level-2 generator go/no-go) and the baked decisions (SVG-first, single token source, per-target interaction, demand-driven porting, gallery-as-spec). Banked variant/composition ledger lives in the 2026-07-11/12 session archive.
+Build runs per the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DOING; reviewed + decomposed 2026-07-12): web extraction first (ungated, lossless), then demand-gated Emacs/waybar ports. Banked variant/composition ledger lives in the 2026-07-11/12 session archive.
*** 2026-07-12 Sun @ 10:14:10 -0500 Wrote the component-generation spec (DRAFT)
[[file:docs/specs/2026-07-12-component-generation-spec.org][2026-07-12-component-generation-spec.org]] via spec-create: full spine (summary, problem, goals, two-altitude design, alternatives, 8 decisions with 1 open, 5 phases, acceptance criteria, readiness dimensions, risks). Gallery cited as the prototype evidence per the ui-prototyping rule (filed references + R01-R31 iteration history + final at 52a43ec). vNext items logged as the "Widget catalogue vNext" task.
-*** VERIFY Web library packaging — classic-script widgets.js + GW namespace?
-The spec's one open decision (owner Craig, by spec-review): =widgets.js= as a classic script exposing one namespace object (working name =GW=), loaded by the gallery via a relative =<script src>= so =file://= keeps working with zero build tooling; shared helpers (svgEl/polar/vuDb, scale tables) move inside it; framework wrappers stay vNext, demand-gated. Approve the shape or name an alternative.
+*** 2026-07-12 Sun @ 20:24:37 -0500 Web library packaging approved — classic-script widgets.js + GW namespace
+Craig approved with the componentization go-ahead (option 1): =widgets.js= as a classic script exposing one =GW= namespace, relative =<script src>= so =file://= keeps working, shared helpers inside, framework wrappers vNext. Decision flipped DONE in the spec (cookie 8/8). Gate per the same approval: extraction proceeds ungated (lossless transform); the validation lamps gate only per-widget Emacs ports and the final blessing.
+*** 2026-07-12 Sun @ 20:57:50 -0500 Spec reviewed and decomposed (DRAFT → READY → DOING)
+spec-review passed (Ready): 3 findings, all accepted and folded same pass — option-1 supersession of the demand-gated extraction order (Phases 1-2 swapped: extraction first, ungated), the card-record refactor named in Phase 1, stale counts refreshed (109 cards / R56). Probe baseline repaired first (753380e — two stale assertions from the evening sprint). Phase tasks below; =:SPEC_ID:= stamped on this parent.
+*** TODO Phase 1 — web library extraction :refactor:solo:
+Lift all 109 card builders into =docs/prototypes/widgets.js= (classic script, =GW= namespace, shared engine inside: svgEl/polar/vuDb/dragX/dragY/dragDelta/seg7/SCREEN_FAMS/gradients); restructure each card as a declarative record (no/title/stage/options/readout/note/spec-sheet incl ref/lamp) rendered by one =card()= path. Batches of ~10-15; per-batch gate = green =tests/gallery-probes/probe.mjs= + =probe-fams.mjs=; commit+push per batch. Gallery stays working from =file://= throughout. Close with the slide toggle (card 01) as the first fully-realized component: its four option groups become constructor args (on/off/offText/thumb).
+*** TODO Phase 2 — demand inventory (Emacs/waybar) :design:
+Widget-to-target matrix for the scripted-port targets: walk the live waybar panels (net/bt/audio/maint) and the Emacs surfaces Craig names; record which cards each actually wants. Lands in the spec's appendix; Craig approves. Tree untouched. Not =:solo:= — the matrix is his call.
+*** TODO Phase 3 — Emacs ports of demanded widgets :feature:
+Extend the =gallery-widget.el= pattern per demanded widget: ERT (tokens, geometry normal/boundary/error, SVG structure, state-tracks-value) + rsvg-convert side-by-side against the card; keymap/click-region interaction per widget; wired into =make test-elisp=. Gated on the Phase 2 matrix and Craig's green lamp per widget.
+*** TODO Phase 4 — waybar pilot: audio panel :feature:dotfiles:
+Restyle the audio panel's GTK CSS onto =tokens-waybar.css= + the banked composition idioms. Lives in =~/.dotfiles=; archsetup drives edit/test/commit/push end to end + inbox note. Visual result gets a manual-testing checklist entry (daily-driver panel).
+*** TODO Phase 5 — Level-2 generator go/no-go :design:
+After ~5 hand ports, weigh widget-level codegen with evidence (mechanical duplication vs judgment per port). Recorded as a dated decision in the spec; go spawns its own spec.
+*** TODO Flip the spec to IMPLEMENTED
+When the phases above close: status heading keyword → =IMPLEMENTED=, dated history line with the reason, Metadata =Status= mirror. Three lines, one file.
** DONE [#C] Net panel: Enterprise error never dismisses :bug:dotfiles:network:
CLOSED: [2026-07-12 Sun]
Fixed in dotfiles =a157bed=. Root cause: error toasts are sticky by design (so background refreshes can't wipe an unread error), but the enterprise join hint's flow posts no follow-up status and row clicks post none either, so nothing ever replaced it. Fix: a window-wide capture-phase click gesture dismisses a sticky toast on the user's next interaction; policy in =viewmodel.toast_action_plan= (unit-tested), timed toasts and background clears unchanged. Panel smoke run confirms launch/doctor/close with the gesture installed. Pointer-level dismiss is a manual-testing child (AT-SPI can't drive pointer gestures). Repro screenshot: =~/pictures/screenshots/2026-07-10_195911.png=.
diff --git a/working/retro-stereo-widgets/references/2026-07-12-pill-toggle-render.jpg b/working/retro-stereo-widgets/references/2026-07-12-pill-toggle-render.jpg
new file mode 100644
index 0000000..c1c0b14
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-pill-toggle-render.jpg
Binary files differ