/* 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 = `';
}
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 = '
';
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 = '';
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 = '';
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 = ``;
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 = ''; 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', `${v}`);
});
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 = `${opts.onLabel || 'ON'}${opts.offLabel || 'OFF'}`;
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 = `
`;
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 = ``;
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 = ''; 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 = '';
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 = `${opts.aLabel || 'A'}${opts.bLabel || 'B'}`;
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 = '';
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 = ''; 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 = ``;
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 };
};
/* ---- widget CSS: injected once, grows as builders move in ---- */
const GW_CSS = ``;
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;
})();