aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-18 15:26:13 -0500
committerCraig Jennings <c@cjennings.net>2026-07-18 15:26:13 -0500
commit17e07d6bcc0b57ced2d319f008c84ed514fbf1c8 (patch)
tree9fc2bcc691f9cbcf2325711272125c1c29938cb0
parent5d8caa697791ba4ea533b066310a69d1a6b58748 (diff)
downloadarchsetup-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.js149
-rw-r--r--tests/gallery-probes/probe.mjs40
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);