aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/widgets.js
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/widgets.js
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/widgets.js')
-rw-r--r--docs/prototypes/widgets.js213
1 files changed, 213 insertions, 0 deletions
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() {