diff options
Diffstat (limited to 'docs/prototypes/widgets.js')
| -rw-r--r-- | docs/prototypes/widgets.js | 4491 |
1 files changed, 4491 insertions, 0 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js new file mode 100644 index 0000000..c12e5b7 --- /dev/null +++ b/docs/prototypes/widgets.js @@ -0,0 +1,4491 @@ +/* widgets.js — retro-instrument widget library (GW namespace). + Classic script: load after the token :root block (tokens.json → gen_tokens.py), + before any GW.* call. Works from file:// — no modules, no build step. + Contract: each gallery card in panel-widget-gallery.html is the visual + + behavioral spec its builder is judged against. + Spec: docs/specs/2026-07-12-component-generation-spec.org */ +(function () { +'use strict'; +const GW = {}; + +/* ================= shared engine ================= */ + +const SVGNS = 'http://www.w3.org/2000/svg'; +function svgEl(parent, tag, attrs) { + const e = document.createElementNS(SVGNS, tag); + for (const k in attrs) e.setAttribute(k, attrs[k]); + parent.appendChild(e); return e; +} +function polar(cx, cy, r, deg) { + const a = deg * Math.PI / 180; + return [cx + r * Math.sin(a), cy - r * Math.cos(a)]; +} + +/* ---- pointer-drag helpers (rect-ratio based, so CSS zoom is transparent) ---- */ +function dragX(el, onPct) { + el.style.touchAction = 'none'; + const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (e.clientX - r.left) / r.width * 100))); }; + el.addEventListener('pointerdown', e => { + el.setPointerCapture(e.pointerId); at(e); + const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); }; + el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault(); + }); +} +function dragY(el, onPct) { + el.style.touchAction = 'none'; + const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (r.bottom - e.clientY) / r.height * 100))); }; + el.addEventListener('pointerdown', e => { + el.setPointerCapture(e.pointerId); at(e); + const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); }; + el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault(); + }); +} +function dragDelta(el, get, set, opts) { + opts = opts || {}; el.style.touchAction = 'none'; + const min = opts.min == null ? 0 : opts.min, max = opts.max == null ? 100 : opts.max, sens = opts.sens || ((max - min) / 160); + el.addEventListener('pointerdown', e => { + if (opts.stop) e.stopPropagation(); el.setPointerCapture(e.pointerId); + const y0 = e.clientY, v0 = get(); + const mv = ev => { let v = v0 + (y0 - ev.clientY) * sens; set(Math.max(min, Math.min(max, v))); }; + const up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); }; + el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault(); + }); +} + +/* ---- seven-segment digit builder ---- */ +const SEG = { '0': 'abcdef', '1': 'bc', '2': 'abged', '3': 'abgcd', '4': 'fbgc', '5': 'afgcd', '6': 'afgedc', '7': 'abc', '8': 'abcdefg', '9': 'abcdfg', '-': 'g', ' ': '' }; +function hseg(cy) { return `5,${cy} 7,${cy - 2} 17,${cy - 2} 19,${cy} 17,${cy + 2} 7,${cy + 2}`; } +function vseg(cx, ty, by) { return `${cx},${ty} ${cx - 2},${ty + 2} ${cx - 2},${by - 2} ${cx},${by} ${cx + 2},${by - 2} ${cx + 2},${ty + 2}`; } +const SEGPTS = { a: hseg(3), g: hseg(22), d: hseg(41), f: vseg(5, 4, 21), b: vseg(19, 4, 21), e: vseg(5, 23, 40), c: vseg(19, 23, 40) }; +function seg7(ch, cls) { + const lit = SEG[ch] || ''; let s = `<svg class="seg7 ${cls || ''}" viewBox="0 0 24 44">`; + for (const k of 'abcdefg') { + const on = lit.includes(k); + const col = on ? (cls === 'red' ? 'var(--sevred)' : 'var(--sevgrn)') : 'var(--sevoff)'; + s += `<polygon points="${SEGPTS[k]}" fill="${col}"/>`; + } + return s + '</svg>'; +} + +function buildBars(el, n) { el.innerHTML = ''; for (let k = 0; k < n; k++) el.appendChild(document.createElement('i')); } + +/* ---- VU law: authentic nonlinear dB scale, dB → 0..1 sweep position ---- */ +const VUDB = [[-20, 0], [-10, .20], [-7, .31], [-5, .40], [-3, .50], [-2, .58], [-1, .66], [0, .75], [1, .84], [2, .91], [3, 1]]; +/* interpolate a dB reading from a 0..1 sweep position */ +function vuDb(t) { + let i = 0; while (i < VUDB.length - 2 && VUDB[i + 1][1] < t) i++; + const [d1, t1] = VUDB[i], [d2, t2] = VUDB[i + 1]; + return d1 + (d2 - d1) * Math.max(0, Math.min(1, (t - t1) / (t2 - t1))); +} + +/* ---- screen-color families: period phosphor/LCD palettes as scoped CSS vars. + Shipped literals are the fallbacks, so defaults are pixel-identical until applied. ---- */ +const SCREEN_FAMS = { + green: { '--scr-hi': '#7fe0a0', '--scr-ink': '#58b87e', '--scr-dim': '#3d5c46', + '--scr-bg1': '#0a120c', '--scr-bg2': '#050c07', '--scr-bgc': '#04140a', '--scr-bge': '#020a05', '--scr-brd': '#123018', + '--scr-grat': 'rgba(111,206,51,.18)', '--scr-gratc': 'rgba(111,206,51,.35)', '--scr-glow': 'rgba(127,224,160,.8)', + '--crt-face1': '#b9d8c0', '--crt-face2': '#8bb296', '--crt-glow': '#eef7ee' }, + amber: { '--scr-hi': '#ffbe54', '--scr-ink': '#e2a038', '--scr-dim': '#7d5c16', + '--scr-bg1': '#140d06', '--scr-bg2': '#0a0705', '--scr-bgc': '#140d06', '--scr-bge': '#0a0603', '--scr-brd': '#33230e', + '--scr-grat': 'rgba(226,160,56,.18)', '--scr-gratc': 'rgba(226,160,56,.35)', '--scr-glow': 'rgba(255,190,84,.8)', + '--crt-face1': '#d8cba6', '--crt-face2': '#b3a276', '--crt-glow': '#ffe7c0' }, + red: { '--scr-hi': '#ff9a4c', '--scr-ink': '#e0742e', '--scr-dim': '#5c3416', + '--scr-bg1': '#140a05', '--scr-bg2': '#0c0603', '--scr-bgc': '#140a05', '--scr-bge': '#0a0502', '--scr-brd': '#331c0c', + '--scr-grat': 'rgba(224,116,46,.18)', '--scr-gratc': 'rgba(224,116,46,.35)', '--scr-glow': 'rgba(255,154,76,.8)', + '--crt-face1': '#d8b9a6', '--crt-face2': '#b39276', '--crt-glow': '#ffdcc0' }, + blue: { '--scr-hi': '#cfe4ff', '--scr-ink': '#94b8d8', '--scr-dim': '#3a4c60', + '--scr-bg1': '#0a0e14', '--scr-bg2': '#05080c', '--scr-bgc': '#0a1018', '--scr-bge': '#04070c', '--scr-brd': '#16283a', + '--scr-grat': 'rgba(148,184,216,.18)', '--scr-gratc': 'rgba(148,184,216,.35)', '--scr-glow': 'rgba(207,228,255,.8)', + '--crt-face1': '#c2d2dd', '--crt-face2': '#93a8b6', '--crt-glow': '#eaf2ff' }, + vfd: { '--scr-hi': '#63e6c8', '--scr-ink': '#46b89e', '--scr-dim': '#1e4a40', + '--scr-bg1': '#06100d', '--scr-bg2': '#040b09', '--scr-bgc': '#041410', '--scr-bge': '#020a08', '--scr-brd': '#0e3028', + '--scr-grat': 'rgba(70,184,158,.18)', '--scr-gratc': 'rgba(70,184,158,.35)', '--scr-glow': 'rgba(99,230,200,.8)', + '--crt-face1': '#b0d8cd', '--crt-face2': '#84ada2', '--crt-glow': '#dcfff5' }, + white: { '--scr-hi': '#f2f4f2', '--scr-ink': '#c8cac8', '--scr-dim': '#5a5c5a', + '--scr-bg1': '#0b0c0b', '--scr-bg2': '#070807', '--scr-bgc': '#0d0e0d', '--scr-bge': '#060706', '--scr-brd': '#2a2c2a', + '--scr-grat': 'rgba(200,202,200,.18)', '--scr-gratc': 'rgba(200,202,200,.35)', '--scr-glow': 'rgba(242,244,242,.7)', + '--crt-face1': '#d0d2d0', '--crt-face2': '#a8aaa8', '--crt-glow': '#f7f7f7' }, +}; + +/* ================= widget builders ================= + Every builder: GW.name(host, opts) → handle. host is an empty element the + widget renders into. opts.onChange(value, text) fires on every state change, + including the initial paint; text is the widget's canonical readout string. */ + +const noop = () => {}; + +/* 01 slide toggle — on/off pill. State colors ride CSS vars (--sw-*). + opts.onStyle / offStyle / offText / thumb pick a named style per axis from + GW.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's + setStyle(axis, name) restyles a live instance (the gallery chips use it). */ +GW.slideToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const sw = document.createElement('span'); sw.className = 'switch'; host.appendChild(sw); + const setStyle = (axis, name) => { + const o = (GW.slideToggle.STYLES[axis] || {})[name]; + if (!o) return; + for (const [k, v] of Object.entries(o.vars)) sw.style.setProperty(k, v); + }; + setStyle('on', opts.onStyle || 'amber'); + setStyle('off', opts.offStyle || 'dark'); + setStyle('offText', opts.offText || 'white'); + setStyle('thumb', opts.thumb || 'light'); + let on; + const set = v => { on = !!v; sw.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); }; + sw.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: sw, get: () => on, set, setStyle }; +}; +/* named styles per axis: dot = swatch color for pickers, vars = --sw-* overrides */ +GW.slideToggle.STYLES = { + on: { + amber: { dot: 'var(--gold-hi)', vars: { '--sw-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--sw-on-brd': 'var(--gold-hi)', '--sw-on-ink': 'var(--panel)' } }, + green: { dot: '#8fb944', vars: { '--sw-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--sw-on-brd': '#a9c95f', '--sw-on-ink': 'var(--panel)' } }, + dark: { dot: '#242019', vars: { '--sw-on-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-on-brd': 'var(--slate)', '--sw-on-ink': 'var(--cream)' } }, + }, + off: { + dark: { dot: '#242019', vars: { '--sw-off-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-off-brd': 'var(--slate)' } }, + red: { dot: 'var(--fail)', vars: { '--sw-off-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-off-brd': 'var(--fail)' } }, + }, + offText: { + white: { dot: 'var(--cream)', vars: { '--sw-off-ink': 'var(--cream)' } }, + red: { dot: '#e0523a', vars: { '--sw-off-ink': '#e0523a' } }, + black: { dot: '#14110e', vars: { '--sw-off-ink': '#14110e' } }, + }, + thumb: { + light: { dot: '#d8d2c4', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#f6f2e8,#b0aa9a)' } }, + dark: { dot: '#2c2824', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#4a453e,#16130f)' } }, + chrome: { dot: '#9aa2b4', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#dfe6f2,#6d7484)' } }, + brass: { dot: '#b8944a', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#e2c47a,#8a6a24)' } }, + }, +}; + +/* 02 console keys — mutually exclusive push buttons; {label, red} per key */ +GW.consoleKeys = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const keys = opts.keys || [{ label: 'LIVE' }, { label: 'SCAN' }, { label: 'MUTED', red: true }]; + const wrap = document.createElement('span'); host.appendChild(wrap); + const btns = keys.map(k => { + const b = document.createElement('button'); b.className = 'key'; b.textContent = k.label; + wrap.appendChild(b); return b; + }); + let idx; + const set = i => { + idx = i; + btns.forEach((b, j) => { b.classList.remove('on', 'red'); if (j === i) b.classList.add(keys[i].red ? 'red' : 'on'); }); + onChange(i, keys[i].label); + }; + btns.forEach((b, i) => b.addEventListener('click', () => set(i))); + set(opts.active || 0); + return { el: wrap, get: () => idx, set }; +}; + +/* 03 horizontal fader — continuous 0-100 */ +GW.faderH = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const f = document.createElement('div'); f.className = 'fader'; + f.innerHTML = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>'; + host.appendChild(f); + let val; + const set = p => { + val = Math.max(0, Math.min(100, p)); + f.querySelector('.fill').style.width = val + '%'; + f.querySelector('.cap').style.left = val + '%'; + onChange(val, 'level ' + Math.round(val)); + }; + dragX(f, set); + set(opts.value !== undefined ? opts.value : 68); + return { el: f, get: () => val, set }; +}; + +/* 04 vertical fader — one channel-strip fader; compose per channel */ +GW.faderV = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const f = document.createElement('div'); f.className = 'vfader'; + f.innerHTML = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>'; + host.appendChild(f); + let val; + const set = p => { + val = Math.max(0, Math.min(100, p)); + f.querySelector('.fill').style.height = val + '%'; + f.querySelector('.cap').style.bottom = val + '%'; + onChange(val, 'level ' + Math.round(val)); + }; + dragY(f, set); + set(opts.value !== undefined ? opts.value : 60); + return { el: f, get: () => val, set }; +}; + +/* 05 rotary knob — drag up/down to turn, -150°..+150° sweep */ +GW.knob = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const min = opts.min !== undefined ? opts.min : 0, max = opts.max !== undefined ? opts.max : 100; + const k = document.createElement('span'); k.className = 'knob'; + k.innerHTML = '<span class="ind"></span>'; + host.appendChild(k); + let val; + const set = v => { + val = Math.max(min, Math.min(max, v)); + k.querySelector('.ind').style.transform = `rotate(${-150 + (val - min) / (max - min) * 300}deg)`; + onChange(val, String(Math.round(val))); + }; + dragDelta(k, () => val, set, { min, max }); + set(opts.value !== undefined ? opts.value : 53); + return { el: k, get: () => val, set }; +}; + +/* 06 segmented selector — pick one of a few. opts.accent picks the lit-segment + color from GW.segmented.STYLES (amber / green / red); defaults match the + stylesheet fallbacks. setStyle(axis, name) restyles a live instance. */ +GW.segmented = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const items = opts.items || ['TIMER', 'ALARM', 'POMO']; + const seg = document.createElement('div'); seg.className = 'seg'; host.appendChild(seg); + const setStyle = (axis, name) => { + const o = (GW.segmented.STYLES[axis] || {})[name]; + if (!o) return; + for (const [k, v] of Object.entries(o.vars)) seg.style.setProperty(k, v); + }; + setStyle('accent', opts.accent || 'amber'); + const btns = items.map(t => { const b = document.createElement('button'); b.textContent = t; seg.appendChild(b); return b; }); + let idx; + const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); }; + btns.forEach((b, i) => b.addEventListener('click', () => set(i))); + set(opts.active || 0); + return { el: seg, get: () => idx, set, setStyle }; +}; +/* named styles per axis: dot = swatch color for pickers, vars = --seg-* overrides */ +GW.segmented.STYLES = { + accent: { + amber: { dot: 'var(--gold-hi)', vars: { '--seg-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--seg-on-ink': 'var(--panel)' } }, + green: { dot: '#8fb944', vars: { '--seg-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--seg-on-ink': 'var(--panel)' } }, + red: { dot: 'var(--fail)', vars: { '--seg-on-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--seg-on-ink': 'var(--panel)' } }, + }, +}; + +/* 07 chip toggle — inline binary inside a line of text */ +GW.chipToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const chip = document.createElement('span'); chip.className = 'chip'; + chip.textContent = opts.label || 'discoverable on'; + host.appendChild(chip); + let on; + const set = v => { on = !!v; chip.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); }; + chip.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: chip, get: () => on, set }; +}; + +/* 08 arm-to-fire — two-stage confirm for destructive actions */ +GW.armButton = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const label = opts.label || 'forget', armLabel = opts.armLabel || label + '? again'; + const a = document.createElement('button'); a.className = 'arm'; a.textContent = label; + host.appendChild(a); + let armed = false; + a.addEventListener('click', () => { + if (!armed) { armed = true; a.classList.add('armed'); a.textContent = armLabel; onChange('armed', 'ARMED'); } + else { armed = false; a.classList.remove('armed'); a.textContent = label; onChange('fired', 'FIRED · reset'); } + }); + onChange('safe', 'SAFE'); + return { el: a, get: () => (armed ? 'armed' : 'safe'), fire: () => a.click() }; +}; + +/* 09 lamp row — actionable list item: lamp + name + status, click to cycle */ +GW.lampRow = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const name = opts.name || 'WH-1000XM4'; + const states = opts.states || [['gold', 'tap to connect'], ['busy', 'connecting…'], ['', 'connected']]; + const row = document.createElement('div'); row.className = 'lrow'; + row.innerHTML = `<span class="lamp"></span><span class="who"><b></b></span><span class="what"></span>`; + row.querySelector('b').textContent = name; + host.appendChild(row); + let idx; + const set = i => { + idx = i; + row.querySelector('.lamp').className = 'lamp ' + states[i][0]; + row.querySelector('.what').textContent = states[i][1]; + onChange(i, states[i][1]); + }; + row.addEventListener('click', () => set((idx + 1) % states.length)); + set(opts.state || 0); + return { el: row, get: () => idx, set }; +}; + +/* 24 rotary selector — pick one of five printed detents by position */ +GW.rotarySelector = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const values = opts.values || [4, 6, 8, 10, 12]; + const fmt = opts.fmt || (v => 'size ' + v); + const POS = [[14, 53], [28, 18], [50, 7], [72, 18], [86, 53]], ANG = [-70, -35, 0, 35, 70]; + const rs = document.createElement('span'); rs.className = 'rotsel'; + values.forEach((v, i) => { + const p = document.createElement('span'); p.className = 'pos'; + p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = v; + rs.appendChild(p); + }); + const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = '<span class="ind"></span>'; rs.appendChild(k); + host.appendChild(rs); + let idx; + const set = i => { + idx = ((i % values.length) + values.length) % values.length; + rs.querySelector('.ind').style.transform = `rotate(${ANG[idx]}deg)`; + rs.querySelectorAll('.pos').forEach((p, j) => p.classList.toggle('on', j === idx)); + onChange(values[idx], fmt(values[idx])); + }; + k.addEventListener('click', () => set(idx + 1)); + set(opts.index !== undefined ? opts.index : 2); + return { el: rs, get: () => values[idx], set }; +}; + +/* 25 slide-rule dial — lit pointer on a printed scale. The printed numerals + are the majors; the integer units between them get minor ticks and are + selectable too. Click a numeral, a mark, or between marks; ←/→ (↑/↓) step + one unit. set() takes a stop index; get() returns the value. opts.skin picks + a face from GW.slideRule.STYLES (warm backlit / chrome / 80s black glass / marantz blue); + setStyle(axis, name) restyles a live instance. */ +GW.slideRule = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const majors = opts.values || [4, 6, 8, 10, 12]; + const fmt = opts.fmt || (v => 'pos ' + v); + const X = [12, 51, 90, 129, 168]; + /* expand to unit stops: every integer between adjacent numeric majors */ + const stops = []; + majors.forEach((v, i) => { + stops.push({ v, x: X[i], major: true }); + const b = majors[i + 1]; + if (typeof v === 'number' && typeof b === 'number') + for (let u = v + 1; u < b; u++) + stops.push({ v: u, x: X[i] + (X[i + 1] - X[i]) * (u - v) / (b - v), major: false }); + }); + const t = document.createElement('span'); t.className = 'tuner'; + t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value'); + const setStyle = (axis, name) => { + const o = (GW.slideRule.STYLES[axis] || {})[name]; + if (!o) return; + for (const [k, v] of Object.entries(o.vars)) t.style.setProperty(k, v); + }; + setStyle('skin', opts.skin || 'warm'); + stops.forEach(s => { + t.insertAdjacentHTML('beforeend', s.major + ? `<span class="tick" style="left:${s.x}px"></span><span class="mk" style="left:${s.x}px">${s.v}</span>` + : `<span class="tick mn" style="left:${s.x}px"></span>`); + }); + const ndl = document.createElement('span'); ndl.className = 'ndl'; t.appendChild(ndl); + host.appendChild(t); + let idx; + const set = i => { + idx = Math.max(0, Math.min(stops.length - 1, i)); + ndl.style.left = stops[idx].x + 'px'; + t.querySelectorAll('.mk').forEach(m => m.classList.toggle('on', +m.textContent === stops[idx].v)); + onChange(stops[idx].v, fmt(stops[idx].v)); + }; + t.addEventListener('click', e => { + const r = t.getBoundingClientRect(); + /* rect is visual px but stop coords are layout px — divide the CSS zoom out */ + const x = (e.clientX - r.left) * (t.offsetWidth / r.width); + let best = 0, bd = 1e9; stops.forEach((s, j) => { const d = Math.abs(s.x - x); if (d < bd) { bd = d; best = j; } }); + set(best); t.focus(); + }); + t.addEventListener('keydown', e => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); set(idx - 1); } + else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); set(idx + 1); } + }); + const initV = opts.value !== undefined ? opts.value : majors[opts.index !== undefined ? opts.index : 2]; + const init = stops.findIndex(s => s.v === initV); + set(init < 0 ? 0 : init); + return { el: t, get: () => stops[idx].v, set, setStyle }; +}; +/* named faces: dot = swatch color for pickers, vars = --tn-* overrides. + warm restates the stylesheet fallbacks so switching back is exact. */ +GW.slideRule.STYLES = { + skin: { + warm: { dot: 'var(--gold-hi)', vars: { + '--tn-bg': 'linear-gradient(180deg,#191510,#0b0908)', '--tn-brd': '#2a251c', + '--tn-glow': 'rgba(var(--glow-lo),.12)', '--tn-tick': 'var(--steel)', '--tn-ink': 'var(--steel)', + '--tn-on': 'var(--gold-hi)', '--tn-onglow': 'rgba(var(--glow-hi),.6)', + '--tn-ndl': 'var(--fail)', '--tn-ndlglow': 'rgba(203,107,77,.85)' } }, + chrome: { dot: '#dfe6f2', vars: { + '--tn-bg': 'linear-gradient(180deg,#e9e9e5,#bcbcb6)', '--tn-brd': '#8e8e88', + '--tn-glow': 'rgba(255,255,255,.28)', '--tn-tick': '#3c3a34', '--tn-ink': '#3c3a34', + '--tn-on': '#14110e', '--tn-onglow': 'none', + '--tn-ndl': 'var(--fail)', '--tn-ndlglow': 'rgba(203,107,77,.45)' } }, + black: { dot: '#1c1a18', vars: { + '--tn-bg': 'linear-gradient(180deg,#17181a,#08090a)', '--tn-brd': '#000', + '--tn-glow': 'rgba(255,255,255,.05)', '--tn-tick': '#c8cac8', '--tn-ink': '#c8cac8', + '--tn-on': '#f2f4f2', '--tn-onglow': 'rgba(242,244,242,.5)', + '--tn-ndl': '#ff3b28', '--tn-ndlglow': 'rgba(255,59,40,.9)' } }, + blue: { dot: '#8fb4d8', vars: { + '--tn-bg': 'linear-gradient(180deg,#0c1520,#070c14)', '--tn-brd': '#1c2c40', + '--tn-glow': 'rgba(120,170,230,.16)', '--tn-tick': '#8fb4d8', '--tn-ink': '#8fb4d8', + '--tn-on': '#cfe4ff', '--tn-onglow': 'rgba(150,200,255,.7)', + '--tn-ndl': '#e8f2ff', '--tn-ndlglow': 'rgba(180,220,255,.9)' } }, + }, +}; + +/* N01 rocker power switch — hard on/off, lit legend */ +GW.rocker = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const r = document.createElement('span'); r.className = 'rocker'; + r.innerHTML = `<span class="half top">${opts.onLabel || 'ON'}</span><span class="half bot">${opts.offLabel || 'OFF'}</span>`; + host.appendChild(r); + let on; + const set = v => { on = !!v; r.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); }; + r.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: r, get: () => on, set }; +}; + +/* N02 transport cluster — rew/play/stop/rec, one lit; reels turn while playing. + opts.animate: reels obey play state (default: not prefers-reduced-motion) */ +GW.transport = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const animate = opts.animate !== undefined ? opts.animate : !matchMedia('(prefers-reduced-motion: reduce)').matches; + const MODES = { rew: '⏮', play: '▶', stop: '⏹', rec: '⏺' }; + const NAMES = { rew: 'REW', play: 'PLAY', stop: 'STOP', rec: 'REC' }; + const wrap = document.createElement('div'); + wrap.style.cssText = 'display:flex;flex-direction:column;gap:6px;align-items:center'; + wrap.innerHTML = `<div class="reels"><span class="reel spin"><i></i><i></i><i></i></span><span class="reel spin"><i></i><i></i><i></i></span></div><div class="transport"></div>`; + const bar = wrap.querySelector('.transport'); + const btns = {}; + for (const m of Object.keys(MODES)) { + const b = document.createElement('button'); b.className = 'tbtn' + (m === 'rec' ? ' rec' : ''); + b.textContent = MODES[m]; bar.appendChild(b); btns[m] = b; + b.addEventListener('click', () => set(m)); + } + host.appendChild(wrap); + let mode; + const set = m => { + mode = m; + for (const k in btns) btns[k].classList.toggle('on', k === m); + wrap.querySelectorAll('.reel.spin').forEach(r => r.style.animationPlayState = (m === 'play' && animate) ? 'running' : 'paused'); + onChange(m, NAMES[m]); + }; + /* initial: light the mode but leave reel play-state to CSS, matching load behavior */ + mode = opts.mode || 'play'; + btns[mode].classList.add('on'); + onChange(mode, NAMES[mode]); + return { el: wrap, get: () => mode, set }; +}; + +/* N03 radio preset bank — mechanically exclusive presets */ +GW.presetBank = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const items = opts.items || ['WIFI', 'ETH', 'CELL', 'OFF']; + const bank = document.createElement('div'); bank.className = 'radiobank'; host.appendChild(bank); + const btns = items.map(t => { const b = document.createElement('button'); b.className = 'preset'; b.textContent = t; bank.appendChild(b); return b; }); + let idx; + const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); }; + btns.forEach((b, i) => b.addEventListener('click', () => set(i))); + set(opts.active || 0); + return { el: bank, get: () => idx, set }; +}; + +/* N04 concentric dual knob — two values on one spindle, outer ring + inner cap */ +GW.dualKnob = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const dk = document.createElement('span'); dk.className = 'dualknob'; + dk.innerHTML = `<span class="outer"><span class="tick" style="transform:rotate(-120deg)"></span><span class="tick" style="transform:rotate(120deg)"></span></span><span class="inner"><span class="ind"></span></span>`; + host.appendChild(dk); + const o = dk.querySelector('.outer'), n = dk.querySelector('.inner'); + let oV, iV; + const paint = () => { + o.style.transform = `rotate(${-150 + oV / 100 * 300}deg)`; + n.style.transform = `rotate(${-150 + iV / 100 * 300}deg)`; + onChange(oV, iV); + }; + dragDelta(o, () => oV, v => { oV = v; paint(); }, { min: 0, max: 100 }); + dragDelta(n, () => iV, v => { iV = v; paint(); }, { min: 0, max: 100, stop: true }); + oV = opts.outer !== undefined ? opts.outer : 50; + iV = opts.inner !== undefined ? opts.inner : 50; + paint(); + return { el: dk, get: () => [oV, iV], set: (a, b) => { if (a !== undefined) oV = a; if (b !== undefined) iV = b; paint(); } }; +}; + +/* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level */ +GW.encoder = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const enc = document.createElement('span'); enc.className = 'encoder'; host.appendChild(enc); + const R = 27, cx = 33, cy = 33; + for (let i = 0; i < 12; i++) { + const a = (i * 30 - 90) * Math.PI / 180; + const d = document.createElement('span'); d.className = 'led'; + d.style.left = (cx + R * Math.cos(a)) + 'px'; d.style.top = (cy + R * Math.sin(a)) + 'px'; + enc.appendChild(d); + } + const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = '<span class="ind"></span>'; enc.appendChild(k); + let val; + const set = v => { + val = v; + const lit = ((Math.floor(val) % 12) + 12) % 12; + enc.querySelectorAll('.led').forEach((l, i) => l.classList.toggle('on', i <= lit)); + k.style.transform = `rotate(${val * 30}deg)`; + onChange(val, 'pos ' + Math.round(val)); + }; + dragDelta(enc, () => val, set, { min: 0, max: 100000, sens: 0.25 }); + set(opts.value !== undefined ? opts.value : 7); + return { el: enc, get: () => val, set }; +}; + +/* N06 keyed mode switch — guarded three-position mode, key-bit points at the live one */ +GW.keySwitch = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const items = opts.items || ['OFF', 'ON', 'RUN']; + const ANG = [-70, 0, 70], POS = [[16, 64], [50, 12], [84, 64]]; + const kl = document.createElement('span'); kl.className = 'keylock'; + items.forEach((t, i) => { + const p = document.createElement('span'); p.className = 'kpos'; + p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = t; + kl.appendChild(p); + }); + const body = document.createElement('span'); body.className = 'body'; + body.innerHTML = '<span class="bit"></span><span class="barrel"></span>'; + kl.appendChild(body); host.appendChild(kl); + let idx; + const set = i => { + idx = ((i % items.length) + items.length) % items.length; + body.querySelector('.bit').style.transform = `rotate(${ANG[idx]}deg)`; + kl.querySelectorAll('.kpos').forEach((p, j) => p.classList.toggle('on', j === idx)); + onChange(idx, items[idx]); + }; + body.addEventListener('click', () => set(idx + 1)); + set(opts.index !== undefined ? opts.index : 1); + return { el: kl, get: () => idx, set }; +}; + +/* N07 center-detented crossfader — throw to either side of zero */ +GW.crossfader = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const xf = document.createElement('div'); xf.className = 'xfader'; + xf.innerHTML = `<div class="slot"></div><div class="detent"></div><span class="end" style="left:0">${opts.aLabel || 'A'}</span><span class="end" style="right:0">${opts.bLabel || 'B'}</span><div class="cap"></div>`; + host.appendChild(xf); + let pct; + const set = p => { + pct = Math.max(0, Math.min(100, p)); + xf.querySelector('.cap').style.left = pct + '%'; + const v = Math.round(pct - 50); + onChange(v, (v > 0 ? '+' : '') + v); + }; + dragX(xf, set); + set(opts.value !== undefined ? opts.value : 50); + return { el: xf, get: () => Math.round(pct - 50), set }; +}; + +/* N08 thumbwheel — knurled edge-wheel with a windowed two-digit value */ +GW.thumbwheel = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const w = document.createElement('div'); w.className = 'thumbw'; + w.innerHTML = '<span class="thumbwheel"></span><span class="win"></span>'; + host.appendChild(w); + let val; + const set = v => { + val = Math.round(v); + const s = ((val % 100) + 100) % 100; + w.querySelector('.win').textContent = String(s).padStart(2, '0'); + onChange(s, 'value ' + s); + }; + dragDelta(w.querySelector('.thumbwheel'), () => val, set, { min: 0, max: 99, sens: 0.2 }); + set(opts.value !== undefined ? opts.value : 42); + return { el: w, get: () => ((val % 100) + 100) % 100, set }; +}; + +/* N09 DIP-switch bank — hard flags, up is on; readout is the binary word */ +GW.dipBank = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const bits = opts.bits || [true, false, true, true, false, false]; + const bank = document.createElement('span'); bank.className = 'dip'; host.appendChild(bank); + const sws = bits.map(on => { + const s = document.createElement('span'); s.className = 'dipsw' + (on ? ' on' : ''); + s.innerHTML = '<i></i>'; bank.appendChild(s); return s; + }); + const word = () => sws.map(x => x.classList.contains('on') ? '1' : '0').join(''); + const upd = () => onChange(word(), word()); + sws.forEach(s => s.addEventListener('click', () => { s.classList.toggle('on'); upd(); })); + upd(); + return { el: bank, get: word, set: w => { sws.forEach((s, i) => s.classList.toggle('on', w[i] === '1')); upd(); } }; +}; + +/* N10 jog / shuttle wheel — scrub fine; position accumulates without limit */ +GW.jogWheel = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const j = document.createElement('span'); j.className = 'jog'; + j.innerHTML = `<span class="shuttle" style="--sh:40deg"></span><span class="inner"><span class="dimple"></span></span>`; + host.appendChild(j); + let val; + const set = v => { + val = v; + j.querySelector('.inner').style.transform = `rotate(${val * 4}deg)`; + onChange(val, 'pos ' + Math.round(val)); + }; + dragDelta(j, () => val, set, { min: -100000, max: 100000, sens: 0.5 }); + set(opts.value !== undefined ? opts.value : 0); + return { el: j, get: () => val, set }; +}; + +/* ---- shared SVG defs: gradients/filters referenced by url(#id) are + document-scoped, and several are used across widgets. Builders ensure the + defs they use; the first caller creates it, later calls are no-ops. ---- */ +let defsRoot = null; +const defsMade = new Set(); +function defsHost() { + if (!defsRoot) { + const svg = document.createElementNS(SVGNS, 'svg'); + svg.setAttribute('width', 0); svg.setAttribute('height', 0); + svg.setAttribute('aria-hidden', 'true'); + svg.style.position = 'absolute'; + defsRoot = document.createElementNS(SVGNS, 'defs'); + svg.appendChild(defsRoot); + document.body.appendChild(svg); + } + return defsRoot; +} +function def(id, make) { if (defsMade.has(id)) return; defsMade.add(id); make(defsHost(), id); } +function gradDef(id, kind, attrs, stops) { + def(id, d => { + const g = svgEl(d, kind, Object.assign({ id }, attrs)); + for (const [offset, color] of stops) svgEl(g, 'stop', { offset, 'stop-color': color }); + }); +} +function stageSvg(host, cls, vw, vh) { + const s = document.createElementNS(SVGNS, 'svg'); + s.setAttribute('class', cls); s.setAttribute('viewBox', `0 0 ${vw} ${vh}`); + s.setAttribute('width', vw); s.setAttribute('height', vh); + host.appendChild(s); return s; +} +/* instance-unique ids for defs that can't be shared (e.g. clip paths sized per instance) */ +let uidN = 0; +function uid(prefix) { return prefix + '-' + (++uidN); } + +/* R02 calibrated vernier dial — the disc turns under a fixed hairline */ +GW.vernierDial = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 150, 150), cx = 75, cy = 75; + gradDef('vernFace', 'radialGradient', { cx: '50%', cy: '45%', r: '60%' }, [['0', '#f7f2da'], ['1', '#e4dfc2']]); + gradDef('bakeKnob', 'radialGradient', { cx: '42%', cy: '36%', r: '75%' }, [['0', '#37332c'], ['1', '#060505']]); + svgEl(s, 'circle', { cx, cy, r: 72, fill: '#0c0b0a', stroke: '#262320' }); + const disc = svgEl(s, 'g', {}); + svgEl(disc, 'circle', { cx, cy, r: 66, fill: 'url(#vernFace)', stroke: '#b7b29a', 'stroke-width': 1 }); + for (let u = 0; u <= 100; u++) { + const a = u * 3, major = u % 10 === 0, half = u % 5 === 0; + const len = major ? 10 : half ? 7 : 4.5, [x1, y1] = polar(cx, cy, 63, a), [x2, y2] = polar(cx, cy, 63 - len, a); + svgEl(disc, 'line', { x1, y1, x2, y2, stroke: '#3a3128', 'stroke-width': major ? 1.3 : half ? 1 : .7 }); + if (major) { + const g = svgEl(disc, 'g', { transform: `rotate(${a},${cx},${cy})` }); + svgEl(g, 'text', { x: cx, y: cy - 44, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: '#3a3128' }).textContent = u; + } + } + for (let k = 0; k < 12; k++) { const [x, y] = polar(cx, cy, 24, k * 30); svgEl(s, 'circle', { cx: x, cy: y, r: 4.8, fill: 'url(#bakeKnob)' }); } + svgEl(s, 'circle', { cx, cy, r: 24, fill: 'url(#bakeKnob)', stroke: '#000', 'stroke-width': .6 }); + svgEl(s, 'circle', { cx, cy, r: 16, fill: 'url(#bakeKnob)' }); + svgEl(s, 'ellipse', { cx: 69, cy: 66, rx: 7, ry: 4, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-32,69,66)' }); + svgEl(s, 'line', { x1: cx, y1: 6, x2: cx, y2: 22, stroke: 'var(--fail)', 'stroke-width': 2.2, 'stroke-linecap': 'round' }); + let val; + const set = v => { val = v; disc.setAttribute('transform', `rotate(${-v * 3},75,75)`); onChange(val, val.toFixed(1)); }; + dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.15 }); + set(opts.value !== undefined ? opts.value : 42.0); + return { el: s, get: () => val, set }; +}; + +/* R03 bat-handle toggle — chrome lever throws between lit legends */ +GW.batToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 70, 90), cx = 35, cy = 44; + gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]); + gradDef('chromeG', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]); + gradDef('tipG', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]); + const lblOn = svgEl(s, 'text', { x: cx, y: 9, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' }); + lblOn.textContent = opts.onLabel || 'ON'; + const lblOff = svgEl(s, 'text', { x: cx, y: 87, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + lblOff.textContent = opts.offLabel || 'OFF'; + const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 17 * Math.cos(a)) + ',' + (cy + 17 * Math.sin(a))); } + svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#nutG)', stroke: '#2b2822' }); + svgEl(s, 'circle', { cx, cy, r: 9, fill: '#14110e', stroke: '#000' }); + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'path', { d: `M ${cx - 3.2} ${cy} L ${cx - 1.8} 21 L ${cx + 1.8} 21 L ${cx + 3.2} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 }); + svgEl(lever, 'ellipse', { cx, cy: 17, rx: 6.5, ry: 8, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 }); + lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s'; + let on; + const set = v => { + on = !!v; + lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)'; + lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)'); + lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)'); + onChange(on, on ? 'ON' : 'OFF'); + }; + s.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: s, get: () => on, set }; +}; + +/* R04 bakelite fluted knob — scallop skirt over a printed 0-10 arc */ +GW.flutedKnob = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 54; + gradDef('bakeSk', 'radialGradient', { cx: '42%', cy: '36%', r: '80%' }, [['0', '#312d27'], ['1', '#050404']]); + gradDef('bakeDome', 'radialGradient', { cx: '40%', cy: '32%', r: '80%' }, [['0', '#3a362f'], ['1', '#0a0908']]); + for (let i = 0; i <= 10; i++) { + const a = -135 + i * 27, major = i % 5 === 0; + const [x1, y1] = polar(cx, cy, 38, a), [x2, y2] = polar(cx, cy, major ? 45 : 43.5, a); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--steel)', 'stroke-width': major ? 1.5 : 1 }); + if (major) { + const [x, y] = polar(cx, cy, 50.5, a); + svgEl(s, 'text', { x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': 8, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = i; + } + } + for (let k = 0; k < 18; k++) { const [x, y] = polar(cx, cy, 31, k * 20); svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: 'url(#bakeSk)' }); } + svgEl(s, 'circle', { cx, cy, r: 31, fill: 'url(#bakeSk)', stroke: '#000', 'stroke-width': .6 }); + const idx = svgEl(s, 'line', { x1: cx, y1: cy - 30, x2: cx, y2: cy - 21, stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round' }); + svgEl(s, 'circle', { cx, cy, r: 19, fill: 'url(#bakeDome)' }); + svgEl(s, 'ellipse', { cx: 49, cy: 46, rx: 6, ry: 3.5, fill: 'rgba(255,255,255,.14)', transform: 'rotate(-28,49,46)' }); + let val; + const set = v => { val = v; idx.setAttribute('transform', `rotate(${v * 2.7 - 135},55,54)`); onChange(val, (val / 10).toFixed(1) + ' / 10'); }; + dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.25 }); + set(opts.value !== undefined ? opts.value : 63); + return { el: s, get: () => val, set }; +}; + +/* R05 filter slider bank — a dense fader wall on a real faceplate. Period skins + as constructor opts backed by GW.filterBank.STYLES, three independent axes: + panel (silver hi-fi aluminum / studio black), caps — the cap SHAPE + (chrome T / short ribbed / tall block fader), and capColor — the cap + FINISH (black white-index / color stripes / chrome / cream). opts.style + picks a native trio (silver+chrome+chrome, studio+ribbed+red); + opts.panel / opts.caps / opts.capColor override an axis; setStyle(axis, name) + restyles live (values kept). After the Pioneer SG-9500, the Technics + SH-8065, and the Zaxcom Oasis block faders. */ +GW.filterBank = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const freqs = opts.freqs || [78, 113, 163, 235, 339, 487, 701, 1010, 1450, 2090, 3010, 4340]; + const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48, 38, 26, 44, 32]).slice(0, freqs.length); + while (vals.length < freqs.length) vals.push(30); + const fmtHz = f => f < 1000 ? f : (f / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; + const ST = GW.filterBank.STYLES; + const native = { + silver: { caps: 'chrome', capColor: 'chrome' }, + studio: { caps: 'ribbed', capColor: 'red' }, + }; + const nat = native[opts.style] || {}; + const cur = { + panel: ST.panel[opts.panel] ? opts.panel : (ST.panel[opts.style] ? opts.style : 'silver'), + caps: ST.caps[opts.caps] ? opts.caps : (nat.caps || 'block'), + capColor: ST.capColor[opts.capColor] ? opts.capColor : (nat.capColor || 'black'), + }; + gradDef('fbPlateSilver', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8d8d4'], ['.5', '#c6c6c1'], ['1', '#adada7']]); + gradDef('fbPlateStudio', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#221f1c'], ['1', '#131110']]); + gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]); + gradDef('fbCapBlack', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#2a2724'], ['1', '#171412']]); + gradDef('fbCapCream', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2ecd8'], ['1', '#cfc8ae']]); + const s = stageSvg(host, 'rsvg', 250, 112); + const y0 = 20, y1 = 92, x0 = 36, dx = (214 - x0) / (freqs.length - 1); + const yOf = db => y0 + db / 60 * (y1 - y0); + const caps = []; + const place = i => caps[i].setAttribute('transform', `translate(0,${yOf(vals[i])})`); + const drawCap = (g, kind, x) => { + const c = ST.capColor[cur.capColor]; + if (kind === 'chrome') { + svgEl(g, 'rect', { x: x - 6, y: -3.25, width: 12, height: 6.5, rx: .9, fill: c.body, stroke: c.edge, 'stroke-width': .6 }); + /* bare metal needs no painted stripe — chrome-on-chrome stays lineless */ + if (cur.capColor !== 'chrome') + svgEl(g, 'line', { x1: x - 6, y1: 0, x2: x + 6, y2: 0, stroke: c.index, 'stroke-width': 1 }); + svgEl(g, 'line', { x1: x - 6, y1: 2, x2: x + 6, y2: 2, stroke: c.rib, 'stroke-width': .5 }); + } else if (kind === 'ribbed') { + svgEl(g, 'rect', { x: x - 5, y: -4, width: 10, height: 8, rx: 1, fill: c.body, stroke: c.edge, 'stroke-width': .6 }); + for (const dy of [-2.4, 2.4]) + svgEl(g, 'line', { x1: x - 4, y1: dy, x2: x + 4, y2: dy, stroke: c.rib, 'stroke-width': .6 }); + svgEl(g, 'line', { x1: x - 5, y1: 0, x2: x + 5, y2: 0, stroke: c.index, 'stroke-width': 1.1 }); + } else { /* block — tall ribbed fader after the Zaxcom Oasis; fine rib + groups above and below a mid-cap index line that marks the value */ + svgEl(g, 'rect', { x: x - 4.5, y: -8, width: 9, height: 16, rx: 1.1, fill: c.body, stroke: c.edge, 'stroke-width': .6 }); + for (const dy of [-6.4, -4.8, -3.2, -1.6, 1.6, 3.2, 4.8, 6.4]) + svgEl(g, 'line', { x1: x - 3.7, y1: dy, x2: x + 3.7, y2: dy, stroke: c.rib, 'stroke-width': .6 }); + svgEl(g, 'line', { x1: x - 4.5, y1: 0, x2: x + 4.5, y2: 0, stroke: c.index, 'stroke-width': 1.2 }); + } + }; + const set = (i, db) => { + db = Math.max(0, Math.min(60, db)); vals[i] = db; place(i); + onChange({ band: i, hz: freqs[i], db }, `${fmtHz(freqs[i])} Hz · −${Math.round(db)} dB`); + }; + const render = () => { + const st = ST.panel[cur.panel]; + s.replaceChildren(); caps.length = 0; + svgEl(s, 'rect', { x: 1.5, y: 1.5, width: 247, height: 109, rx: 5, fill: st.plate, stroke: st.edge, 'stroke-width': 1 }); + svgEl(s, 'rect', { x: 3, y: 3, width: 244, height: 106, rx: 4, fill: 'none', stroke: st.bevel, 'stroke-width': .7 }); + [[9, 9], [241, 9], [9, 103], [241, 103]].forEach(([sx, sy], k) => { + svgEl(s, 'circle', { cx: sx, cy: sy, r: 2.7, fill: st.screw, stroke: 'rgba(0,0,0,.55)', 'stroke-width': .6 }); + svgEl(s, 'line', { x1: sx - 1.9, y1: sy, x2: sx + 1.9, y2: sy, stroke: 'rgba(0,0,0,.6)', 'stroke-width': .7, transform: `rotate(${25 + k * 40},${sx},${sy})` }); + }); + for (let db = 0; db <= 60; db += 10) { + const y = yOf(db), major = db % 20 === 0, len = major ? 4 : 2.5; + svgEl(s, 'line', { x1: 24 - len, y1: y, x2: 24, y2: y, stroke: st.dim, 'stroke-width': major ? .9 : .6 }); + svgEl(s, 'line', { x1: 226, y1: y, x2: 226 + len, y2: y, stroke: st.dim, 'stroke-width': major ? .9 : .6 }); + if (major) { + svgEl(s, 'text', { x: 17, y: y + 2, 'text-anchor': 'end', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: st.ink }).textContent = db; + svgEl(s, 'text', { x: 233, y: y + 2, 'text-anchor': 'start', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: st.ink }).textContent = db; + } + } + svgEl(s, 'text', { x: 20, y: 100, 'text-anchor': 'end', 'font-size': 4.6, 'font-family': 'var(--mono)', fill: st.dim }).textContent = 'dB'; + freqs.forEach((f, i) => { + const x = x0 + i * dx; + svgEl(s, 'text', { x, y: 13, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', fill: st.ink }).textContent = fmtHz(f); + svgEl(s, 'line', { x1: x, y1: y0, x2: x, y2: y1, stroke: st.track, 'stroke-width': st.trackW, 'stroke-linecap': 'round' }); + if (st.trackHi) svgEl(s, 'line', { x1: x - st.trackW / 2 - .35, y1: y0, x2: x - st.trackW / 2 - .35, y2: y1, stroke: st.trackHi, 'stroke-width': .7 }); + const cap = svgEl(s, 'g', {}); + drawCap(cap, cur.caps, x); + caps.push(cap); place(i); + const hit = svgEl(s, 'rect', { x: x - dx / 2, y: y0 - 6, width: dx, height: y1 - y0 + 12, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragY(hit, pct => set(i, (100 - pct) / 100 * 60)); + }); + }; + const setStyle = (axis, name) => { + if (!ST[axis] || !ST[axis][name]) return; + cur[axis] = name; render(); + }; + render(); + onChange(null, 'drag a band'); + return { el: s, get: () => vals.slice(), set, setStyle }; +}; +/* named styles per axis: dot = swatch color for pickers. caps is the cap + shape; capColor is the cap finish (body + rib + index line), applicable to + any shape. */ +GW.filterBank.STYLES = { + panel: { + silver: { dot: '#c9c9c5', plate: 'url(#fbPlateSilver)', edge: '#8e8e88', bevel: 'rgba(255,255,255,.4)', ink: '#33332f', dim: '#5c5c56', screw: '#9c9c96', track: '#121210', trackW: 2.0, trackHi: null }, + studio: { dot: '#1c1a18', plate: 'url(#fbPlateStudio)', edge: '#000', bevel: 'rgba(255,255,255,.06)', ink: '#b9b4a6', dim: '#6f6a5e', screw: '#3c3832', track: '#050505', trackW: 2.2, trackHi: 'rgba(255,255,255,.05)' }, + }, + caps: { + block: { dot: '#2c2824' }, + ribbed: { dot: '#6f6a5e' }, + chrome: { dot: '#dfe6f2' }, + }, + capColor: { + black: { dot: '#211e1b', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: '#f2f4f2', edge: '#000' }, + red: { dot: 'var(--sevred)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--sevred)', edge: '#000' }, + green: { dot: 'var(--sevgrn)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--sevgrn)', edge: '#000' }, + blue: { dot: '#4f9fe0', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: '#4f9fe0', edge: '#000' }, + amber: { dot: 'var(--gold-hi)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--gold-hi)', edge: '#000' }, + chrome: { dot: '#dfe6f2', body: 'url(#sfChrome)', rib: 'rgba(0,0,0,.28)', index: '#14110e', edge: '#3c3a34' }, + cream: { dot: '#efe9d4', body: 'url(#fbCapCream)', rib: 'rgba(0,0,0,.18)', index: '#3a3128', edge: '#8a8474' }, + }, +}; + +/* R06 chicken-head selector — tapered lever aims at the position */ +GW.chickenHead = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const items = opts.items || [['OFF', -60], ['LO', -20], ['MID', 20], ['HI', 60]]; + const s = stageSvg(host, 'rsvg press', 110, 96), cx = 55, cy = 58; + gradDef('chickG', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#35312b'], ['1', '#070606']]); + const lbls = []; + items.forEach(([lbl, a]) => { + const [tx, ty] = polar(cx, cy, 42, a); + const t = svgEl(s, 'text', { x: tx, y: ty + 2.5, 'text-anchor': 'middle', 'font-size': 6.5, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + t.textContent = lbl; lbls.push(t); + const [mx1, my1] = polar(cx, cy, 33, a), [mx2, my2] = polar(cx, cy, 29, a); + svgEl(s, 'line', { x1: mx1, y1: my1, x2: mx2, y2: my2, stroke: 'var(--steel)', 'stroke-width': 1.2 }); + }); + svgEl(s, 'circle', { cx, cy, r: 12, fill: '#0c0b0a' }); + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'path', { d: `M ${cx} ${cy - 29} L ${cx + 8.5} ${cy - 4} Q ${cx + 9} ${cy + 8} ${cx + 5} ${cy + 11} A 7 7 0 0 1 ${cx - 5} ${cy + 11} Q ${cx - 9} ${cy + 8} ${cx - 8.5} ${cy - 4} Z`, fill: 'url(#chickG)', stroke: '#000', 'stroke-width': .7 }); + svgEl(lever, 'line', { x1: cx, y1: cy - 26, x2: cx, y2: cy + 6, stroke: '#4a463e', 'stroke-width': 1.4, 'stroke-linecap': 'round' }); + lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s'; + let idx; + const set = i => { + idx = ((i % items.length) + items.length) % items.length; + lever.style.transform = `rotate(${items[idx][1]}deg)`; + lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)')); + onChange(idx, items[idx][0]); + }; + s.addEventListener('click', () => set(idx + 1)); + set(opts.index !== undefined ? opts.index : 2); + return { el: s, get: () => idx, set }; +}; + +/* R12 chrome slot fader — engraved dB scale, chrome T-handle in a screwed plate */ +GW.slotFader = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 90, 150), cx = 45, yTop = 30, yBot = 120; + const yOf = db => yTop + (12 - db) / 36 * (yBot - yTop); + gradDef('sfPlate', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#242019'], ['1', '#131110']]); + gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]); + gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]); + svgEl(s, 'rect', { x: 14, y: 8, width: 62, height: 134, rx: 6, fill: 'url(#sfPlate)', stroke: '#0a0908', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 15.5, y: 9.5, width: 59, height: 131, rx: 5, fill: 'none', stroke: 'rgba(255,255,255,.05)', 'stroke-width': 1 }); + for (const sy of [16, 134]) { + svgEl(s, 'circle', { cx, cy: sy, r: 3.2, fill: 'url(#sfScrew)', stroke: '#14110e', 'stroke-width': .6 }); + const [x1, y1] = polar(cx, sy, 3, 112), [x2, y2] = polar(cx, sy, 3, 292); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#221f1a', 'stroke-width': 1 }); + } + svgEl(s, 'rect', { x: 40.5, y: yTop - 4, width: 9, height: yBot - yTop + 8, rx: 4, fill: '#0b0a09', stroke: '#050404' }); + for (const db of [12, 8, 4, 0, -4, -8, -12, -16, -20, -24]) { + const y = yOf(db); + svgEl(s, 'line', { x1: 26, y1: y, x2: 39, y2: y, stroke: 'var(--steel)', 'stroke-width': db === 0 ? 1.3 : .8, opacity: db === 0 ? 1 : .75 }); + svgEl(s, 'line', { x1: 51, y1: y, x2: 64, y2: y, stroke: 'var(--steel)', 'stroke-width': db === 0 ? 1.3 : .8, opacity: db === 0 ? 1 : .75 }); + svgEl(s, 'text', { x: 23, y: y + 2.2, 'text-anchor': 'end', 'font-size': 6, 'font-family': 'var(--mono)', fill: db === 0 ? 'var(--cream)' : 'var(--steel)' }).textContent = Math.abs(db); + } + const handle = svgEl(s, 'g', {}); + svgEl(handle, 'polygon', { points: '45,0 58,-5 58,5', fill: '#b9b6ae', stroke: '#5e5a52', 'stroke-width': .5 }); + svgEl(handle, 'rect', { x: 58, y: -7, width: 22, height: 14, rx: 3.5, fill: 'url(#sfChrome)', stroke: '#4e4a42', 'stroke-width': .6 }); + svgEl(handle, 'rect', { x: 60, y: -5.2, width: 18, height: 2.2, rx: 1, fill: 'rgba(255,255,255,.4)' }); + let db; + const set = v => { + db = Math.max(-24, Math.min(12, v)); + handle.setAttribute('transform', `translate(0,${30 + (12 - db) / 36 * 90})`); + onChange(db, (db > 0 ? '+' : db < 0 ? '−' : '') + Math.abs(db).toFixed(1) + ' dB'); + }; + dragY(s, pct => set(-24 + pct / 100 * 36)); + set(opts.value !== undefined ? opts.value : -4); + return { el: s, get: () => db, set }; +}; + +/* R14 spade-pointer tuning knob — engraved relief arc, knurl ring turns with it */ +GW.spadeKnob = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 140, 124), cx = 70, cy = 82; + const sweep = v => -80 + v / 10 * 160; + gradDef('spadeKnob', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#33302a'], ['1', '#080706']]); + gradDef('spadeMetal', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e9e7e0'], ['1', '#8d897f']]); + /* engraved arc: every stroke carries a faint light twin offset below (incised relief) */ + const engrave = (x1, y1, x2, y2, w) => { + svgEl(s, 'line', { x1, y1: y1 + .8, x2, y2: y2 + .8, stroke: 'rgba(255,255,255,.13)', 'stroke-width': w }); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#050404', 'stroke-width': w }); + }; + for (let i = 0; i <= 40; i++) { + const v = i / 4, a = sweep(v), major = i % 4 === 0; + const [x1, y1] = polar(cx, cy, major ? 50 : 52, a), [x2, y2] = polar(cx, cy, 58, a); + engrave(x1, y1, x2, y2, major ? 1.6 : .9); + if (major) { + const [nx, ny] = polar(cx, cy, 65, a); + svgEl(s, 'text', { x: nx, y: ny + 3.2, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: 'rgba(255,255,255,.13)' }).textContent = v; + svgEl(s, 'text', { x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: '#0a0908' }).textContent = v; + } + } + const grp = svgEl(s, 'g', {}); + svgEl(grp, 'path', { d: `M ${cx - 2} ${cy - 28} L ${cx - 4.5} ${cy - 42} L ${cx} ${cy - 54} L ${cx + 4.5} ${cy - 42} L ${cx + 2} ${cy - 28} Z`, fill: 'url(#spadeMetal)', stroke: '#55524a', 'stroke-width': .6 }); + svgEl(grp, 'circle', { cx, cy, r: 30, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .8 }); + svgEl(grp, 'circle', { cx, cy, r: 28.5, fill: 'none', stroke: '#0a0908', 'stroke-width': 3, 'stroke-dasharray': '2.2 2.2' }); + svgEl(grp, 'circle', { cx, cy, r: 22, fill: 'url(#spadeKnob)' }); + svgEl(grp, 'ellipse', { cx: cx - 8, cy: cy - 9, rx: 8, ry: 5, fill: 'rgba(255,255,255,.08)', transform: `rotate(-30,${cx - 8},${cy - 9})` }); + let val; + const set = v => { + val = Math.max(0, Math.min(10, v)); + grp.setAttribute('transform', `rotate(${-80 + val / 10 * 160},70,82)`); + onChange(val, val.toFixed(1)); + }; + dragDelta(s, () => val, set, { min: 0, max: 10, sens: .05 }); + set(opts.value !== undefined ? opts.value : 8.3); + return { el: s, get: () => val, set }; +}; + +/* R15 multi-band dial — nested arcs, one needle; the bandspread dial selects the ring */ +GW.multiBandDial = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const ranges = opts.ranges || [[0.54, 1.6], [1.6, 5.1], [5.1, 15.5], [15.5, 30.5]]; + const s = stageSvg(host, 'rsvg', 190, 110), cx = 62, cy = 62; + const sweep = t => -70 + t * 140; + gradDef('mbFace', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#eee5c8'], ['1', '#dcd2ae']]); + gradDef('spadeKnob', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#33302a'], ['1', '#080706']]); + svgEl(s, 'rect', { x: 4, y: 4, width: 182, height: 102, rx: 12, fill: '#171412', stroke: '#060505' }); + svgEl(s, 'rect', { x: 9, y: 9, width: 172, height: 92, rx: 9, fill: 'url(#mbFace)', stroke: '#0a0908' }); + const INK = '#2c2318'; + const rings = []; + [18, 26, 34, 42].forEach(r => { + const ring = svgEl(s, 'g', {}); + const [x1, y1] = polar(cx, cy, r, -70), [x2, y2] = polar(cx, cy, r, 70); + svgEl(ring, 'path', { d: `M ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2}`, fill: 'none', stroke: INK, 'stroke-width': .9 }); + for (let i = 0; i <= 8; i++) { + const a = sweep(i / 8), [tx1, ty1] = polar(cx, cy, r, a), [tx2, ty2] = polar(cx, cy, r + 3, a); + svgEl(ring, 'line', { x1: tx1, y1: ty1, x2: tx2, y2: ty2, stroke: INK, 'stroke-width': .8 }); + } + rings.push(ring); + }); + const needle = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 46, stroke: '#1a1613', 'stroke-width': 1.6, 'stroke-linecap': 'round' }); + svgEl(s, 'circle', { cx, cy, r: 7, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 }); + svgEl(s, 'text', { x: 14, y: 100, 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'MEGACYCLES'; + /* bandspread dial (click = band select) */ + const bx = 142, by = 56; + for (let i = 0; i < 12; i++) { const a = i * 30, [x1, y1] = polar(bx, by, 24, a), [x2, y2] = polar(bx, by, 21, a); svgEl(s, 'line', { x1, y1, x2, y2, stroke: INK, 'stroke-width': .8 }); } + svgEl(s, 'circle', { cx: bx, cy: by, r: 17, fill: 'none', stroke: INK, 'stroke-width': .7, opacity: .5 }); + const spread = svgEl(s, 'line', { x1: bx, y1: by, x2: bx, y2: by - 22, stroke: '#1a1613', 'stroke-width': 1.4, 'stroke-linecap': 'round' }); + svgEl(s, 'circle', { cx: bx, cy: by, r: 6, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 }); + svgEl(s, 'text', { x: bx, y: 96, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'BANDSPREAD'; + let val, band; + const set = (v, b) => { + val = Math.max(0, Math.min(100, v)); band = b; + needle.setAttribute('transform', `rotate(${-70 + val / 100 * 140},62,62)`); + spread.setAttribute('transform', `rotate(${-45 + b * 30},142,56)`); + rings.forEach((g, i) => g.setAttribute('opacity', i === b ? '1' : '.4')); + const [lo, hi] = ranges[b]; const mc = lo + val / 100 * (hi - lo); + onChange({ band: b, mc }, `B${b + 1} · ${mc.toFixed(2)} Mc`); + }; + const dragHit = svgEl(s, 'rect', { x: 9, y: 9, width: 100, height: 92, fill: 'transparent' }); + dragHit.style.cursor = 'ns-resize'; + dragDelta(dragHit, () => val, v => set(v, band), { min: 0, max: 100 }); + const clickHit = svgEl(s, 'rect', { x: 112, y: 9, width: 69, height: 92, fill: 'transparent' }); + clickHit.style.cursor = 'pointer'; + clickHit.addEventListener('click', () => set(val, (band + 1) % ranges.length)); + set(opts.value !== undefined ? opts.value : 45, opts.band !== undefined ? opts.band : 2); + return { el: s, get: () => [val, band], set }; +}; + +/* R16 entry keypad — worn keys feed the amber display; lamps watch state */ +GW.entryKeypad = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 150, 190); + gradDef('kpKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#dbd8cf'], ['1', '#a29d92']]); + gradDef('kpAmber', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'var(--amber-grad-top)'], ['1', 'var(--amber-grad-mid)']]); + const lamp = cy => svgEl(s, 'circle', { cx: 13, cy, r: 6.5, fill: '#3a0f0a', stroke: '#14100e', 'stroke-width': 1.5 }); + const lampTop = lamp(20), lampBot = lamp(38); + svgEl(s, 'rect', { x: 26, y: 8, width: 116, height: 32, rx: 5, fill: '#0a0806', stroke: '#2c261d', 'stroke-width': 2 }); + const disp = svgEl(s, 'text', { x: 84, y: 31, 'text-anchor': 'middle', 'font-size': 16, 'letter-spacing': '.22em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' }); + svgEl(s, 'rect', { x: 24, y: 48, width: 118, height: 138, rx: 8, fill: '#1a1714', stroke: '#0a0908', 'stroke-width': 1.5 }); + let buf = ''; + const render = () => { + disp.textContent = buf.padEnd(6, '–'); + lampTop.setAttribute('fill', buf ? 'var(--jewel-r)' : '#3a0f0a'); + }; + const press = k => { + if (k === '✓') { onChange(buf, buf ? 'OK · ' + buf : 'empty'); buf = ''; render(); return; } + if (k === '✗') { + buf = ''; render(); onChange(buf, 'cleared'); + lampBot.setAttribute('fill', 'var(--jewel-r)'); + setTimeout(() => lampBot.setAttribute('fill', '#3a0f0a'), 350); return; + } + if (buf.length < 6) { buf += k; render(); onChange(buf, buf); } + }; + const KEYS = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['✓', '0', '✗']]; + const jitter = [-2, 1, -1, 2, 0, -2, 1, -1, 2, 0, -1, 1]; + KEYS.forEach((row, r) => row.forEach((k, c) => { + const x = 47 + c * 36, y = 68 + r * 32, amber = (k === '✓' || k === '✗'); + const g = svgEl(s, 'g', {}); g.style.cursor = 'pointer'; g.style.transition = 'transform .07s'; + svgEl(g, 'rect', { x: x - 14, y: y - 11, width: 28, height: 24, rx: 4, fill: amber ? 'url(#kpAmber)' : 'url(#kpKey)', stroke: '#4e4a42', 'stroke-width': 1, 'stroke-opacity': .8 }); + svgEl(g, 'text', { x, y: y + 6, 'text-anchor': 'middle', 'font-size': 13, 'font-weight': 700, 'font-family': 'var(--mono)', fill: k === '✓' ? '#3f5a1f' : k === '✗' ? '#7a2a1a' : '#2b2721', transform: `rotate(${jitter[r * 3 + c]},${x},${y})` }).textContent = k; + g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); press(k); }); + })); + render(); onChange('', 'enter a code'); + return { el: s, get: () => buf, press }; +}; + +/* R18 thumb-slide attenuator pair — lit numeral strip, cream side tab */ +GW.thumbSlide = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const chans = (opts.channels || [{ name: 'BLEND', v: 74 }, { name: 'MIX', v: 92 }]).map(c => ({ name: c.name, v: c.v })); + const s = stageSvg(host, 'rsvg', 130, 150), y0 = 22, y1 = 122; + gradDef('thRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]); + gradDef('thTab', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2efe6'], ['1', '#c6c1b3']]); + gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]); + const parts = []; + const set = (i, v) => { + v = Math.max(0, Math.min(100, Math.round(v))); chans[i].v = v; + const p = parts[i]; + p.tab.setAttribute('transform', `translate(0,${28 + (100 - v) * 0.88})`); + p.nums.forEach(t => { + const on = Math.abs(parseInt(t.textContent) - v) <= 5; + t.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--gold)'); t.setAttribute('opacity', on ? '1' : '.55'); + }); + onChange(chans.map(c => c.v), `${chans[0].name} ${chans[0].v} · ${chans[1].name} ${chans[1].v}`); + }; + chans.forEach((ch, i) => { + const x = 40 + i * 50; + svgEl(s, 'rect', { x: x - 9, y: y0 - 10, width: 18, height: y1 - y0 + 20, rx: 3, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 }); + for (const sy of [y0 - 6, y1 + 6]) { + svgEl(s, 'circle', { cx: x, cy: sy, r: 2.4, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 }); + svgEl(s, 'line', { x1: x - 1.8, y1: sy - 1, x2: x + 1.8, y2: sy + 1, stroke: '#14110e', 'stroke-width': .8 }); + } + svgEl(s, 'rect', { x: x - 5, y: y0, width: 10, height: y1 - y0, fill: '#0d0a08', stroke: '#050404' }); + const nums = []; + for (let n = 0; n <= 10; n++) { + const val = 100 - n * 10, y = y0 + 6 + n * (y1 - y0 - 12) / 10; + const t = svgEl(s, 'text', { x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .55 }); + t.textContent = val; nums.push(t); + } + const tab = svgEl(s, 'g', {}); + svgEl(tab, 'rect', { x: x + 7, y: -6, width: 8, height: 12, rx: 2, fill: 'url(#thTab)', stroke: '#7a766a', 'stroke-width': .6 }); + svgEl(tab, 'rect', { x: x + 8.2, y: -1, width: 5.6, height: 1.6, fill: 'rgba(0,0,0,.25)' }); + svgEl(s, 'text', { x, y: 145, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name; + const hit = svgEl(s, 'rect', { x: x - 12, y: y0 - 10, width: 30, height: y1 - y0 + 20, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + parts.push({ tab, nums }); + dragY(hit, pct => set(i, pct)); + }); + set(0, chans[0].v); set(1, chans[1].v); + return { el: s, get: () => chans.map(c => c.v), set }; +}; + +/* R19 waveform region editor — monochrome LCD, draggable S/E flags */ +GW.waveRegion = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 190, 100), x0 = 10, x1 = 180, yMid = 48, N = 85; + svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 98, rx: 6, fill: 'var(--scr-bg1,#0b0c0b)', stroke: 'var(--scr-brd,#2a2c2a)', 'stroke-width': 2 }); + svgEl(s, 'text', { x: 12, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C1:START'; + svgEl(s, 'text', { x: 78, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C2:OFF'; + svgEl(s, 'text', { x: 136, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C3:END'; + svgEl(s, 'line', { x1: x0, y1: yMid, x2: x1, y2: yMid, stroke: '#3a3c3a', 'stroke-width': .6, 'stroke-dasharray': '1.5 2' }); + const bars = []; + for (let i = 0; i < N; i++) { + const x = x0 + (i / (N - 1)) * (x1 - x0); + const env = Math.abs(Math.sin(i * 0.19)) * Math.abs(Math.sin(i * 0.045)) * (i % 17 < 11 ? 1 : .25); + const h = Math.max(1.2, env * 26); + bars.push(svgEl(s, 'rect', { x: x - 0.8, y: yMid - h, width: 1.6, height: h * 2, fill: 'var(--scr-dim,#5a5c5a)' })); + } + const flag = lbl => { + const g = svgEl(s, 'g', {}); + svgEl(g, 'line', { x1: 0, y1: 18, x2: 0, y2: 78, stroke: 'var(--scr-hi,#f2f4f2)', 'stroke-width': 1 }); + svgEl(g, 'rect', { x: lbl === 'S' ? 0 : -8, y: 70, width: 8, height: 8, fill: 'var(--scr-hi,#f2f4f2)' }); + svgEl(g, 'text', { x: lbl === 'S' ? 4 : -4, y: 76.5, 'text-anchor': 'middle', 'font-size': 6.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = lbl; + return g; + }; + const flagS = flag('S'), flagE = flag('E'); + svgEl(s, 'rect', { x: 10, y: 84, width: 78, height: 11, fill: 'var(--scr-hi,#e8eae8)' }); + svgEl(s, 'text', { x: 14, y: 92.5, 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = 'ENC:ZOOM(1x)'; + svgEl(s, 'text', { x: 96, y: 92.5, 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'A-3 M:[S]'; + svgEl(s, 'rect', { x: 148, y: 84, width: 32, height: 11, fill: 'var(--scr-hi,#e8eae8)' }); + svgEl(s, 'text', { x: 164, y: 92.5, 'text-anchor': 'middle', 'font-size': 6.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = 'MENU'; + s.style.cursor = 'ew-resize'; + let S, E; + const set = (sv, ev) => { + S = Math.max(0, Math.min(96, sv)); E = Math.max(4, Math.min(100, ev)); + const xOf = p => x0 + p / 100 * (x1 - x0); + flagS.setAttribute('transform', `translate(${xOf(S)},0)`); + flagE.setAttribute('transform', `translate(${xOf(E)},0)`); + bars.forEach((b, i) => { + const p = i / (bars.length - 1) * 100; + b.setAttribute('fill', (p >= S && p <= E) ? 'var(--scr-hi,#f2f4f2)' : 'var(--scr-dim,#5a5c5a)'); + }); + onChange({ s: S, e: E }, `S ${Math.round(S)}% · E ${Math.round(E)}%`); + }; + dragX(s, pct => { + /* move whichever flag is nearer the pointer */ + if (Math.abs(pct - S) <= Math.abs(pct - E)) set(Math.min(pct, E - 4), E); + else set(S, Math.max(pct, S + 4)); + }); + set(opts.start !== undefined ? opts.start : 22, opts.end !== undefined ? opts.end : 76); + return { el: s, get: () => ({ s: S, e: E }), set }; +}; + +/* R20 drum roller selector — numbered paper drum in a window, center chip reads it */ +GW.drumRoller = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const chans = (opts.channels || [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }]).map(c => ({ name: c.name, v: c.v })); + const s = stageSvg(host, 'rsvg', 130, 140), cy = 76, step = 17; + gradDef('thRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]); + gradDef('drPaper', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#cfc6a8'], ['.5', '#ece4c8'], ['1', '#c6bd9e']]); + gradDef('drCurve', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'rgba(0,0,0,.4)'], ['.28', 'rgba(0,0,0,0)'], ['.72', 'rgba(0,0,0,0)'], ['1', 'rgba(0,0,0,.4)']]); + const localDefs = svgEl(s, 'defs', {}); + svgEl(s, 'text', { x: 65, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'EQUALIZER'; + const grps = []; + const set = (i, v) => { + v = Math.max(1, Math.min(10, v)); chans[i].v = v; + const p = grps[i]; + p.nums.style.transform = `translateY(${(v - p.base) * step}px)`; + p.chip.textContent = Math.round(v); + onChange(chans.map(c => c.v), `${chans[0].name} ${Math.round(chans[0].v)} · ${chans[1].name} ${Math.round(chans[1].v)}`); + }; + chans.forEach((ch, i) => { + const x = 45 + i * 50, cid = uid('drClip'); + svgEl(s, 'text', { x: x + 18, y: 34, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '+'; + svgEl(s, 'text', { x: x + 18, y: 124, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '−'; + svgEl(s, 'rect', { x: x - 12, y: 24, width: 24, height: 104, rx: 4, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 }); + const clip = svgEl(localDefs, 'clipPath', { id: cid }); + svgEl(clip, 'rect', { x: x - 9, y: 28, width: 18, height: 96 }); + svgEl(s, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drPaper)' }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${cid})` }); + const nums = svgEl(g, 'g', {}); nums.style.transition = 'transform .12s'; + for (let n = 1; n <= 10; n++) + svgEl(nums, 'text', { x, y: cy + 3 + (ch.v - n) * step, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#2b2418' }).textContent = n; + svgEl(g, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drCurve)', 'pointer-events': 'none' }); + svgEl(s, 'rect', { x: x - 8, y: cy - 6.5, width: 16, height: 13, rx: 1.5, fill: '#1a1613', opacity: .92 }); + const chip = svgEl(s, 'text', { x, y: cy + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }); + svgEl(s, 'text', { x, y: 136, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name; + const hit = svgEl(s, 'rect', { x: x - 14, y: 24, width: 28, height: 104, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + grps.push({ nums, chip, base: ch.v }); + dragDelta(hit, () => chans[i].v, v => set(i, v), { min: 1, max: 10, sens: .08 }); + }); + set(0, chans[0].v); set(1, chans[1].v); + return { el: s, get: () => chans.map(c => c.v), set }; +}; + +/* R21 LED program row — exclusive select, the LED above the key carries the state */ +GW.ledRow = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const items = opts.items || ['Sm Hall B', 'VocPlate', 'Lg Hall B', 'Chamber', 'ParcPlate', 'Sm Hall A', 'Room A', 'Const Plate']; + const s = stageSvg(host, 'rsvg', 190, 58); + gradDef('lrKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#2b2823'], ['1', '#171512']]); + const leds = []; + let idx; + const set = i => { + idx = i; + leds.forEach((l, k) => { + const on = k === i; + l.setAttribute('fill', on ? 'var(--jewel-r)' : '#3a0f0a'); + l.setAttribute('style', on ? 'filter:drop-shadow(0 0 3px rgba(255,91,69,.8))' : ''); + }); + onChange(idx, `${idx + 1} · ${items[idx]}`); + }; + items.forEach((_, i) => { + const x = 16 + i * 22.6; + svgEl(s, 'text', { x, y: 9, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = i + 1; + leds.push(svgEl(s, 'circle', { cx: x, cy: 17, r: 2.4, fill: '#3a0f0a' })); + const g = svgEl(s, 'g', {}); g.style.cursor = 'pointer'; g.style.transition = 'transform .07s'; + svgEl(g, 'rect', { x: x - 8.5, y: 24, width: 17, height: 22, rx: 2.5, fill: 'url(#lrKey)', stroke: '#0a0908', 'stroke-width': 1 }); + svgEl(g, 'rect', { x: x - 6.5, y: 26.5, width: 13, height: 3, rx: 1.5, fill: 'rgba(255,255,255,.06)' }); + g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); set(i); }); + }); + svgEl(s, 'text', { x: 95, y: 56, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'PROGRAM'; + set(opts.index !== undefined ? opts.index : 5); + return { el: s, get: () => idx, set }; +}; + +/* R22 three-position slide — chrome pill between detents, honest LED pair */ +GW.pillSlide = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const positions = opts.positions || ['A', 'AB', 'B']; + const s = stageSvg(host, 'rsvg press', 110, 64), detX = [34, 55, 76]; + gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]); + svgEl(s, 'text', { x: 55, y: 10, 'text-anchor': 'middle', 'font-size': 5.5, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'BASIC · VARIATION'; + svgEl(s, 'rect', { x: 20, y: 16, width: 70, height: 14, rx: 7, fill: '#0b0a09', stroke: '#000', 'stroke-width': 1 }); + svgEl(s, 'rect', { x: 21, y: 17, width: 68, height: 5, rx: 2.5, fill: 'rgba(255,255,255,.04)' }); + const pill = svgEl(s, 'rect', { x: -13, y: 18.5, width: 26, height: 9, rx: 4.5, fill: 'url(#sfChrome)', stroke: '#4e4a42', 'stroke-width': .6 }); + pill.style.transition = 'transform .12s'; + const lbls = positions.map((lbl, i) => { + const t = svgEl(s, 'text', { x: detX[i], y: 40, 'text-anchor': 'middle', 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + t.textContent = lbl; return t; + }); + svgEl(s, 'rect', { x: 32, y: 45, width: 46, height: 11, rx: 2, fill: '#141210', stroke: '#060505' }); + const leds = [44, 66].map(x => svgEl(s, 'circle', { cx: x, cy: 50.5, r: 2.4, fill: '#3a0f0a' })); + let idx; + const set = i => { + idx = i; + pill.setAttribute('transform', `translate(${detX[i]},0)`); + lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)')); + const lit = [i === 0 || i === 1, i === 2 || i === 1]; + leds.forEach((l, k) => l.setAttribute('fill', lit[k] ? 'var(--jewel-r)' : '#3a0f0a')); + onChange(idx, positions[i]); + }; + s.addEventListener('click', e => { + const r = s.getBoundingClientRect(); + const x = (e.clientX - r.left) / r.width * 110; + set(x < 45 ? 0 : x < 66 ? 1 : 2); + }); + set(opts.index !== undefined ? opts.index : 1); + return { el: s, get: () => idx, set }; +}; + +/* R23 spun-aluminum knob — machined rings in a knurled grip, red index */ +GW.spunKnob = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52; + gradDef('spunFace', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#e8e6e0'], ['.7', '#b3b0a8'], ['1', '#8a877e']]); + for (let k = 0; k < 8; k++) { const [x, y] = polar(cx, cy, 45, -135 + k * 38.6); svgEl(s, 'circle', { cx: x, cy: y, r: 1.3, fill: 'var(--steel)' }); } + for (let k = 0; k < 22; k++) { const [x, y] = polar(cx, cy, 36, k * 16.36); svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: '#14110e' }); } + svgEl(s, 'circle', { cx, cy, r: 36, fill: '#1a1714', stroke: '#000', 'stroke-width': .8 }); + svgEl(s, 'circle', { cx, cy, r: 30, fill: 'url(#spunFace)', stroke: '#6e6b63', 'stroke-width': .8 }); + for (const r of [24, 18, 12, 6]) + svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: r % 12 === 0 ? 'rgba(0,0,0,.12)' : 'rgba(255,255,255,.3)', 'stroke-width': .6 }); + const idx = svgEl(s, 'line', { x1: cx, y1: cy - 28, x2: cx, y2: cy - 10, stroke: 'var(--fail)', 'stroke-width': 2.6, 'stroke-linecap': 'round' }); + svgEl(s, 'ellipse', { cx: cx - 9, cy: cy - 11, rx: 9, ry: 5, fill: 'rgba(255,255,255,.28)', transform: `rotate(-32,${cx - 9},${cy - 11})` }); + svgEl(s, 'text', { x: cx, y: 103, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'SPEED'; + let val; + const set = v => { + val = Math.max(0, Math.min(100, v)); + idx.setAttribute('transform', `rotate(${-135 + val / 100 * 270},${cx},${cy})`); + onChange(val, Math.round(val) + '%'); + }; + dragDelta(s, () => val, set, { min: 0, max: 100 }); + set(opts.value !== undefined ? opts.value : 62); + return { el: s, get: () => val, set }; +}; + +/* R24 stomp switch + jewel — press to engage, the jewel reports */ +GW.stompSwitch = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55; + gradDef('stompDome', 'radialGradient', { cx: '40%', cy: '30%', r: '80%' }, [['0', '#f4f2ec'], ['.55', '#b9b6ae'], ['1', '#6e6b63']]); + gradDef('stompJewel', 'radialGradient', { cx: '38%', cy: '30%', r: '80%' }, [['0', '#ffe9b0'], ['.45', 'var(--jewel-a)'], ['1', '#5c3d0c']]); + def('avGlow', d => { + const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' }); + svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 }); + }); + const glow = svgEl(s, 'circle', { cx, cy: 22, r: 14, fill: 'rgba(255,180,58,.35)', filter: 'url(#avGlow)', opacity: 0 }); + const jewel = svgEl(s, 'circle', { cx, cy: 22, r: 9, fill: '#241f1b', stroke: '#0a0908', 'stroke-width': 1.5 }); + for (let k = 0; k < 6; k++) { + const a1 = k * 60, a2 = k * 60 + 30; + const [x1, y1] = polar(cx, 22, 9, a1), [x2, y2] = polar(cx, 22, 9, a2); + svgEl(s, 'path', { d: `M ${cx} 22 L ${x1} ${y1} A 9 9 0 0 1 ${x2} ${y2} Z`, fill: k % 2 ? 'rgba(255,255,255,.09)' : 'rgba(0,0,0,.10)', 'pointer-events': 'none' }); + } + for (let k = 0; k < 20; k++) { const [x, y] = polar(cx, 72, 25, k * 18); svgEl(s, 'circle', { cx: x, cy: y, r: 2.4, fill: '#14110e' }); } + svgEl(s, 'circle', { cx, cy: 72, r: 25, fill: '#1a1714', stroke: '#000', 'stroke-width': .8 }); + const dome = svgEl(s, 'g', {}); dome.style.transition = 'transform .08s'; + svgEl(dome, 'circle', { cx, cy: 72, r: 19, fill: 'url(#stompDome)', stroke: '#55524a', 'stroke-width': .8 }); + svgEl(dome, 'ellipse', { cx: cx - 6, cy: 64, rx: 8, ry: 4.5, fill: 'rgba(255,255,255,.5)', transform: `rotate(-24,${cx - 6},64)` }); + let on; + const set = v => { + on = !!v; + jewel.setAttribute('fill', on ? 'url(#stompJewel)' : '#241f1b'); + glow.setAttribute('opacity', on ? '1' : '0'); + onChange(on, on ? 'ENGAGED' : 'bypass'); + }; + s.addEventListener('click', () => { dome.style.transform = 'translateY(1.5px)'; setTimeout(() => dome.style.transform = '', 90); set(!on); }); + set(opts.on !== undefined ? opts.on : false); + return { el: s, get: () => on, set }; +}; + +/* R27 winged gain selector — red T-bar over a dot ring, stepped detents */ +GW.wingSelector = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const steps = opts.steps || [-80, -70, -60, -50, -40, -30, -20, -10, -5, 0, 5, 10]; + const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52; + gradDef('wingRed', 'radialGradient', { cx: '40%', cy: '32%', r: '80%' }, [['0', '#d98a6f'], ['.6', 'var(--fail)'], ['1', '#6e2415']]); + steps.forEach((db, i) => { + const a = -135 + i / (steps.length - 1) * 270; + const [dx, dy] = polar(cx, cy, 38, a); + svgEl(s, 'circle', { cx: dx, cy: dy, r: 1.5, fill: 'var(--steel)' }); + if (i % 2 === 1 || db === 0 || db === 10 || db === -80) { + const [tx, ty] = polar(cx, cy, 46.5, a); + svgEl(s, 'text', { x: tx, y: ty + 2.2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = db > 0 ? '+' + db : db; + } + }); + const grp = svgEl(s, 'g', {}); grp.style.transition = 'transform .09s'; + svgEl(grp, 'rect', { x: cx - 6.5, y: cy - 30, width: 13, height: 60, rx: 6, fill: 'url(#wingRed)', stroke: '#4e150a', 'stroke-width': .8 }); + svgEl(grp, 'circle', { cx, cy, r: 15, fill: 'url(#wingRed)', stroke: '#4e150a', 'stroke-width': .8 }); + svgEl(grp, 'line', { x1: cx, y1: cy - 28, x2: cx, y2: cy - 18, stroke: 'var(--cream)', 'stroke-width': 2, 'stroke-linecap': 'round' }); + svgEl(grp, 'ellipse', { cx: cx - 4, cy: cy - 8, rx: 5, ry: 3, fill: 'rgba(255,255,255,.18)', transform: `rotate(-30,${cx - 4},${cy - 8})` }); + let idx; + const set = v => { + idx = Math.max(0, Math.min(steps.length - 1, Math.round(v))); + grp.style.transform = `rotate(${-135 + idx / (steps.length - 1) * 270}deg)`; + grp.style.transformOrigin = `${cx}px ${cy}px`; + const db = steps[idx]; + onChange(db, (db > 0 ? '+' : '') + db + ' dB'); + }; + dragDelta(s, () => idx, set, { min: 0, max: steps.length - 1, sens: .05 }); + set(opts.index !== undefined ? opts.index : 7); + return { el: s, get: () => idx, set }; +}; + +/* R28 rotary disc switch — the whole disc turns between heavy positions */ +GW.discSwitch = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const positions = opts.positions || [['OFF', -45], ['ON', 45], ['COMBINE', 135]]; + const s = stageSvg(host, 'rsvg press', 110, 116), cx = 55, cy = 58; + gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]); + gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]); + svgEl(s, 'circle', { cx, cy, r: 48, fill: '#1a1714', stroke: '#060505', 'stroke-width': 1.5 }); + for (let k = 0; k < 4; k++) { + const [x, y] = polar(cx, cy, 44, 45 + k * 90); + svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 }); + } + const lbls = positions.map(([lbl, a]) => { + const [tx, ty] = polar(cx, cy, 42, a); + const t = svgEl(s, 'text', { x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 5.8, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + t.textContent = lbl; return t; + }); + svgEl(s, 'circle', { cx, cy, r: 36, fill: 'url(#discRed)', stroke: '#4e150a', 'stroke-width': 1 }); + const grp = svgEl(s, 'g', {}); grp.style.transition = 'transform .15s'; grp.style.transformOrigin = `${cx}px ${cy}px`; + svgEl(grp, 'rect', { x: cx - 27, y: cy - 8, width: 54, height: 16, rx: 8, fill: 'url(#discRed)', stroke: '#8f3520', 'stroke-width': 1 }); + svgEl(grp, 'rect', { x: cx - 25, y: cy - 6.5, width: 50, height: 4, rx: 2, fill: 'rgba(255,255,255,.16)' }); + svgEl(grp, 'line', { x1: cx + 20, y1: cy, x2: cx + 26, y2: cy, stroke: 'var(--cream)', 'stroke-width': 2.5, 'stroke-linecap': 'round' }); + svgEl(s, 'ellipse', { cx: cx - 12, cy: cy - 14, rx: 12, ry: 6, fill: 'rgba(255,255,255,.10)', transform: `rotate(-28,${cx - 12},${cy - 14})` }); + let idx; + const set = i => { + idx = i; + grp.style.transform = `rotate(${positions[i][1]}deg)`; + lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)')); + onChange(idx, positions[i][0]); + }; + s.addEventListener('click', () => set((idx + 1) % positions.length)); + set(opts.index !== undefined ? opts.index : 1); + return { el: s, get: () => idx, set }; +}; + +/* R29 guarded toggle — guard posts + red collar mark the critical throw */ +GW.guardedToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52; + gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]); + gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]); + gradDef('chromeG', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]); + gradDef('tipG', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]); + const lblOn = svgEl(s, 'text', { x: cx, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' }); + lblOn.textContent = opts.onLabel || 'ON'; + const lblOff = svgEl(s, 'text', { x: cx, y: 96, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + lblOff.textContent = opts.offLabel || 'OFF'; + svgEl(s, 'circle', { cx, cy, r: 13, fill: 'url(#discRed)', stroke: '#4e150a', 'stroke-width': 1 }); + const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 11 * Math.cos(a)) + ',' + (cy + 11 * Math.sin(a))); } + svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#nutG)', stroke: '#2b2822' }); + svgEl(s, 'circle', { cx, cy, r: 6, fill: '#14110e', stroke: '#000' }); + for (const gx of [16, 74]) { + svgEl(s, 'rect', { x: gx - 6, y: cy - 24, width: 12, height: 48, rx: 6, fill: 'url(#nutG)', stroke: '#2b2822', 'stroke-width': 1 }); + svgEl(s, 'rect', { x: gx - 4, y: cy - 21, width: 3.5, height: 42, rx: 2, fill: 'rgba(255,255,255,.14)' }); + } + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'path', { d: `M ${cx - 3} ${cy} L ${cx - 2} 26 L ${cx + 2} 26 L ${cx + 3} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 }); + svgEl(lever, 'rect', { x: cx - 4.5, y: 16, width: 9, height: 12, rx: 2, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 }); + for (let r = 0; r < 4; r++) svgEl(lever, 'line', { x1: cx - 3.5, y1: 18.5 + r * 2.4, x2: cx + 3.5, y2: 18.5 + r * 2.4, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 }); + lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s'; + let on; + const set = v => { + on = !!v; + lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)'; + lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)'); + lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)'); + onChange(on, on ? 'ON' : 'OFF'); + }; + s.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: s, get: () => on, set }; +}; + +/* R32 mechanical timer dial — dial rotates under a fixed index; wind by drag, stop by the red knob. + Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set. */ +GW.timerDial = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 158, 132), cx = 62, cy = 58; + gradDef('mtFace', 'radialGradient', { cx: '50%', cy: '42%', r: '75%' }, [['0', '#282320'], ['1', '#14110e']]); + gradDef('mtRed', 'radialGradient', { cx: '38%', cy: '32%', r: '80%' }, [['0', '#e0523a'], ['1', '#8f2416']]); + gradDef('mtHub', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#c9c4b8'], ['1', '#6e685c']]); + /* bezel + knurled coin edge */ + svgEl(s, 'circle', { cx, cy, r: 50, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 47.5, fill: 'none', stroke: '#3a352c', 'stroke-width': 4, 'stroke-dasharray': '2 1.6' }); + svgEl(s, 'circle', { cx, cy, r: 44, fill: 'url(#mtFace)', stroke: '#0a0908', 'stroke-width': 1 }); + /* rotating scale: OFF at 0, minutes climb clockwise at 4.5 deg/min (60 at 270) */ + const rot = svgEl(s, 'g', {}); + for (let m = 0; m <= 60; m += 5) { + const major = m % 20 === 0; + const [x1, y1] = polar(cx, cy, 40, m * 4.5), [x2, y2] = polar(cx, cy, major ? 34 : 37, m * 4.5); + svgEl(rot, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': major ? 1.2 : .55, opacity: major ? .9 : .55 }); + } + for (const m of [20, 40, 60]) { + const [x, y] = polar(cx, cy, 27, m * 4.5); + svgEl(rot, 'text', { x, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = String(m); + } + const [ox, oy] = polar(cx, cy, 27, 0); + svgEl(rot, 'text', { x: ox, y: oy + 3, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#e0523a' }).textContent = 'OFF'; + const [mx, my] = polar(cx, cy, 16, 180); + svgEl(rot, 'text', { x: mx, y: my + 2.5, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'MINUTES'; + /* hub with screw slot */ + svgEl(s, 'circle', { cx, cy, r: 9, fill: 'url(#mtHub)', stroke: '#3c382f', 'stroke-width': 1 }); + svgEl(s, 'line', { x1: cx - 5.5, y1: cy, x2: cx + 5.5, y2: cy, stroke: '#4a4438', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 2.2, fill: '#8f897b' }); + /* fixed red index at top + winding-direction arrow */ + svgEl(s, 'line', { x1: cx, y1: cy - 44, x2: cx, y2: cy - 32, stroke: '#e0523a', 'stroke-width': 1.6 }); + svgEl(s, 'path', { d: 'M 122 14 A 26 26 0 0 1 136 26', fill: 'none', stroke: 'var(--steel)', 'stroke-width': 1.6 }); + svgEl(s, 'polygon', { points: '136,26 130.5,24.5 134.5,19.5', fill: 'var(--steel)' }); + /* engraved plate captions */ + const cap = (t, y) => svgEl(s, 'text', { x: 4, y, 'font-size': 6.2, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = t; + cap('PUSH TO STOP', 118); cap('TURN TO START', 128); + let min; + const set = m => { + min = Math.max(0, Math.min(60, m)); + rot.setAttribute('transform', `rotate(${(-min * 4.5).toFixed(2)},${cx},${cy})`); + onChange(min, min > 0.5 ? 'T-' + Math.round(min) + ' MIN' : 'OFF'); + }; + /* dial drag surface (under the knob in DOM so the knob wins the overlap) */ + const hit = svgEl(s, 'circle', { cx, cy, r: 50, fill: 'transparent' }); + hit.style.cursor = 'grab'; + dragDelta(hit, () => min, set, { min: 0, max: 60 }); + /* red stop knob: fluted edge, domed center */ + const knob = svgEl(s, 'g', {}); + svgEl(knob, 'circle', { cx: 126, cy: 104, r: 20, fill: 'url(#mtRed)', stroke: '#5c150c', 'stroke-width': 1.5 }); + svgEl(knob, 'circle', { cx: 126, cy: 104, r: 18, fill: 'none', stroke: 'rgba(0,0,0,.4)', 'stroke-width': 3.4, 'stroke-dasharray': '3 2.4' }); + svgEl(knob, 'circle', { cx: 126, cy: 104, r: 8.5, fill: 'url(#mtRed)', stroke: 'rgba(0,0,0,.3)', 'stroke-width': .8 }); + svgEl(knob, 'ellipse', { cx: 121, cy: 98, rx: 6, ry: 3.4, fill: 'rgba(255,255,255,.18)', transform: 'rotate(-28,121,98)' }); + knob.style.cursor = 'pointer'; + knob.addEventListener('click', () => { set(0); onChange(0, 'STOP · OFF'); }); + set(opts.minutes !== undefined ? opts.minutes : 0); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { if (min > 0) { set(min - 1); if (min === 0) onChange(0, 'DING · OFF'); } }, 1000); + return { el: s, get: () => min, set }; +}; + +/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press */ +GW.rockerPad = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55; + gradDef('rk4Pad', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#33302b'], ['1', '#141210']]); + gradDef('rk4Arr', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#e9e4d6'], ['1', '#9d988b']]); + gradDef('rk4Nub', 'radialGradient', { cx: '38%', cy: '30%', r: '85%' }, [['0', '#4a453e'], ['1', '#0d0c0a']]); + /* recessed plate + rubber pad */ + svgEl(s, 'rect', { x: 3, y: 3, width: 104, height: 104, rx: 18, fill: '#211e1a', stroke: '#0c0b09', 'stroke-width': 1.5 }); + svgEl(s, 'circle', { cx, cy, r: 45, fill: '#0f0d0b' }); + svgEl(s, 'circle', { cx, cy, r: 41, fill: 'url(#rk4Pad)', stroke: '#060505', 'stroke-width': 1 }); + /* four arrows, pointing outward */ + const ARR = { up: '55,22 45,36 65,36', down: '55,88 45,74 65,74', left: '22,55 36,45 36,65', right: '88,55 74,45 74,65' }; + const arrows = {}; + for (const [dir, pts] of Object.entries(ARR)) + arrows[dir] = svgEl(s, 'polygon', { points: pts, fill: 'url(#rk4Arr)', stroke: '#3c382f', 'stroke-width': .7 }); + /* center pivot nub */ + svgEl(s, 'circle', { cx, cy, r: 7, fill: 'url(#rk4Nub)', stroke: '#060505', 'stroke-width': .8 }); + svgEl(s, 'ellipse', { cx: cx - 2, cy: cy - 2.5, rx: 2.6, ry: 1.7, fill: 'rgba(255,255,255,.25)' }); + /* quadrant hit zones (drawn last so they win), press flash + tracked position */ + let rx = 0, ry = 0; + const press = (dir, dx, dy) => { + rx += dx; ry += dy; + onChange({ x: rx, y: ry, dir }, dir.toUpperCase() + ' · x ' + rx + ' y ' + ry); + arrows[dir].setAttribute('fill', 'var(--gold-hi)'); + setTimeout(() => arrows[dir].setAttribute('fill', 'url(#rk4Arr)'), 160); + }; + const zone = (dir, pts, dx, dy) => { + const z = svgEl(s, 'polygon', { points: pts, fill: 'transparent' }); + z.style.cursor = 'pointer'; z.addEventListener('click', () => press(dir, dx, dy)); + }; + zone('up', `${cx},${cy} 20,20 90,20`, 0, 1); + zone('down', `${cx},${cy} 20,90 90,90`, 0, -1); + zone('left', `${cx},${cy} 20,20 20,90`, -1, 0); + zone('right', `${cx},${cy} 90,20 90,90`, 1, 0); + onChange({ x: 0, y: 0, dir: null }, 'x 0 y 0'); + return { el: s, get: () => ({ x: rx, y: ry }) }; +}; + +/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state */ +GW.fourWayToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65; + gradDef('fw4Chrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e8e6e0'], ['.5', '#9a968c'], ['1', '#55524a']]); + gradDef('fw4Ball', 'radialGradient', { cx: '36%', cy: '30%', r: '80%' }, [['0', '#f2f0ea'], ['.55', '#8e8a80'], ['1', '#3c3a34']]); + /* printed panel graphics: axes, circle, diagonal square, labels */ + svgEl(s, 'line', { x1: 8, y1: cy, x2: 122, y2: cy, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .7 }); + svgEl(s, 'line', { x1: cx, y1: 8, x2: cx, y2: 122, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .7 }); + svgEl(s, 'text', { x: cx + 2, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'Y'; + svgEl(s, 'text', { x: 116, y: cy - 4, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'X'; + svgEl(s, 'circle', { cx, cy, r: 42, fill: 'none', stroke: 'var(--steel)', 'stroke-width': .9, opacity: .8 }); + const QUAD = { A: 315, B: 45, C: 135, D: 225 }; + const diag = Object.values(QUAD).map(a => polar(cx, cy, 42, a)); + svgEl(s, 'polygon', { points: diag.map(p => p.join(',')).join(' '), fill: 'none', stroke: 'var(--steel)', 'stroke-width': .8, opacity: .8 }); + const LAMP_AT = { A: [14, 14], B: [116, 14], C: [116, 116], D: [14, 116] }; + const lamps = {}; + for (const [q, a] of Object.entries(QUAD)) { + const [lx, ly] = polar(cx, cy, 42, a); + const [px, py] = LAMP_AT[q]; + svgEl(s, 'line', { x1: lx, y1: ly, x2: px, y2: py, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .5 }); + svgEl(s, 'circle', { cx: lx, cy: ly, r: 6.5, fill: '#14110e', stroke: 'var(--steel)', 'stroke-width': .9 }); + svgEl(s, 'text', { x: lx, y: ly + 2.8, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = q; + svgEl(s, 'circle', { cx: px, cy: py, r: 5.5, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 }); + lamps[q] = svgEl(s, 'circle', { cx: px, cy: py, r: 3.6, fill: '#3a3426' }); + } + /* bezel + boot */ + svgEl(s, 'circle', { cx, cy, r: 20, fill: '#0d0c0a', stroke: '#2a2722', 'stroke-width': 1.5 }); + svgEl(s, 'circle', { cx, cy, r: 15, fill: '#17140f', stroke: '#060505', 'stroke-width': 1 }); + /* lever: shaft + grip rotate to the selected diagonal; ball pivot stays centered */ + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'polygon', { points: `${cx - 3},${cy} ${cx + 3},${cy} ${cx + 2},${cy - 26} ${cx - 2},${cy - 26}`, fill: 'url(#fw4Chrome)', stroke: '#3c3a34', 'stroke-width': .6 }); + svgEl(lever, 'rect', { x: cx - 3.4, y: cy - 40, width: 6.8, height: 16, rx: 3, fill: 'url(#fw4Chrome)', stroke: '#3c3a34', 'stroke-width': .6 }); + svgEl(s, 'circle', { cx, cy, r: 8.5, fill: 'url(#fw4Ball)', stroke: '#26241f', 'stroke-width': .8 }); + svgEl(s, 'ellipse', { cx: cx - 2.6, cy: cy - 3, rx: 2.8, ry: 1.9, fill: 'rgba(255,255,255,.5)' }); + let pos = null; + const set = q => { + pos = q; + lever.setAttribute('transform', `rotate(${QUAD[q]},${cx},${cy})`); + for (const k of Object.keys(QUAD)) { + lamps[k].setAttribute('fill', k === q ? 'var(--jewel-a)' : '#3a3426'); + lamps[k].setAttribute('filter', k === q ? 'drop-shadow(0 0 4px rgba(255,180,58,.8))' : 'none'); + } + onChange(pos, 'POS ' + q); + }; + /* quadrant click zones (diagonal quadrants, drawn last) */ + const zone = (q, pts) => { + const z = svgEl(s, 'polygon', { points: pts, fill: 'transparent' }); + z.style.cursor = 'pointer'; z.addEventListener('click', () => set(q)); + }; + zone('A', `${cx},${cy} ${cx},5 5,5 5,${cy}`); + zone('B', `${cx},${cy} ${cx},5 125,5 125,${cy}`); + zone('C', `${cx},${cy} 125,${cy} 125,125 ${cx},125`); + zone('D', `${cx},${cy} ${cx},125 5,125 5,${cy}`); + set(opts.position || 'C'); + return { el: s, get: () => pos, set }; +}; + +/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many */ +GW.pinMatrix = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV']; + const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD']; + const pins = new Set(opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA']); /* a legible default patch */ + const s = stageSvg(host, 'rsvg press', 160, 110); + const X0 = 42, Y0 = 30, DX = 19, DY = 15.5; + svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + COLS.forEach((c, j) => svgEl(s, 'text', { x: X0 + j * DX, y: 16, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = c); + ROWS.forEach((r, i) => svgEl(s, 'text', { x: 34, y: Y0 + i * DY + 2, 'text-anchor': 'end', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = r); + ROWS.forEach((r, i) => COLS.forEach((c, j) => { + const cx = X0 + j * DX, cy = Y0 + i * DY, key = r + '>' + c; + svgEl(s, 'circle', { cx, cy, r: 3.4, fill: '#0a0908', stroke: '#2c2820', 'stroke-width': 1 }); + const cap = svgEl(s, 'circle', { cx, cy, r: 4.4, fill: 'var(--gold)', stroke: '#7d5c16', 'stroke-width': 1, opacity: pins.has(key) ? 1 : 0, filter: 'drop-shadow(0 1px 2px rgba(0,0,0,.6))' }); + const hit = svgEl(s, 'circle', { cx, cy, r: 8, fill: 'transparent' }); + hit.style.cursor = 'pointer'; + hit.addEventListener('click', () => { + const on = !pins.has(key); + if (on) pins.add(key); else pins.delete(key); + cap.setAttribute('opacity', on ? 1 : 0); + onChange([...pins], (on ? r + ' → ' + c : 'pulled ' + r + ' → ' + c) + ' · ' + pins.size + ' routes'); + }); + })); + onChange([...pins], pins.size + ' routes'); + return { el: s, get: () => [...pins] }; +}; + +/* R38 dead-man button — state exists only while the pointer holds it down */ +GW.deadMan = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 120, 110), cx = 60, cy = 58; + gradDef('dmBtn', 'radialGradient', { cx: '40%', cy: '32%', r: '85%' }, [['0', '#3a3631'], ['1', '#16130f']]); + svgEl(s, 'rect', { x: 4, y: 4, width: 112, height: 102, rx: 12, fill: '#211e1a', stroke: '#0c0b09', 'stroke-width': 1.5 }); + svgEl(s, 'text', { x: cx, y: 18, 'text-anchor': 'middle', 'font-size': 6.2, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.caption || 'HOLD TO RUN'; + const lamp = svgEl(s, 'circle', { cx: 104, cy: 16, r: 4.5, fill: '#1e2a12' }); + const ring = svgEl(s, 'circle', { cx, cy, r: 33, fill: 'none', stroke: '#2c2820', 'stroke-width': 3 }); + const btn = svgEl(s, 'g', {}); + svgEl(btn, 'circle', { cx, cy, r: 28, fill: 'url(#dmBtn)', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(btn, 'circle', { cx, cy, r: 22, fill: 'none', stroke: 'rgba(255,255,255,.07)', 'stroke-width': 1 }); + svgEl(btn, 'text', { x: cx, y: cy + 2.5, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--silver)' }).textContent = opts.label || 'RUN'; + btn.style.cursor = 'pointer'; + let t0 = null, iv = null; + const stop = () => { + if (t0 === null) return; + const held = ((Date.now() - t0) / 1000).toFixed(1); + t0 = null; clearInterval(iv); + btn.removeAttribute('transform'); + ring.setAttribute('stroke', '#2c2820'); ring.removeAttribute('filter'); + lamp.setAttribute('fill', '#1e2a12'); lamp.removeAttribute('filter'); + onChange(false, 'SAFE (held ' + held + 's)'); + }; + btn.addEventListener('pointerdown', e => { + btn.setPointerCapture(e.pointerId); + t0 = Date.now(); + btn.setAttribute('transform', 'translate(0,1.5)'); + ring.setAttribute('stroke', 'var(--gold)'); ring.setAttribute('filter', 'drop-shadow(0 0 5px rgba(var(--glow-lo),.7))'); + lamp.setAttribute('fill', 'var(--pass)'); lamp.setAttribute('filter', 'drop-shadow(0 0 4px rgba(116,147,47,.8))'); + onChange(true, 'RUNNING 0.0s'); + iv = setInterval(() => { if (t0 !== null) onChange(true, 'RUNNING ' + ((Date.now() - t0) / 1000).toFixed(1) + 's'); }, 100); + e.preventDefault(); + }); + btn.addEventListener('pointerup', stop); + btn.addEventListener('pointercancel', stop); + onChange(false, 'SAFE'); + return { el: s, get: () => t0 !== null }; +}; + +/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns */ +GW.telephoneDial = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 63; + const DIGITS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; + const angOf = i => (60 - i * 30 + 360) % 360; /* 1 at 60cw, counterclockwise to 0 at 150 */ + const STOP = 105; + gradDef('tdWheel', 'radialGradient', { cx: '42%', cy: '36%', r: '85%' }, [['0', '#8f8a7e'], ['1', '#4a463e']]); + svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1e1a16', stroke: '#0a0908', 'stroke-width': 1 }); + /* rotating finger wheel: ring + hole openings (dark wells punched in the metal) */ + const wheel = svgEl(s, 'g', {}); + svgEl(wheel, 'circle', { cx, cy, r: 46, fill: 'none', stroke: 'url(#tdWheel)', 'stroke-width': 17, opacity: .95 }); + DIGITS.forEach((_, i) => { + const [x, y] = polar(cx, cy, 38, angOf(i)); + svgEl(wheel, 'circle', { cx: x, cy: y, r: 7.2, fill: '#1e1a16', stroke: '#26221c', 'stroke-width': 1.4 }); + }); + /* fixed digits ON TOP so they read through the holes (they stay put while the wheel spins) */ + DIGITS.forEach((d, i) => { + const [x, y] = polar(cx, cy, 38, angOf(i)); + svgEl(s, 'text', { x, y: y + 3, 'text-anchor': 'middle', 'font-size': 8.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = d; + }); + svgEl(s, 'circle', { cx, cy, r: 16, fill: 'url(#tdWheel)', stroke: '#26221c', 'stroke-width': 1 }); + /* finger stop */ + const [fx, fy] = polar(cx, cy, 52, STOP); + svgEl(s, 'rect', { x: fx - 2.5, y: fy - 8, width: 5, height: 16, rx: 2, fill: '#b8b2a4', stroke: '#3c382f', 'stroke-width': 1, transform: `rotate(${STOP},${fx},${fy})` }); + let dialed = '', spinning = false; + const setW = a => wheel.setAttribute('transform', `rotate(${a},${cx},${cy})`); + const finish = d => { dialed = (dialed + d).slice(-10); onChange(dialed, dialed); spinning = false; }; + DIGITS.forEach((d, i) => { + const [x, y] = polar(cx, cy, 38, angOf(i)); + const hit = svgEl(s, 'circle', { cx: x, cy: y, r: 8.5, fill: 'transparent' }); + hit.style.cursor = 'pointer'; + hit.addEventListener('click', () => { + if (spinning) return; spinning = true; + const R = (STOP - angOf(i) + 360) % 360; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { setW(R); setTimeout(() => { setW(0); finish(d); }, 160); return; } + const t0 = performance.now(), Tf = R / 300 * 1000, Tb = R / 450 * 1000; + const step = t => { + const el = t - t0; + if (el < Tf) { setW(R * el / Tf); requestAnimationFrame(step); } + else if (el < Tf + 80) { setW(R); requestAnimationFrame(step); } + else if (el < Tf + 80 + Tb) { setW(R * (1 - (el - Tf - 80) / Tb)); requestAnimationFrame(step); } + else { setW(0); finish(d); } + }; + requestAnimationFrame(step); + }); + }); + onChange('', 'dial a number'); + return { el: s, get: () => dialed }; +}; + +/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step */ +GW.breakerPanel = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const NAMES = opts.names || ['MAIN', 'PUMP', 'LAMP', 'AUX']; + const s = stageSvg(host, 'rsvg press', 160, 110); + svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + const brk = []; + const draw = () => { + let closed = 0, trip = null; + brk.forEach(b => { + if (b.state === 'on') closed++; if (b.state === 'tripped') trip = b.name; + b.lamp.setAttribute('fill', b.state === 'on' ? 'var(--pass)' : '#1e2a12'); + b.lamp.setAttribute('filter', b.state === 'on' ? 'drop-shadow(0 0 3px rgba(116,147,47,.8))' : 'none'); + b.handle.setAttribute('y', b.state === 'on' ? 40 : b.state === 'tripped' ? 57 : 74); + b.collar.setAttribute('opacity', b.state === 'tripped' ? 1 : 0); + }); + onChange(brk.map(b => b.state), closed + '/' + NAMES.length + ' closed' + (trip ? ' · ' + trip + ' TRIPPED' : '')); + }; + NAMES.forEach((name, i) => { + const x = 14 + i * 30; + svgEl(s, 'rect', { x, y: 36, width: 20, height: 52, rx: 4, fill: '#14110e', stroke: '#2c2820', 'stroke-width': 1 }); + const lamp = svgEl(s, 'circle', { cx: x + 10, cy: 26, r: 4, fill: '#1e2a12' }); + const collar = svgEl(s, 'rect', { x: x + 4, y: 69, width: 12, height: 5, fill: 'var(--amber-warn)', opacity: 0 }); + const handle = svgEl(s, 'rect', { x: x + 4, y: 40, width: 12, height: 14, rx: 2, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); + svgEl(s, 'text', { x: x + 10, y: 98, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = name; + const hit = svgEl(s, 'rect', { x, y: 20, width: 20, height: 80, fill: 'transparent' }); + hit.style.cursor = 'pointer'; + const b = { name, state: i < 3 ? 'on' : 'off', lamp, collar, handle }; + hit.addEventListener('click', () => { b.state = b.state === 'on' ? 'off' : b.state === 'off' ? 'on' : 'off'; draw(); }); + brk.push(b); + }); + const tb = svgEl(s, 'g', {}); + svgEl(tb, 'rect', { x: 132, y: 40, width: 22, height: 16, rx: 4, fill: 'var(--fail)', stroke: '#5c150c', 'stroke-width': 1 }); + svgEl(tb, 'text', { x: 143, y: 50.5, 'text-anchor': 'middle', 'font-size': 6, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = 'TRIP'; + tb.style.cursor = 'pointer'; + tb.addEventListener('click', () => { for (let i = brk.length - 1; i >= 0; i--) if (brk[i].state === 'on') { brk[i].state = 'tripped'; break; } draw(); }); + draw(); + return { el: s, get: () => brk.map(b => b.state) }; +}; + +/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb */ +GW.dsky = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const LAMPS = ['UPLINK', 'TEMP', 'GIMBAL', 'PROG', 'RESTART', 'OPR ERR']; + const el = document.createElement('div'); el.className = 'dsky'; host.appendChild(el); + el.innerHTML = + `<div class="lampcol">${LAMPS.map(l => `<div class="sl" data-l="${l}">${l}</div>`).join('')}</div>` + + `<div class="right"><div class="wins">` + + `<div class="win" data-w="prog"><span class="wl">PROG</span><span class="wd"></span></div>` + + `<div class="win" data-w="verb"><span class="wl">VERB</span><span class="wd"></span></div>` + + `<div class="win" data-w="noun"><span class="wl">NOUN</span><span class="wd"></span></div>` + + `</div><div class="pad"></div></div>`; + const win = w => el.querySelector(`[data-w="${w}"]`); + const setWin = (w, txt) => { win(w).querySelector('.wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); }; + const lampEl = l => el.querySelector(`[data-l="${l}"]`); + let mode = null, vals = { prog: ' ', verb: ' ', noun: ' ' }, entry = ''; + const KEYS = ['VERB', 'NOUN', 'CLR', 'ENTR', 'RSET', '7', '8', '9', '4', '5', '6', '1', '2', '3', '0']; + const pad = el.querySelector('.pad'); + const hot = () => { el.querySelectorAll('.win').forEach(w => w.classList.toggle('hot', w.dataset.w === mode)); }; + const oprErr = () => { lampEl('OPR ERR').classList.add('on'); setTimeout(() => lampEl('OPR ERR').classList.remove('on'), 1200); onChange(vals, 'OPR ERR'); }; + const commit = () => { + if (vals.verb.trim().length < 2) { oprErr(); return; } + const v = vals.verb, n = vals.noun.trim(); + if (v === '35') { + LAMPS.forEach(l => lampEl(l).classList.add('on')); + setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('on')), 1400); + onChange(vals, 'V35 · lamp test'); + } + else if (v === '16' && n === '36') { vals.prog = '16'; setWin('prog', vals.prog); onChange(vals, 'V16 N36 · monitor clock'); } + else onChange(vals, 'V' + v + (n ? ' N' + n : '') + ' ENTR'); + vals.verb = ' '; vals.noun = ' '; entry = ''; mode = null; hot(); + }; + KEYS.forEach(k => { + const b = document.createElement('button'); b.className = 'key'; b.textContent = k; + b.addEventListener('click', () => { + if (k === 'VERB' || k === 'NOUN') { mode = k.toLowerCase(); entry = ''; vals[mode] = ' '; setWin(mode, ' '); hot(); } + else if (k === 'CLR') { if (mode) { vals[mode] = ' '; entry = ''; setWin(mode, ' '); } } + else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('on')); onChange(vals, 'RSET'); } + else if (k === 'ENTR') commit(); + else if (/\d/.test(k)) { + if (!mode) { oprErr(); return; } + entry = (entry + k).slice(0, 2); vals[mode] = entry.padEnd(2, ' '); setWin(mode, vals[mode]); + } + }); + pad.appendChild(b); + }); + vals.prog = '00'; setWin('prog', vals.prog); setWin('verb', ' '); setWin('noun', ' '); + onChange(vals, 'VERB ## ENTR'); + return { el, get: () => ({ ...vals }) }; +}; + +/* R42 cam-timer program drum — the program is a ring; the pointer self-advances through it. + Runs its own reduced-motion-gated 2 s step interval once started. */ +GW.camTimer = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const STEPS = opts.steps || ['OFF', 'FILL', 'WASH', 'WASH', 'WASH', 'RINSE', 'RINSE', 'DRAIN', 'SPIN', 'SPIN', 'FLUFF', 'COOL']; + const COLS = opts.colors || { OFF: '#3a3426', FILL: '#54677d', WASH: '#d29638', RINSE: '#46b89e', DRAIN: '#969385', SPIN: '#cb6b4d', FLUFF: '#c9b98a', COOL: '#7c99b0' }; + const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65; + svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + STEPS.forEach((st, i) => { + const a0 = i * 30 - 90, a1 = a0 + 30; + const p0o = polar(cx, cy, 52, a0 + 90), p1o = polar(cx, cy, 52, a1 + 90), p0i = polar(cx, cy, 38, a0 + 90), p1i = polar(cx, cy, 38, a1 + 90); + svgEl(s, 'path', { d: `M ${p0i[0]} ${p0i[1]} L ${p0o[0]} ${p0o[1]} A 52 52 0 0 1 ${p1o[0]} ${p1o[1]} L ${p1i[0]} ${p1i[1]} A 38 38 0 0 0 ${p0i[0]} ${p0i[1]} Z`, fill: COLS[st], opacity: .75, stroke: '#0a0908', 'stroke-width': .8 }); + const [tx, ty] = polar(cx, cy, 45, i * 30 + 15); + svgEl(s, 'text', { x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.4, 'font-weight': 700, 'font-family': 'var(--mono)', fill: st === 'OFF' ? 'var(--cream)' : '#14110e' }).textContent = st === 'OFF' ? 'OFF' : st[0]; + }); + svgEl(s, 'circle', { cx, cy, r: 34, fill: '#211e1a', stroke: '#0a0908', 'stroke-width': 1.5 }); + STEPS.forEach((st, i) => { + if (i === 0) return; const [lx, ly] = polar(cx, cy, 27, i * 30 + 15); + svgEl(s, 'text', { x: lx, y: ly + 1.8, 'text-anchor': 'middle', 'font-size': 4.2, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = st; + }); + svgEl(s, 'circle', { cx, cy, r: 17, fill: '#14110e', stroke: '#2c2820', 'stroke-width': 1.5 }); + const ptr = svgEl(s, 'polygon', { points: `${cx - 2.5},${cy} ${cx + 2.5},${cy} ${cx},${cy - 31}`, fill: 'var(--gold-hi)', filter: 'drop-shadow(0 0 3px rgba(var(--glow-lo),.7))' }); + svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); + let pos; + const set = i => { + pos = i; + ptr.setAttribute('transform', `rotate(${i * 30 + 15},${cx},${cy})`); + onChange(pos, pos === 0 ? 'OFF' : 'STEP ' + pos + ' · ' + STEPS[pos]); + }; + s.style.cursor = 'pointer'; + s.addEventListener('click', () => set((pos + 1) % STEPS.length)); + set(opts.position !== undefined ? opts.position : 0); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { if (pos > 0) { const n = (pos + 1) % STEPS.length; set(n); if (n === 0) onChange(0, 'CYCLE DONE · OFF'); } }, 2000); + return { el: s, get: () => pos, set }; +}; + +/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw */ +GW.knifeSwitch = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 130, 110); + gradDef('ksCu', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d09a5c'], ['1', '#8a5a2a']]); + const HX = 32, HY = 74; /* hinge pivot */ + svgEl(s, 'rect', { x: 6, y: 56, width: 118, height: 44, rx: 6, fill: '#22262a', stroke: '#0c0d0e', 'stroke-width': 1.5 }); + const lamp = svgEl(s, 'circle', { cx: 116, cy: 22, r: 4.5, fill: '#1e2a12' }); + svgEl(s, 'text', { x: 116, y: 36, 'text-anchor': 'middle', 'font-size': 4.8, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'LIVE'; + /* hinge post (left) and jaw clip (right), both on the base */ + svgEl(s, 'rect', { x: HX - 6, y: HY - 6, width: 12, height: 16, rx: 2, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .8 }); + svgEl(s, 'rect', { x: 96, y: HY - 9, width: 5, height: 18, rx: 1.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .7 }); + svgEl(s, 'rect', { x: 104, y: HY - 9, width: 5, height: 18, rx: 1.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .7 }); + /* blade + upright handle, hinged at (HX,HY) */ + const blade = svgEl(s, 'g', {}); + svgEl(blade, 'rect', { x: HX, y: HY - 3, width: 71, height: 6, rx: 2.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .8 }); + svgEl(blade, 'rect', { x: 88, y: HY - 26, width: 7, height: 26, rx: 3, fill: '#14110e', stroke: '#000', 'stroke-width': 1 }); + svgEl(blade, 'circle', { cx: 91.5, cy: HY - 26, r: 5, fill: '#14110e', stroke: '#000', 'stroke-width': 1 }); + svgEl(s, 'circle', { cx: HX, cy: HY, r: 3, fill: '#5c3a18' }); + blade.style.transformBox = 'view-box'; blade.style.transformOrigin = `${HX}px ${HY}px`; + blade.style.transition = 'transform .28s ease'; + let closed; + const set = v => { + closed = !!v; + blade.style.transform = closed ? 'rotate(0deg)' : 'rotate(-46deg)'; + lamp.setAttribute('fill', closed ? 'var(--pass)' : '#1e2a12'); + lamp.setAttribute('filter', closed ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' : 'none'); + onChange(closed, closed ? 'CLOSED · live' : 'OPEN · visibly dead'); + }; + s.style.cursor = 'pointer'; + s.addEventListener('click', () => set(!closed)); + set(opts.closed !== undefined ? opts.closed : true); + return { el: s, get: () => closed, set }; +}; + +/* R49 decade box — four skirted knobs, one digit each; the value is their sum */ +GW.decadeBox = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const MUL = [1000, 100, 10, 1], LBL = ['x1000', 'x100', 'x10', 'x1']; + const digs = (opts.digits || [3, 5, 0, 0]).slice(); + const s = stageSvg(host, 'rsvg', 160, 100); + svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 }); + const wins = [], knobs = []; + const total = () => digs.reduce((a, d, i) => a + d * MUL[i], 0); + const draw = () => { + wins.forEach((w, i) => w.textContent = String(digs[i])); + knobs.forEach((k, i) => k.setAttribute('transform', `rotate(${digs[i] * 36},${28 + i * 35},58)`)); + onChange(total(), total().toLocaleString('en-US') + ' Ω'); + }; + MUL.forEach((_, i) => { + const cx = 28 + i * 35, cy = 58; + svgEl(s, 'rect', { x: cx - 8, y: 16, width: 16, height: 12, rx: 2, fill: '#0a0806', stroke: '#2c2820', 'stroke-width': 1 }); + wins.push(svgEl(s, 'text', { x: cx, y: 25.5, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' })); + svgEl(s, 'circle', { cx, cy, r: 15, fill: '#211e1a', stroke: '#0a0908', 'stroke-width': 1.5 }); /* skirt */ + for (let t = 0; t < 10; t++) { + const [x1, y1] = polar(cx, cy, 14, t * 36), [x2, y2] = polar(cx, cy, 12, t * 36); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--steel)', 'stroke-width': .6, opacity: .7 }); + } + const k = svgEl(s, 'g', {}); + svgEl(k, 'circle', { cx, cy, r: 10, fill: '#2c2824', stroke: '#060505', 'stroke-width': 1 }); + svgEl(k, 'line', { x1: cx, y1: cy - 9, x2: cx, y2: cy - 4, stroke: 'var(--gold-hi)', 'stroke-width': 1.8 }); + knobs.push(k); + svgEl(s, 'text', { x: cx, y: 88, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = LBL[i]; + const hit = svgEl(s, 'circle', { cx, cy, r: 16, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragDelta(hit, () => digs[i], v => { digs[i] = Math.round(Math.max(0, Math.min(9, v))); draw(); }, { min: 0, max: 9, sens: 9 / 70 }); + }); + draw(); + return { el: s, get: () => total() }; +}; + +/* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle */ +GW.twoHandSafety = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 160, 100); + svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + const runLamp = svgEl(s, 'circle', { cx: 80, cy: 24, r: 5.5, fill: '#1e2a12' }); + svgEl(s, 'text', { x: 80, y: 40, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'CYCLE'; + const mkBtn = (cx, lbl) => { + const g = svgEl(s, 'g', {}); + svgEl(g, 'ellipse', { cx, cy: 70, rx: 19, ry: 6, fill: '#0d0b09' }); + const cap = svgEl(g, 'circle', { cx, cy: 62, r: 16, fill: '#8f2416', stroke: '#5c150c', 'stroke-width': 1.5 }); + svgEl(g, 'ellipse', { cx: cx - 5, cy: 56, rx: 6, ry: 3.4, fill: 'rgba(255,255,255,.22)' }); + svgEl(s, 'text', { x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = lbl; + g.style.cursor = 'pointer'; return { g, cap }; + }; + const L = mkBtn(32, 'LEFT PALM'), R = mkBtn(128, 'RIGHT PALM'); + let armed = null, armT = null, busy = false; + const flash = cap => { cap.setAttribute('fill', '#e0523a'); setTimeout(() => cap.setAttribute('fill', '#8f2416'), 200); }; + const fault = () => { armed = null; clearTimeout(armT); onChange('fault', 'TIE-DOWN FAULT · release and retry'); }; + const press = side => { + if (busy) return; + if (armed === null) { + armed = side; flash(side === 'L' ? L.cap : R.cap); + onChange('armed', side + ' armed · other hand within 0.5s'); + armT = setTimeout(fault, 500); return; + } + if (armed === side) { fault(); return; } /* same hand twice = tie-down */ + clearTimeout(armT); armed = null; busy = true; + flash(side === 'L' ? L.cap : R.cap); + runLamp.setAttribute('fill', 'var(--pass)'); + runLamp.setAttribute('filter', 'drop-shadow(0 0 5px rgba(116,147,47,.8))'); + onChange('running', 'CYCLE RUNNING'); + setTimeout(() => { + runLamp.setAttribute('fill', '#1e2a12'); runLamp.removeAttribute('filter'); + busy = false; onChange('ready', 'ready'); + }, 1500); + }; + L.g.addEventListener('click', () => press('L')); + R.g.addEventListener('click', () => press('R')); + onChange('ready', 'ready'); + return { el: s, press }; +}; + +/* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker */ +GW.voiceLoop = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const LOOPS = opts.loops || ['FD', 'GNC', 'ECOM', 'SURG', 'A/G', 'NET1', 'NET2', 'PAO']; + const el = document.createElement('div'); el.className = 'vloop'; host.appendChild(el); + const keys = LOOPS.map(l => { + const k = document.createElement('div'); k.className = 'vk'; + k.innerHTML = l + '<span class="bar"></span>'; k.dataset.state = '0'; el.appendChild(k); return k; + }); + /* a legible default: FD + A/G monitored, GNC talking */ + keys[0].dataset.state = '1'; keys[0].classList.add('mon'); + keys[4].dataset.state = '1'; keys[4].classList.add('mon'); + keys[1].dataset.state = '2'; keys[1].classList.add('mon', 'tlk'); + const refresh = () => { + const mon = keys.filter(k => k.dataset.state !== '0').length; + const tlk = keys.findIndex(k => k.dataset.state === '2'); + onChange(keys.map(k => k.dataset.state), mon === 0 ? 'all loops dropped' : mon + ' monitored' + (tlk >= 0 ? ' · talk ' + LOOPS[tlk] : '')); + }; + keys.forEach(k => k.addEventListener('click', () => { + const st = k.dataset.state; + if (st === '0') { k.dataset.state = '1'; k.classList.add('mon'); } + else if (st === '1') { + keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('tlk'); } }); + k.dataset.state = '2'; k.classList.add('tlk'); + } + else { k.dataset.state = '0'; k.classList.remove('mon', 'tlk', 'act'); } + refresh(); + })); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { + const t = performance.now() / 1000; + keys.forEach((k, i) => { + if (k.dataset.state === '0') { k.classList.remove('act'); return; } + k.classList.toggle('act', Math.sin(t * (1.1 + i * 0.37) + i * 2.1) > 0.55); + }); + }, 300); + refresh(); + return { el, get: () => keys.map(k => k.dataset.state) }; +}; + +/* ---- meters & gauges ---- + Tick contract: the page owns the clock and the signal; live meters expose + value-driven handles (set/push) that repaint synchronously and fire + onChange(value, text) like every other builder. Display-side state that + belongs to the instrument (peak-hold, history buffers) lives in here. */ + +/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc */ +GW.needleGauge = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let v = opts.value ?? 50; + const el = document.createElement('div'); el.className = 'gauge'; + el.innerHTML = `<div class="dial"><div class="arc"></div> + <div class="tk" style="transform:rotate(-60deg)"></div><div class="tk" style="transform:rotate(0)"></div><div class="tk" style="transform:rotate(60deg)"></div> + <div class="ndl"></div><div class="hub"></div></div><div class="gv"><span>0</span>%</div>`; + host.appendChild(el); + const ndl = el.querySelector('.ndl'), num = el.querySelector('.gv span'); + function set(nv) { + v = nv; + ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`; + num.textContent = Math.round(v); + onChange(v, 'value ' + Math.round(v) + '%'); + } + dragDelta(el, () => v, set, { min: 0, max: 100 }); + set(v); + return { el, get: () => v, set }; +}; + +/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both */ +GW.vuPair = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const n = opts.bars || 16; + const el = document.createElement('div'); el.className = 'vu'; + el.innerHTML = `<div class="vurow"><span class="ch">L</span><span class="vubar"></span></div> + <div class="vurow"><span class="ch">R</span><span class="vubar"></span></div>`; + host.appendChild(el); + const bars = el.querySelectorAll('.vubar'); + bars.forEach(b => buildBars(b, n)); + const pkL = { v: 0 }, pkR = { v: 0 }; + function paint(bar, l, pk) { + const b = bar.children, lit = Math.round(l * n); + pk.v = Math.max(lit, (pk.v || 0) - 0.4); const p = Math.round(pk.v); + for (let k = 0; k < n; k++) { + let c = k < lit ? (k >= n - 2 ? 'clip' : k >= n - 4 ? 'hot' : 'on') : ''; + if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'peak'; + b[k].className = c; + } + } + let lv = 0, rv = 0; + function set(l, r) { + lv = l; rv = r; + paint(bars[0], l, pkL); paint(bars[1], r, pkR); + onChange([l, r], 'L ' + Math.round(l * 100) + ' · R ' + Math.round(r * 100)); + } + return { el, get: () => [lv, rv], set }; +}; + +/* 12 mini 4-bar signal — compact activity meter; set(level) */ +GW.miniSig = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const el = document.createElement('span'); el.className = 'sig'; + el.innerHTML = '<i></i><i></i><i></i><i></i>'; + host.appendChild(el); + let v = 0; + function set(l) { + v = l; + const b = el.children, lit = Math.round(l * 4); + for (let k = 0; k < 4; k++) b[k].className = k < lit ? (k >= 3 ? 'clip' : k >= 2 ? 'hot' : 'on') : ''; + onChange(l, Math.round(l * 100) + '%'); + } + return { el, get: () => v, set }; +}; + +/* 13 signal ladder — stepped 0-4 strength; click cycles */ +GW.signalLadder = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let v = opts.value ?? 3; + const el = document.createElement('span'); el.className = 'ladder'; + el.innerHTML = '<i></i><i></i><i></i><i></i>'; + host.appendChild(el); + function set(nv) { + v = nv; + const bars = el.children; + for (let i = 0; i < bars.length; i++) bars[i].style.background = i < v ? 'var(--gold)' : 'var(--wash)'; + onChange(v, v + '/4'); + } + el.onclick = () => set((v + 1) % 5); + set(v); + return { el, get: () => v, set }; +}; + +/* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set */ +GW.fuelBar = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const warnAt = opts.warnAt ?? 20; + let v = opts.value ?? 50; + const el = document.createElement('div'); el.className = 'bar'; + el.innerHTML = '<span></span>'; + host.appendChild(el); + const fill = el.querySelector('span'); + function set(p) { + v = p; + fill.style.width = p + '%'; + el.classList.toggle('warn', p < warnAt); + onChange(p, Math.round(p) + '%'); + } + dragX(el, set); + set(v); + return { el, get: () => v, set }; +}; + +/* 15 radial ring — percentage donut; drag up/down to set */ +GW.radialRing = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let v = opts.value ?? 68; + const el = document.createElement('span'); el.className = 'ring'; + el.innerHTML = '<b></b>'; + host.appendChild(el); + const num = el.querySelector('b'); + function set(nv) { + v = nv; + el.style.setProperty('--p', v); + num.textContent = Math.round(v); + onChange(v, Math.round(v) + '%'); + } + dragDelta(el, () => v, set, { min: 0, max: 100 }); + set(v); + return { el, get: () => v, set }; +}; + +/* 16 sparkline — rolling history trace; push(v) appends a sample, fill(v) levels it */ +GW.sparkline = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const n = opts.samples || 40; + const hist = Array.from({ length: n }, () => opts.value ?? 0.5); + const el = document.createElement('span'); el.className = 'spark'; + el.innerHTML = '<svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg>'; + host.appendChild(el); + const line = el.querySelector('polyline'); + const clamp = x => Math.max(0, Math.min(1, x)); + function paint() { + line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 170},${44 - clamp(v) * 40 - 2}`).join(' ')); + onChange(hist[n - 1], String(Math.round(clamp(hist[n - 1]) * 100))); + } + function push(v) { hist.push(v); hist.shift(); paint(); } + function fill(v) { hist.fill(v); paint(); } + return { el, get: () => hist[n - 1], push, fill }; +}; + +/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1 */ +GW.waveStrip = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const el = document.createElement('span'); el.className = 'wave'; + el.innerHTML = '<svg viewBox="0 0 170 38" preserveAspectRatio="none"><path fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg>'; + host.appendChild(el); + const path = el.querySelector('path'); + let amp = 0; + function set(samples, a) { + amp = a; + let d = 'M0 19'; + if (samples.length < 2) d += ' L170 19'; + else for (let i = 0; i < samples.length; i++) + d += ` L${(i / (samples.length - 1) * 170).toFixed(1)} ${(19 + samples[i] * 14).toFixed(1)}`; + path.setAttribute('d', d); + onChange(amp, 'amp ' + Math.round(amp * 100) + '%'); + } + set([], opts.amp ?? 0.6); + return { el, get: () => amp, set }; +}; + +/* N11 oscilloscope — sampled phosphor trace; set(samples, vpp) with samples in -1..1 */ +GW.scope = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const el = document.createElement('span'); el.className = 'scope'; + el.innerHTML = '<span class="grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>'; + host.appendChild(el); + const line = el.querySelector('polyline'); + let vpp = 0; + function set(samples, v) { + vpp = v; + const n = samples.length; + line.setAttribute('points', samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + s * 22).toFixed(1)}`).join(' ')); + onChange(vpp, 'Vpp ' + Math.round(vpp * 100)); + } + return { el, get: () => vpp, set }; +}; + +/* N12 spectrum / EQ bars — set(values) paints one column per band */ +GW.eqBars = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const bands = opts.bands || 11, cells = opts.cells || 9; + const el = document.createElement('span'); el.className = 'eq'; + host.appendChild(el); + for (let b = 0; b < bands; b++) { + const band = document.createElement('span'); band.className = 'band'; + for (let s = 0; s < cells; s++) band.appendChild(document.createElement('i')); + el.appendChild(band); + } + let vals = []; + function set(values) { + vals = values; + let peak = 0; + for (let b = 0; b < bands; b++) { + const col = el.children[b].children, val = values[b], lit = Math.round(val * cells); + peak = Math.max(peak, val); + for (let k = 0; k < cells; k++) { + let c = ''; + if (k < lit) c = (k >= cells - 1 ? 'clip' : k >= cells - 3 ? 'hot' : 'on'); + col[k].className = c; + } + } + onChange(vals, 'peak ' + Math.round(peak * 100) + '%'); + } + return { el, get: () => vals, set }; +}; + +/* N13 crossed-needle meter — one drive value, FWD and RFL needles cross */ +GW.crossNeedle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let v = opts.value ?? 55; + const el = document.createElement('div'); el.className = 'crossm'; + el.innerHTML = `<div class="face"><div class="arc"></div><div class="nA"></div><div class="nB"></div></div> + <div class="lbl"><span>FWD</span><span>RFL</span></div>`; + host.appendChild(el); + const face = el.querySelector('.face'), nA = el.querySelector('.nA'), nB = el.querySelector('.nB'); + function set(nv) { + v = nv; + const fwd = v, rfl = v * 0.68; + nA.style.transform = `rotate(${-42 + fwd / 100 * 84}deg)`; + nB.style.transform = `rotate(${42 - rfl / 100 * 84}deg)`; + onChange(v, 'FWD ' + Math.round(fwd) + ' · RFL ' + Math.round(rfl)); + } + dragDelta(face, () => v, set, { min: 0, max: 100 }); + set(v); + return { el, get: () => v, set }; +}; + +/* N14 thermometer column — mercury rises with the value */ +GW.thermometer = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let v = opts.value ?? 58; + const el = document.createElement('div'); el.className = 'thermo'; + el.innerHTML = `<div class="scale"><span>90</span><span>60</span><span>30</span></div> + <div class="wrapcol"><div class="tube"><div class="fill"></div></div><div class="bulb"></div></div>`; + host.appendChild(el); + const fill = el.querySelector('.fill'); + function set(nv) { + v = nv; + fill.style.height = v + '%'; + onChange(v, Math.round(30 + v / 100 * 60) + '°'); + } + dragDelta(el, () => v, set, { min: 0, max: 100 }); + set(v); + return { el, get: () => v, set }; +}; + +/* N15 bourdon pressure gauge — needle over a printed arc with a redline */ +GW.bourdon = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let v = opts.value ?? 45; + const el = document.createElement('div'); el.className = 'bourdon'; + el.innerHTML = `<svg viewBox="0 0 82 82"><path d="M14 62 A34 34 0 0 1 68 62" fill="none" stroke="#2c2f32" stroke-width="3"/> + <path d="M52 22 A34 34 0 0 1 68 62" fill="none" stroke="#cb6b4d" stroke-width="3"/></svg> + <div class="ndl"></div><div class="hub"></div><div class="cap">PSI</div>`; + host.appendChild(el); + const ndl = el.querySelector('.ndl'); + function set(nv) { + v = nv; + ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`; + onChange(v, Math.round(v / 100 * 160) + ' PSI'); + } + dragDelta(el, () => v, set, { min: 0, max: 100 }); + set(v); + return { el, get: () => v, set }; +}; + +/* N16 strip-chart recorder — scrolling history, pen rides the newest value; + push(v) appends, set(samples, current) replaces the trace (values 0..1) */ +GW.stripChart = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const n = opts.samples || 60; + const hist = Array.from({ length: n }, () => opts.value ?? 0.5); + const el = document.createElement('span'); el.className = 'strip'; + el.innerHTML = '<span class="rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline/></svg><span class="pen" style="top:31px"></span>'; + host.appendChild(el); + const line = el.querySelector('polyline'), pen = el.querySelector('.pen'); + const clamp = x => Math.max(0, Math.min(1, x)); + const yPx = v => 59 - clamp(v) * 56; + function paint(current) { + line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 176},${yPx(v).toFixed(1)}`).join(' ')); + pen.style.top = yPx(current) + 'px'; + onChange(current, String(Math.round(clamp(current) * 100))); + } + function push(v) { hist.push(v); hist.shift(); paint(v); } + function set(samples, current) { + for (let i = 0; i < n; i++) hist[i] = samples[i] ?? 0.5; + paint(current ?? hist[n - 1]); + } + return { el, get: () => hist[n - 1], push, set }; +}; + +/* N17 correlation meter — needle rests at 0 and swings ±; drag left/right */ +GW.corrMeter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let p = opts.value ?? 58; + const el = document.createElement('div'); el.className = 'corr'; + el.innerHTML = `<div class="face"><div class="arc"></div><div class="zero"></div><div class="ndl"></div></div> + <div class="lbl"><span>−1</span><span>0</span><span>+1</span></div>`; + host.appendChild(el); + const ndl = el.querySelector('.ndl'); + function set(np) { + p = np; + const v = (p - 50) / 50; + ndl.style.transform = `rotate(${v * 38}deg)`; + onChange(v, (v >= 0 ? '+' : '') + v.toFixed(2)); + } + dragX(el, set); + set(p); + return { el, get: () => p, set }; +}; + +/* N18 battery-cell gauge — charge as discrete cells, the low end warns */ +GW.battCells = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const cells = opts.cells || 8, warnAt = opts.warnAt ?? 25; + let p = opts.value ?? 62; + const el = document.createElement('div'); el.className = 'batt'; + el.innerHTML = '<div class="cells"></div><span class="nub"></span>'; + host.appendChild(el); + const cc = el.querySelector('.cells'); + for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'cell'; cc.appendChild(c); } + function set(np) { + p = np; + const lit = Math.round(p / 100 * cells); + for (let i = 0; i < cells; i++) cc.children[i].className = 'cell' + (i < lit ? ' on' : '') + ((p <= warnAt && i < lit) ? ' warn' : ''); + onChange(p, Math.round(p) + '%'); + } + dragX(el, set); + set(p); + return { el, get: () => p, set }; +}; + +/* R01 moving-coil VU — pivot below the face, authentic nonlinear dB scale; + set(t) positions instantly (0..1.02); ballistics belong to the signal owner */ +GW.mcVu = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 150, 96); + const cx = 75, cy = 112, sweep = t => -43 + t * 86; + const glassId = uid('vuGlass'), clipId = uid('vuFace'); + const defs = svgEl(s, 'defs', {}); + const g1 = svgEl(defs, 'linearGradient', { id: glassId, x1: 0, y1: 0, x2: 0, y2: 1 }); + svgEl(g1, 'stop', { offset: '0', 'stop-color': 'rgba(255,255,255,.13)' }); + svgEl(g1, 'stop', { offset: '.35', 'stop-color': 'rgba(255,255,255,0)' }); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3 }); + svgEl(s, 'rect', { x: .5, y: .5, width: 149, height: 95, rx: 7, fill: '#16130f', stroke: '#060505' }); + svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: 'var(--cream)' }); + const face = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const arcPt = t => polar(cx, cy, 58, sweep(t)); + const [ax1, ay1] = arcPt(0), [ax2, ay2] = arcPt(1); + svgEl(face, 'path', { d: `M ${ax1} ${ay1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.2 }); + const [rx1, ry1] = arcPt(.75); + svgEl(face, 'path', { d: `M ${rx1} ${ry1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3.5 }); + for (const [db, t] of VUDB) { + const a = sweep(t), [x1, y1] = polar(cx, cy, 55, a), [x2, y2] = polar(cx, cy, 63, a); + svgEl(face, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1.1 }); + } + for (const [db, t] of VUDB) { + if (![-20, -10, -5, -3, 0, 1, 2, 3].includes(db)) continue; + const [x, y] = polar(cx, cy, 69, sweep(t)); + svgEl(face, 'text', { + x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.8, 'font-family': 'var(--mono)', + fill: db > 0 ? 'var(--fail)' : '#3a3128' + }).textContent = Math.abs(db); + } + svgEl(face, 'text', { + x: 75, y: 78, 'text-anchor': 'middle', 'font-size': 11, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#3a3128' + }).textContent = 'VU'; + const needle = svgEl(face, 'line', { + x1: cx, y1: cy, x2: cx, y2: cy - 62, stroke: '#1a1613', 'stroke-width': 1.6, + transform: `rotate(${sweep(.35)},${cx},${cy})` + }); + svgEl(face, 'circle', { cx, cy: 88, r: 9, fill: '#16130f' }); + svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: `url(#${glassId})` }); + let t = opts.value ?? .35; + function set(nt) { + t = Math.max(0, Math.min(1.02, nt)); + needle.setAttribute('transform', `rotate(${sweep(Math.min(1, t))},${cx},${cy})`); + const db = vuDb(Math.min(1, t)); + onChange(t, (db > 0 ? '+' : '') + db.toFixed(1) + ' VU'); + } + return { el: s, get: () => t, set }; +}; + +/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down */ +GW.roundMeter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 104); + const cx = 55, fy = 54, py = 74, sweep = t => -40 + t * 80; + const clipId = uid('rndFace'); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'circle', { cx, cy: fy, r: 40 }); + svgEl(s, 'circle', { cx, cy: fy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy: fy, r: 40, fill: 'var(--cream)' }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const pt = (r, t) => polar(cx, py, r, sweep(t)); + const [ax1, ay1] = pt(44, 0), [ax2, ay2] = pt(44, 1); + svgEl(g, 'path', { d: `M ${ax1} ${ay1} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.1 }); + const [rzx, rzy] = pt(44, .75); + svgEl(g, 'path', { d: `M ${rzx} ${rzy} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3 }); + for (const [db, t] of VUDB) { + const [x1, y1] = pt(41.5, t), [x2, y2] = pt(47, t); + svgEl(g, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1 }); + } + for (const [db, t] of VUDB) { + if (![-20, -10, -5, -3, 0, 3].includes(db)) continue; + const [x, y] = pt(52, t); + svgEl(g, 'text', { + x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', + fill: db > 0 ? 'var(--fail)' : '#3a3128' + }).textContent = Math.abs(db); + } + svgEl(g, 'text', { + x: cx, y: fy + 14, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#3a3128' + }).textContent = 'dB'; + const needle = svgEl(g, 'line', { + x1: cx, y1: py, x2: cx, y2: py - 46, stroke: '#1a1613', 'stroke-width': 1.5, + transform: `rotate(0,${cx},${py})` + }); + svgEl(g, 'circle', { cx, cy: py, r: 5, fill: '#141110' }); + svgEl(s, 'ellipse', { cx: cx - 8, cy: fy - 22, rx: 20, ry: 9, fill: 'rgba(255,255,255,.07)', transform: `rotate(-18,${cx - 8},${fy - 22})` }); + let v = opts.value ?? 50; + function set(nv) { + v = nv; + const t = Math.max(0, Math.min(1, v / 100)); + needle.setAttribute('transform', `rotate(${-40 + t * 80},${cx},${py})`); + const db = vuDb(t); + onChange(v, (db > 0 ? '+' : '') + db.toFixed(1) + ' dB'); + } + dragDelta(s, () => v, set, { min: 0, max: 100 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome */ +GW.chromeMinMax = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 104); + const cx = 55, cy = 58; + gradDef('chrRing', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#eceef0'], ['.5', '#9aa0a8'], ['1', '#54585e']]); + gradDef('chrDome', 'radialGradient', { cx: '48%', cy: '20%', r: '85%' }, [['0', '#e6e8ec'], ['1', '#82878f']]); + svgEl(s, 'circle', { cx, cy: 54, r: 47, fill: 'url(#chrRing)' }); + svgEl(s, 'circle', { cx, cy: 54, r: 40, fill: '#101214', stroke: '#2c2f33', 'stroke-width': 1 }); + svgEl(s, 'path', { d: `M 21 60 A 34 34 0 0 1 89 60 Z`, fill: 'url(#chrDome)', stroke: '#55595f', 'stroke-width': .5 }); + for (const r of [14, 22, 30]) + svgEl(s, 'path', { d: `M ${cx - r} 60 A ${r} ${r} 0 0 1 ${cx + r} 60`, fill: 'none', stroke: 'rgba(255,255,255,.13)', 'stroke-width': .7 }); + const ptr = svgEl(s, 'line', { x1: cx, y1: cy - 10, x2: cx, y2: cy - 31, stroke: '#17191b', 'stroke-width': 2.2, 'stroke-linecap': 'round' }); + svgEl(s, 'circle', { cx, cy, r: 6, fill: '#23262a' }); + for (let k = 0; k < 8; k++) { + const [x, y] = polar(cx, cy, 6, k * 45); + svgEl(s, 'circle', { cx: x, cy: y, r: 1.3, fill: '#23262a' }); + } + svgEl(s, 'circle', { cx, cy, r: 1.6, fill: '#0c0d0e' }); + svgEl(s, 'text', { + x: 24, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em', + 'font-family': 'var(--mono)', fill: '#d7dade' + }).textContent = 'MIN'; + svgEl(s, 'text', { + x: 86, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em', + 'font-family': 'var(--mono)', fill: '#d7dade' + }).textContent = 'MAX'; + svgEl(s, 'text', { + x: cx, y: 84, 'text-anchor': 'middle', 'font-size': 7, 'font-style': 'italic', + 'font-family': 'var(--mono)', fill: '#787c82' + }).textContent = 'dupre.'; + svgEl(s, 'ellipse', { cx: 47, cy: 34, rx: 22, ry: 9, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-16,47,34)' }); + let v = opts.value ?? 50; + function set(nv) { + v = Math.max(0, Math.min(100, nv)); + ptr.setAttribute('transform', `rotate(${-55 + v / 100 * 110},${cx},${cy})`); + onChange(v, v <= 2 ? 'MIN' : v >= 98 ? 'MAX' : Math.round(v) + '%'); + } + dragDelta(s, () => v, set, { min: 0, max: 100 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme */ +GW.aviationGauge = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 104); + const cx = 55, cy = 52, sweep = t => -135 + t * 270; + def('avGlow', d => { + const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' }); + svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 }); + }); + svgEl(s, 'circle', { cx, cy, r: 47, fill: '#1b1917', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 41, fill: '#0a0908', stroke: '#3a352c', 'stroke-width': 1 }); + const zone = (t1, t2, color) => { + const [x1, y1] = polar(cx, cy, 37, sweep(t1)), [x2, y2] = polar(cx, cy, 37, sweep(t2)); + svgEl(s, 'path', { d: `M ${x1} ${y1} A 37 37 0 ${(t2 - t1) > .5 ? 1 : 0} 1 ${x2} ${y2}`, fill: 'none', stroke: color, 'stroke-width': 3, opacity: .85 }); + }; + zone(.15, .6, 'var(--pass)'); zone(.6, .85, 'var(--gold)'); zone(.85, 1, 'var(--fail)'); + for (let i = 0; i <= 40; i++) { + const t = i / 40, major = i % 5 === 0, a = sweep(t); + const [x1, y1] = polar(cx, cy, 40, a), [x2, y2] = polar(cx, cy, major ? 32 : 36, a); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--gold)', 'stroke-width': major ? 1.6 : .8, opacity: major ? 1 : .7 }); + if (major) { + const [nx, ny] = polar(cx, cy, 25, a); + svgEl(s, 'text', { + x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 6.5, 'font-family': 'var(--mono)', + fill: 'var(--gold-hi)' + }).textContent = i / 5; + } + } + svgEl(s, 'text', { + x: cx, y: cy + 16, 'text-anchor': 'middle', 'font-size': 5.5, 'letter-spacing': '.14em', + 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .8 + }).textContent = '×100 RPM'; + const needle = svgEl(s, 'g', {}); + svgEl(needle, 'line', { + x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 4, + 'stroke-linecap': 'round', opacity: .5, filter: 'url(#avGlow)' + }); + svgEl(needle, 'line', { + x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 2.2, + 'stroke-linecap': 'round' + }); + svgEl(s, 'circle', { cx, cy, r: 5, fill: '#1b1917', stroke: '#3a352c' }); + svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 24, rx: 18, ry: 8, fill: 'rgba(255,255,255,.05)', transform: `rotate(-18,${cx - 10},${cy - 24})` }); + let v = opts.value ?? 52; + function set(nv) { + v = Math.max(0, Math.min(100, nv)); + needle.setAttribute('transform', `rotate(${-135 + v / 100 * 270},${cx},${cy})`); + onChange(v, (v / 100 * 8).toFixed(1) + ' ×100 rpm'); + } + dragDelta(s, () => v, set, { min: 0, max: 100 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R13 edgewise strip meter — compressed log scale, the bar rides the edge */ +GW.edgeMeter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const SCALE = [[0, .08], [3, .26], [6, .44], [12, .62], [20, .78], [40, .94]]; + const s = stageSvg(host, 'rsvg drag', 70, 130); + const wy0 = 12, wy1 = 118; + const faceId = uid('edgeFace'); + const defs = svgEl(s, 'defs', {}); + const pg = svgEl(defs, 'linearGradient', { id: faceId, x1: 0, y1: 0, x2: 1, y2: 0 }); + svgEl(pg, 'stop', { offset: '0', 'stop-color': '#efe6ca' }); + svgEl(pg, 'stop', { offset: '1', 'stop-color': '#d9cfae' }); + svgEl(s, 'rect', { x: 8, y: 6, width: 54, height: 118, rx: 8, fill: '#171412', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: wy1 - wy0, fill: `url(#${faceId})`, stroke: '#0a0908' }); + const yOf = f => wy0 + f * (wy1 - wy0); + for (const [db, f] of SCALE) { + const y = yOf(f); + svgEl(s, 'line', { x1: 19, y1: y, x2: 40, y2: y, stroke: '#3a3128', 'stroke-width': 1 }); + svgEl(s, 'text', { + x: 52, y: y + 2.4, 'text-anchor': 'end', 'font-size': 7, 'font-family': 'var(--mono)', + fill: '#3a3128' + }).textContent = db; + } + for (let i = 0; i < SCALE.length - 1; i++) { + const y = yOf((SCALE[i][1] + SCALE[i + 1][1]) / 2); + svgEl(s, 'line', { x1: 19, y1: y, x2: 32, y2: y, stroke: '#3a3128', 'stroke-width': .6, opacity: .7 }); + } + const bar = svgEl(s, 'polygon', { points: '56,0 24,-1.4 24,1.4', fill: '#1a1613' }); + svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: 24, fill: 'rgba(255,255,255,.08)' }); + let frac = opts.value ?? .62; + function set(f) { + frac = Math.max(.08, Math.min(.94, f)); + bar.setAttribute('transform', `translate(0,${wy0 + frac * (wy1 - wy0)})`); + let i = 0; while (i < SCALE.length - 2 && SCALE[i + 1][1] < frac) i++; + const [d1, f1] = SCALE[i], [d2, f2] = SCALE[i + 1]; + const db = d1 + (d2 - d1) * Math.max(0, Math.min(1, (frac - f1) / (f2 - f1))); + onChange(frac, '−' + db.toFixed(1) + ' dB'); + } + dragY(s, pct => set(.08 + (100 - pct) / 100 * .86)); + set(frac); + return { el: s, get: () => frac, set }; +}; + +/* R17 round CRT scope — pale phosphor face; the trace is the widget's own + animation, so it lives here with its reduced-motion gate */ +GW.roundCrt = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 110, 104); + const cx = 55, cy = 52; + const faceId = uid('rcrtFace'), clipId = uid('rcrtClip'); + gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]); + def('avGlow', d => { + const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' }); + svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 }); + }); + /* the face gradient reads screen-family vars from this widget's subtree, + so it must live in the local defs, not the shared def sink */ + const defs = svgEl(s, 'defs', {}); + const ph = svgEl(defs, 'radialGradient', { id: faceId, cx: '50%', cy: '44%', r: '70%' }); + svgEl(ph, 'stop', { offset: '0', 'stop-color': 'var(--crt-face1,#b9d8c0)' }); + svgEl(ph, 'stop', { offset: '1', 'stop-color': 'var(--crt-face2,#8bb296)' }); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'circle', { cx, cy, r: 38 }); + svgEl(s, 'circle', { cx, cy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 }); + for (let k = 0; k < 4; k++) { + const [x, y] = polar(cx, cy, 43, 45 + k * 90); + svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 }); + } + svgEl(s, 'circle', { cx, cy, r: 38, fill: `url(#${faceId})` }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + for (let i = -4; i <= 4; i++) { + svgEl(g, 'line', { x1: cx + i * 9, y1: cy - 38, x2: cx + i * 9, y2: cy + 38, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 }); + svgEl(g, 'line', { x1: cx - 38, y1: cy + i * 9, x2: cx + 38, y2: cy + i * 9, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 }); + } + const glow = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: 'var(--crt-glow,#eef7ee)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' }); + const trace = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: '#f4fcf4', 'stroke-width': 1.3 }); + svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 16, rx: 20, ry: 10, fill: 'rgba(255,255,255,.10)', transform: `rotate(-20,${cx - 10},${cy - 16})` }); + let tph = 0; + function tick() { + tph += 0.12; + const amp = 8 + 3 * Math.sin(tph * 0.35); + let pts = ''; + for (let x = -38; x <= 38; x += 2) { + const y = Math.sin(x * 0.55 + tph * 2.5) * amp; + pts += `${cx + x},${(cy + y).toFixed(1)} `; + } + trace.setAttribute('points', pts.trim()); + glow.setAttribute('points', pts.trim()); + onChange(amp, 'Vpp ' + Math.round(amp * 2 * 4.5)); + } + tick(); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 80); + return { el: s, tick }; +}; + +/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */ +GW.attitudeIndicator = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 130, 130); + const cx = 65, cy = 65; + const clipId = uid('aiClip'); + gradDef('aiSky', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#54677d'], ['1', '#3a4c60']]); + gradDef('aiGnd', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#5c4630'], ['1', '#3c2e1e']]); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'circle', { cx, cy, r: 48 }); + svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const ball = svgEl(g, 'g', {}); + svgEl(ball, 'rect', { x: cx - 90, y: cy - 180, width: 180, height: 180, fill: 'url(#aiSky)' }); + svgEl(ball, 'rect', { x: cx - 90, y: cy, width: 180, height: 180, fill: 'url(#aiGnd)' }); + svgEl(ball, 'line', { x1: cx - 90, y1: cy, x2: cx + 90, y2: cy, stroke: '#f3e7c5', 'stroke-width': 1.4 }); + for (const p of [-20, -10, 10, 20]) { + const y = cy - p * 1.6, w = p % 20 === 0 ? 16 : 10; + svgEl(ball, 'line', { x1: cx - w, y1: y, x2: cx + w, y2: y, stroke: '#f3e7c5', 'stroke-width': .8, opacity: .85 }); + svgEl(ball, 'text', { + x: cx + w + 3, y: y + 2, 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#f3e7c5', opacity: .85 + }).textContent = String(Math.abs(p)); + } + /* fixed bank scale + pointer + miniature aircraft */ + for (const b of [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60]) { + const [x1, y1] = polar(cx, cy, 47, b), [x2, y2] = polar(cx, cy, b % 30 === 0 ? 42 : 44, b); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': b === 0 ? 1.6 : .9, opacity: .9 }); + } + svgEl(s, 'polygon', { points: `${cx - 3.5},${cy - 38} ${cx + 3.5},${cy - 38} ${cx},${cy - 44}`, fill: 'var(--gold-hi)' }); + svgEl(s, 'path', { + d: `M ${cx - 22} ${cy} L ${cx - 7} ${cy} L ${cx - 4} ${cy + 4} M ${cx + 22} ${cy} L ${cx + 7} ${cy} L ${cx + 4} ${cy + 4}`, + fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round' + }); + svgEl(s, 'circle', { cx, cy, r: 2, fill: 'var(--gold-hi)' }); + let bank = 0, pitch = 0; + function set(b, p) { + bank = Math.max(-60, Math.min(60, b)); pitch = Math.max(-20, Math.min(20, p)); + ball.setAttribute('transform', `rotate(${(-bank).toFixed(1)},${cx},${cy}) translate(0,${(pitch * 1.6).toFixed(1)})`); + onChange({ bank, pitch }, 'BANK ' + (bank < 0 ? 'L' : bank > 0 ? 'R' : '') + Math.abs(Math.round(bank)) + + ' · PITCH ' + (pitch >= 0 ? '+' : '-') + String(Math.abs(Math.round(pitch))).padStart(2, '0')); + } + s.style.touchAction = 'none'; s.style.cursor = 'move'; + s.addEventListener('pointerdown', e => { + s.setPointerCapture(e.pointerId); + const x0 = e.clientX, y0 = e.clientY, b0 = bank, p0 = pitch; + const mv = ev => set(b0 + (ev.clientX - x0) * 0.4, p0 + (ev.clientY - y0) * 0.25); + const up = () => { s.removeEventListener('pointermove', mv); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); }; + s.addEventListener('pointermove', mv); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up); e.preventDefault(); + }); + set(opts.bank ?? 0, opts.pitch ?? 0); + return { el: s, get: () => ({ bank, pitch }), set }; +}; + +/* R44 heading bug + servo needle — drag parks the command; the needle chases + with honest servo lag (widget-owned animation, reduced-motion gated) */ +GW.headingBug = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 130, 130); + const cx = 65, cy = 65; + svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 }); + for (let d = 0; d < 360; d += 10) { + const maj = d % 30 === 0; + const [x1, y1] = polar(cx, cy, 48, d), [x2, y2] = polar(cx, cy, maj ? 42 : 45, d); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': maj ? 1 : .5, opacity: maj ? .9 : .5 }); + } + for (let d = 0; d < 360; d += 30) { + const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : String(d / 10); + const [x, y] = polar(cx, cy, 35, d); + svgEl(s, 'text', { + x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': d % 90 === 0 ? 8 : 6.4, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: d % 90 === 0 ? 'var(--cream)' : 'var(--steel)' + }).textContent = lbl; + } + svgEl(s, 'line', { x1: cx, y1: cy - 56, x2: cx, y2: cy - 48, stroke: 'var(--fail)', 'stroke-width': 2 }); + const bug = svgEl(s, 'path', { + d: `M ${cx - 6} ${cy - 49} L ${cx - 6} ${cy - 44} L ${cx - 2.5} ${cy - 44} L ${cx} ${cy - 47} L ${cx + 2.5} ${cy - 44} L ${cx + 6} ${cy - 44} L ${cx + 6} ${cy - 49} Z`, + fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': .7 + }); + const needle = svgEl(s, 'g', {}); + svgEl(needle, 'polygon', { + points: `${cx - 2},${cy + 10} ${cx + 2},${cy + 10} ${cx + 1.2},${cy - 40} ${cx},${cy - 44} ${cx - 1.2},${cy - 40}`, + fill: '#bfc4d0', stroke: '#4a4e58', 'stroke-width': .6 + }); + svgEl(s, 'circle', { cx, cy, r: 4.5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); + let cmd = opts.value ?? 90, act = cmd; + const draw = () => { + bug.setAttribute('transform', `rotate(${cmd.toFixed(1)},${cx},${cy})`); + needle.setAttribute('transform', `rotate(${act.toFixed(1)},${cx},${cy})`); + onChange({ cmd, act }, 'CMD ' + String(Math.round((cmd % 360 + 360) % 360)).padStart(3, '0') + + ' · ACT ' + String(Math.round((act % 360 + 360) % 360)).padStart(3, '0')); + }; + function set(v) { cmd = (v % 360 + 360) % 360; draw(); } + dragDelta(s, () => cmd, set, { min: -100000, max: 100000, sens: 1.2 }); + draw(); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { + const diff = ((cmd - act + 540) % 360) - 180; + if (Math.abs(diff) < 0.4) { if (act !== cmd) { act = cmd; draw(); } return; } + act = (act + diff * 0.07 + 360) % 360; draw(); + }, 80); + return { el: s, get: () => ({ cmd, act }), set }; +}; + +/* R53 circular chart recorder — a day per revolution; the pen draws the cycle + (widget-owned clock, reduced-motion paints the full day once); click for fresh paper */ +GW.chartRecorder = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 130, 130); + const cx = 65, cy = 65; + svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 54, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': .8 }); + for (const r of [20, 31, 42]) + svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: '#b8ae98', 'stroke-width': .5 }); + for (let h = 0; h < 24; h++) { + const a = h * 15; + const [x1, y1] = polar(cx, cy, 54, a), [x2, y2] = polar(cx, cy, h % 6 === 0 ? 12 : 48, a); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#b8ae98', 'stroke-width': h % 6 === 0 ? .6 : .35, opacity: .8 }); + if (h % 6 === 0) { + const [tx, ty] = polar(cx, cy, 50, a + 5); + svgEl(s, 'text', { + x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#6e6552' + }).textContent = String(h).padStart(2, '0'); + } + } + const trace = svgEl(s, 'path', { d: '', fill: 'none', stroke: '#8f2416', 'stroke-width': 1.1, 'stroke-linejoin': 'round' }); + const pen = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 54, stroke: '#4a463e', 'stroke-width': 1 }); + const penDot = svgEl(s, 'circle', { cx, cy: cy - 30, r: 2, fill: '#8f2416' }); + svgEl(s, 'circle', { cx, cy, r: 4, fill: '#4a463e' }); + const val = h => 52 + 30 * Math.sin(h / 24 * 2 * Math.PI - 2.1) + 12 * Math.sin(h / 24 * 6 * Math.PI) + 4 * Math.sin(h * 2.7); + const rOf = v => 12 + Math.max(0, Math.min(100, v)) * 0.36; + let pts = [], hour = 0; + const draw = () => { + const a = hour * 15, r = rOf(val(hour)); + pts.push(polar(cx, cy, r, a)); + if (pts.length > 1) trace.setAttribute('d', 'M ' + pts.map(p => p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' L ')); + pen.setAttribute('transform', `rotate(${a},${cx},${cy})`); + const [px, py] = polar(cx, cy, r, a); + penDot.setAttribute('cx', px); penDot.setAttribute('cy', py); + onChange(hour, String(Math.floor(hour)).padStart(2, '0') + ':' + (hour % 1 >= 0.5 ? '30' : '00') + ' · ' + Math.round(val(hour)) + '%'); + }; + const reset = () => { pts = []; hour = 0; trace.setAttribute('d', ''); draw(); }; + s.style.cursor = 'pointer'; + s.addEventListener('click', reset); + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { + for (hour = 0; hour < 24; hour += 0.25) draw(); + hour = 23.75; + } else { + reset(); + setInterval(() => { hour += 0.25; if (hour >= 24) { pts = []; hour = 0; } draw(); }, 375); + } + return { el: s, get: () => hour, reset }; +}; + +/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive */ +GW.verticalTape = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 70, 130); + const CX = 38, CY = 65; + const MIN = 5, MAX = 35, PPU = 5.5; /* rpm x100, px per unit */ + const clipId = uid('vtClip'); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4 }); + svgEl(s, 'rect', { x: 2, y: 2, width: 66, height: 126, rx: 7, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4, fill: '#0d0c0a', stroke: '#2c2820', 'stroke-width': 1 }); + const tapeG = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const tape = svgEl(tapeG, 'g', {}); + for (let u = MIN; u <= MAX; u++) { + const y = -u * PPU; /* higher value further up the tape */ + const maj = u % 5 === 0; + svgEl(tape, 'line', { x1: maj ? 20 : 26, y1: y, x2: 34, y2: y, stroke: 'var(--cream)', 'stroke-width': maj ? 1.1 : .55, opacity: maj ? .95 : .6 }); + if (maj) svgEl(tape, 'text', { + x: 48, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: 'var(--cream)' + }).textContent = String(u); + } + /* fixed index: side pointer + line across the window at center height */ + svgEl(s, 'polygon', { points: `8,${CY} 15,${CY - 4} 15,${CY + 4}`, fill: 'var(--gold-hi)' }); + svgEl(s, 'line', { x1: 15, y1: CY, x2: 62, y2: CY, stroke: 'var(--gold-hi)', 'stroke-width': 1, opacity: .85 }); + svgEl(s, 'text', { + x: CX, y: 126, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.1em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'RPM x100'; + let v = opts.value ?? 24; + function set(nv) { + v = Math.max(MIN, Math.min(MAX, nv)); + tape.setAttribute('transform', `translate(0,${(CY + v * PPU).toFixed(1)})`); + onChange(v, 'RPM ' + Math.round(v * 100)); + } + s.style.cursor = 'ns-resize'; + dragDelta(s, () => v, set, { min: MIN, max: MAX, sens: (MAX - MIN) / 140 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles */ +GW.twinNeedle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 120, 110); + const cx = 60, cy = 62, R = 44; + svgEl(s, 'circle', { cx, cy, r: 52, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 46, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 }); + /* mirrored arcs: left = FUEL (0 at bottom-left up to 4 at top), right = OIL */ + const angL = v => -170 + v / 4 * 160; /* 0..4 -> -170..-10 (left side, cw from top) */ + const angR = v => 170 - v / 4 * 160; /* 0..4 -> 170..10 (right side) */ + for (let v = 0; v <= 4; v++) { + for (const ang of [angL(v), angR(v)]) { + const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 5, ang); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': 1, opacity: .9 }); + const [tx, ty] = polar(cx, cy, R - 11, ang); + svgEl(s, 'text', { + x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)', + fill: 'var(--cream)' + }).textContent = String(v); + } + } + for (let v = 0.5; v < 4; v += 0.5) { + if (v % 1 === 0) continue; + for (const ang of [angL(v), angR(v)]) { + const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 3, ang); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': .5, opacity: .55 }); + } + } + svgEl(s, 'text', { + x: cx - 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'FUEL'; + svgEl(s, 'text', { + x: cx + 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'OIL'; + svgEl(s, 'text', { + x: cx, y: cy - 26, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.06em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'kg/cm2'; + const needle = color => { + const g = svgEl(s, 'g', {}); + svgEl(g, 'polygon', { + points: `${cx - 1.6},${cy + 8} ${cx + 1.6},${cy + 8} ${cx + 1},${cy - R + 8} ${cx},${cy - R + 4} ${cx - 1},${cy - R + 8}`, + fill: color, stroke: 'rgba(0,0,0,.4)', 'stroke-width': .5 + }); return g; + }; + const nF = needle('#e0523a'), nO = needle('#bfc4d0'); + svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); + let vF = opts.fuel ?? 2.4, vO = opts.oil ?? 3.1; + const draw = () => { + nF.setAttribute('transform', `rotate(${angL(vF).toFixed(1)},${cx},${cy})`); + nO.setAttribute('transform', `rotate(${angR(vO).toFixed(1)},${cx},${cy})`); + onChange([vF, vO], 'FUEL ' + vF.toFixed(1) + ' · OIL ' + vO.toFixed(1)); + }; + function set(f, o) { vF = Math.max(0, Math.min(4, f)); vO = Math.max(0, Math.min(4, o)); draw(); } + /* each half is its own drag surface */ + const half = (x, get, setV) => { + const hit = svgEl(s, 'rect', { x, y: 10, width: 60, height: 104, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragDelta(hit, get, v => { setV(Math.max(0, Math.min(4, v))); draw(); }, { min: 0, max: 4, sens: 4 / 110 }); + }; + half(0, () => vF, v => vF = v); + half(60, () => vO, v => vO = v); + draw(); + return { el: s, get: () => [vF, vO], set }; +}; + +/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts */ +GW.comfortMeter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 130, 122); + const cx = 65, cy = 60; + const clipId = uid('cmClip'); + gradDef('cmBrass', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8b25c'], ['.5', '#a07c30'], ['1', '#6e5218']]); + const defs = svgEl(s, 'defs', {}); + svgEl(s, 'circle', { cx, cy, r: 56, fill: 'url(#cmBrass)', stroke: '#4a3610', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 53, fill: 'none', stroke: '#7a5c1e', 'stroke-width': 2.5, 'stroke-dasharray': '2 1.4' }); + svgEl(s, 'circle', { cx, cy, r: 49, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': 1 }); + const PT = [38, 96], PH = [92, 96]; /* temp pivot (BL), humidity pivot (BR) */ + const angT = t => 105 - (t - 40) / 60 * 70; /* 40..100F -> bearing 105..35 from PT */ + const angH = h => -105 + h / 100 * 70; /* 0..100% -> bearing -105..-35 from PH */ + /* scale ticks along each sweep, near the rim */ + for (let t = 40; t <= 100; t += 10) { + const a = angT(t); + const [x1, y1] = polar(PT[0], PT[1], 86, a), [x2, y2] = polar(PT[0], PT[1], 82, a); + if (Math.hypot(x1 - cx, y1 - cy) < 48) { + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': t % 20 === 0 ? 1 : .5 }); + if (t % 20 === 0) { + const [tx, ty] = polar(PT[0], PT[1], 78, a); + svgEl(s, 'text', { + x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#3c382f' + }).textContent = String(t); + } + } + } + for (let h = 0; h <= 100; h += 10) { + const a = angH(h); + const [x1, y1] = polar(PH[0], PH[1], 86, a), [x2, y2] = polar(PH[0], PH[1], 82, a); + if (Math.hypot(x1 - cx, y1 - cy) < 48) { + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': h % 20 === 0 ? 1 : .5 }); + if (h % 20 === 0) { + const [tx, ty] = polar(PH[0], PH[1], 78, a); + svgEl(s, 'text', { + x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#3c382f' + }).textContent = String(h); + } + } + } + /* printed zone verdicts; the active one goes red */ + const zones = {}; + const zone = (key, txt, x, y, rot, size) => { + zones[key] = svgEl(s, 'text', { + x, y, 'text-anchor': 'middle', + 'font-size': size || 5.2, 'font-weight': 700, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', + fill: '#8f897b', transform: `rotate(${rot},${x},${y})` + }); zones[key].textContent = txt; + }; + zone('humid', 'TOO HUMID', 42, 34, -32); + zone('warm', 'TOO WARM', 89, 34, 32); + zone('right', 'JUST RIGHT', 65, 58, 0, 6.2); + zone('dry', 'TOO DRY', 38, 74, 28); + zone('cold', 'TOO COLD', 93, 74, -28); + svgEl(s, 'text', { + x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.6, 'letter-spacing': '.18em', + 'font-family': 'var(--mono)', fill: '#3c382f' + }).textContent = 'WEATHER STATION'; + /* needles: temp from bottom-left, humidity from bottom-right — clipped to the face */ + const nclip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(nclip, 'circle', { cx, cy, r: 49 }); + const ng = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const needle = () => { + const g = svgEl(ng, 'g', {}); + svgEl(g, 'line', { x1: 0, y1: 0, x2: 0, y2: -84, stroke: '#c23a28', 'stroke-width': 1.6, 'stroke-linecap': 'round' }); + svgEl(g, 'circle', { cx: 0, cy: 0, r: 2.6, fill: '#8f2416' }); return g; + }; + const nT = needle(), nH = needle(); + nT.setAttribute('transform', `translate(${PT[0]},${PT[1]})`); + nH.setAttribute('transform', `translate(${PH[0]},${PH[1]})`); + let vT = opts.temp ?? 72, vH = opts.humidity ?? 45; + const verdict = () => vT > 78 ? 'warm' : vT < 62 ? 'cold' : vH > 60 ? 'humid' : vH < 30 ? 'dry' : 'right'; + const draw = () => { + nT.setAttribute('transform', `translate(${PT[0]},${PT[1]}) rotate(${angT(vT).toFixed(1)})`); + nH.setAttribute('transform', `translate(${PH[0]},${PH[1]}) rotate(${angH(vH).toFixed(1)})`); + const v = verdict(); + for (const k of Object.keys(zones)) zones[k].setAttribute('fill', k === v ? '#c23a28' : '#8f897b'); + onChange([vT, vH], Math.round(vT) + 'F · ' + Math.round(vH) + '% RH · ' + zones[v].textContent); + }; + function set(t, h) { vT = Math.max(40, Math.min(100, t)); vH = Math.max(0, Math.min(100, h)); draw(); } + const half = (x, get, setV, min, max) => { + const hit = svgEl(s, 'rect', { x, y: 8, width: 65, height: 106, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragDelta(hit, get, v => { setV(Math.max(min, Math.min(max, v))); draw(); }, { min, max, sens: (max - min) / 110 }); + }; + half(0, () => vT, v => vT = v, 40, 100); + half(65, () => vH, v => vH = v, 0, 100); + draw(); + return { el: s, get: () => [vT, vH], set }; +}; + +/* ================= indicators & readouts ================= */ + +/* 18 status lamps — one lamp per health state; click any lamp to cycle it */ +GW.statusLamps = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const CLS = ['lamp', 'lamp gold', 'lamp red', 'lamp off', 'lamp busy']; + const NAMES = ['ok', 'engaged', 'fault', 'off', 'busy']; + const states = (opts.states || [0, 1, 2, 3, 4]).slice(); + const wrap = document.createElement('span'); + wrap.style.cssText = 'display:inline-flex;gap:9px;align-items:center'; + const lamps = states.map((st, i) => { + const l = document.createElement('span'); l.className = CLS[st]; l.style.cursor = 'pointer'; + l.addEventListener('click', () => set(i, states[i] + 1)); + wrap.appendChild(l); return l; + }); + host.appendChild(wrap); + function set(i, st) { + states[i] = ((st % CLS.length) + CLS.length) % CLS.length; + lamps[i].className = CLS[states[i]]; + onChange(states.slice(), NAMES[states[i]]); + } + onChange(states.slice(), states.length === 5 ? 'five states' : states.length + ' states'); + return { el: wrap, get: () => states.slice(), set }; +}; + +/* 19 badges — labelled flags; click a badge to cycle its variant */ +GW.badges = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const CLS = ['badge', 'badge red', 'badge ghost']; + const items = opts.items || [['TUNNEL', 0], ['LOW BATT', 1], ['2.4G', 2]]; + const vars = items.map(it => it[1]); + const wrap = document.createElement('span'); + const els = items.map(([txt, v], i) => { + const b = document.createElement('span'); b.className = CLS[v]; b.textContent = txt; b.style.cursor = 'pointer'; + b.addEventListener('click', () => set(i, vars[i] + 1)); + wrap.appendChild(b); + if (i < items.length - 1) wrap.appendChild(document.createTextNode(' ')); + return b; + }); + host.appendChild(wrap); + function set(i, v) { + vars[i] = ((v % CLS.length) + CLS.length) % CLS.length; + els[i].className = CLS[vars[i]]; + onChange(vars.slice(), items[i][0]); + } + onChange(vars.slice(), items.length === 3 ? 'three variants' : items.length + ' variants'); + return { el: wrap, get: () => vars.slice(), set }; +}; + +/* 20 tabular readout — mm:ss countdown; builder owns the state, the page + drives tick() on its own clock (tick contract); click pauses/resumes */ +GW.tabularReadout = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const wrap = document.createElement('div'); wrap.style.textAlign = 'center'; + wrap.innerHTML = '<div class="readout"></div><span class="u"></span>'; + const out = wrap.querySelector('.readout'); + wrap.querySelector('.u').textContent = opts.unit || 'timer'; + host.appendChild(wrap); + let secs, run = opts.run !== undefined ? !!opts.run : true; + const draw = () => { + out.textContent = String(Math.floor(secs / 60)).padStart(2, '0') + ':' + String(secs % 60).padStart(2, '0'); + onChange(secs, run ? 'running' : 'paused'); + }; + const set = v => { secs = ((v % 3600) + 3600) % 3600; draw(); }; + out.addEventListener('click', () => { run = !run; draw(); }); + set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10); + return { el: wrap, get: () => secs, set, tick: () => { if (run) set(secs - 1); } }; +}; + +/* 21 engraved label — hairline-flanked caps label with a count; click bumps 1-9 */ +GW.engravedLabel = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const e = document.createElement('span'); e.className = 'engrave'; + e.append(opts.label || 'outputs'); + const c = document.createElement('span'); c.className = 'cnt'; e.appendChild(c); + host.appendChild(e); + let n; + const set = v => { n = v; c.textContent = '· ' + n; onChange(n, 'count ' + n); }; + e.addEventListener('click', () => set(n % 9 + 1)); + set(opts.count !== undefined ? opts.count : 3); + return { el: e, get: () => n, set }; +}; + +/* 22 output well — streaming step log, lamp per step; click streams the next + demo step; push([lampCls, name, evidence]) appends programmatically */ +GW.outputWell = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const seed = opts.seed || [['', 'Link', 'wlp170s0 · @Hyatt'], ['gold', 'DNS', 'resolving…']]; + const steps = opts.steps || [['gold', 'Probe', '8.8.8.8 …'], ['', 'Gateway', '10.0.0.1 ok'], ['', 'DNS', '1.1.1.1 ok'], ['red', 'Retry', 'timeout']]; + const keep = opts.keep || 5; + const w = document.createElement('div'); w.className = 'owell'; host.appendChild(w); + const add = s => { + const d = document.createElement('div'); d.className = 'ostep'; + d.innerHTML = `<span class="lamp ${s[0]}"></span><span><b>${s[1]}</b><span class="ev">${s[2]}</span></span>`; + w.appendChild(d); while (w.children.length > keep) w.removeChild(w.firstChild); + }; + seed.forEach(add); + const push = s => { add(s); onChange(s, '+ ' + s[1]); }; + let i = 0; + w.addEventListener('click', () => { i = (i + 1) % steps.length; push(steps[i]); }); + onChange(null, 'click to stream'); + return { el: w, push }; +}; + +/* 23 toast — one-line transient confirmation; click fires the next demo + message; fire(msg) shows any message with the fade-in */ +GW.toast = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const msgs = opts.msgs || ['link up · 300 Mbps', 'bt paired — WH-1000XM4', 'profile switched', 'joined @Hyatt_WiFi — saved']; + const t = document.createElement('span'); t.className = 'toastw'; + t.textContent = opts.text || msgs[msgs.length - 1]; + host.appendChild(t); + const fire = msg => { + t.textContent = msg; t.style.opacity = '0'; + requestAnimationFrame(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '1'; }); + }; + let n = 0; + t.addEventListener('click', () => { n++; fire(msgs[n % msgs.length]); onChange(n, 'fired ' + n); }); + onChange(0, 'click to fire'); + return { el: t, fire }; +}; + +/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments */ +GW.nixie = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const digits = opts.digits || 2; + const max = Math.pow(10, digits); + const nx = document.createElement('span'); nx.className = 'nixie'; + for (let i = 0; i < digits; i++) { + const tu = document.createElement('span'); tu.className = 'tube'; tu.innerHTML = '<b></b>'; nx.appendChild(tu); + } + host.appendChild(nx); + let v; + const set = x => { + v = ((x % max) + max) % max; + const s = String(v).padStart(digits, '0'); + [...nx.children].forEach((tu, i) => { + tu.classList.toggle('off', i < digits - 1 && v < Math.pow(10, digits - 1 - i)); + tu.querySelector('b').textContent = s[i]; + }); + onChange(v, s); + }; + nx.addEventListener('click', () => set(v + 1)); + set(opts.value !== undefined ? opts.value : 8); + return { el: nx, get: () => v, set }; +}; + +/* N20 split-flap — windows flip to the next word with the mechanical clack; + the page drives next() on its own cadence; first paint is silent like the original */ +GW.splitFlap = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const words = opts.words || ['DNS ', 'LINK', 'SYNC', 'WIFI', 'SCAN']; + const animate = opts.animate !== undefined ? !!opts.animate : true; + const cells = opts.cells || 3; + const f = document.createElement('span'); f.className = 'flap'; + for (let i = 0; i < cells; i++) { + const d = document.createElement('span'); d.className = 'flapd'; d.innerHTML = '<b></b>'; f.appendChild(d); + } + host.appendChild(f); + let idx = 0; + const paint = () => { + const w = words[idx].padEnd(cells + 1, ' '); + [...f.children].forEach((dd, i) => { + const ch = w[i] || ' ', bel = dd.querySelector('b'); + if (bel.textContent !== ch) { + bel.textContent = ch; + if (animate) { dd.classList.remove('flip'); void dd.offsetWidth; dd.classList.add('flip'); } + } + }); + }; + const set = i => { idx = ((i % words.length) + words.length) % words.length; paint(); onChange(idx, words[idx].trim()); }; + f.addEventListener('click', () => set(idx + 1)); + paint(); + return { el: f, get: () => idx, set, next: () => set(idx + 1) }; +}; + +/* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick(); + click adds a minute */ +GW.sevenSeg = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const sv = document.createElement('span'); sv.className = 'seven'; host.appendChild(sv); + let secs; + const set = v => { + secs = ((v % 3600) + 3600) % 3600; + const mm = String(Math.floor(secs / 60)).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0'); + sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '<span class="colon"><i></i><i></i></span>' + seg7(ss[0]) + seg7(ss[1]); + onChange(secs, mm + ':' + ss); + }; + sv.addEventListener('click', () => set(secs + 60)); + set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10); + return { el: sv, get: () => secs, set, tick: () => set(secs - 1) }; +}; + +/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message */ +GW.vfdMarquee = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const msgs = opts.msgs || ['ARCHSETUP · NET OK · BT 2 · SND 62%', 'WIFI @Hyatt · 300 Mbps · VPN UP', 'BATTERY 84% · DISK 61% · TEMP 47C']; + const W = opts.width || 176; + const m = document.createElement('span'); m.className = 'vfdm'; + m.innerHTML = '<span class="txt"></span><span class="mesh"></span>'; + const t = m.querySelector('.txt'); t.textContent = msgs[0]; + host.appendChild(m); + let mi = 0, x = W; + m.addEventListener('click', () => { + mi = (mi + 1) % msgs.length; t.textContent = msgs[mi]; x = W; + onChange(mi, 'msg ' + (mi + 1) + '/' + msgs.length); + }); + onChange(0, 'scrolling'); + return { + el: m, get: () => mi, + tick: () => { x -= 1.1; if (x < -t.offsetWidth) x = W; t.style.left = x + 'px'; } + }; +}; + +/* N23 annunciator — named alarm grid with the raise → MSTR CAUTION → ACK → RESET + lifecycle; TEST proves the bulbs then restores the board */ +GW.annunciator = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const CLS = ['acell', 'acell warn', 'acell fault']; + const NAMES = ['ok', 'warn', 'fault']; + const cells = opts.cells || [['SYNC', 0], ['LOW BATT', 1], ['LINK', 0], ['NO DNS', 2], ['VPN', 0], ['DISK', 0]]; + const wrap = document.createElement('div'); wrap.className = 'annwrap'; + const grid = document.createElement('div'); grid.className = 'annun'; wrap.appendChild(grid); + const bar = document.createElement('div'); bar.className = 'annbar'; + const mc = document.createElement('span'); mc.className = 'mc'; mc.textContent = 'MSTR CAUTION'; bar.appendChild(mc); + ['ACK', 'TEST', 'RESET'].forEach(a => { + const b = document.createElement('button'); b.className = 'key'; b.dataset.k = a; b.textContent = a; bar.appendChild(b); + }); + wrap.appendChild(bar); host.appendChild(wrap); + const els = cells.map(([label, st]) => { + const c = document.createElement('span'); c.className = CLS[st]; c.textContent = label; grid.appendChild(c); return c; + }); + let acked = false; + const active = () => grid.querySelectorAll('.warn,.fault').length; + const refresh = () => { + const n = active(); + mc.classList.toggle('on', n > 0); mc.classList.toggle('fl', n > 0 && !acked); + onChange(n, n === 0 ? 'clear' : n + ' active · ' + (acked ? 'ACK' : 'UNACK')); + }; + els.forEach((c, i) => c.addEventListener('click', () => { + let j = CLS.indexOf(c.className.trim()); if (j < 0) j = 0; j = (j + 1) % CLS.length; c.className = CLS[j]; + if (j > 0) acked = false; /* a new alarm re-arms the flasher */ + refresh(); onChange(j, cells[i][0] + ' → ' + NAMES[j]); + })); + bar.querySelectorAll('.key').forEach(k => k.addEventListener('click', () => { + const a = k.dataset.k; + if (a === 'ACK') { if (active() > 0) acked = true; refresh(); } + else if (a === 'RESET') { els.forEach(c => c.className = 'acell'); acked = false; refresh(); } + else if (a === 'TEST') { + const prev = els.map(c => c.className); + els.forEach(c => c.className = 'acell fault'); + mc.classList.add('on', 'fl'); onChange(null, 'lamp test'); + setTimeout(() => { els.forEach((c, i) => c.className = prev[i]); refresh(); }, 1100); + } + })); + refresh(); + return { + el: wrap, get: () => els.map(c => Math.max(0, CLS.indexOf(c.className.trim()))), + set: (i, st) => { els[i].className = CLS[st]; if (st > 0) acked = false; refresh(); } + }; +}; + +/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark */ +GW.jewels = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const cols = ['var(--jewel-r)', 'var(--jewel-a)', 'var(--jewel-g)']; + const NAMES = ['red', 'amber', 'green']; + const init = opts.states || [0, 1, 2, -1]; /* index into cols; -1 = dark */ + const wrap = document.createElement('span'); + wrap.style.cssText = 'display:inline-flex;gap:10px;align-items:center'; + init.forEach(st => { + const j = document.createElement('span'); j.className = 'jewel' + (st < 0 ? ' dim' : ''); + if (st >= 0) j.style.setProperty('--jc', cols[st]); + j.addEventListener('click', () => { + if (j.classList.contains('dim')) { j.classList.remove('dim'); j.style.setProperty('--jc', cols[0]); onChange(0, 'red'); return; } + const cur = j.style.getPropertyValue('--jc').trim(); const i = cols.indexOf(cur); + if (i >= cols.length - 1 || i < 0) { j.classList.add('dim'); onChange(-1, 'dark'); } + else { j.style.setProperty('--jc', cols[i + 1]); onChange(i + 1, NAMES[i + 1]); } + }); + wrap.appendChild(j); + }); + host.appendChild(wrap); + onChange(null, 'click to cycle'); + return { el: wrap }; +}; + +/* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit */ +GW.tapeCounter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const wheels = opts.wheels || 6, redFrom = opts.redFrom !== undefined ? opts.redFrom : 4; + const ct = document.createElement('span'); ct.className = 'counter'; host.appendChild(ct); + for (let idx = 0; idx < wheels; idx++) { + const w = document.createElement('span'); w.className = 'cwheel' + (idx >= redFrom ? ' redw' : ''); + const col = document.createElement('span'); col.className = 'col'; + for (let n = -1; n <= 10; n++) { const sp = document.createElement('span'); sp.textContent = ((n + 10) % 10); col.appendChild(sp); } + w.appendChild(col); ct.appendChild(w); + } + const max = Math.pow(10, wheels); + let v; + const set = x => { + v = ((Math.round(x) % max) + max) % max; + const s = String(v).padStart(wheels, '0'); + [...ct.children].forEach((w, i) => { w.querySelector('.col').style.top = (-(+s[i] + 1) * 34) + 'px'; }); + onChange(v, s); + }; + set(opts.value !== undefined ? opts.value : 471300); + return { el: ct, get: () => v, set }; +}; + +/* N26 analog clock — engraved ticks + three hands; the page owns the time source + and drives set(h, m, s); silent until the first set, like the live original */ +GW.analogClock = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const cl = document.createElement('span'); cl.className = 'clock'; + for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); } + cl.insertAdjacentHTML('beforeend', '<span class="hh"></span><span class="mh"></span><span class="sh"></span><span class="pin"></span>'); + host.appendChild(cl); + const hh = cl.querySelector('.hh'), mh = cl.querySelector('.mh'), sh = cl.querySelector('.sh'); + const set = (h, m, s) => { + sh.style.transform = `rotate(${s * 6}deg)`; + mh.style.transform = `rotate(${m * 6 + s * 0.1}deg)`; + hh.style.transform = `rotate(${(h % 12) * 30 + m * 0.5}deg)`; + onChange([h, m, s], String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')); + }; + return { el: cl, set }; +}; + +/* N27 frequency-dial scale — printed log axis, marks crowd low and spread high; + drag the pointer to tune */ +GW.freqScale = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const W = opts.width || 184; + const marks = opts.marks || [0.5, 1, 2, 3, 5, 7, 10, 14, 20]; + const unit = opts.unit || 'MHz'; + const lo = Math.log10(marks[0]), hi = Math.log10(marks[marks.length - 1]); + const px = m => 6 + ((Math.log10(m) - lo) / (hi - lo)) * (W - 12); + const fs = document.createElement('span'); fs.className = 'freqscale'; + const band = document.createElement('span'); band.className = 'band'; band.textContent = unit; fs.appendChild(band); + marks.forEach(m => { + const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '11px'; fs.appendChild(tk); + const mk = document.createElement('span'); mk.className = 'mk'; mk.style.left = px(m) + 'px'; mk.textContent = m; fs.appendChild(mk); + }); + for (let m = marks[0]; m <= marks[marks.length - 1]; m += (m < 2 ? 0.25 : m < 10 ? 1 : 2)) { + const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '6px'; tk.style.opacity = '.5'; fs.appendChild(tk); + } + const ptr = document.createElement('span'); ptr.className = 'fptr'; fs.appendChild(ptr); + host.appendChild(fs); + let pct; + const set = p => { + pct = Math.max(0, Math.min(100, p)); + ptr.style.left = (6 + pct / 100 * (W - 12)) + 'px'; + const f = Math.pow(10, lo + (pct / 100) * (hi - lo)); + onChange(f, (f < 10 ? f.toFixed(1) : Math.round(f)) + ' ' + unit); + }; + dragX(fs, set); + set(opts.value !== undefined ? opts.value : 45); + return { el: fs, get: () => pct, set }; +}; + +/* N28 patch bay — jack grid with SVG cables; click one jack then another to + patch or unpatch the pair; redraws itself on window resize */ +GW.patchBay = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const rows = opts.rows || 2, cols = opts.cols || 4; + const patch = document.createElement('div'); patch.className = 'patch'; + const jacks = []; + for (let r = 0; r < rows; r++) { + const rowEl = document.createElement('div'); rowEl.className = 'row'; + for (let c = 0; c < cols; c++) { const j = document.createElement('span'); j.className = 'jack'; j.dataset.id = (r * cols + c); rowEl.appendChild(j); jacks.push(j); } + patch.appendChild(rowEl); + } + const svg = document.createElementNS(SVGNS, 'svg'); patch.appendChild(svg); + host.appendChild(patch); + let conns = (opts.conns || ['0-5', '2-7']).slice(); let pending = null; + const key = (a, b) => Math.min(a, b) + '-' + Math.max(a, b); + const draw = () => { + while (svg.firstChild) svg.removeChild(svg.firstChild); + const pr = patch.getBoundingClientRect(); + jacks.forEach(j => j.classList.remove('hot')); + conns.forEach(pair => { + const [a, b] = pair.split('-').map(Number); + const ra = jacks[a].getBoundingClientRect(), rb = jacks[b].getBoundingClientRect(); + const x1 = ra.left - pr.left + ra.width / 2, y1 = ra.top - pr.top + ra.height / 2; + const x2 = rb.left - pr.left + rb.width / 2, y2 = rb.top - pr.top + rb.height / 2; + const p = document.createElementNS(SVGNS, 'path'); + const my = Math.max(y1, y2) + 16; p.setAttribute('d', `M${x1} ${y1} C${x1} ${my} ${x2} ${my} ${x2} ${y2}`); svg.appendChild(p); + jacks[a].classList.add('hot'); jacks[b].classList.add('hot'); + }); + onChange(conns.slice(), conns.length ? conns.map(c => { const [a, b] = c.split('-'); return a + '↔' + b; }).join(' ') : 'no cables'); + }; + jacks.forEach(j => { + j.addEventListener('click', () => { + const id = +j.dataset.id; + if (pending == null) { pending = id; j.classList.add('sel'); return; } + if (pending === id) { pending = null; j.classList.remove('sel'); return; } + const k = key(pending, id); const i = conns.indexOf(k); if (i >= 0) conns.splice(i, 1); else conns.push(k); + jacks[pending].classList.remove('sel'); pending = null; draw(); + }); + }); + draw(); window.addEventListener('resize', draw); + return { el: patch, get: () => conns.slice(), set: c => { conns = c.slice(); draw(); } }; +}; + +/* R10 data matrix readout — a page of labeled amber fields; click cycles pages; + a page marked live re-renders on the builder's own 1s clock (reduced-motion gated) */ +GW.dataMatrix = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const pages = opts.pages || [ + ['SYS', () => ['CPU 42 MEM 61', 'DSK 58 TMP 47C', 'ARCH 6.18 LTS']], + ['NET', () => ['WIFI @HYATT 300M', 'VPN UP DNS OK', 'BT 2 PAIRED']], + ['TIME', () => { + const d = new Date(), p = n => String(n).padStart(2, '0'); + return [`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`, + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`, + 'UP 14D 06:12']; + }, true]]; + const s = stageSvg(host, 'rsvg press', 190, 74); + def('dmxGlow', d => { + const fl = svgEl(d, 'filter', { id: 'dmxGlow', x: '-30%', y: '-30%', width: '160%', height: '160%' }); + svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.1 }); + }); + /* the background gradient reads screen-family vars from this widget's subtree, + so it lives in the local defs, not the shared def sink */ + const bgId = uid('dmxBg'); + const defs = svgEl(s, 'defs', {}); + const bg = svgEl(defs, 'linearGradient', { id: bgId, x1: 0, y1: 0, x2: 0, y2: 1 }); + svgEl(bg, 'stop', { offset: '0', 'stop-color': 'var(--scr-bg1,#140d06)' }); + svgEl(bg, 'stop', { offset: '1', 'stop-color': 'var(--scr-bg2,#0a0705)' }); + svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 72, rx: 5, fill: `url(#${bgId})`, stroke: '#2c261d' }); + const lines = []; + for (let r = 0; r < 3; r++) { + const glow = svgEl(s, 'text', { + x: 12, y: 24 + r * 18, 'font-size': 9, 'letter-spacing': '.15em', + 'font-family': 'var(--mono)', fill: 'var(--scr-hi,var(--gold-hi))', opacity: .6, filter: 'url(#dmxGlow)' + }); + const crisp = svgEl(s, 'text', { + x: 12, y: 24 + r * 18, 'font-size': 9, 'letter-spacing': '.15em', + 'font-family': 'var(--mono)', fill: 'var(--scr-hi,var(--gold-hi))' + }); + lines.push([glow, crisp]); + } + let pi; + const set = i => { + pi = ((i % pages.length) + pages.length) % pages.length; + const [name, rowsFn] = pages[pi]; + rowsFn().forEach((txt, r) => { lines[r][0].textContent = txt; lines[r][1].textContent = txt; }); + onChange(pi, 'page ' + name); + }; + s.addEventListener('click', () => set(pi + 1)); + set(opts.page || 0); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { if (pages[pi][2]) set(pi); }, 1000); + return { el: s, get: () => pi, set }; +}; + +/* R11 warning flag window — the striped mechanical flag slides into the window + when the condition trips; click trips and clears it */ +GW.warningFlag = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 120, 64); + def('barber', d => { + const pat = svgEl(d, 'pattern', { id: 'barber', width: 10, height: 10, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }); + svgEl(pat, 'rect', { x: 0, y: 0, width: 10, height: 10, fill: '#17130c' }); + svgEl(pat, 'rect', { x: 0, y: 0, width: 5, height: 10, fill: 'var(--gold)' }); + }); + const clipId = uid('flagWin'); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'rect', { x: 14, y: 20, width: 56, height: 24 }); + svgEl(s, 'rect', { x: 11, y: 17, width: 62, height: 30, rx: 3, fill: '#0a0908', stroke: '#2c261d', 'stroke-width': 2 }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const stripes = svgEl(g, 'rect', { x: 14, y: 20, width: 56, height: 24, fill: 'url(#barber)' }); + stripes.style.transition = 'transform .18s'; stripes.style.transform = 'translateX(-58px)'; + svgEl(s, 'text', { + x: 92, y: 35, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.14em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = opts.label || 'VIB'; + let on; + const set = v => { on = !!v; stripes.style.transform = on ? 'translateX(0)' : 'translateX(-58px)'; onChange(on, on ? 'FLAG' : 'clear'); }; + s.addEventListener('click', () => set(!on)); + set(opts.on || false); + return { el: s, get: () => on, set }; +}; + +/* R25 fourteen-segment display — the starburst alphanumeric that spells words; + click cycles the word */ +GW.seg14 = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const words = opts.words || ['ZOOM', 'ECHO', 'TAPE', 'MOOD', 'HALL', 'COMP']; + const MAP = { + A: ['a', 'b', 'c', 'e', 'f', 'g1', 'g2'], C: ['a', 'd', 'e', 'f'], + D: ['a', 'b', 'c', 'd', 'i', 'l'], E: ['a', 'd', 'e', 'f', 'g1', 'g2'], H: ['b', 'c', 'e', 'f', 'g1', 'g2'], + L: ['d', 'e', 'f'], M: ['b', 'c', 'e', 'f', 'h', 'j'], O: ['a', 'b', 'c', 'd', 'e', 'f'], + P: ['a', 'b', 'e', 'f', 'g1', 'g2'], T: ['a', 'i', 'l'], Z: ['a', 'd', 'j', 'k'] + }; + const s = stageSvg(host, 'rsvg press', 130, 54); + svgEl(s, 'rect', { x: 1, y: 1, width: 128, height: 52, rx: 5, fill: '#0a0f08', stroke: '#1c2a16', 'stroke-width': 2 }); + /* segment shapes in a 22x36 local box */ + const TH = 2.6, W = 22, H = 36, mid = H / 2; + const segs = { + a: [[2, 0], [W - 2, 0], [W - 4, TH], [4, TH]], + d: [[2, H], [W - 2, H], [W - 4, H - TH], [4, H - TH]], + f: [[0, 2], [TH, 4], [TH, mid - 2], [0, mid - 1]], + e: [[0, H - 2], [TH, H - 4], [TH, mid + 2], [0, mid + 1]], + b: [[W, 2], [W - TH, 4], [W - TH, mid - 2], [W, mid - 1]], + c: [[W, H - 2], [W - TH, H - 4], [W - TH, mid + 2], [W, mid + 1]], + g1: [[2, mid], [4, mid - TH / 2], [W / 2 - 1, mid - TH / 2], [W / 2 - 1, mid + TH / 2], [4, mid + TH / 2]], + g2: [[W - 2, mid], [W - 4, mid - TH / 2], [W / 2 + 1, mid - TH / 2], [W / 2 + 1, mid + TH / 2], [W - 4, mid + TH / 2]], + i: [[W / 2 - TH / 2, 3], [W / 2 + TH / 2, 3], [W / 2 + TH / 2, mid - 2], [W / 2 - TH / 2, mid - 2]], + l: [[W / 2 - TH / 2, H - 3], [W / 2 + TH / 2, H - 3], [W / 2 + TH / 2, mid + 2], [W / 2 - TH / 2, mid + 2]], + h: [[3, 3], [5.5, 3], [W / 2 - 2, mid - 3], [W / 2 - 4.5, mid - 3]], + j: [[W - 3, 3], [W - 5.5, 3], [W / 2 + 2, mid - 3], [W / 2 + 4.5, mid - 3]], + k: [[3, H - 3], [5.5, H - 3], [W / 2 - 2, mid + 3], [W / 2 - 4.5, mid + 3]], + m: [[W - 3, H - 3], [W - 5.5, H - 3], [W / 2 + 2, mid + 3], [W / 2 + 4.5, mid + 3]] + }; + const cells = []; + for (let d = 0; d < 4; d++) { + const x0 = 13 + d * 28, y0 = 9, cell = {}; + for (const k in segs) { + cell[k] = svgEl(s, 'polygon', { points: segs[k].map(p => `${x0 + p[0]},${y0 + p[1]}`).join(' '), fill: 'var(--sevoff)' }); + } + cells.push(cell); + } + let wi; + const set = i => { + wi = ((i % words.length) + words.length) % words.length; + const word = words[wi].padEnd(4, ' '); + cells.forEach((cell, d) => { + const lit = MAP[word[d]] || []; + for (const k in cell) { + const on = lit.includes(k); + cell[k].setAttribute('fill', on ? 'var(--sevgrn)' : 'var(--sevoff)'); + cell[k].setAttribute('style', on ? 'filter:drop-shadow(0 0 2.5px rgba(87,211,87,.6))' : ''); + } + }); + onChange(wi, words[wi]); + }; + s.addEventListener('click', () => set(wi + 1)); + set(opts.index || 0); + return { el: s, get: () => wi, set }; +}; + +/* R26 response graph — log-frequency axes with a draggable amber peak; 2D drag + places the peak in both axes at once */ +GW.responseGraph = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 190, 110); + const RG = { x0: 30, x1: 182, y0: 8, y1: 86, fLo: Math.log10(32), fHi: Math.log10(16000), db: 12 }; + def('avGlow', d => { + const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' }); + svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 }); + }); + svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 108, rx: 5, fill: '#0d1410', stroke: '#22301f', 'stroke-width': 2 }); + const FREQS = [32, 64, 128, 250, 500, 1000, 2000, 4000, 8000, 16000]; + const xOf = f => RG.x0 + (Math.log10(f) - RG.fLo) / (RG.fHi - RG.fLo) * (RG.x1 - RG.x0); + const yOf = db => RG.y0 + (RG.db - db) / (2 * RG.db) * (RG.y1 - RG.y0); + FREQS.forEach(f => { + const x = xOf(f); + svgEl(s, 'line', { x1: x, y1: RG.y0, x2: x, y2: RG.y1, stroke: '#22301f', 'stroke-width': .6 }); + svgEl(s, 'text', { + x, y: RG.y1 + 9, 'text-anchor': 'middle', 'font-size': 4.8, 'font-family': 'var(--mono)', + fill: 'var(--steel)' + }).textContent = f < 1000 ? f : (f / 1000) + 'k'; + }); + for (const db of [12, 6, 0, -6, -12]) { + const y = yOf(db); + svgEl(s, 'line', { x1: RG.x0, y1: y, x2: RG.x1, y2: y, stroke: '#22301f', 'stroke-width': db === 0 ? 1 : .6 }); + svgEl(s, 'text', { + x: RG.x0 - 4, y: y + 2, 'text-anchor': 'end', 'font-size': 4.8, 'font-family': 'var(--mono)', + fill: 'var(--steel)' + }).textContent = (db > 0 ? '+' : '') + db; + } + const glowLine = svgEl(s, 'polyline', { points: '', fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' }); + const curve = svgEl(s, 'polyline', { points: '', fill: 'none', stroke: 'var(--gold)', 'stroke-width': 1.6 }); + const handle = svgEl(s, 'circle', { r: 4, fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': 1 }); + handle.style.cursor = 'move'; + let fc, gain; + const set = (f, g) => { + fc = Math.max(32, Math.min(16000, f)); gain = Math.max(-12, Math.min(12, g)); + const sigma = 0.28; let pts = ''; + for (let i = 0; i <= 76; i++) { + const lf = RG.fLo + i / 76 * (RG.fHi - RG.fLo); + const db = gain * Math.exp(-Math.pow(lf - Math.log10(fc), 2) / (2 * sigma * sigma)); + pts += `${xOf(Math.pow(10, lf)).toFixed(1)},${yOf(db).toFixed(1)} `; + } + curve.setAttribute('points', pts.trim()); glowLine.setAttribute('points', pts.trim()); + handle.setAttribute('cx', xOf(fc)); handle.setAttribute('cy', yOf(gain)); + const fLbl = fc < 1000 ? Math.round(fc) + ' Hz' : (fc / 1000).toFixed(1) + ' kHz'; + onChange([fc, gain], `${fLbl} · ${gain >= 0 ? '+' : ''}${gain.toFixed(1)} dB`); + }; + /* 2D drag: the plot places the peak in both axes at once */ + s.style.touchAction = 'none'; + s.addEventListener('pointerdown', e => { + s.setPointerCapture(e.pointerId); + const move = ev => { + const r = s.getBoundingClientRect(), sx = 190 / r.width, sy = 110 / r.height; + const px = (ev.clientX - r.left) * sx, py = (ev.clientY - r.top) * sy; + const lf = RG.fLo + Math.max(0, Math.min(1, (px - RG.x0) / (RG.x1 - RG.x0))) * (RG.fHi - RG.fLo); + const g = RG.db - Math.max(0, Math.min(1, (py - RG.y0) / (RG.y1 - RG.y0))) * 2 * RG.db; + set(Math.pow(10, lf), g); + }; + move(e); + const up = () => { s.removeEventListener('pointermove', move); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); }; + s.addEventListener('pointermove', move); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up); + e.preventDefault(); + }); + set(opts.fc !== undefined ? opts.fc : 1200, opts.gain !== undefined ? opts.gain : 6.5); + return { el: s, get: () => [fc, gain], set }; +}; + +/* R30 telegraph indicator — the pointer names the active state on labeled + sectors, engine-telegraph style; click steps the state */ +GW.telegraphIndicator = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const states = opts.states || ['OK', 'TEST', 'BUSY', 'STOP', 'ATTN', 'FAULT']; + const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55, N = states.length; + svgEl(s, 'circle', { cx, cy, r: 50, fill: '#171412', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 43, fill: 'var(--cream)', stroke: '#b7b29a', 'stroke-width': 1 }); + states.forEach((st, i) => { + const a = i * 360 / N - 90 + 180 / N; + const [lx1, ly1] = polar(cx, cy, 9, i * 360 / N), [lx2, ly2] = polar(cx, cy, 43, i * 360 / N); + svgEl(s, 'line', { x1: lx1, y1: ly1, x2: lx2, y2: ly2, stroke: '#2b2418', 'stroke-width': 1 }); + const g = svgEl(s, 'g', { transform: `rotate(${a + 90},${cx},${cy})` }); + svgEl(g, 'text', { + x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 7, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#2b2418' + }).textContent = st; + }); + const ptr = svgEl(s, 'g', {}); ptr.style.transition = 'transform .18s'; ptr.style.transformOrigin = `${cx}px ${cy}px`; + svgEl(ptr, 'polygon', { points: `${cx - 2.5},${cy} ${cx},${cy - 38} ${cx + 2.5},${cy}`, fill: 'var(--fail)', stroke: '#7a2a1a', 'stroke-width': .5 }); + svgEl(ptr, 'rect', { x: cx + 1, y: cy - 16, width: 10, height: 7, rx: 1, fill: 'var(--gold)', stroke: '#7d5c16', 'stroke-width': .6 }); + svgEl(s, 'circle', { cx, cy, r: 8, fill: '#171412', stroke: '#000' }); + let si; + const set = i => { + si = ((i % N) + N) % N; + ptr.style.transform = `rotate(${si * 360 / N + 180 / N}deg)`; + onChange(si, states[si]); + }; + s.addEventListener('click', () => set(si + 1)); + set(opts.index || 0); + return { el: s, get: () => si, set }; +}; + +/* R31 radar sweep — the beam circles the bearing ring on the builder's own + clock (reduced-motion gated), contacts bloom as it passes; click marks the bearing */ +GW.radarSweep = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const contacts = opts.contacts || [[60, .55], [205, .7], [318, .4]]; + const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65; + const clipId = uid('radarClip'); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'circle', { cx, cy, r: 46 }); + svgEl(s, 'circle', { cx, cy, r: 62, fill: '#1a1714', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 52, fill: 'var(--scr-bg1,#120b05)', stroke: 'var(--scr-brd,#3a2a12)', 'stroke-width': 1 }); + for (let d = 0; d < 360; d += 30) { + const [x1, y1] = polar(cx, cy, 51, d), [x2, y2] = polar(cx, cy, 47, d); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--scr-ink,var(--gold))', 'stroke-width': 1, opacity: .8 }); + const g = svgEl(s, 'g', { transform: `rotate(${d},${cx},${cy})` }); + svgEl(g, 'text', { + x: cx, y: cy - 47.5, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: 'var(--scr-ink,var(--gold))', opacity: .9 + }).textContent = String(d).padStart(3, '0'); + } + for (let d = 0; d < 360; d += 10) { + if (d % 30 === 0) continue; + const [x1, y1] = polar(cx, cy, 51, d), [x2, y2] = polar(cx, cy, 49, d); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--scr-ink,var(--gold))', 'stroke-width': .6, opacity: .5 }); + } + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + svgEl(g, 'circle', { cx, cy, r: 46, fill: 'var(--scr-bg2,#0d0803)' }); + for (const r of [15, 30, 45]) + svgEl(g, 'circle', { cx, cy, r, fill: 'none', stroke: 'var(--scr-ink,var(--gold))', 'stroke-opacity': .18, 'stroke-width': .7 }); + const sweep = svgEl(g, 'g', {}); + for (let t = 0; t < 5; t++) { + const a0 = -14 + t * 2.8, a1 = a0 + 2.8; + const [x1, y1] = polar(cx, cy, 46, a0), [x2, y2] = polar(cx, cy, 46, a1); + svgEl(sweep, 'path', { + d: `M ${cx} ${cy} L ${x1} ${y1} A 46 46 0 0 1 ${x2} ${y2} Z`, + fill: 'var(--scr-ink,var(--gold))', 'fill-opacity': (0.04 + t * 0.045).toFixed(3) + }); + } + svgEl(sweep, 'line', { + x1: cx, y1: cy, x2: cx, y2: cy - 46, stroke: 'var(--scr-hi,var(--gold-hi))', 'stroke-width': 1.4, + transform: `rotate(0,${cx},${cy})`, opacity: .95 + }); + const blips = contacts.map(([bd, rf]) => { + const [x, y] = polar(cx, cy, 46 * rf, bd); + return svgEl(g, 'circle', { cx: x, cy: y, r: 2.2, fill: 'var(--scr-hi,var(--gold-hi))', opacity: 0 }); + }); + svgEl(s, 'circle', { cx, cy, r: 3, fill: 'var(--scr-ink,var(--gold))', opacity: .8 }); + const mark = svgEl(s, 'text', { + x: cx, y: 124, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }); + mark.textContent = 'BEARING —'; + let a = opts.bearing !== undefined ? opts.bearing : 210; + const tick = () => { + a = (a + 2.6) % 360; + sweep.setAttribute('transform', `rotate(${a},${cx},${cy})`); + contacts.forEach(([bd], i) => { + const d = (a - bd + 360) % 360; /* degrees since beam passed */ + blips[i].setAttribute('opacity', d < 200 ? Math.max(0, .95 - d / 200) : 0); + }); + mark.textContent = mark.textContent.startsWith('MARK') ? mark.textContent : + 'BEARING ' + String(Math.round(a) % 360).padStart(3, '0'); + }; + s.addEventListener('click', () => { + const b = String(Math.round(a) % 360).padStart(3, '0'); + mark.textContent = 'MARK ' + b; + onChange(a, 'mark ' + b); + }); + tick(); onChange(a, 'sweeping'); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 50); + return { el: s, get: () => a, tick }; +}; + +/* R35 day-date disc calendar — coaxial printed discs rotate so today reads + under the fixed hand; starts on today; click rolls midnight forward */ +GW.dayDateCal = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; + const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65, HAND = 135; + gradDef('ddFace', 'radialGradient', { cx: '46%', cy: '40%', r: '75%' }, [['0', '#efe9da'], ['1', '#cfc8b6']]); + /* movement plate + white calendar disc */ + svgEl(s, 'circle', { cx, cy, r: 62, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + for (let k = 0; k < 5; k++) { + const [x, y] = polar(cx, cy, 58, 20 + k * 72); + svgEl(s, 'circle', { cx: x, cy: y, r: 1.8, fill: '#060505' }); + } + svgEl(s, 'circle', { cx, cy, r: 54, fill: 'url(#ddFace)', stroke: '#8f897b', 'stroke-width': .8 }); + /* date ring: 31 radial numerals on its own group */ + const dateG = svgEl(s, 'g', {}); + for (let d = 1; d <= 31; d++) { + const a = (d - 1) * (360 / 31); + const g = svgEl(dateG, 'g', { transform: `rotate(${a},${cx},${cy})` }); + svgEl(g, 'text', { + x: cx, y: cy - 46, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#14110e' + }).textContent = String(d); + } + /* inner weekday disc: 7 names twice around, separated by a hairline ring */ + svgEl(s, 'circle', { cx, cy, r: 38, fill: 'none', stroke: '#8f897b', 'stroke-width': .7 }); + const dayG = svgEl(s, 'g', {}); + for (let i = 0; i < 14; i++) { + const a = i * (360 / 14); + const g = svgEl(dayG, 'g', { transform: `rotate(${a},${cx},${cy})` }); + svgEl(g, 'text', { + x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 6.8, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#14110e' + }).textContent = DAYS[i % 7]; + } + /* fixed yellow hand from hub toward the read index, and the hub */ + const [hx, hy] = polar(cx, cy, 44, HAND); + svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: '#e8cf4a', 'stroke-width': 3, 'stroke-linecap': 'round' }); + svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 }); + svgEl(s, 'circle', { cx, cy, r: 6, fill: '#b8b2a4', stroke: '#5c574c', 'stroke-width': 1.2 }); + svgEl(s, 'circle', { cx, cy, r: 2.4, fill: '#7a7466' }); + s.style.cursor = 'pointer'; + let date, day; + const set = (d, w) => { + date = d; day = w; + dateG.setAttribute('transform', `rotate(${(HAND - (d - 1) * (360 / 31)).toFixed(2)},${cx},${cy})`); + dayG.setAttribute('transform', `rotate(${(HAND - w * (360 / 14)).toFixed(2)},${cx},${cy})`); + onChange([d, w], DAYS[w] + ' ' + d); + }; + s.addEventListener('click', () => set(date % 31 + 1, (day + 1) % 7)); + const now = new Date(); + set(opts.date !== undefined ? opts.date : now.getDate(), + opts.day !== undefined ? opts.day : now.getDay()); + return { el: s, get: () => [date, day], set }; +}; + +/* R36 LED dot matrix — 8x8 paintable bitmap behind a tinted window; click dots + to paint; starts on the reference K */ +GW.dotMatrix = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const N = 8, STEP = 11, X0 = 10.5, Y0 = 10.5; + const glyph = opts.glyph || ['10000100', '10001000', '10010000', '11100000', '10100000', '10010000', '10001000', '10000100']; + const s = stageSvg(host, 'rsvg press', 108, 108); + svgEl(s, 'rect', { x: 2, y: 2, width: 104, height: 104, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 6, y: 6, width: 96, height: 96, rx: 5, fill: '#0d0505', stroke: '#241010', 'stroke-width': 1 }); + let lit = 0; + const paint = (d, on) => { + d.on = on; + d.el.setAttribute('fill', on ? '#ff4a30' : '#2a1210'); + d.el.setAttribute('filter', on ? 'drop-shadow(0 0 3.5px rgba(255,74,48,.85))' : 'none'); + }; + for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) { + const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2, cy: Y0 + r * STEP + STEP / 2, r: 4.1, fill: '#2a1210' }); + el.style.cursor = 'pointer'; + const d = { el, on: false }; + paint(d, glyph[r][c] === '1'); if (d.on) lit++; + el.addEventListener('click', () => { + paint(d, !d.on); lit += d.on ? 1 : -1; + onChange(lit, lit + ' / 64 lit'); + }); + } + onChange(lit, lit + ' / 64 lit'); + return { el: s, get: () => lit }; +}; + +/* R45 flip-disc tile array — bistable mechanical pixels; scaleX flip with the + color swap at the midpoint; click a disc to flip it */ +GW.flipDisc = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const COLS = 7, ROWS = 5, STEP = 14, X0 = 13, Y0 = 13; + const glyph = opts.glyph || ['0000100', '0000010', '1111111', '0000010', '0000100']; /* arrow right */ + const s = stageSvg(host, 'rsvg press', 120, 92); + svgEl(s, 'rect', { x: 2, y: 2, width: 116, height: 88, rx: 7, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 }); + let lit = 0; + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2 - 2, cy: Y0 + r * STEP + STEP / 2 - 2, r: 5.6, stroke: '#060505', 'stroke-width': .8 }); + el.style.transformBox = 'fill-box'; el.style.transformOrigin = 'center'; el.style.transition = 'transform .11s ease-in'; + el.style.cursor = 'pointer'; + const d = { el, on: glyph[r][c] === '1' }; + el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); if (d.on) lit++; + el.addEventListener('click', () => { + d.on = !d.on; lit += d.on ? 1 : -1; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); } + else { + el.style.transform = 'scaleX(0)'; + setTimeout(() => { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); el.style.transform = 'scaleX(1)'; }, 115); + } + onChange(lit, lit + ' discs showing'); + }); + } + onChange(lit, lit + ' discs showing'); + return { el: s, get: () => lit }; +}; + +/* R46 dekatron counting ring — each click pulses the neon glow one cathode + around; the wrap flashes the carry dot */ +GW.dekatron = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 120, 120), cx = 60, cy = 60; + svgEl(s, 'circle', { cx, cy, r: 52, fill: '#171310', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 46, fill: '#0d0a08', stroke: '#2a221c', 'stroke-width': 1 }); + svgEl(s, 'ellipse', { cx: cx - 14, cy: cy - 18, rx: 20, ry: 11, fill: 'rgba(255,255,255,.05)', transform: `rotate(-24,${cx - 14},${cy - 18})` }); + const dots = []; + for (let i = 0; i < 10; i++) { + const [x, y] = polar(cx, cy, 34, i * 36); + svgEl(s, 'text', { + x: polar(cx, cy, 43, i * 36)[0], y: polar(cx, cy, 43, i * 36)[1] + 2, 'text-anchor': 'middle', 'font-size': 5.2, + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = String(i); + dots.push(svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: '#3a2a20' })); + } + const carry = svgEl(s, 'circle', { cx, cy: cy - 6, r: 3, fill: '#2a1c14' }); + svgEl(s, 'text', { + x: cx, y: cy + 8, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.12em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'CARRY'; + let pos = opts.count || 0, carries = 0; + const draw = () => { + dots.forEach((d, i) => { + const on = i === pos; + d.setAttribute('fill', on ? '#ff9a4c' : '#3a2a20'); + d.setAttribute('filter', on ? 'drop-shadow(0 0 5px rgba(255,154,76,.9))' : 'none'); + }); + onChange(pos, 'COUNT ' + pos + (carries ? ' · CARRY x' + carries : '')); + }; + s.style.cursor = 'pointer'; + s.addEventListener('click', () => { + pos = (pos + 1) % 10; + if (pos === 0) { + carries++; carry.setAttribute('fill', '#ff9a4c'); + carry.setAttribute('filter', 'drop-shadow(0 0 4px rgba(255,154,76,.9))'); + setTimeout(() => { carry.setAttribute('fill', '#2a1c14'); carry.removeAttribute('filter'); }, 220); + } + draw(); + }); + draw(); + return { el: s, get: () => pos }; +}; + +/* R47 landing gear indicator — three greens or nothing; the lever cycles + down → transit (amber pulse) → up */ +GW.gearIndicator = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 140, 110); + svgEl(s, 'rect', { x: 2, y: 2, width: 136, height: 106, rx: 9, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + const POS = [['NOSE', 48, 28], ['L', 24, 74], ['R', 72, 74]]; + const lamps = {}; + POS.forEach(([n, x, y]) => { + svgEl(s, 'circle', { cx: x, cy: y, r: 9, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1.5 }); + lamps[n] = svgEl(s, 'circle', { cx: x, cy: y, r: 6.2, fill: '#16240f' }); + svgEl(s, 'text', { + x, y: y + 18, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = n; + }); + /* gear lever: slot + wheel-shaped knob */ + svgEl(s, 'rect', { x: 106, y: 22, width: 8, height: 66, rx: 4, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 }); + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'rect', { x: 107.5, y: 36, width: 5, height: 26, fill: '#8f8a7e' }); + svgEl(lever, 'circle', { cx: 110, cy: 32, r: 8, fill: '#26221c', stroke: '#4a463e', 'stroke-width': 2.4 }); + svgEl(lever, 'circle', { cx: 110, cy: 32, r: 3, fill: '#4a463e' }); + svgEl(s, 'text', { + x: 110, y: 100, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'GEAR'; + let state = opts.state || 'down', busy = false; + const paint = () => { + const c = state === 'down' ? 'var(--pass)' : state === 'transit' ? 'var(--amber-warn)' : '#16240f'; + for (const n of Object.keys(lamps)) { + lamps[n].setAttribute('fill', c); + lamps[n].setAttribute('filter', state === 'down' ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' : + state === 'transit' ? 'drop-shadow(0 0 4px rgba(var(--glow-lo),.7))' : 'none'); + lamps[n].style.animation = state === 'transit' ? 'pulse var(--pulse-rate) ease-in-out infinite' : 'none'; + } + lever.setAttribute('transform', state === 'up' ? 'translate(0,-8)' : state === 'transit' ? 'translate(0,-4)' : ''); + onChange(state, state === 'down' ? 'GEAR DOWN · 3 GREEN' : state === 'up' ? 'GEAR UP' : 'IN TRANSIT'); + }; + s.style.cursor = 'pointer'; + s.addEventListener('click', () => { + if (busy) return; + const target = state === 'down' ? 'up' : 'down'; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { state = target; paint(); return; } + busy = true; state = 'transit'; paint(); + setTimeout(() => { state = target; busy = false; paint(); }, 1500); + }); + paint(); + return { el: s, get: () => state }; +}; + +/* R52 blinkenlights front panel — address/data lamps ripple with a live + pseudo-PC (builder-owned clock, reduced-motion gated) that folds in the + switch register; flip the SR bits and the pattern changes */ +GW.blinkenlights = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 170, 100); + svgEl(s, 'rect', { x: 2, y: 2, width: 166, height: 96, rx: 8, fill: '#3a1420', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 8, y: 8, width: 154, height: 62, rx: 5, fill: '#14090e' }); + const mkRow = (y, lbl) => { + const row = []; + svgEl(s, 'text', { + x: 12, y: y + 2.6, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', + fill: '#b08a96' + }).textContent = lbl; + for (let i = 0; i < 12; i++) row.push(svgEl(s, 'circle', { cx: 46 + i * 10, cy: y, r: 3, fill: '#2a1210' })); + return row; + }; + const addr = mkRow(24, 'ADDR'), data = mkRow(46, 'DATA'); + const paint = (row, val) => row.forEach((d, i) => { + const on = (val >> (11 - i)) & 1; + d.setAttribute('fill', on ? '#ffb43a' : '#2a1210'); + d.setAttribute('filter', on ? 'drop-shadow(0 0 3px rgba(255,180,58,.8))' : 'none'); + }); + /* switch register: 8 mini toggles */ + let sr = opts.sr !== undefined ? opts.sr : 0b10100101; + const toggles = []; + for (let i = 0; i < 8; i++) { + const x = 46 + i * 14; + svgEl(s, 'rect', { x: x - 3, y: 76, width: 6, height: 16, rx: 3, fill: '#0d0b09', stroke: '#4a2432', 'stroke-width': 1 }); + const t = svgEl(s, 'circle', { cx: x, cy: 80, r: 4, fill: '#8f8a7e', stroke: '#3c382f', 'stroke-width': 1 }); + t.style.cursor = 'pointer'; + const hit = svgEl(s, 'rect', { x: x - 7, y: 72, width: 14, height: 24, fill: 'transparent' }); + hit.style.cursor = 'pointer'; + hit.addEventListener('click', () => { sr ^= (1 << (7 - i)); draw(); }); + toggles.push(t); + } + svgEl(s, 'text', { + x: 12, y: 83, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', + fill: '#b08a96' + }).textContent = 'SR'; + let pc = 0o1234; + const draw = () => { + toggles.forEach((t, i) => t.setAttribute('cy', ((sr >> (7 - i)) & 1) ? 76 : 84)); + onChange(sr, 'SR ' + sr.toString(8).padStart(3, '0') + ' (octal)'); + }; + const tick = () => { pc = ((pc * 5 + sr + 1) & 0xFFF); paint(addr, pc); paint(data, (pc ^ (pc << 3) ^ sr) & 0xFFF); }; + draw(); tick(); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 250); + return { el: s, get: () => sr, tick }; +}; + +/* ---- widget CSS: injected once, grows as builders move in ---- */ +/* widget-internal CSS (moved from the gallery <style> block; gallery keeps page furniture) */ +const GW_CSS = ` +/* ---- shared primitives ---- */ +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse var(--pulse-rate) ease-in-out infinite} +/* standard pulse: 1s ease-in-out — the norm for every pulsing / flashing element */ +@keyframes pulse{50%{opacity:.25}} + +/* slide toggle — after the iPod-hold-switch pill: dome thumb, revealed state engraving. + Flex layout with fixed px only: absolute insets and stretched widths misrender under stage zoom. */ +.switch{display:inline-flex;vertical-align:middle;align-items:center;justify-content:space-between; + width:52px;height:23px;border-radius:12px;padding:2px 7px 2px 2px;cursor:pointer; + background:var(--sw-off-bg,linear-gradient(180deg,#15130f,#242019)); + border:1px solid var(--sw-off-brd,var(--slate)); + box-shadow:inset 0 1.5px 3px rgba(0,0,0,.55)} +.switch::before{content:"OFF";order:2;font-size:7px;letter-spacing:.06em;font-weight:700; + font-family:var(--mono);color:var(--sw-off-ink,var(--cream))} +.switch::after{content:"";order:1;width:17px;height:17px;border-radius:50%; + background:var(--sw-thumb,radial-gradient(circle at 35% 28%,#f6f2e8,#b0aa9a)); + box-shadow:0 1px 2.5px rgba(0,0,0,.65),inset 0 1px 0 rgba(255,255,255,.25)} +.switch.on{background:var(--sw-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold))); + border-color:var(--sw-on-brd,var(--gold-hi)); + padding:2px 2px 2px 7px;box-shadow:inset 0 1.5px 3px rgba(0,0,0,.3)} +.switch.on::before{content:"ON";order:1;color:var(--sw-on-ink,var(--panel))} +.switch.on::after{order:2} +.switch.red{background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail);padding:2px 2px 2px 7px} +.switch.red::before{content:"OFF";order:1;color:var(--cream)} +.switch.red::after{order:2} + +.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:8px 12px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.off{opacity:.4} + +.chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px} +.chip.on{color:var(--gold);border-color:var(--gold)} + +.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} + +/* fader */ +.fader{width:150px;height:16px;position:relative;cursor:pointer;touch-action:none} +.fader .slot{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold))} +.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px; + background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)} +/* vertical fader */ +.vfader{width:16px;height:64px;position:relative;cursor:pointer;touch-action:none} +.vfader .slot{position:absolute;left:6px;top:0;bottom:0;width:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.vfader .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,var(--amber-grad-bot),var(--gold))} +.vfader .cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px; + background:linear-gradient(90deg,var(--amber-grad-mid),var(--amber-grad-top));border:1px solid var(--amber-edge)} + +/* rotary knob */ +.knob{width:52px;height:52px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none; + background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c; + box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} +.knob .ind{position:absolute;left:50%;top:5px;width:2px;height:16px;background:var(--gold-hi); + margin-left:-1px;transform-origin:50% 21px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)} + +/* needle gauge */ +.gauge{width:96px;cursor:ns-resize;touch-action:none} +.gauge .dial{position:relative;height:48px;overflow:hidden} +.gauge .arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%} +.gauge .tk{position:absolute;left:50%;top:1px;width:1.5px;height:8px;margin-left:-.75px;background:var(--steel);transform-origin:50% 47px} +.gauge .ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi); + transform-origin:50% 100%;transform:rotate(0deg);border-radius:2px;box-shadow:0 0 6px rgba(var(--glow-hi),.5); + transition:transform .12s cubic-bezier(.3,1.3,.5,1)} +.gauge .hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)} +.gauge .gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums} + +/* segmented VU / LED bar */ +.vu{width:170px;display:flex;flex-direction:column;gap:5px} +.vurow{display:flex;align-items:center;gap:7px} +.vurow .ch{color:var(--steel);font-size:.6rem;width:8px} +.vubar{flex:1;display:flex;gap:2px;height:9px} +.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3} +.vubar i.on{opacity:1;background:var(--pass)}.vubar i.hot{opacity:1;background:var(--gold)} +.vubar i.clip{opacity:1;background:var(--fail)}.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px} + +/* mini 4-bar signal */ +.sig{display:flex;align-items:flex-end;gap:2px;height:18px} +.sig i{width:4px;background:var(--wash);border-radius:1px} +.sig i:nth-child(1){height:5px}.sig i:nth-child(2){height:9px}.sig i:nth-child(3){height:13px}.sig i:nth-child(4){height:17px} +.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)} + +/* signal ladder (wifi bars) */ +.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px;cursor:pointer} +.ladder i{width:5px;background:var(--wash);border-radius:1px} +.ladder i:nth-child(1){height:6px}.ladder i:nth-child(2){height:10px} +.ladder i:nth-child(3){height:14px}.ladder i:nth-child(4){height:18px} + +/* linear progress / fuel bar */ +.bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold));border-radius:6px} +.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} + +/* radial ring */ +.ring{width:60px;height:60px;border-radius:50%;cursor:ns-resize;touch-action:none; + background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative} +.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} +.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums} + +/* tabular readout */ +.readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em;cursor:pointer} +.readout small{color:var(--dim);font-size:12px;font-weight:400} +.readout .u{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px} + +/* sparkline */ +.spark{width:170px;height:44px} +.spark svg{display:block;width:100%;height:100%} + +/* lamp row (list item) */ +.lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px} +.lrow:hover{background:var(--wash)} +.lrow .who{color:var(--silver)}.lrow .who b{color:var(--cream)} +.lrow .what{margin-left:auto;color:var(--dim);font-size:11px} + +/* arm-to-fire */ +.arm{font:inherit;font-size:11.5px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b; + border-radius:8px;padding:7px 12px} +.arm.armed{background:rgba(203,107,77,.12);border-color:var(--fail);color:var(--fail)} + +/* stepper / segmented selector */ +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} +.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer} +.seg button:last-child{border-right:0} +.seg button.on{background:var(--seg-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold)));color:var(--seg-on-ink,var(--panel));font-weight:700} + +/* engraved section label */ +.engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer; + display:flex;align-items:center;gap:9px} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:10px} +.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} + +/* waveform strip */ +.wave{width:170px;height:38px} +.wave svg{width:100%;height:100%;display:block} + +/* toast */ +.toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px;cursor:pointer} + +/* output well (log step) */ +.owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px;cursor:pointer} +.ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0} +.ostep .lamp{margin-top:3px;width:7px;height:7px} +.ostep b{color:var(--cream);font-weight:700}.ostep .ev{color:var(--steel);display:block;font-size:10.5px} + +/* rotary selector */ +.rotsel{position:relative;width:118px;height:74px} +.rotsel>.knob{position:absolute;left:50%;top:20px;margin-left:-26px;cursor:pointer} +.rotsel .pos{position:absolute;font-size:9px;color:var(--dim);transform:translate(-50%,-50%);letter-spacing:.02em} +.rotsel .pos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)} + +/* slide-rule tuner dial */ +.tuner{width:180px;height:46px;position:relative;border-radius:6px;overflow:hidden;cursor:pointer; + background:var(--tn-bg,linear-gradient(180deg,#191510,#0b0908));border:1px solid var(--tn-brd,#2a251c); + box-shadow:inset 0 0 20px var(--tn-glow,rgba(var(--glow-lo),.12)),inset 0 1px 0 rgba(255,255,255,.03)} +.tuner .tick{position:absolute;top:6px;width:1px;height:11px;background:var(--tn-tick,var(--steel));transform:translateX(-50%)} +.tuner .tick.mn{height:6px;opacity:.5} +.tuner .mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--tn-ink,var(--steel));font-size:10px} +.tuner .mk.on{color:var(--tn-on,var(--gold-hi));text-shadow:0 0 6px var(--tn-onglow,rgba(var(--glow-hi),.6))} +.tuner:focus{outline:1px solid rgba(var(--glow-lo),.5);outline-offset:2px} +.tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px} +.tuner .ndl{position:absolute;top:3px;bottom:3px;width:2px;margin-left:-1px;border-radius:1px; + background:var(--tn-ndl,var(--fail));box-shadow:0 0 7px var(--tn-ndlglow,rgba(203,107,77,.85));transition:left .25s} + +/* nixie tube */ +.nixie{display:inline-flex;gap:5px;cursor:pointer} +.nixie .tube{width:30px;height:44px;border-radius:5px;position:relative;overflow:hidden; + background:radial-gradient(circle at 50% 38%,#241a12,#0b0807);border:1px solid #2c261d; + display:grid;place-items:center;box-shadow:inset 0 0 12px rgba(0,0,0,.6)} +.nixie .tube b{font-size:26px;font-weight:400;color:#ff9a3c; + text-shadow:0 0 6px rgba(255,140,50,.85),0 0 15px rgba(255,120,40,.45)} +.nixie .tube.off b{color:#3a2a1c;text-shadow:none} +.nixie .tube::after{content:"";position:absolute;inset:0;pointer-events:none; + background:linear-gradient(180deg,rgba(255,255,255,.06),transparent 32%)} + +/* ===== candidate-unique CSS ===== */ +/* rocker */ +.rocker{width:66px;height:40px;border-radius:7px;cursor:pointer;position:relative; + background:linear-gradient(180deg,#25211c,#141210);border:1px solid #34302a; + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5);overflow:hidden} +.rocker .half{position:absolute;left:0;right:0;height:50%;display:flex;align-items:center;justify-content:center; + font-size:12px;letter-spacing:.06em;color:var(--dim)} +.rocker .top{top:0;border-bottom:1px solid #0c0b0a;background:linear-gradient(180deg,#211d19,#181513)} +.rocker .bot{bottom:0;background:linear-gradient(180deg,#141210,#100e0c);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)} +.rocker.on .top{background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color:var(--panel);font-weight:700;box-shadow:0 0 10px rgba(var(--glow-hi),.35)} +.rocker.on .bot{color:var(--steel)} +.rocker:not(.on) .top{color:var(--steel)} +.rocker:not(.on) .bot{background:linear-gradient(180deg,#2a1512,#1c0f0d);color:var(--fail);box-shadow:inset 0 3px 5px rgba(0,0,0,.55),0 0 8px rgba(203,107,77,.25)} + +/* transport */ +.reels{display:flex;gap:16px;align-items:center;justify-content:center;margin-bottom:2px} +.reel{width:26px;height:26px;border-radius:50%;border:2px solid #34302a;position:relative; + background:radial-gradient(circle at 45% 40%,#211d18,#0d0b09)} +.reel::before{content:"";position:absolute;inset:9px;border-radius:50%;border:1px solid #4a443a;background:#161310} +.reel i{position:absolute;left:50%;top:50%;width:2px;height:11px;background:var(--steel);margin:-5.5px 0 0 -1px;transform-origin:50% 5.5px} +.reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(var(--glow-hi),.55)} +.reel i:nth-child(2){transform:rotate(120deg)}.reel i:nth-child(3){transform:rotate(240deg)} +.transport{display:flex;gap:5px} +.tbtn{font:inherit;font-size:12px;color:var(--silver);cursor:pointer;width:30px;height:26px;display:grid;place-items:center; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:6px} +.tbtn:hover{color:var(--gold);border-color:var(--gold)} +.tbtn.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi)} +.tbtn.rec.on{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} + +/* radio bank */ +.radiobank{display:flex;gap:0;border:1px solid #0c0b0a;border-radius:7px;overflow:hidden; + background:#0c0b0a;padding:3px;box-shadow:inset 0 2px 4px rgba(0,0,0,.5)} +.preset{font:inherit;font-size:11px;color:var(--dim);cursor:pointer;padding:8px 9px;border:0;border-radius:4px; + background:linear-gradient(180deg,#211d19,#161310);box-shadow:inset 0 1px 0 rgba(255,255,255,.04)} +.preset+.preset{margin-left:3px} +.preset:hover{color:var(--silver)} +.preset.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));font-weight:700; + box-shadow:inset 0 2px 4px rgba(0,0,0,.4)} + +/* concentric dual knob */ +.dualknob{width:64px;height:64px;position:relative} +.dualknob .outer{width:64px;height:64px;border-radius:50%;position:absolute;inset:0;cursor:ns-resize;touch-action:none; + background:radial-gradient(circle at 40% 35%,#2a2622,#100e0c);border:1px solid #3a352c; + box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} +.dualknob .outer .tick{position:absolute;left:50%;top:3px;width:2px;height:9px;background:var(--steel);margin-left:-1px;transform-origin:50% 29px} +.dualknob .inner{width:34px;height:34px;border-radius:50%;position:absolute;left:15px;top:15px;cursor:ns-resize;touch-action:none; + background:radial-gradient(circle at 40% 35%,#37322a,#1a1713);border:1px solid #4a443a;box-shadow:0 2px 4px rgba(0,0,0,.5)} +.dualknob .inner .ind{position:absolute;left:50%;top:3px;width:2px;height:11px;background:var(--gold-hi);margin-left:-1px; + transform-origin:50% 14px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)} + +/* rotary encoder + LED ring */ +.encoder{position:relative;width:66px;height:66px;display:grid;place-items:center;cursor:ns-resize;touch-action:none} +.encoder .led{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--wash); + left:50%;top:50%;margin:-2.5px} +.encoder .led.on{background:var(--gold);box-shadow:0 0 5px 1px rgba(var(--glow-lo),.7)} +.encoder .knob{width:40px;height:40px;cursor:ns-resize} +.encoder .knob .ind{transform-origin:50% 15px;top:4px;height:12px} + +/* keyed mode switch */ +.keylock{position:relative;width:110px;height:70px} +.keylock .body{position:absolute;left:50%;top:30px;margin-left:-21px;width:42px;height:42px;border-radius:50%; + background:radial-gradient(circle at 42% 36%,#2a2622,#0f0d0b);border:1px solid #3a352c;cursor:pointer; + box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} +.keylock .barrel{position:absolute;left:50%;top:50%;width:5px;height:5px;margin:-2.5px;border-radius:1px;background:#0a0908; + box-shadow:0 0 2px rgba(0,0,0,.8)} +.keylock .bit{position:absolute;left:50%;top:50%;width:3px;height:15px;margin:-15px 0 0 -1.5px;background:var(--gold-hi); + border-radius:1px;transform-origin:50% 100%;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .2s} +.keylock .kpos{position:absolute;font-size:9px;color:var(--dim);letter-spacing:.05em;transform:translate(-50%,-50%)} +.keylock .kpos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)} + +/* crossfader */ +.xfader{width:160px;height:22px;position:relative;cursor:pointer;touch-action:none} +.xfader .slot{position:absolute;top:9px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18} +.xfader .detent{position:absolute;top:2px;left:50%;width:1px;height:18px;background:var(--steel);margin-left:-.5px;opacity:.7} +.xfader .end{position:absolute;top:11px;font-size:9px;color:var(--steel);transform:translateY(-50%)} +.xfader .cap{position:absolute;top:3px;width:9px;height:16px;border-radius:2px;margin-left:-4.5px;transition:left .05s; + background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)} + +/* thumbwheel */ +.thumbw{display:flex;align-items:center;gap:10px} +.thumbwheel{width:34px;height:52px;border-radius:6px;cursor:ns-resize;touch-action:none;position:relative;overflow:hidden; + border:1px solid #34302a;box-shadow:inset 0 0 8px rgba(0,0,0,.6); + background:repeating-linear-gradient(0deg,#0e0c0a 0 2px,#221e19 2px 3px,#2f2a23 3px 5px,#221e19 5px 6px)} +.thumbwheel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.55),transparent 30%,transparent 70%,rgba(0,0,0,.55))} +.thumbw .win{color:var(--cream);font-size:15px;font-weight:700;font-variant-numeric:tabular-nums; + background:var(--well);border:1px solid #231f18;border-radius:4px;padding:3px 8px} + +/* DIP bank */ +.dip{display:flex;gap:3px;padding:6px 7px;background:#0f1a2a;border:1px solid #24344a;border-radius:5px; + box-shadow:inset 0 1px 3px rgba(0,0,0,.5)} +.dipsw{width:12px;height:26px;background:#0a1220;border-radius:2px;position:relative;cursor:pointer;border:1px solid #1c2c42} +.dipsw i{position:absolute;left:1px;right:1px;height:11px;border-radius:1px;background:linear-gradient(180deg,#eae4d2,#b4ad98); + bottom:1px;transition:bottom .12s,top .12s} +.dipsw.on i{bottom:auto;top:1px;background:linear-gradient(180deg,var(--amber-grad-top),var(--gold))} + +/* jog / shuttle */ +.jog{position:relative;width:74px;height:74px;cursor:ns-resize;touch-action:none} +.jog .shuttle{position:absolute;inset:0;border-radius:50%;border:6px solid #1a1713; + background:conic-gradient(from -90deg,#3a352c 0 var(--sh,40deg),#141210 0 360deg); + box-shadow:inset 0 0 8px rgba(0,0,0,.6)} +.jog .inner{position:absolute;inset:12px;border-radius:50%;transition:transform .05s linear; + background:radial-gradient(circle at 42% 36%,#2c2822,#100e0c);border:1px solid #3a352c;box-shadow:0 2px 5px rgba(0,0,0,.6)} +.jog .dimple{position:absolute;left:50%;top:7px;width:8px;height:8px;margin-left:-4px;border-radius:50%; + background:radial-gradient(circle at 40% 35%,#151210,#000);box-shadow:inset 0 1px 2px rgba(0,0,0,.9)} + +/* oscilloscope — screen-family vars with the original green as fallback */ +.scope{width:176px;height:78px;border-radius:6px;position:relative;overflow:hidden; + background:radial-gradient(circle at 50% 45%,var(--scr-bgc,#04140a),var(--scr-bge,#020a05)); + border:1px solid var(--scr-brd,#123018); + box-shadow:inset 0 0 16px rgba(0,0,0,.7)} +.scope .grat{position:absolute;inset:0;opacity:.5; + background-image:linear-gradient(var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px),linear-gradient(90deg,var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px); + background-size:22px 19.5px} +.scope .grat::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:var(--scr-gratc,rgba(111,206,51,.35))} +.scope svg{position:absolute;inset:0;width:100%;height:100%} +.scope polyline{fill:none;stroke:var(--scr-hi,var(--phos));stroke-width:1.6;filter:drop-shadow(0 0 3px var(--scr-glow,rgba(127,224,160,.8)))} + +/* voice-loop keyset */ +.vloop{display:grid;grid-template-columns:repeat(4,52px);gap:4px} +.vk{display:flex;flex-direction:column;align-items:stretch;gap:4px;font-size:7px;letter-spacing:.05em; + text-align:center;padding:6px 4px 4px;cursor:pointer; + color:var(--dim);background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-radius:4px; + font-family:var(--mono)} +.vk .bar{height:3px;width:40px;align-self:center;border-radius:2px;background:#16240f} +.vk.mon{color:var(--silver)} +.vk.mon .bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)} +.vk.mon.act .bar{background:#a8d84a;box-shadow:0 0 7px rgba(150,200,60,.9)} +.vk.tlk{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold)); + border-color:var(--gold-hi);font-weight:700} +.vk.tlk .bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)} + +/* spectrum / EQ */ +.eq{display:flex;align-items:flex-end;gap:3px;height:60px} +.eq .band{width:9px;display:flex;flex-direction:column-reverse;gap:2px;height:100%} +.eq .band i{height:5px;border-radius:1px;background:var(--wash);opacity:.3} +.eq .band i.on{opacity:1;background:var(--pass)} +.eq .band i.hot{opacity:1;background:var(--gold)} +.eq .band i.clip{opacity:1;background:var(--fail)} + +/* crossed-needle */ +.crossm{width:120px} +.crossm .face{position:relative;height:56px;overflow:hidden;cursor:ns-resize;touch-action:none} +.crossm .arc{position:absolute;inset:2px 4px -56px;border:2px solid var(--wash);border-radius:50%} +.crossm .nA{position:absolute;left:14px;bottom:2px;width:2px;height:52px;background:var(--gold-hi);transform-origin:50% 100%; + border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} +.crossm .nB{position:absolute;right:14px;bottom:2px;width:2px;height:52px;background:var(--fail);transform-origin:50% 100%; + border-radius:2px;box-shadow:0 0 5px rgba(203,107,77,.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} +.crossm .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px} + +/* thermometer */ +.thermo{display:flex;align-items:flex-end;gap:6px;height:74px;cursor:ns-resize;touch-action:none} +.thermo .tube{width:12px;height:64px;border-radius:6px 6px 0 0;position:relative;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.thermo .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#a35a3f,var(--fail))} +.thermo .bulb{width:20px;height:20px;border-radius:50%;background:var(--fail);position:absolute;left:-4px;bottom:-9px; + box-shadow:0 0 6px rgba(203,107,77,.5)} +.thermo .scale{display:flex;flex-direction:column;justify-content:space-between;height:64px;font-size:9px;color:var(--steel)} +.thermo .wrapcol{position:relative;padding-bottom:9px} + +/* bourdon */ +.bourdon{width:82px;height:82px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none; + background:radial-gradient(circle at 48% 42%,#1d1a16,#0c0b09);border:2px solid #2c261d; + box-shadow:inset 0 2px 5px rgba(0,0,0,.6)} +.bourdon svg{position:absolute;inset:0} +.bourdon .ndl{position:absolute;left:50%;top:50%;width:2px;height:32px;margin:-32px 0 0 -1px;background:var(--gold-hi); + transform-origin:50% 100%;border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} +.bourdon .hub{position:absolute;left:50%;top:50%;width:8px;height:8px;margin:-4px;border-radius:50%;background:var(--gold)} +.bourdon .cap{position:absolute;left:0;right:0;bottom:12px;text-align:center;font-size:8px;letter-spacing:.16em;color:var(--steel)} + +/* strip-chart */ +.strip{width:176px;height:62px;border-radius:5px;position:relative;overflow:hidden; + background:#0c0e0f;border:1px solid #231f18} +.strip .rule{position:absolute;inset:0;opacity:.4; + background-image:linear-gradient(90deg,rgba(150,147,133,.14) 1px,transparent 1px);background-size:16px 100%} +.strip svg{position:absolute;inset:0;width:100%;height:100%} +.strip polyline{fill:none;stroke:var(--gold);stroke-width:1.4} +.strip .pen{position:absolute;right:2px;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold-hi); + box-shadow:0 0 6px rgba(var(--glow-hi),.8);transition:top .08s linear} + +/* correlation */ +.corr{width:150px;cursor:pointer;touch-action:none} +.corr .face{position:relative;height:44px;overflow:hidden} +.corr .arc{position:absolute;inset:0 0 -44px;border:2px solid var(--wash);border-radius:50%} +.corr .zero{position:absolute;left:50%;top:2px;width:1px;height:10px;background:var(--gold);margin-left:-.5px} +.corr .ndl{position:absolute;left:50%;bottom:0;width:2px;height:38px;background:var(--gold-hi);transform-origin:50% 100%; + border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .1s ease-out} +.corr .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px} + +/* battery */ +.batt{display:flex;align-items:center;cursor:pointer;touch-action:none} +.batt .cells{display:flex;gap:2px;padding:3px;border:1px solid #34302a;border-radius:4px;background:#0d0f10} +.batt .cell{width:12px;height:26px;border-radius:1px;background:var(--wash);opacity:.35} +.batt .cell.on{opacity:1;background:linear-gradient(180deg,var(--pass),#5c7526)} +.batt .cell.warn.on{background:linear-gradient(180deg,var(--fail),#a04a34)} +.batt .nub{width:4px;height:12px;background:#34302a;border-radius:0 2px 2px 0} + +/* split-flap */ +.flap{display:inline-flex;gap:4px;cursor:pointer} +.flapd{width:30px;height:42px;border-radius:5px;position:relative;overflow:hidden; + background:linear-gradient(180deg,#211d18,#141210);border:1px solid #34302a;box-shadow:0 2px 4px rgba(0,0,0,.5)} +.flapd b{position:absolute;inset:0;display:grid;place-items:center;font-size:24px;color:var(--cream); + text-shadow:0 1px 2px rgba(0,0,0,.7)} +.flapd::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:rgba(0,0,0,.65);box-shadow:0 1px 0 rgba(255,255,255,.03)} +.flapd.flip b{animation:flipdrop .3s ease-in} +@keyframes flipdrop{0%{transform:translateY(-42%) scaleY(.4);opacity:.3}100%{transform:none;opacity:1}} + +/* seven-segment */ +.seven{display:inline-flex;gap:6px;padding:6px 9px;border-radius:6px;background:#0a0806;border:1px solid #231f18;cursor:pointer; + box-shadow:inset 0 0 10px rgba(0,0,0,.6)} +.seg7{width:22px;height:40px;filter:drop-shadow(0 0 3px rgba(87,211,87,.55))} +.seg7.red{filter:drop-shadow(0 0 3px rgba(226,84,63,.55))} +.seven .colon{align-self:center;display:flex;flex-direction:column;gap:9px} +.seven .colon i{width:4px;height:4px;border-radius:50%;background:var(--sevgrn);box-shadow:0 0 4px rgba(87,211,87,.7)} + +/* DSKY verb/noun panel */ +.dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px} +.dsky .lampcol{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px} +.dsky .sl{font-size:6.5px;letter-spacing:.04em;display:flex;align-items:center;justify-content:center;text-align:center; + background:#141210;color:#5c574c;border-radius:2px;border:1px solid #26221c} +.dsky .sl.on{background:linear-gradient(180deg,var(--amber-warn),var(--gold));color:var(--panel);font-weight:700; + box-shadow:0 0 6px rgba(var(--glow-lo),.5)} +.dsky .right{display:flex;flex-direction:column;gap:6px} +.dsky .wins{display:flex;gap:8px} +.dsky .win{display:flex;flex-direction:column;align-items:center;gap:2px} +.dsky .win .wl{font-size:6px;letter-spacing:.14em;color:var(--steel)} +.dsky .win .wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px} +.dsky .win .wd .seg7{width:13px;height:24px} +.dsky .win.hot .wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)} +.dsky .pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px} +.dsky .pad .key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em} + +/* VFD marquee */ +.vfdm{width:176px;height:34px;border-radius:5px;overflow:hidden;position:relative;cursor:pointer; + background:linear-gradient(180deg,#04100e,#020807);border:1px solid #123028;box-shadow:inset 0 0 12px rgba(0,0,0,.6)} +.vfdm .txt{position:absolute;top:50%;transform:translateY(-50%);white-space:nowrap;font-size:15px;letter-spacing:.22em; + color:var(--vfd);text-shadow:0 0 6px rgba(99,230,200,.65)} +.vfdm .mesh{position:absolute;inset:0;pointer-events:none;opacity:.35; + background-image:radial-gradient(rgba(0,0,0,.6) 40%,transparent 41%);background-size:3px 3px} + +/* annunciator */ +.annwrap{display:flex;flex-direction:column;gap:6px} +.annbar{display:flex;gap:5px;align-items:center} +.mc{font-size:7px;letter-spacing:.08em;padding:4px 8px;border-radius:3px;background:#141210;color:#5c574c; + border:1px solid #26221c;text-align:center} +.mc.on{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream);font-weight:700; + box-shadow:0 0 8px rgba(203,107,77,.5)} +.mc.on.fl{animation:pulse var(--pulse-rate) ease-in-out infinite} +.annbar .key{padding:3px 7px;font-size:7.5px;border-radius:4px} +.annun{display:grid;grid-template-columns:repeat(3,1fr);gap:3px} +.acell{font-size:8.5px;letter-spacing:.08em;text-align:center;color:var(--dim);padding:6px 4px;border-radius:3px;cursor:pointer; + background:#141210;border:1px solid #262320;line-height:1.2} +.acell.warn{color:var(--panel);background:linear-gradient(180deg,var(--amber-warn),var(--gold));font-weight:700;box-shadow:0 0 8px rgba(var(--glow-lo),.4)} +.acell.fault{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));font-weight:700;box-shadow:0 0 8px rgba(203,107,77,.4);animation:pulse var(--pulse-rate) ease-in-out infinite} + +/* jewel */ +.jewel{width:24px;height:24px;border-radius:50%;position:relative;cursor:pointer; + background:radial-gradient(circle at 36% 30%,rgba(255,255,255,.75),var(--jc) 42%,#3a0d08 100%); + box-shadow:0 0 10px 1px var(--jc),inset 0 -2px 3px rgba(0,0,0,.5)} +.jewel::after{content:"";position:absolute;inset:0;border-radius:50%; + background:conic-gradient(from 0deg,rgba(255,255,255,.12) 0 22deg,transparent 22deg 45deg,rgba(0,0,0,.18) 45deg 67deg,transparent 67deg 90deg); + background-repeat:repeat} +.jewel.dim{background:radial-gradient(circle at 36% 30%,rgba(120,120,120,.3),#241f1b 55%,#0e0c0a);box-shadow:inset 0 -2px 3px rgba(0,0,0,.5)} + +/* tape counter */ +.counter{display:inline-flex;gap:2px;padding:4px;background:#0c0b0a;border:1px solid #2c261d;border-radius:4px; + box-shadow:inset 0 1px 3px rgba(0,0,0,.6)} +.cwheel{width:18px;height:34px;border-radius:2px;position:relative;overflow:hidden; + background:linear-gradient(180deg,#efe9d6,#c7c0ac);box-shadow:inset 0 0 3px rgba(0,0,0,.3)} +.cwheel .col{position:absolute;left:0;right:0;text-align:center;color:#1a1613;font-size:18px;font-weight:700;line-height:34px; + transition:top .35s cubic-bezier(.4,1.4,.5,1)} +.cwheel .col span{display:block;height:34px} +.cwheel::before,.cwheel::after{content:"";position:absolute;left:0;right:0;height:9px;z-index:2;pointer-events:none} +.cwheel::before{top:0;background:linear-gradient(180deg,rgba(0,0,0,.4),transparent)} +.cwheel::after{bottom:0;background:linear-gradient(0deg,rgba(0,0,0,.4),transparent)} +.counter .redw{background:linear-gradient(180deg,#e7b46a,#cf9440)} + +/* analog clock */ +.clock{width:78px;height:78px;border-radius:50%;position:relative; + background:radial-gradient(circle at 46% 40%,#1c1915,#0c0b09);border:2px solid #2c261d;box-shadow:inset 0 2px 5px rgba(0,0,0,.6)} +.clock .tk{position:absolute;left:50%;top:4px;width:1.5px;height:6px;background:var(--steel);margin-left:-.75px;transform-origin:50% 35px} +.clock .hh{position:absolute;left:50%;top:50%;width:3px;height:20px;margin:-20px 0 0 -1.5px;background:var(--cream); + transform-origin:50% 100%;border-radius:2px} +.clock .mh{position:absolute;left:50%;top:50%;width:2px;height:28px;margin:-28px 0 0 -1px;background:var(--silver); + transform-origin:50% 100%;border-radius:2px} +.clock .sh{position:absolute;left:50%;top:50%;width:1px;height:30px;margin:-30px 0 0 -.5px;background:var(--gold-hi); + transform-origin:50% 100%;box-shadow:0 0 4px rgba(var(--glow-hi),.5)} +.clock .pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold)} + +/* frequency-dial scale */ +.freqscale{width:184px;height:44px;position:relative;border-radius:5px;overflow:hidden;cursor:pointer;touch-action:none; + background:linear-gradient(180deg,#191510,#0b0908);border:1px solid #2a251c; + box-shadow:inset 0 0 16px rgba(var(--glow-lo),.1)} +.freqscale .tick{position:absolute;top:5px;width:1px;background:var(--steel);transform:translateX(-50%)} +.freqscale .mk{position:absolute;bottom:6px;transform:translateX(-50%);color:var(--steel);font-size:9px} +.freqscale .band{position:absolute;bottom:2px;left:6px;color:var(--gold);font-size:8px;letter-spacing:.18em} +.freqscale .fptr{position:absolute;top:3px;bottom:14px;width:2px;margin-left:-1px;border-radius:1px; + background:var(--fail);box-shadow:0 0 7px rgba(203,107,77,.85)} + +/* patch-bay */ +.patch{padding:7px 9px;background:#141210;border:1px solid #2c261d;border-radius:5px;position:relative} +.patch .row{display:flex;gap:9px} +.patch .row+.row{margin-top:9px} +.patch .jack{width:13px;height:13px;border-radius:50%;cursor:pointer;background:radial-gradient(circle at 40% 35%,#3a352c,#0a0908 70%); + border:1px solid #4a443a;box-shadow:inset 0 1px 2px rgba(0,0,0,.8)} +.patch .jack.hot{background:radial-gradient(circle at 40% 35%,var(--amber-edge),#1a1408 70%)} +.patch .jack.sel{border-color:var(--gold-hi);box-shadow:0 0 6px 1px rgba(var(--glow-hi),.7)} +.patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%} +.patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round} + +/* ===== reference-batch (R) widgets — SVG-first, after period hardware ===== */ +.rsvg{display:block} +.rsvg.drag{cursor:ns-resize;touch-action:none} +.rsvg.press{cursor:pointer} + +/* transport reels spin (was a page-side keyframe injector) */ +@keyframes reelspin{to{transform:rotate(360deg)}} +.reel.spin{animation:reelspin 2.6s linear infinite} +`; +function ensureCss() { + if (document.getElementById('gw-css') || !GW_CSS) return; + const st = document.createElement('style'); st.id = 'gw-css'; st.textContent = GW_CSS; + document.head.appendChild(st); +} +if (document.head) ensureCss(); +else document.addEventListener('DOMContentLoaded', ensureCss); + +Object.assign(GW, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS }); +window.GW = GW; +})(); |
