/* 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 = ``; for (const k of 'abcdefg') { const on = lit.includes(k); const col = on ? (cls === 'red' ? 'var(--sevred)' : 'var(--sevgrn)') : 'var(--sevoff)'; s += ``; } return 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; })();