aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-12 21:22:56 -0500
committerCraig Jennings <c@cjennings.net>2026-07-12 21:22:56 -0500
commit933a5f8658498bbcaa9bf47006a94c8b260e80a5 (patch)
tree1cd326b4163c36f49f70b28dcc788b8af75595ca /docs/prototypes
parentacee657f802bd71c2a0cc999c0f1eeaa82c12651 (diff)
downloadarchsetup-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.
Diffstat (limited to 'docs/prototypes')
-rw-r--r--docs/prototypes/panel-widget-gallery.html99
-rw-r--r--docs/prototypes/widgets.js213
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() {