From 9758f1710161b0353654e83da04af8e13253e561 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 12 Jul 2026 21:54:42 -0500 Subject: refactor(gallery): extract controls R39-R42, R48-R51 into GW builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rotary telephone dial, circuit breaker panel, DSKY, cam-timer drum, knife switch, decade box, two-hand safety, and voice-loop keyset — the Controls section is now fully extracted. The dial keeps its wind-and-return animation (reduced-motion path included); the cam timer and voice loop keep their gated self-advance and activity-flicker intervals inside their builders. DSKY and voice loop are the first HTML-based builders with structural CSS still gallery-side. Behavioral pass now 118 checks; both probes green. --- docs/prototypes/widgets.js | 334 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) (limited to 'docs/prototypes/widgets.js') diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index f213518..ed0feb4 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -1472,6 +1472,340 @@ GW.deadMan = function (host, opts = {}) { 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 = + `
${LAMPS.map(l => `
${l}
`).join('')}
` + + `
` + + `
PROG
` + + `
VERB
` + + `
NOUN
` + + `
`; + 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 + ''; 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) }; +}; + /* ---- widget CSS: injected once, grows as builders move in ---- */ const GW_CSS = ``; function ensureCss() { -- cgit v1.2.3