From 933a5f8658498bbcaa9bf47006a94c8b260e80a5 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 12 Jul 2026 21:22:56 -0500 Subject: 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. --- docs/prototypes/panel-widget-gallery.html | 99 ++++---------- docs/prototypes/widgets.js | 213 ++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 75 deletions(-) (limited to 'docs/prototypes') 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=`
${no}${name}
`+ - `
${html}
`+ + `
${isBuild?'':html}
`+ `
${note}
`; 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+=`
reference
photo
`; d.innerHTML=`spec sheet
${rows}
`; 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', - ``, + (st,rd)=>GW.slideToggle(st,{on:true,onChange:(v,t)=>rd(t)}), 'on / off, the touchscreen way. 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', - ``, + (st,rd)=>GW.consoleKeys(st,{onChange:(i,t)=>rd(t)}), 'physical push button. Mutually exclusive — click one to engage. Gold = live, terracotta = muted.'); card(C,'03','Horizontal fader', - `
`, + (st,rd)=>GW.faderH(st,{value:68,onChange:(v,t)=>rd(t)}), 'continuous 0-100. Per-device volume, brightness. Drag the gold cap; readout tracks.'); card(C,'04','Vertical fader', - `
-
`, + (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};}, 'channel-strip style. A mixer column per device. Drag each fader up or down.'); card(C,'05','Rotary knob', - ``, + (st,rd)=>GW.knob(st,{value:53,onChange:v=>rd('gain '+Math.round(v))}), 'dial in a value. Volume/gain the analog way. Drag up/down to turn; readout shows the level.'); card(C,'06','Segmented selector', - `
`, + (st,rd)=>GW.segmented(st,{onChange:(i,t)=>rd(t)}), 'pick one of a few. Timer type, layout mode. Click a segment; readout names the choice.'); card(C,'07','Chip toggle', - `discoverable on`, + (st,rd)=>GW.chipToggle(st,{on:true,onChange:(v,t)=>rd(t)}), 'inline binary. A soft toggle inside a line of text. Click to flip; gold when on.'); card(C,'08','Arm-to-fire', - ``, + (st,rd)=>GW.armButton(st,{onChange:(v,t)=>rd(t)}), 'two-stage confirm. Destructive actions. First click arms (red), second fires. Readout shows the stage.'); card(C,'09','Lamp row', - `
WH-1000XM4tap to connect
`, + (st,rd)=>GW.lampRow(st,{onChange:(i,t)=>rd(t)}), 'actionable list item. Lamp + name + status. Click to cycle idle → connecting → connected.'); card(C,'24','Rotary selector', - ` - 4681012 - - `, + (st,rd)=>GW.rotarySelector(st,{onChange:(v,t)=>rd(t)}), 'pick one of N by position. Printed detents, the pointer names the value. Click to turn; readout shows it.'); card(C,'25','Slide-rule dial', - ` - - 4681012 - - `, + (st,rd)=>GW.slideRule(st,{onChange:(v,t)=>rd(t)}), 'value on a printed scale. 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', `ONOFF`, @@ -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{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 = '
'; + 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 = '
'; + 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 = ''; + 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 = ``; + 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 = ''; 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', `${v}`); + }); + 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() { -- cgit v1.2.3