aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/widgets.js
diff options
context:
space:
mode:
Diffstat (limited to 'docs/prototypes/widgets.js')
-rw-r--r--docs/prototypes/widgets.js306
1 files changed, 306 insertions, 0 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index 2cc7b51..cd74771 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -549,6 +549,9 @@ function stageSvg(host, cls, 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 = {}) {
@@ -833,6 +836,309 @@ GW.multiBandDial = function (host, opts = {}) {
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 };
+};
+
/* ---- widget CSS: injected once, grows as builders move in ---- */
const GW_CSS = ``;
function ensureCss() {