diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-12 21:22:56 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-12 21:22:56 -0500 |
| commit | 933a5f8658498bbcaa9bf47006a94c8b260e80a5 (patch) | |
| tree | 1cd326b4163c36f49f70b28dcc788b8af75595ca | |
| parent | acee657f802bd71c2a0cc999c0f1eeaa82c12651 (diff) | |
| download | archsetup-933a5f8658498bbcaa9bf47006a94c8b260e80a5.tar.gz archsetup-933a5f8658498bbcaa9bf47006a94c8b260e80a5.zip | |
refactor(gallery): extract controls 01-09, 24, 25 into GW builders
Eleven CSS-form controls (slide toggle, console keys, both faders, knob, segmented, chip, arm-to-fire, lamp row, rotary selector, slide-rule dial) move into widgets.js as parameterized builders returning handles; card() now accepts a builder function in place of stage HTML, so these cards are declarative records wiring GW.* onChange into their readouts. Probes green plus a per-widget behavioral pass over all eleven.
| -rw-r--r-- | docs/prototypes/panel-widget-gallery.html | 99 | ||||
| -rw-r--r-- | docs/prototypes/widgets.js | 213 |
2 files changed, 237 insertions, 75 deletions
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index d6e847b..a6d40c9 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -1515,10 +1515,14 @@ const INFO={ const INFO_FIELDS=[['input','input'],['solves','solves'],['use','use'],['limits','limits'], ['origin','origin'],['difficulty','difficulty'],['prefer','prefer when'],['period','period']]; const VLAMP_TITLES={off:'validation: not done',amber:'validation: in progress',green:'validation: done'}; +/* card(host, no, name, htmlOrBuild, note) — the declarative card record. + htmlOrBuild: a legacy stage-HTML string, or a builder function (stage, rd) => handle + that instantiates a GW.* widget into the stage; rd(txt) writes the card readout. */ function card(host, no, name, html, note){ + const isBuild=typeof html==='function'; const c=document.createElement('div'); c.className='card'; c.id='card-'+no; c.innerHTML=`<div class="wname"><span class="no">${no}</span>${name}<span class="vlamp" data-v="off"></span></div>`+ - `<div class="stagew">${html}</div><div class="wrd" id="rd-${no}">—</div>`+ + `<div class="stagew">${isBuild?'':html}</div><div class="wrd" id="rd-${no}">—</div>`+ `<div class="opts"></div><div class="wnote">${note}</div>`; const lamp=c.querySelector('.vlamp'), VKEY='gv-'+no, VSTATES=['off','amber','green']; const setV=v=>{lamp.dataset.v=v;lamp.title=VLAMP_TITLES[v];}; @@ -1533,7 +1537,9 @@ function card(host, no, name, html, note){ if(inf.ref) rows+=`<div class="ik">reference</div><div class="iv"><a href="${inf.ref}" target="_blank">photo</a></div>`; d.innerHTML=`<summary>spec sheet</summary><div class="igrid">${rows}</div>`; c.appendChild(d);} - host.appendChild(c); return c; + host.appendChild(c); + if(isBuild)c.gw=html(c.querySelector('.stagew'),txt=>setRd(no,txt)); + return c; } function setRd(no,txt){const e=$('rd-'+no);if(e)e.textContent=txt;} /* drag helpers, seg7, buildBars live in widgets.js (GW) */ @@ -1541,45 +1547,41 @@ function setRd(no,txt){const e=$('rd-'+no);if(e)e.textContent=txt;} /* ============ CONTROLS ============ */ const C=$('controls'); card(C,'01','Slide toggle', - `<span id="toggle01"><span class="switch on"></span></span>`, + (st,rd)=>GW.slideToggle(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>on / off, the touchscreen way.</b> The one born-digital control in the kit — the smartphone pill toggle the live net/bt/audio panels use. Click to flip; readout shows the state. Physical cousins: the bat-handle (R03) and the rocker (N01).'); card(C,'02','Console key', - `<span id="keys02"><button class="key on">LIVE</button><button class="key">SCAN</button><button class="key red">MUTED</button></span>`, + (st,rd)=>GW.consoleKeys(st,{onChange:(i,t)=>rd(t)}), '<b>physical push button.</b> Mutually exclusive — click one to engage. Gold = live, terracotta = muted.'); card(C,'03','Horizontal fader', - `<div class="fader" id="f1"><div class="slot"><div class="fill" style="width:68%"></div></div><div class="cap" style="left:68%"></div></div>`, + (st,rd)=>GW.faderH(st,{value:68,onChange:(v,t)=>rd(t)}), '<b>continuous 0-100.</b> Per-device volume, brightness. Drag the gold cap; readout tracks.'); card(C,'04','Vertical fader', - `<div class="vfader" id="v1"><div class="slot"><div class="fill" style="height:60%"></div></div><div class="cap" style="bottom:60%"></div></div> - <div class="vfader" id="v2"><div class="slot"><div class="fill" style="height:35%"></div></div><div class="cap" style="bottom:35%"></div></div>`, + (st,rd)=>{let ready=false; + const upd=()=>{if(ready)rd('A '+Math.round(a.get())+' · B '+Math.round(b.get()));}; + const a=GW.faderV(st,{value:60,onChange:upd}); + const b=GW.faderV(st,{value:35,onChange:upd}); + ready=true;upd();return {a,b};}, '<b>channel-strip style.</b> A mixer column per device. Drag each fader up or down.'); card(C,'05','Rotary knob', - `<span class="knob" id="knob"><span class="ind" id="kind"></span></span>`, + (st,rd)=>GW.knob(st,{value:53,onChange:v=>rd('gain '+Math.round(v))}), '<b>dial in a value.</b> Volume/gain the analog way. Drag up/down to turn; readout shows the level.'); card(C,'06','Segmented selector', - `<div class="seg" id="seg06"><button class="on">TIMER</button><button>ALARM</button><button>POMO</button></div>`, + (st,rd)=>GW.segmented(st,{onChange:(i,t)=>rd(t)}), '<b>pick one of a few.</b> Timer type, layout mode. Click a segment; readout names the choice.'); card(C,'07','Chip toggle', - `<span class="chip on" id="chip07">discoverable on</span>`, + (st,rd)=>GW.chipToggle(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>inline binary.</b> A soft toggle inside a line of text. Click to flip; gold when on.'); card(C,'08','Arm-to-fire', - `<button class="arm" id="arm">forget</button>`, + (st,rd)=>GW.armButton(st,{onChange:(v,t)=>rd(t)}), '<b>two-stage confirm.</b> Destructive actions. First click arms (red), second fires. Readout shows the stage.'); card(C,'09','Lamp row', - `<div class="lrow" id="lrow9"><span class="lamp gold"></span><span class="who"><b>WH-1000XM4</b></span><span class="what">tap to connect</span></div>`, + (st,rd)=>GW.lampRow(st,{onChange:(i,t)=>rd(t)}), '<b>actionable list item.</b> Lamp + name + status. Click to cycle idle → connecting → connected.'); card(C,'24','Rotary selector', - `<span class="rotsel" id="rotsel24"> - <span class="pos" style="left:14%;top:53%">4</span><span class="pos" style="left:28%;top:18%">6</span><span class="pos on" style="left:50%;top:7%">8</span><span class="pos" style="left:72%;top:18%">10</span><span class="pos" style="left:86%;top:53%">12</span> - <span class="knob"><span class="ind"></span></span> - </span>`, + (st,rd)=>GW.rotarySelector(st,{onChange:(v,t)=>rd(t)}), '<b>pick one of N by position.</b> Printed detents, the pointer names the value. Click to turn; readout shows it.'); card(C,'25','Slide-rule dial', - `<span class="tuner" id="tuner" tabindex="0" role="slider" aria-label="slide-rule value"> - <span class="tick" style="left:12px"></span><span class="tick" style="left:51px"></span><span class="tick" style="left:90px"></span><span class="tick" style="left:129px"></span><span class="tick" style="left:168px"></span> - <span class="mk" style="left:12px">4</span><span class="mk" style="left:51px">6</span><span class="mk on" style="left:90px">8</span><span class="mk" style="left:129px">10</span><span class="mk" style="left:168px">12</span> - <span class="ndl" id="tundl" style="left:90px"></span> - </span>`, + (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>`, @@ -1926,60 +1928,7 @@ card(I,'R52','Blinkenlights front panel', /* ============ CONTROL WIRING ============ */ /* 01 toggle */ -(function(){const sw=$('toggle01').querySelector('.switch'); - function upd(){setRd('01',sw.classList.contains('on')?'ON':'OFF');} - sw.onclick=()=>{sw.classList.toggle('on');upd();};upd();})(); -/* 02 console key */ -$('keys02').querySelectorAll('.key').forEach(k=>k.onclick=()=>{ - $('keys02').querySelectorAll('.key').forEach(x=>x.classList.remove('on','red')); - k.classList.add(k.textContent==='MUTED'?'red':'on');setRd('02',k.textContent);}); -setRd('02','LIVE'); -/* 03 horizontal fader */ -function setF1(p){const f=$('f1');f.querySelector('.fill').style.width=p+'%';f.querySelector('.cap').style.left=p+'%';setRd('03','level '+Math.round(p));} -dragX($('f1'),setF1);setF1(68); -/* 04 vertical faders */ -let vfA=60,vfB=35; -function vfUpd(){setRd('04','A '+Math.round(vfA)+' · B '+Math.round(vfB));} -function setVf(el,p){el.querySelector('.fill').style.height=p+'%';el.querySelector('.cap').style.bottom=p+'%';} -dragY($('v1'),p=>{vfA=p;setVf($('v1'),p);vfUpd();}); -dragY($('v2'),p=>{vfB=p;setVf($('v2'),p);vfUpd();}); -setVf($('v1'),60);setVf($('v2'),35);vfUpd(); -/* 05 knob */ -let knobV=53; -function setKnob(v){knobV=v;$('kind').style.transform=`rotate(${-150+v/100*300}deg)`;setRd('05','gain '+Math.round(v));} -dragDelta($('knob'),()=>knobV,setKnob,{min:0,max:100});setKnob(53); -/* 06 segmented */ -$('seg06').querySelectorAll('button').forEach(b=>b.onclick=()=>{ - $('seg06').querySelectorAll('button').forEach(x=>x.classList.remove('on'));b.classList.add('on');setRd('06',b.textContent);}); -setRd('06','TIMER'); -/* 07 chip */ -$('chip07').onclick=()=>{$('chip07').classList.toggle('on');setRd('07',$('chip07').classList.contains('on')?'ON':'OFF');}; -setRd('07','ON'); -/* 08 arm */ -let armed=false; -$('arm').onclick=()=>{const a=$('arm');if(!armed){armed=true;a.classList.add('armed');a.textContent='forget? again';setRd('08','ARMED');} - else{armed=false;a.classList.remove('armed');a.textContent='forget';setRd('08','FIRED · reset');}}; -setRd('08','SAFE'); -/* 09 lamp row */ -const lrStates=[['gold','tap to connect'],['busy','connecting…'],['','connected']];let lrI=0; -$('lrow9').onclick=()=>{lrI=(lrI+1)%3;const s=lrStates[lrI];$('lrow9').querySelector('.lamp').className='lamp '+s[0]; - $('lrow9').querySelector('.what').textContent=s[1];setRd('09',s[1]);}; -setRd('09','tap to connect'); -/* 24 rotary selector */ -const rotVals=[4,6,8,10,12];let rotIdx=2;const rotAng=[-70,-35,0,35,70]; -$('rotsel24').querySelector('.knob').onclick=()=>{rotIdx=(rotIdx+1)%5; - $('rotsel24').querySelector('.ind').style.transform=`rotate(${rotAng[rotIdx]}deg)`; - $('rotsel24').querySelectorAll('.pos').forEach((p,i)=>p.classList.toggle('on',i===rotIdx));setRd('24','size '+rotVals[rotIdx]);}; -setRd('24','size 8'); -/* 25 slide-rule */ -const tunVals=[4,6,8,10,12];let tunIdx=2;const tunX=[12,51,90,129,168]; -function setTun(i){tunIdx=Math.max(0,Math.min(tunVals.length-1,i));$('tundl').style.left=tunX[tunIdx]+'px'; - $('tuner').querySelectorAll('.mk').forEach((m,j)=>m.classList.toggle('on',j===tunIdx));setRd('25','pos '+tunVals[tunIdx]);} -$('tuner').onclick=(e)=>{const r=$('tuner').getBoundingClientRect(),x=e.clientX-r.left; - let best=0,bd=1e9;tunX.forEach((tx,j)=>{const d=Math.abs(tx-x);if(d<bd){bd=d;best=j;}});setTun(best);$('tuner').focus();}; -$('tuner').onkeydown=(e)=>{if(e.key==='ArrowLeft'||e.key==='ArrowDown'){e.preventDefault();setTun(tunIdx-1);} - else if(e.key==='ArrowRight'||e.key==='ArrowUp'){e.preventDefault();setTun(tunIdx+1);}}; -setRd('25','pos 8'); +/* 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'); diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index b88981e..9e0f73d 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -107,6 +107,219 @@ const SCREEN_FAMS = { '--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-*). */ +GW.slideToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const sw = document.createElement('span'); sw.className = 'switch'; host.appendChild(sw); + 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 }; +}; + +/* 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 */ +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 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 }; +}; + +/* 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; click a mark or arrow-key */ +GW.slideRule = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const values = opts.values || [4, 6, 8, 10, 12]; + const fmt = opts.fmt || (v => 'pos ' + v); + const X = [12, 51, 90, 129, 168]; + const t = document.createElement('span'); t.className = 'tuner'; + t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value'); + values.forEach((v, i) => { + t.insertAdjacentHTML('beforeend', `<span class="tick" style="left:${X[i]}px"></span><span class="mk" style="left:${X[i]}px">${v}</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(values.length - 1, i)); + ndl.style.left = X[idx] + 'px'; + t.querySelectorAll('.mk').forEach((m, j) => m.classList.toggle('on', j === idx)); + onChange(values[idx], fmt(values[idx])); + }; + t.addEventListener('click', e => { + const r = t.getBoundingClientRect(), x = e.clientX - r.left; + let best = 0, bd = 1e9; X.slice(0, values.length).forEach((tx, j) => { const d = Math.abs(tx - 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); } + }); + set(opts.index !== undefined ? opts.index : 2); + return { el: t, get: () => values[idx], set }; +}; + /* ---- widget CSS: injected once, grows as builders move in ---- */ const GW_CSS = ``; function ensureCss() { |
