diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-18 15:26:13 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-18 15:26:13 -0500 |
| commit | 17e07d6bcc0b57ced2d319f008c84ed514fbf1c8 (patch) | |
| tree | 9fc2bcc691f9cbcf2325711272125c1c29938cb0 | |
| parent | 5d8caa697791ba4ea533b066310a69d1a6b58748 (diff) | |
| download | archsetup-17e07d6bcc0b57ced2d319f008c84ed514fbf1c8.tar.gz archsetup-17e07d6bcc0b57ced2d319f008c84ed514fbf1c8.zip | |
feat(gallery): bring the keypad-through-guarded ten to the extraction bar
entryKeypad, thumbSlide, waveRegion, ledRow, pillSlide, spunKnob, stompSwitch, wingSelector, discSwitch, and guardedToggle each get the contract comment. All ten are SVG-stage builders, so there were no classes to prefix.
set() hardening lands where a handle could wedge: ledRow, pillSlide, and discSwitch clamp their index (discSwitch previously threw), waveRegion keeps E at least 4 above S from set() itself rather than only the drag path, and entryKeypad ignores keys outside its documented 0-9/confirm/clear set. ledRow's default selection is index 5 only for the default program list. A caller's own items start at 0.
Extraction audit: 54/111 contract comments.
| -rw-r--r-- | docs/prototypes/widgets.js | 149 | ||||
| -rw-r--r-- | tests/gallery-probes/probe.mjs | 40 |
2 files changed, 166 insertions, 23 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 64a0283..f009140 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -1743,7 +1743,17 @@ DUPRE.multiBandDial = function (host, opts = {}) { return { el: s, get: () => [val, band], set }; }; -/* R16 entry keypad — worn keys feed the amber display; lamps watch state */ +/* R16 entry keypad — worn keys feed the amber display; lamps watch state. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(buffer, text) fires on every keypress and on the initial + paint ('', 'enter a code'); ✓ reports 'OK · <code>' (or 'empty') + then clears, ✗ reports 'cleared' and flashes the lower lamp. + handle: el, get() (the buffer string, up to 6 digits), press(key) — any + of '0'-'9', '✓', '✗'; digits past 6 are ignored. The upper lamp + lights while the buffer is non-empty. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.entryKeypad = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 150, 190); @@ -1766,7 +1776,7 @@ DUPRE.entryKeypad = function (host, opts = {}) { lampBot.setAttribute('fill', 'var(--jewel-r)'); setTimeout(() => lampBot.setAttribute('fill', '#3a0f0a'), 350); return; } - if (buf.length < 6) { buf += k; render(); onChange(buf, buf); } + if (/^[0-9]$/.test(k) && buf.length < 6) { buf += k; render(); onChange(buf, buf); } }; const KEYS = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['✓', '0', '✗']]; const jitter = [-2, 1, -1, 2, 0, -2, 1, -1, 2, 0, -1, 1]; @@ -1781,7 +1791,17 @@ DUPRE.entryKeypad = function (host, opts = {}) { return { el: s, get: () => buf, press }; }; -/* R18 thumb-slide attenuator pair — lit numeral strip, cream side tab */ +/* R18 thumb-slide attenuator pair — lit numeral strip, cream side tab. + + Contract (everything a consumer needs; no page globals touched): + opts: channels (array of { name, v } strips, v 0-100, default BLEND/MIX + pair — the layout is sized for two); onChange(values array, + 'NAME v · NAME v') fires on every set, including the initial paint. + handle: el, get() (values array), set(i, v) — v clamps 0-100; each strip + drags vertically on its own hit rect, and the numerals within 5 + of the value light up. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.thumbSlide = function (host, opts = {}) { const onChange = opts.onChange || noop; const chans = (opts.channels || [{ name: 'BLEND', v: 74 }, { name: 'MIX', v: 92 }]).map(c => ({ name: c.name, v: c.v })); @@ -1827,7 +1847,18 @@ DUPRE.thumbSlide = function (host, opts = {}) { return { el: s, get: () => chans.map(c => c.v), set }; }; -/* R19 waveform region editor — monochrome LCD, draggable S/E flags */ +/* R19 waveform region editor — monochrome LCD, draggable S/E flags. + + Contract (everything a consumer needs; no page globals touched): + opts: start / end (region bounds in percent, defaults 22 / 76); + onChange({ s, e }, 'S n% · E n%') fires on every set, including + the initial paint. + handle: el, get() ({ s, e }), set(s, e) — s clamps 0-96, e clamps 4-100, + and e is kept at least 4 above s; dragging moves whichever flag + is nearer the pointer. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the LCD inks read the --scr-* screen tokens with hex + fallbacks. */ DUPRE.waveRegion = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 190, 100), x0 = 10, x1 = 180, yMid = 48, N = 85; @@ -1860,6 +1891,7 @@ DUPRE.waveRegion = function (host, opts = {}) { let S, E; const set = (sv, ev) => { S = Math.max(0, Math.min(96, sv)); E = Math.max(4, Math.min(100, ev)); + if (E < S + 4) E = S + 4; const xOf = p => x0 + p / 100 * (x1 - x0); flagS.setAttribute('transform', `translate(${xOf(S)},0)`); flagE.setAttribute('transform', `translate(${xOf(E)},0)`); @@ -1931,7 +1963,18 @@ DUPRE.drumRoller = function (host, opts = {}) { return { el: s, get: () => chans.map(c => c.v), set }; }; -/* R21 LED program row — exclusive select, the LED above the key carries the state */ +/* R21 LED program row — exclusive select, the LED above the key carries the state. + + Contract (everything a consumer needs; no page globals touched): + opts: items (program names, default 8 reverb programs — the layout is + sized for about eight); label (engraved caption, default + 'PROGRAM'); index (initial selection — default 5 for the default + set, first item for a caller's own set); onChange(index, + 'n · name') fires on every set, including the initial paint. + handle: el, get() (selected index), set(i) — i clamps to the items + range; exactly one LED is lit at a time. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.ledRow = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || ['Sm Hall B', 'VocPlate', 'Lg Hall B', 'Chamber', 'ParcPlate', 'Sm Hall A', 'Room A', 'Const Plate']; @@ -1940,9 +1983,9 @@ DUPRE.ledRow = function (host, opts = {}) { const leds = []; let idx; const set = i => { - idx = i; + idx = Math.max(0, Math.min(items.length - 1, i)); leds.forEach((l, k) => { - const on = k === i; + const on = k === idx; l.setAttribute('fill', on ? 'var(--jewel-r)' : '#3a0f0a'); l.setAttribute('style', on ? 'filter:drop-shadow(0 0 3px rgba(255,91,69,.8))' : ''); }); @@ -1958,11 +2001,23 @@ DUPRE.ledRow = function (host, opts = {}) { g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); set(i); }); }); svgEl(s, 'text', { x: 95, y: 56, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'PROGRAM'; - set(opts.index !== undefined ? opts.index : 5); + set(opts.index !== undefined ? opts.index : opts.items ? 0 : 5); return { el: s, get: () => idx, set }; }; -/* R22 three-position slide — chrome pill between detents, honest LED pair */ +/* R22 three-position slide — chrome pill between detents, honest LED pair. + + Contract (everything a consumer needs; no page globals touched): + opts: positions (three detent labels, default A / AB / B — the detents + are fixed at three); title (engraved header, default + 'BASIC · VARIATION'); index (initial detent, default 1); + onChange(index, label) fires on every set, including the initial + paint. + handle: el, get() (detent index), set(i) — i clamps to the detents; + clicking the stage snaps the pill to the nearest detent, and the + LED pair reports A-side / B-side engagement (both lit at AB). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.pillSlide = function (host, opts = {}) { const onChange = opts.onChange || noop; const positions = opts.positions || ['A', 'AB', 'B']; @@ -1981,12 +2036,12 @@ DUPRE.pillSlide = function (host, opts = {}) { const leds = [44, 66].map(x => svgEl(s, 'circle', { cx: x, cy: 50.5, r: 2.4, fill: '#3a0f0a' })); let idx; const set = i => { - idx = i; - pill.setAttribute('transform', `translate(${detX[i]},0)`); - lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)')); - const lit = [i === 0 || i === 1, i === 2 || i === 1]; + idx = Math.max(0, Math.min(positions.length - 1, i)); + pill.setAttribute('transform', `translate(${detX[idx]},0)`); + lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)')); + const lit = [idx === 0 || idx === 1, idx === 2 || idx === 1]; leds.forEach((l, k) => l.setAttribute('fill', lit[k] ? 'var(--jewel-r)' : '#3a0f0a')); - onChange(idx, positions[i]); + onChange(idx, positions[idx]); }; s.addEventListener('click', e => { const r = s.getBoundingClientRect(); @@ -1997,7 +2052,16 @@ DUPRE.pillSlide = function (host, opts = {}) { return { el: s, get: () => idx, set }; }; -/* R23 spun-aluminum knob — machined rings in a knurled grip, red index */ +/* R23 spun-aluminum knob — machined rings in a knurled grip, red index. + + Contract (everything a consumer needs; no page globals touched): + opts: label (engraved caption, default 'SPEED'); value (initial 0-100, + default 62); onChange(value, 'n%') fires on every set, including + the initial paint. + handle: el, get() (value), set(v) — v clamps 0-100; the whole stage + drags vertically. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.spunKnob = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52; @@ -2022,7 +2086,17 @@ DUPRE.spunKnob = function (host, opts = {}) { return { el: s, get: () => val, set }; }; -/* R24 stomp switch + jewel — press to engage, the jewel reports */ +/* R24 stomp switch + jewel — press to engage, the jewel reports. + + Contract (everything a consumer needs; no page globals touched): + opts: on (initial state, default false); onChange(on, 'ENGAGED' / + 'bypass') fires on every set, including the initial paint. + handle: el, get() (boolean), set(v) — coerced to boolean; clicking the + stage toggles, the dome dips, and the amber jewel glows while + engaged. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients and the avGlow filter register in the shared defs + plate. */ DUPRE.stompSwitch = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55; @@ -2056,7 +2130,17 @@ DUPRE.stompSwitch = function (host, opts = {}) { return { el: s, get: () => on, set }; }; -/* R27 winged gain selector — red T-bar over a dot ring, stepped detents */ +/* R27 winged gain selector — red T-bar over a dot ring, stepped detents. + + Contract (everything a consumer needs; no page globals touched): + opts: steps (dB values, one detent each, default 12 from -80 to +10); + index (initial detent, default 7); onChange(db, '+n dB') fires on + every set with the step's dB value, including the initial paint. + handle: el, get() (detent index, not the dB value), set(i) — i rounds + and clamps to the steps range; the whole stage drags vertically + between detents. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.wingSelector = function (host, opts = {}) { const onChange = opts.onChange || noop; const steps = opts.steps || [-80, -70, -60, -50, -40, -30, -20, -10, -5, 0, 5, 10]; @@ -2089,7 +2173,16 @@ DUPRE.wingSelector = function (host, opts = {}) { return { el: s, get: () => idx, set }; }; -/* R28 rotary disc switch — the whole disc turns between heavy positions */ +/* R28 rotary disc switch — the whole disc turns between heavy positions. + + Contract (everything a consumer needs; no page globals touched): + opts: positions (array of [label, angle°] stops, default OFF / ON / + COMBINE); index (initial stop, default 1); onChange(index, label) + fires on every set, including the initial paint. + handle: el, get() (stop index), set(i) — i clamps to the stops; clicking + the stage advances to the next stop, wrapping. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.discSwitch = function (host, opts = {}) { const onChange = opts.onChange || noop; const positions = opts.positions || [['OFF', -45], ['ON', 45], ['COMBINE', 135]]; @@ -2114,17 +2207,27 @@ DUPRE.discSwitch = function (host, opts = {}) { svgEl(s, 'ellipse', { cx: cx - 12, cy: cy - 14, rx: 12, ry: 6, fill: 'rgba(255,255,255,.10)', transform: `rotate(-28,${cx - 12},${cy - 14})` }); let idx; const set = i => { - idx = i; - grp.style.transform = `rotate(${positions[i][1]}deg)`; - lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)')); - onChange(idx, positions[i][0]); + idx = Math.max(0, Math.min(positions.length - 1, i)); + grp.style.transform = `rotate(${positions[idx][1]}deg)`; + lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)')); + onChange(idx, positions[idx][0]); }; s.addEventListener('click', () => set((idx + 1) % positions.length)); set(opts.index !== undefined ? opts.index : 1); return { el: s, get: () => idx, set }; }; -/* R29 guarded toggle — guard posts + red collar mark the critical throw */ +/* R29 guarded toggle — guard posts + red collar mark the critical throw. + + Contract (everything a consumer needs; no page globals touched): + opts: on (initial state, default true); onLabel / offLabel (engraved + legends, defaults ON / OFF); onChange(on, label) fires on every + set, including the initial paint. + handle: el, get() (boolean), set(v) — coerced to boolean; clicking the + stage throws the lever, and the active legend carries the gold + ink. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.guardedToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52; diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs index 87197d2..ed09d8d 100644 --- a/tests/gallery-probes/probe.mjs +++ b/tests/gallery-probes/probe.mjs @@ -1200,6 +1200,46 @@ try { ok('dipBank word round-trips and jogWheel rotates via dupre- classes', b4dom === JSON.stringify({ word: '101000', sw: 6, rot: 'rotate(40deg)' }), b4dom); + // Batch-5 set() hardening, same detached-host shape: ledRow, pillSlide and + // discSwitch clamp their index (discSwitch used to throw); waveRegion keeps + // E at least 4 above S however set() is called, not only on the drag path. + // ledRow's default index is 5 only for the default program list — a caller's + // own items start at 0 (the consoleKeys default-active rule). + const clamps5 = await evl(`(() => { + const lr = DUPRE.ledRow(document.createElement('div')); + lr.set(99); const lrHi = lr.get(); + lr.set(-3); const lrLo = lr.get(); + const lrDef = DUPRE.ledRow(document.createElement('div')).get(); + const lrOwn = DUPRE.ledRow(document.createElement('div'), { items: ['A', 'B', 'C'] }).get(); + const ps = DUPRE.pillSlide(document.createElement('div')); + ps.set(9); const psHi = ps.get(); + ps.set(-1); const psLo = ps.get(); + let dsHi; + try { const ds = DUPRE.discSwitch(document.createElement('div')); ds.set(9); dsHi = ds.get(); } + catch (e) { dsHi = 'threw'; } + const wr = DUPRE.waveRegion(document.createElement('div')); + wr.set(80, 20); const { s: wrS, e: wrE } = wr.get(); + wr.set(-10, 200); const { s: wrS2, e: wrE2 } = wr.get(); + return JSON.stringify({ lrHi, lrLo, lrDef, lrOwn, psHi, psLo, dsHi, wrS, wrE, wrS2, wrE2 }); + })()`); + ok('batch-5 set() clamps: ledRow/pillSlide/discSwitch index, waveRegion S/E order', + clamps5 === JSON.stringify({ lrHi: 7, lrLo: 0, lrDef: 5, lrOwn: 0, psHi: 2, psLo: 0, dsHi: 2, wrS: 80, wrE: 84, wrS2: 0, wrE2: 100 }), clamps5); + + // entryKeypad buffer round-trip on a detached host: digits accumulate, the + // buffer caps at 6, keys outside the contract's set are ignored, ✗ clears, + // ✓ commits and clears. + const b5dom = await evl(`(() => { + const kp = DUPRE.entryKeypad(document.createElement('div')); + ['1', '2', '3'].forEach(k => kp.press(k)); const buf3 = kp.get(); + kp.press('X'); const stray = kp.get(); + ['4', '5', '6', '7'].forEach(k => kp.press(k)); const cap = kp.get(); + kp.press('✗'); const clr = kp.get(); + kp.press('1'); kp.press('✓'); const commit = kp.get(); + return JSON.stringify({ buf3, stray, cap, clr, commit }); + })()`); + ok('entryKeypad buffers digits only, caps at 6, clears and commits', + b5dom === JSON.stringify({ buf3: '123', stray: '123', cap: '123456', clr: '', commit: '' }), b5dom); + // late exceptions from interactions const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); ok('no exceptions after interaction', errs2.length === 0); |
