aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-12 21:26:16 -0500
committerCraig Jennings <c@cjennings.net>2026-07-12 21:26:16 -0500
commit7e081036a5a29316b702a58b28a1ce21c685d4a4 (patch)
tree0c8ebfa224ca689cadc2d89cccda8d31686679c2
parent933a5f8658498bbcaa9bf47006a94c8b260e80a5 (diff)
downloadarchsetup-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.html97
-rw-r--r--docs/prototypes/widgets.js199
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() {