diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-12 21:26:16 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-12 21:26:16 -0500 |
| commit | 7e081036a5a29316b702a58b28a1ce21c685d4a4 (patch) | |
| tree | 0c8ebfa224ca689cadc2d89cccda8d31686679c2 | |
| parent | 933a5f8658498bbcaa9bf47006a94c8b260e80a5 (diff) | |
| download | archsetup-7e081036a5a29316b702a58b28a1ce21c685d4a4.tar.gz archsetup-7e081036a5a29316b702a58b28a1ce21c685d4a4.zip | |
refactor(gallery): extract controls N01-N10 into GW builders
Rocker, transport cluster, preset bank, dual knob, encoder, key switch, crossfader, thumbwheel, DIP bank, and jog wheel move into widgets.js; their inline onclick globals (transport/preset/keyTurn/dip) become listeners inside the builders. Transport takes an explicit animate option so reduced-motion behavior is the consumer's call; its initial paint leaves reel play-state to CSS, matching load behavior. Probes green plus the per-widget behavioral pass (43 checks).
| -rw-r--r-- | docs/prototypes/panel-widget-gallery.html | 97 | ||||
| -rw-r--r-- | docs/prototypes/widgets.js | 199 |
2 files changed, 210 insertions, 86 deletions
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index a6d40c9..947fd16 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -1584,60 +1584,34 @@ card(C,'25','Slide-rule dial', (st,rd)=>GW.slideRule(st,{onChange:(v,t)=>rd(t)}), '<b>value on a printed scale.</b> A lit pointer glides a warm-backlit strip. Click a mark to jump, or focus it and press ←/→ (↑/↓); readout shows it.'); card(C,'N01','Rocker power switch', - `<span class="rocker on" id="rocker"><span class="half top">ON</span><span class="half bot">OFF</span></span>`, + (st,rd)=>GW.rocker(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>hard on / off, lit legend.</b> A master power paddle — the pressed half glows. Click to rock.'); card(C,'N02','Transport cluster', - `<div style="display:flex;flex-direction:column;gap:6px;align-items:center"> - <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" id="tport"> - <button class="tbtn" data-t="rew" onclick="transport(this)">⏮</button> - <button class="tbtn on" data-t="play" onclick="transport(this)">▶</button> - <button class="tbtn" data-t="stop" onclick="transport(this)">⏹</button> - <button class="tbtn rec" data-t="rec" onclick="transport(this)">⏺</button> - </div></div>`, + (st,rd)=>GW.transport(st,{mode:'play',animate:!reduced,onChange:(m,t)=>rd(t)}), '<b>run / pause / capture.</b> One mode lit at a time, reels turn while it plays. Click a button.'); card(C,'N03','Radio preset bank', - `<div class="radiobank" id="rbank"> - <button class="preset on" onclick="preset(this)">WIFI</button> - <button class="preset" onclick="preset(this)">ETH</button> - <button class="preset" onclick="preset(this)">CELL</button> - <button class="preset" onclick="preset(this)">OFF</button> - </div>`, + (st,rd)=>GW.presetBank(st,{onChange:(i,t)=>rd(t)}), '<b>pick one, the rest release.</b> The car-radio preset row — mechanically exclusive. Click one.'); card(C,'N04','Concentric dual knob', - `<span class="dualknob"> - <span class="outer" id="dko"><span class="tick" style="transform:rotate(-120deg)"></span><span class="tick" style="transform:rotate(120deg)"></span></span> - <span class="inner" id="dki"><span class="ind"></span></span> - </span>`, + (st,rd)=>GW.dualKnob(st,{outer:50,inner:50,onChange:(o,i)=>rd('bass '+Math.round(o)+' · treble '+Math.round(i))}), '<b>two values on one spindle.</b> Bass / treble, coarse / fine. Drag the outer ring or inner cap — each is independent.'); card(C,'N05','Rotary encoder + LED ring', - `<span class="encoder" id="enc"></span>`, + (st,rd)=>GW.encoder(st,{value:7,onChange:(v,t)=>rd(t)}), '<b>endless dial, lit position.</b> A detentless encoder; the LED arc lights to the level and the value keeps accumulating. Drag to turn.'); card(C,'N06','Keyed mode switch', - `<span class="keylock" id="keylock6"> - <span class="kpos" style="left:16%;top:64%">OFF</span><span class="kpos on" style="left:50%;top:12%">ON</span><span class="kpos" style="left:84%;top:64%">RUN</span> - <span class="body" onclick="keyTurn()"><span class="bit" id="kbit"></span><span class="barrel"></span></span> - </span>`, + (st,rd)=>GW.keySwitch(st,{index:1,onChange:(i,t)=>rd(t)}), '<b>guarded three-position mode.</b> Off · on · run. The key-bit points at the live position. Click to turn.'); card(C,'N07','Center-detented crossfader', - `<div class="xfader" id="xf"> - <div class="slot"></div><div class="detent"></div> - <span class="end" style="left:0">A</span><span class="end" style="right:0">B</span> - <div class="cap" id="xfcap" style="left:50%"></div> - </div>`, + (st,rd)=>GW.crossfader(st,{onChange:(v,t)=>rd(t)}), '<b>throw to either side of zero.</b> A fader notched at center — balance, L/R mix. Drag; readout shows the signed value.'); card(C,'N08','Thumbwheel', - `<div class="thumbw"><span class="thumbwheel" id="thumbwheel"></span><span class="win" id="thval">42</span></div>`, + (st,rd)=>GW.thumbwheel(st,{value:42,onChange:(v,t)=>rd(t)}), '<b>ribbed edge-wheel, one value.</b> A knurled wheel with a windowed number. Drag up/down to roll.'); card(C,'N09','DIP-switch bank', - `<span class="dip" id="dips"> - <span class="dipsw on" onclick="dip(this)"><i></i></span><span class="dipsw" onclick="dip(this)"><i></i></span> - <span class="dipsw on" onclick="dip(this)"><i></i></span><span class="dipsw on" onclick="dip(this)"><i></i></span> - <span class="dipsw" onclick="dip(this)"><i></i></span><span class="dipsw" onclick="dip(this)"><i></i></span> - </span>`, + (st,rd)=>GW.dipBank(st,{onChange:(w,t)=>rd(t)}), '<b>a bank of hard flags.</b> Hardware slide switches — up is on. Click each to flip; readout is the binary word.'); card(C,'N10','Jog / shuttle wheel', - `<span class="jog" id="jog10"><span class="shuttle" style="--sh:40deg"></span><span class="inner" id="jogi"><span class="dimple"></span></span></span>`, + (st,rd)=>GW.jogWheel(st,{onChange:(v,t)=>rd(t)}), '<b>scrub fine, shuttle fast.</b> The edit-deck wheel. Drag to jog the inner wheel; readout shows the position.'); card(C,'R02','Calibrated vernier dial', `<svg id="vern" class="rsvg drag" viewBox="0 0 150 150" width="150" height="150"></svg>`, @@ -1928,56 +1902,7 @@ card(I,'R52','Blinkenlights front panel', /* ============ CONTROL WIRING ============ */ /* 01 toggle */ -/* 01-09, 24, 25 extracted to widgets.js (GW.*) — wired at their card records */ -/* N01 rocker */ -$('rocker').onclick=()=>{$('rocker').classList.toggle('on');setRd('N01',$('rocker').classList.contains('on')?'ON':'OFF');}; -setRd('N01','ON'); -/* N02 transport */ -function transport(b){b.parentElement.querySelectorAll('.tbtn').forEach(x=>x.classList.remove('on'));b.classList.add('on'); - const playing=b.dataset.t==='play';document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState=playing&&!reduced?'running':'paused'); - setRd('N02',{rew:'REW',play:'PLAY',stop:'STOP',rec:'REC'}[b.dataset.t]);} -setRd('N02','PLAY'); -/* N03 radio preset */ -function preset(b){b.parentElement.querySelectorAll('.preset').forEach(x=>x.classList.remove('on'));b.classList.add('on');setRd('N03',b.textContent);} -setRd('N03','WIFI'); -/* N04 concentric dual knob */ -let dkoV=50,dkiV=50; -function setDual(){$('dko').style.transform=`rotate(${-150+dkoV/100*300}deg)`;$('dki').style.transform=`rotate(${-150+dkiV/100*300}deg)`; - setRd('N04','bass '+Math.round(dkoV)+' · treble '+Math.round(dkiV));} -dragDelta($('dko'),()=>dkoV,v=>{dkoV=v;setDual();},{min:0,max:100}); -dragDelta($('dki'),()=>dkiV,v=>{dkiV=v;setDual();},{min:0,max:100,stop:true}); -setDual(); -/* N05 encoder */ -let encV=7; -function paintEnc(){const leds=$('enc').querySelectorAll('.led');const lit=((Math.floor(encV)%12)+12)%12; - leds.forEach((l,i)=>l.classList.toggle('on',i<=lit));} -function setEnc(v){encV=v;paintEnc();const ind=$('enc').querySelector('.knob .ind');if(ind)ind.parentElement.style.transform=`rotate(${encV*30}deg)`; - setRd('N05','pos '+Math.round(encV));} -(function(){const enc=$('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);})(); -dragDelta($('enc'),()=>encV,setEnc,{min:0,max:100000,sens:0.25});setEnc(7); -/* N06 keylock */ -let keyIdx=1;const keyAng=[-70,0,70];const keyNm=['OFF','ON','RUN']; -function keyTurn(){keyIdx=(keyIdx+1)%3;$('kbit').style.transform=`rotate(${keyAng[keyIdx]}deg)`; - $('keylock6').querySelectorAll('.kpos').forEach((p,i)=>p.classList.toggle('on',i===keyIdx));setRd('N06',keyNm[keyIdx]);} -setRd('N06','ON'); -/* N07 crossfader */ -function setXf(p){$('xfcap').style.left=p+'%';const v=Math.round(p-50);setRd('N07',(v>0?'+':'')+v);} -dragX($('xf'),setXf);setXf(50); -/* N08 thumbwheel */ -let thumbV=42; -function setThumb(v){thumbV=Math.round(v);const s=(((thumbV%100)+100)%100);$('thval').textContent=String(s).padStart(2,'0');setRd('N08','value '+s);} -dragDelta($('thumbwheel'),()=>thumbV,setThumb,{min:0,max:99,sens:0.2});setThumb(42); -/* N09 DIP */ -function dipUpd(){const bits=[...$('dips').querySelectorAll('.dipsw')].map(x=>x.classList.contains('on')?'1':'0').join('');setRd('N09',bits);} -function dip(s){s.classList.toggle('on');dipUpd();} -dipUpd(); -/* N10 jog */ -let jogV=0; -function setJog(v){jogV=v;$('jogi').style.transform=`rotate(${jogV*4}deg)`;setRd('N10','pos '+Math.round(jogV));} -dragDelta($('jog10'),()=>jogV,setJog,{min:-100000,max:100000,sens:0.5});setJog(0); +/* 01-09, 24, 25, N01-N10 extracted to widgets.js (GW.*) — wired at their card records */ /* ============ METER WIRING (drag-driven) ============ */ /* 10 needle gauge */ diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 9e0f73d..eba1fd3 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -320,6 +320,205 @@ GW.slideRule = function (host, opts = {}) { return { el: t, get: () => values[idx], set }; }; +/* N01 rocker power switch — hard on/off, lit legend */ +GW.rocker = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const r = document.createElement('span'); r.className = 'rocker'; + r.innerHTML = `<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 }; +}; + /* ---- widget CSS: injected once, grows as builders move in ---- */ const GW_CSS = ``; function ensureCss() { |
