diff options
| -rw-r--r-- | docs/prototypes/widgets.js | 183 | ||||
| -rw-r--r-- | tests/gallery-probes/probe.mjs | 35 |
2 files changed, 177 insertions, 41 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index f009140..f09bedc 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -2266,7 +2266,16 @@ DUPRE.guardedToggle = function (host, opts = {}) { }; /* R32 mechanical timer dial — dial rotates under a fixed index; wind by drag, stop by the red knob. - Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set. */ + Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set. + + Contract (everything a consumer needs; no page globals touched): + opts: minutes (initial wind, clamps 0-60, default 0); + onChange(minutes, 'T-N MIN'|'OFF'|'STOP · OFF'|'DING · OFF') fires + on every set, including the initial paint. + handle: el, get() (minutes remaining), set(m) — m clamps 0-60; the dial + face drags, the red knob clicks to 0. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.timerDial = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 158, 132), cx = 62, cy = 58; @@ -2327,7 +2336,15 @@ DUPRE.timerDial = function (host, opts = {}) { return { el: s, get: () => min, set }; }; -/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press */ +/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange({x, y, dir}, 'DIR · x N y N') fires on every press and + once at the initial paint (dir null, origin). + handle: el, get() ({x, y} cursor position). No set — the cursor is a + relative accumulator; position only moves by presses. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.rockerPad = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55; @@ -2366,7 +2383,16 @@ DUPRE.rockerPad = function (host, opts = {}) { return { el: s, get: () => ({ x: rx, y: ry }) }; }; -/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state */ +/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state. + + Contract (everything a consumer needs; no page globals touched): + opts: position ('A'|'B'|'C'|'D', default 'C'; anything else falls back + to 'C'); onChange(pos, 'POS Q') fires on every set, including the + initial paint. + handle: el, get() (current quadrant letter), set(q) — invalid quadrants + are ignored; quadrant click zones select directly. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.fourWayToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65; @@ -2403,6 +2429,7 @@ DUPRE.fourWayToggle = function (host, opts = {}) { svgEl(s, 'ellipse', { cx: cx - 2.6, cy: cy - 3, rx: 2.8, ry: 1.9, fill: 'rgba(255,255,255,.5)' }); let pos = null; const set = q => { + if (!Object.hasOwn(QUAD, q)) return; pos = q; lever.setAttribute('transform', `rotate(${QUAD[q]},${cx},${cy})`); for (const k of Object.keys(QUAD)) { @@ -2420,16 +2447,29 @@ DUPRE.fourWayToggle = function (host, opts = {}) { zone('B', `${cx},${cy} ${cx},5 125,5 125,${cy}`); zone('C', `${cx},${cy} 125,${cy} 125,125 ${cx},125`); zone('D', `${cx},${cy} ${cx},125 5,125 5,${cy}`); - set(opts.position || 'C'); + set(Object.hasOwn(QUAD, opts.position || '') ? opts.position : 'C'); return { el: s, get: () => pos, set }; }; -/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many */ +/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many. + + Contract (everything a consumer needs; no page globals touched): + opts: rows (source labels, default 5-row synth set); cols (destination + labels, default 6-col set; the layout is sized for 5x6); pins + (initial 'ROW>COL' keys — keys naming no intersection are + dropped); onChange(pins array, 'N routes') fires on every seat or + pull, including the initial paint. + handle: el, get() ('ROW>COL' keys array). No set — pins seat and pull + by intersection click. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.pinMatrix = function (host, opts = {}) { const onChange = opts.onChange || noop; const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV']; const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD']; - const pins = new Set(opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA']); /* a legible default patch */ + /* a legible default patch; keys that name no intersection are dropped */ + const pins = new Set((opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA']) + .filter(k => { const [r, c] = k.split('>'); return ROWS.includes(r) && COLS.includes(c) && k === r + '>' + c; })); const s = stageSvg(host, 'rsvg press', 160, 110); const X0 = 42, Y0 = 30, DX = 19, DY = 15.5; svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); @@ -2452,7 +2492,17 @@ DUPRE.pinMatrix = function (host, opts = {}) { return { el: s, get: () => [...pins] }; }; -/* R38 dead-man button — state exists only while the pointer holds it down */ +/* R38 dead-man button — state exists only while the pointer holds it down. + + Contract (everything a consumer needs; no page globals touched): + opts: caption (plate legend, default 'HOLD TO RUN'); label (button + face, default 'RUN'); onChange(held, 'RUNNING N.Ns'|'SAFE...') + fires on press, release, every 100 ms while held, and the + initial paint. + handle: el, get() (true while held). No set — by design the state + exists only under a live pointer. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.deadMan = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 120, 110), cx = 60, cy = 58; @@ -2492,7 +2542,16 @@ DUPRE.deadMan = function (host, opts = {}) { return { el: s, get: () => t0 !== null }; }; -/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns */ +/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns. + Spin animation is reduced-motion-gated; clicks during a spin are ignored. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(dialed, dialed) fires when each digit's return spin + lands, plus once at the initial paint ('dial a number'). + handle: el, get() (dialed string, last 10 digits kept). No set — digits + only arrive through the dial. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.telephoneDial = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 63; @@ -2544,7 +2603,18 @@ DUPRE.telephoneDial = function (host, opts = {}) { return { el: s, get: () => dialed }; }; -/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step */ +/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step + (a tripped handle clicks to off, then to on). + + Contract (everything a consumer needs; no page globals touched): + opts: names (breaker labels, default MAIN/PUMP/LAMP/AUX — the layout is + sized for four); onChange(states array, 'N/M closed[ · NAME + TRIPPED]') fires on every click or trip, including the initial + paint. The first three breakers start on, the rest off. + handle: el, get() (per-breaker 'on'|'off'|'tripped' array). No set — + breakers move by handle click and the TRIP button. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.breakerPanel = function (host, opts = {}) { const onChange = opts.onChange || noop; const NAMES = opts.names || ['MAIN', 'PUMP', 'LAMP', 'AUX']; @@ -2584,32 +2654,42 @@ DUPRE.breakerPanel = function (host, opts = {}) { return { el: s, get: () => brk.map(b => b.state) }; }; -/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb */ +/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(vals {prog, verb, noun}, status text) fires on ENTR, + RSET, OPR ERR, and the initial paint. Grammar: VERB/NOUN arm a + window, digits fill it (2 max), ENTR commits — V35 runs the lamp + test, V16 N36 sets PROG 16 (monitor clock), a bare-verb commit + with under 2 digits flashes OPR ERR. + handle: el, get() ({prog, verb, noun} copy). + CSS lives in the "DSKY" block of DUPRE_CSS; keys reuse the shared + .dupre-key face and digits render as shared .seg7 glyphs. */ DUPRE.dsky = function (host, opts = {}) { const onChange = opts.onChange || noop; const LAMPS = ['UPLINK', 'TEMP', 'GIMBAL', 'PROG', 'RESTART', 'OPR ERR']; - const el = document.createElement('div'); el.className = 'dsky'; host.appendChild(el); + const el = document.createElement('div'); el.className = 'dupre-dsky'; host.appendChild(el); el.innerHTML = - `<div class="lampcol">${LAMPS.map(l => `<div class="sl" data-l="${l}">${l}</div>`).join('')}</div>` + - `<div class="right"><div class="wins">` + - `<div class="win" data-w="prog"><span class="wl">PROG</span><span class="wd"></span></div>` + - `<div class="win" data-w="verb"><span class="wl">VERB</span><span class="wd"></span></div>` + - `<div class="win" data-w="noun"><span class="wl">NOUN</span><span class="wd"></span></div>` + - `</div><div class="pad"></div></div>`; + `<div class="dupre-dsky-lamps">${LAMPS.map(l => `<div class="dupre-dsky-sl" data-l="${l}">${l}</div>`).join('')}</div>` + + `<div class="dupre-dsky-right"><div class="dupre-dsky-wins">` + + `<div class="dupre-dsky-win" data-w="prog"><span class="dupre-dsky-wl">PROG</span><span class="dupre-dsky-wd"></span></div>` + + `<div class="dupre-dsky-win" data-w="verb"><span class="dupre-dsky-wl">VERB</span><span class="dupre-dsky-wd"></span></div>` + + `<div class="dupre-dsky-win" data-w="noun"><span class="dupre-dsky-wl">NOUN</span><span class="dupre-dsky-wd"></span></div>` + + `</div><div class="dupre-dsky-pad"></div></div>`; const win = w => el.querySelector(`[data-w="${w}"]`); - const setWin = (w, txt) => { win(w).querySelector('.wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); }; + const setWin = (w, txt) => { win(w).querySelector('.dupre-dsky-wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); }; const lampEl = l => el.querySelector(`[data-l="${l}"]`); let mode = null, vals = { prog: ' ', verb: ' ', noun: ' ' }, entry = ''; const KEYS = ['VERB', 'NOUN', 'CLR', 'ENTR', 'RSET', '7', '8', '9', '4', '5', '6', '1', '2', '3', '0']; - const pad = el.querySelector('.pad'); - const hot = () => { el.querySelectorAll('.win').forEach(w => w.classList.toggle('hot', w.dataset.w === mode)); }; - const oprErr = () => { lampEl('OPR ERR').classList.add('on'); setTimeout(() => lampEl('OPR ERR').classList.remove('on'), 1200); onChange(vals, 'OPR ERR'); }; + const pad = el.querySelector('.dupre-dsky-pad'); + const hot = () => { el.querySelectorAll('.dupre-dsky-win').forEach(w => w.classList.toggle('dupre-hot', w.dataset.w === mode)); }; + const oprErr = () => { lampEl('OPR ERR').classList.add('dupre-on'); setTimeout(() => lampEl('OPR ERR').classList.remove('dupre-on'), 1200); onChange(vals, 'OPR ERR'); }; const commit = () => { if (vals.verb.trim().length < 2) { oprErr(); return; } const v = vals.verb, n = vals.noun.trim(); if (v === '35') { - LAMPS.forEach(l => lampEl(l).classList.add('on')); - setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('on')), 1400); + LAMPS.forEach(l => lampEl(l).classList.add('dupre-on')); + setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('dupre-on')), 1400); onChange(vals, 'V35 · lamp test'); } else if (v === '16' && n === '36') { vals.prog = '16'; setWin('prog', vals.prog); onChange(vals, 'V16 N36 · monitor clock'); } @@ -2621,7 +2701,7 @@ DUPRE.dsky = function (host, opts = {}) { b.addEventListener('click', () => { if (k === 'VERB' || k === 'NOUN') { mode = k.toLowerCase(); entry = ''; vals[mode] = ' '; setWin(mode, ' '); hot(); } else if (k === 'CLR') { if (mode) { vals[mode] = ' '; entry = ''; setWin(mode, ' '); } } - else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('on')); onChange(vals, 'RSET'); } + else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('dupre-on')); onChange(vals, 'RSET'); } else if (k === 'ENTR') commit(); else if (/\d/.test(k)) { if (!mode) { oprErr(); return; } @@ -2636,7 +2716,19 @@ DUPRE.dsky = function (host, opts = {}) { }; /* R42 cam-timer program drum — the program is a ring; the pointer self-advances through it. - Runs its own reduced-motion-gated 2 s step interval once started. */ + Runs its own reduced-motion-gated 2 s step interval once started. + + Contract (everything a consumer needs; no page globals touched): + opts: steps (ring labels, index 0 is OFF, default 12-step wash + program — the layout is sized for 12); colors (label→wedge + fill map, default wash palette); position (initial step, clamps + to the ring, default 0); onChange(pos, 'OFF'|'STEP N · + LABEL'|'CYCLE DONE · OFF') fires on every set, including the + initial paint. + handle: el, get() (step index), set(i) — i clamps to the ring; any + click advances one step. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.camTimer = function (host, opts = {}) { const onChange = opts.onChange || noop; const STEPS = opts.steps || ['OFF', 'FILL', 'WASH', 'WASH', 'WASH', 'RINSE', 'RINSE', 'DRAIN', 'SPIN', 'SPIN', 'FLUFF', 'COOL']; @@ -2660,8 +2752,8 @@ DUPRE.camTimer = function (host, opts = {}) { svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); let pos; const set = i => { - pos = i; - ptr.setAttribute('transform', `rotate(${i * 30 + 15},${cx},${cy})`); + pos = Math.max(0, Math.min(STEPS.length - 1, i | 0)); + ptr.setAttribute('transform', `rotate(${pos * 30 + 15},${cx},${cy})`); onChange(pos, pos === 0 ? 'OFF' : 'STEP ' + pos + ' · ' + STEPS[pos]); }; s.style.cursor = 'pointer'; @@ -2672,7 +2764,16 @@ DUPRE.camTimer = function (host, opts = {}) { return { el: s, get: () => pos, set }; }; -/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw */ +/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw. + + Contract (everything a consumer needs; no page globals touched): + opts: closed (initial state, coerced to boolean, default true); + onChange(closed, 'CLOSED · live'|'OPEN · visibly dead') fires on + every set, including the initial paint. + handle: el, get() (true when closed), set(v) — coerced to boolean; any + click toggles. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.knifeSwitch = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 130, 110); @@ -5497,21 +5598,21 @@ const DUPRE_CSS = ` .dupre-seven .dupre-colon i{width:4px;height:4px;border-radius:50%;background:var(--sevgrn);box-shadow:0 0 4px rgba(87,211,87,.7)} /* DSKY verb/noun panel */ -.dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px} -.dsky .lampcol{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px} -.dsky .sl{font-size:6.5px;letter-spacing:.04em;display:flex;align-items:center;justify-content:center;text-align:center; +.dupre-dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px} +.dupre-dsky-lamps{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px} +.dupre-dsky-sl{font-size:6.5px;letter-spacing:.04em;display:flex;align-items:center;justify-content:center;text-align:center; background:#141210;color:#5c574c;border-radius:2px;border:1px solid #26221c} -.dsky .sl.on{background:linear-gradient(180deg,var(--amber-warn),var(--gold));color:var(--panel);font-weight:700; +.dupre-dsky-sl.dupre-on{background:linear-gradient(180deg,var(--amber-warn),var(--gold));color:var(--panel);font-weight:700; box-shadow:0 0 6px rgba(var(--glow-lo),.5)} -.dsky .right{display:flex;flex-direction:column;gap:6px} -.dsky .wins{display:flex;gap:8px} -.dsky .win{display:flex;flex-direction:column;align-items:center;gap:2px} -.dsky .win .wl{font-size:6px;letter-spacing:.14em;color:var(--steel)} -.dsky .win .wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px} -.dsky .win .wd .seg7{width:13px;height:24px} -.dsky .win.hot .wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)} -.dsky .pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px} -.dsky .pad .dupre-key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em} +.dupre-dsky-right{display:flex;flex-direction:column;gap:6px} +.dupre-dsky-wins{display:flex;gap:8px} +.dupre-dsky-win{display:flex;flex-direction:column;align-items:center;gap:2px} +.dupre-dsky-wl{font-size:6px;letter-spacing:.14em;color:var(--steel)} +.dupre-dsky-wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px} +.dupre-dsky-wd .seg7{width:13px;height:24px} +.dupre-dsky-win.dupre-hot .dupre-dsky-wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)} +.dupre-dsky-pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px} +.dupre-dsky-pad .dupre-key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em} /* VFD marquee */ .dupre-vfdm{width:176px;height:34px;border-radius:5px;overflow:hidden;position:relative;cursor:pointer; diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs index ed09d8d..16bff17 100644 --- a/tests/gallery-probes/probe.mjs +++ b/tests/gallery-probes/probe.mjs @@ -1240,6 +1240,41 @@ try { ok('entryKeypad buffers digits only, caps at 6, clears and commits', b5dom === JSON.stringify({ buf3: '123', stray: '123', cap: '123456', clr: '', commit: '' }), b5dom); + // batch-6 domain gates on detached hosts: fourWayToggle falls back to C on a + // bad initial position and ignores invalid quadrants in set(), camTimer + // clamps set() to the ring (12 steps → 0..11, fractions floor), pinMatrix + // drops initial pins that name no intersection. + const clamps6 = await evl(`(() => { + const fw = DUPRE.fourWayToggle(document.createElement('div'), { position: 'Z' }); + const fwDef = fw.get(); fw.set('B'); const fwB = fw.get(); fw.set('Q'); const fwQ = fw.get(); + fw.set('toString'); const fwProto = fw.get(); + const ct = DUPRE.camTimer(document.createElement('div'), { position: 99 }); + const ctHi = ct.get(); ct.set(-5); const ctLo = ct.get(); ct.set(2.7); const ctInt = ct.get(); + const pm = DUPRE.pinMatrix(document.createElement('div'), { pins: ['OSC1>VCF', 'BAD>KEY', 'LFO>NOPE', 'OSC2>VCA>X'] }); + return JSON.stringify({ fwDef, fwB, fwQ, fwProto, ctHi, ctLo, ctInt, pmN: pm.get().length }); + })()`); + ok('batch-6 domain gates: fourWayToggle quadrant, camTimer step clamp, pinMatrix pin filter', + clamps6 === JSON.stringify({ fwDef: 'C', fwB: 'B', fwQ: 'B', fwProto: 'B', ctHi: 11, ctLo: 0, ctInt: 2, pmN: 1 }), clamps6); + + // dsky verb/noun grammar through the prefixed DOM (catches rename stragglers): + // VERB arms exactly one hot window, V16 N36 commits PROG 16 and disarms, + // V35 lights all six status lamps. + const b6dom = await evl(`(() => { + const d = DUPRE.dsky(document.createElement('div')); + const K = {}; + d.el.querySelectorAll('.dupre-dsky-pad .dupre-key').forEach(b => K[b.textContent] = b); + K.VERB.click(); + const hot1 = d.el.querySelectorAll('.dupre-dsky-win.dupre-hot').length; + K['1'].click(); K['6'].click(); K.NOUN.click(); K['3'].click(); K['6'].click(); K.ENTR.click(); + const prog = d.get().prog; + const hot0 = d.el.querySelectorAll('.dupre-dsky-win.dupre-hot').length; + K.VERB.click(); K['3'].click(); K['5'].click(); K.ENTR.click(); + const lit = d.el.querySelectorAll('.dupre-dsky-sl.dupre-on').length; + return JSON.stringify({ hot1, prog, hot0, lit }); + })()`); + ok('dsky grammar drives prefixed DOM: hot window, V16 N36 sets PROG, V35 lights all lamps', + b6dom === JSON.stringify({ hot1: 1, prog: '16', hot0: 0, lit: 6 }), b6dom); + // late exceptions from interactions const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); ok('no exceptions after interaction', errs2.length === 0); |
