aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-18 14:09:35 -0500
committerCraig Jennings <c@cjennings.net>2026-07-18 14:09:35 -0500
commitd0a0c0e7714e63f410a4615fdf0a8f266b471d42 (patch)
tree3bca0e4484d60938e77ba2b7efc0d24781773140
parent0b596f1c3e98d57f23aee10931348aea39da727d (diff)
downloadarchsetup-d0a0c0e7714e63f410a4615fdf0a8f266b471d42.tar.gz
archsetup-d0a0c0e7714e63f410a4615fdf0a8f266b471d42.zip
feat(gallery): bring ten more controls to the extraction bar
rotarySelector, slideRule, rocker, transport, presetBank, dualKnob, encoder, keySwitch, crossfader, and thumbwheel get contract comments, dupre- prefixed CSS, and cached element refs. presetBank and dualKnob set() now clamp out-of-range input, with a probe check covering both. rotarySelector and keySwitch cap their inputs at the five- and three-stop plates they draw. The audit's contract count moves to 34/111.
-rw-r--r--docs/prototypes/panel-widget-gallery.html4
-rw-r--r--docs/prototypes/widgets.js320
-rw-r--r--tests/gallery-probes/probe.mjs14
3 files changed, 212 insertions, 126 deletions
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html
index 3d31b6b..9ff492d 100644
--- a/docs/prototypes/panel-widget-gallery.html
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -1742,10 +1742,10 @@ if(!reduced){
setInterval(tickCounter,1600);
setInterval(tickSeven,1000);
setInterval(tickT,1000);
- document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState='running');
+ document.querySelectorAll('.dupre-reel.dupre-spin').forEach(r=>r.style.animationPlayState='running');
}else{
paintStatic();
- document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState='paused');
+ document.querySelectorAll('.dupre-reel.dupre-spin').forEach(r=>r.style.animationPlayState='paused');
}
/* R35, R36 extracted to widgets.js (DUPRE.*) */
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index 1665b23..4789763 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -890,25 +890,34 @@ DUPRE.lampRow = function (host, opts = {}) {
return { el: row, get: () => idx, set };
};
-/* 24 rotary selector — pick one of five printed detents by position */
+/* 24 rotary selector — pick one of five printed detents by position; click
+ the knob to step (wraps). Position/angle plates are fixed at five stops,
+ so values takes at most five entries.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: values (up to 5 labels, default [4,6,8,10,12]); index (2);
+ fmt(v) → readout label; onChange(value, label) per step.
+ handle: el, get() (the value), set(i) (wraps modulo values).
+ CSS lives in the "rotary selector" block of DUPRE_CSS; the spindle
+ reuses the shared dupre-knob classes from the "rotary knob" block. */
DUPRE.rotarySelector = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const values = opts.values || [4, 6, 8, 10, 12];
+ const values = (opts.values || [4, 6, 8, 10, 12]).slice(0, 5);
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';
+ const rs = document.createElement('span'); rs.className = 'dupre-rotsel';
+ const marks = values.map((v, i) => {
+ const p = document.createElement('span'); p.className = 'dupre-pos';
p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = v;
- rs.appendChild(p);
+ rs.appendChild(p); return p;
});
const k = document.createElement('span'); k.className = 'dupre-knob'; k.innerHTML = '<span class="dupre-ind"></span>'; rs.appendChild(k);
host.appendChild(rs);
+ const ind = k.querySelector('.dupre-ind');
let idx;
const set = i => {
idx = ((i % values.length) + values.length) % values.length;
- rs.querySelector('.dupre-ind').style.transform = `rotate(${ANG[idx]}deg)`;
- rs.querySelectorAll('.pos').forEach((p, j) => p.classList.toggle('on', j === idx));
+ ind.style.transform = `rotate(${ANG[idx]}deg)`;
+ marks.forEach((p, j) => p.classList.toggle('dupre-on', j === idx));
onChange(values[idx], fmt(values[idx]));
};
k.addEventListener('click', () => set(idx + 1));
@@ -919,9 +928,15 @@ DUPRE.rotarySelector = function (host, opts = {}) {
/* 25 slide-rule dial — lit pointer on a printed scale. The printed numerals
are the majors; the integer units between them get minor ticks and are
selectable too. Click a numeral, a mark, or between marks; ←/→ (↑/↓) step
- one unit. set() takes a stop index; get() returns the value. opts.skin picks
- a face from DUPRE.slideRule.STYLES (warm backlit / chrome / 80s black glass / marantz blue);
- setStyle(axis, name) restyles a live instance. */
+ one unit.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: values (majors, default [4,6,8,10,12]); value or index (2) for the
+ initial stop; fmt(v) → readout label; skin ('warm', a STYLES name:
+ warm backlit / chrome / 80s black glass / marantz blue);
+ onChange(value, label) per step.
+ handle: el, get() (the value), set(stopIndex) (clamped), setStyle(axis, name).
+ CSS lives in the "slide-rule tuner dial" block of DUPRE_CSS; skins override
+ its --tn-* vars. */
DUPRE.slideRule = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const majors = opts.values || [4, 6, 8, 10, 12];
@@ -936,7 +951,7 @@ DUPRE.slideRule = function (host, opts = {}) {
for (let u = v + 1; u < b; u++)
stops.push({ v: u, x: X[i] + (X[i + 1] - X[i]) * (u - v) / (b - v), major: false });
});
- const t = document.createElement('span'); t.className = 'tuner';
+ const t = document.createElement('span'); t.className = 'dupre-tuner';
t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value');
const setStyle = (axis, name) => {
const o = (DUPRE.slideRule.STYLES[axis] || {})[name];
@@ -946,16 +961,16 @@ DUPRE.slideRule = function (host, opts = {}) {
setStyle('skin', opts.skin || 'warm');
stops.forEach(s => {
t.insertAdjacentHTML('beforeend', s.major
- ? `<span class="tick" style="left:${s.x}px"></span><span class="mk" style="left:${s.x}px">${s.v}</span>`
- : `<span class="tick mn" style="left:${s.x}px"></span>`);
+ ? `<span class="dupre-tick" style="left:${s.x}px"></span><span class="dupre-mk" style="left:${s.x}px">${s.v}</span>`
+ : `<span class="dupre-tick dupre-mn" style="left:${s.x}px"></span>`);
});
- const ndl = document.createElement('span'); ndl.className = 'ndl'; t.appendChild(ndl);
+ const ndl = document.createElement('span'); ndl.className = 'dupre-ndl'; t.appendChild(ndl);
host.appendChild(t);
let idx;
const set = i => {
idx = Math.max(0, Math.min(stops.length - 1, i));
ndl.style.left = stops[idx].x + 'px';
- t.querySelectorAll('.mk').forEach(m => m.classList.toggle('on', +m.textContent === stops[idx].v));
+ t.querySelectorAll('.dupre-mk').forEach(m => m.classList.toggle('dupre-on', +m.textContent === stops[idx].v));
onChange(stops[idx].v, fmt(stops[idx].v));
};
t.addEventListener('click', e => {
@@ -1001,21 +1016,32 @@ DUPRE.slideRule.STYLES = {
},
};
-/* N01 rocker power switch — hard on/off, lit legend */
+/* N01 rocker power switch — hard on/off, lit legend.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onLabel ('ON'); offLabel ('OFF'); on (true); onChange(on,
+ 'ON'|'OFF') on every flip.
+ handle: el, get(), set(bool).
+ CSS lives in the "rocker" block of DUPRE_CSS; no other styles involved. */
DUPRE.rocker = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const r = document.createElement('span'); r.className = 'rocker';
- r.innerHTML = `<span class="half top">${opts.onLabel || 'ON'}</span><span class="half bot">${opts.offLabel || 'OFF'}</span>`;
+ const r = document.createElement('span'); r.className = 'dupre-rocker';
+ r.innerHTML = `<span class="dupre-half dupre-top">${opts.onLabel || 'ON'}</span><span class="dupre-half dupre-bot">${opts.offLabel || 'OFF'}</span>`;
host.appendChild(r);
let on;
- const set = v => { on = !!v; r.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
+ const set = v => { on = !!v; r.classList.toggle('dupre-on', on); onChange(on, on ? 'ON' : 'OFF'); };
r.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: r, get: () => on, set };
};
/* N02 transport cluster — rew/play/stop/rec, one lit; reels turn while playing.
- opts.animate: reels obey play state (default: not prefers-reduced-motion) */
+ Contract (everything a consumer needs; no page globals touched):
+ opts: mode ('play', one of rew|play|stop|rec); animate (reels obey play
+ state; default: not prefers-reduced-motion); onChange(mode, NAME)
+ on every pick.
+ handle: el, get() (the mode key), set(mode).
+ CSS lives in the "transport" block of DUPRE_CSS plus the reelspin
+ keyframes in the motion section. */
DUPRE.transport = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const animate = opts.animate !== undefined ? opts.animate : !matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -1023,11 +1049,12 @@ DUPRE.transport = function (host, opts = {}) {
const NAMES = { rew: 'REW', play: 'PLAY', stop: 'STOP', rec: 'REC' };
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;flex-direction:column;gap:6px;align-items:center';
- wrap.innerHTML = `<div class="reels"><span class="reel spin"><i></i><i></i><i></i></span><span class="reel spin"><i></i><i></i><i></i></span></div><div class="transport"></div>`;
- const bar = wrap.querySelector('.transport');
+ wrap.innerHTML = `<div class="dupre-reels"><span class="dupre-reel dupre-spin"><i></i><i></i><i></i></span><span class="dupre-reel dupre-spin"><i></i><i></i><i></i></span></div><div class="dupre-transport"></div>`;
+ const bar = wrap.querySelector('.dupre-transport');
+ const reels = wrap.querySelectorAll('.dupre-reel');
const btns = {};
for (const m of Object.keys(MODES)) {
- const b = document.createElement('button'); b.className = 'tbtn' + (m === 'rec' ? ' rec' : '');
+ const b = document.createElement('button'); b.className = 'dupre-tbtn' + (m === 'rec' ? ' dupre-rec' : '');
b.textContent = MODES[m]; bar.appendChild(b); btns[m] = b;
b.addEventListener('click', () => set(m));
}
@@ -1035,37 +1062,51 @@ DUPRE.transport = function (host, opts = {}) {
let mode;
const set = m => {
mode = m;
- for (const k in btns) btns[k].classList.toggle('on', k === m);
- wrap.querySelectorAll('.reel.spin').forEach(r => r.style.animationPlayState = (m === 'play' && animate) ? 'running' : 'paused');
+ for (const k in btns) btns[k].classList.toggle('dupre-on', k === m);
+ reels.forEach(r => r.style.animationPlayState = (m === 'play' && animate) ? 'running' : 'paused');
onChange(m, NAMES[m]);
};
/* initial: light the mode but leave reel play-state to CSS, matching load behavior */
mode = opts.mode || 'play';
- btns[mode].classList.add('on');
+ btns[mode].classList.add('dupre-on');
onChange(mode, NAMES[mode]);
return { el: wrap, get: () => mode, set };
};
-/* N03 radio preset bank — mechanically exclusive presets */
+/* N03 radio preset bank — mechanically exclusive presets.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: items (labels, default WIFI/ETH/CELL/OFF); active (0);
+ onChange(idx, label) on every pick.
+ handle: el, get(), set(i) (clamped).
+ CSS lives in the "radio bank" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.presetBank = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || ['WIFI', 'ETH', 'CELL', 'OFF'];
- const bank = document.createElement('div'); bank.className = 'radiobank'; host.appendChild(bank);
- const btns = items.map(t => { const b = document.createElement('button'); b.className = 'preset'; b.textContent = t; bank.appendChild(b); return b; });
+ const bank = document.createElement('div'); bank.className = 'dupre-radiobank'; host.appendChild(bank);
+ const btns = items.map(t => { const b = document.createElement('button'); b.className = 'dupre-preset'; b.textContent = t; bank.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]); };
+ const set = i => { idx = Math.max(0, Math.min(items.length - 1, i)); btns.forEach((b, j) => b.classList.toggle('dupre-on', j === idx)); onChange(idx, items[idx]); };
btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
set(opts.active || 0);
return { el: bank, get: () => idx, set };
};
-/* N04 concentric dual knob — two values on one spindle, outer ring + inner cap */
+/* N04 concentric dual knob — two values on one spindle, outer ring + inner cap.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: outer (50), inner (50), each 0..100; onChange(outer, inner) on
+ every move of either.
+ handle: el, get() ([outer, inner]), set(outer, inner) (either may be
+ undefined to hold; clamped 0..100).
+ CSS lives in the "concentric dual knob" block of DUPRE_CSS; its indicator
+ is self-styled, independent of the shared dupre-knob classes. */
DUPRE.dualKnob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const dk = document.createElement('span'); dk.className = 'dualknob';
- dk.innerHTML = `<span class="outer"><span class="tick" style="transform:rotate(-120deg)"></span><span class="tick" style="transform:rotate(120deg)"></span></span><span class="inner"><span class="ind"></span></span>`;
+ const clamp = v => Math.max(0, Math.min(100, v));
+ const dk = document.createElement('span'); dk.className = 'dupre-dualknob';
+ dk.innerHTML = `<span class="dupre-outer"><span class="dupre-tick" style="transform:rotate(-120deg)"></span><span class="dupre-tick" style="transform:rotate(120deg)"></span></span><span class="dupre-inner"><span class="dupre-ind"></span></span>`;
host.appendChild(dk);
- const o = dk.querySelector('.outer'), n = dk.querySelector('.inner');
+ const o = dk.querySelector('.dupre-outer'), n = dk.querySelector('.dupre-inner');
let oV, iV;
const paint = () => {
o.style.transform = `rotate(${-150 + oV / 100 * 300}deg)`;
@@ -1074,29 +1115,37 @@ DUPRE.dualKnob = function (host, opts = {}) {
};
dragDelta(o, () => oV, v => { oV = v; paint(); }, { min: 0, max: 100 });
dragDelta(n, () => iV, v => { iV = v; paint(); }, { min: 0, max: 100, stop: true });
- oV = opts.outer !== undefined ? opts.outer : 50;
- iV = opts.inner !== undefined ? opts.inner : 50;
+ oV = clamp(opts.outer !== undefined ? opts.outer : 50);
+ iV = clamp(opts.inner !== undefined ? opts.inner : 50);
paint();
- return { el: dk, get: () => [oV, iV], set: (a, b) => { if (a !== undefined) oV = a; if (b !== undefined) iV = b; paint(); } };
+ return { el: dk, get: () => [oV, iV], set: (a, b) => { if (a !== undefined) oV = clamp(a); if (b !== undefined) iV = clamp(b); paint(); } };
};
-/* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level */
+/* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (7, accumulates without bound); onChange(value, label) per
+ step.
+ handle: el, get(), set(v).
+ CSS lives in the "rotary encoder + LED ring" block of DUPRE_CSS; the
+ spindle reuses the shared dupre-knob classes from the "rotary knob"
+ block. */
DUPRE.encoder = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const enc = document.createElement('span'); enc.className = 'encoder'; host.appendChild(enc);
+ const enc = document.createElement('span'); enc.className = 'dupre-encoder'; host.appendChild(enc);
const R = 27, cx = 33, cy = 33;
+ const leds = [];
for (let i = 0; i < 12; i++) {
const a = (i * 30 - 90) * Math.PI / 180;
- const d = document.createElement('span'); d.className = 'led';
+ const d = document.createElement('span'); d.className = 'dupre-led';
d.style.left = (cx + R * Math.cos(a)) + 'px'; d.style.top = (cy + R * Math.sin(a)) + 'px';
- enc.appendChild(d);
+ enc.appendChild(d); leds.push(d);
}
const k = document.createElement('span'); k.className = 'dupre-knob'; k.innerHTML = '<span class="dupre-ind"></span>'; enc.appendChild(k);
let val;
const set = v => {
val = v;
const lit = ((Math.floor(val) % 12) + 12) % 12;
- enc.querySelectorAll('.led').forEach((l, i) => l.classList.toggle('on', i <= lit));
+ leds.forEach((l, i) => l.classList.toggle('dupre-on', i <= lit));
k.style.transform = `rotate(${val * 30}deg)`;
onChange(val, 'pos ' + Math.round(val));
};
@@ -1105,25 +1154,34 @@ DUPRE.encoder = function (host, opts = {}) {
return { el: enc, get: () => val, set };
};
-/* N06 keyed mode switch — guarded three-position mode, key-bit points at the live one */
+/* N06 keyed mode switch — guarded three-position mode, key-bit points at the
+ live one; click the barrel to step (wraps). Position/angle plates are fixed
+ at three stops, so items takes at most three entries.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: items (up to 3 labels, default OFF/ON/RUN); index (1);
+ onChange(idx, label) per step.
+ handle: el, get(), set(i) (wraps modulo items).
+ CSS lives in the "keyed mode switch" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.keySwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const items = opts.items || ['OFF', 'ON', 'RUN'];
+ const items = (opts.items || ['OFF', 'ON', 'RUN']).slice(0, 3);
const ANG = [-70, 0, 70], POS = [[16, 64], [50, 12], [84, 64]];
- const kl = document.createElement('span'); kl.className = 'keylock';
- items.forEach((t, i) => {
- const p = document.createElement('span'); p.className = 'kpos';
+ const kl = document.createElement('span'); kl.className = 'dupre-keylock';
+ const marks = items.map((t, i) => {
+ const p = document.createElement('span'); p.className = 'dupre-kpos';
p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = t;
- kl.appendChild(p);
+ kl.appendChild(p); return p;
});
- const body = document.createElement('span'); body.className = 'body';
- body.innerHTML = '<span class="bit"></span><span class="barrel"></span>';
+ const body = document.createElement('span'); body.className = 'dupre-body';
+ body.innerHTML = '<span class="dupre-bit"></span><span class="dupre-barrel"></span>';
kl.appendChild(body); host.appendChild(kl);
+ const bit = body.querySelector('.dupre-bit');
let idx;
const set = i => {
idx = ((i % items.length) + items.length) % items.length;
- body.querySelector('.bit').style.transform = `rotate(${ANG[idx]}deg)`;
- kl.querySelectorAll('.kpos').forEach((p, j) => p.classList.toggle('on', j === idx));
+ bit.style.transform = `rotate(${ANG[idx]}deg)`;
+ marks.forEach((p, j) => p.classList.toggle('dupre-on', j === idx));
onChange(idx, items[idx]);
};
body.addEventListener('click', () => set(idx + 1));
@@ -1131,16 +1189,23 @@ DUPRE.keySwitch = function (host, opts = {}) {
return { el: kl, get: () => idx, set };
};
-/* N07 center-detented crossfader — throw to either side of zero */
+/* N07 center-detented crossfader — throw to either side of zero.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: aLabel ('A'), bLabel ('B'); value (50, the cap position 0..100);
+ onChange(v, label) with v = position - 50 (so -50..+50) per move.
+ handle: el, get() (-50..+50), set(pct) (0..100, clamped).
+ CSS lives in the "crossfader" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.crossfader = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const xf = document.createElement('div'); xf.className = 'xfader';
- xf.innerHTML = `<div class="slot"></div><div class="detent"></div><span class="end" style="left:0">${opts.aLabel || 'A'}</span><span class="end" style="right:0">${opts.bLabel || 'B'}</span><div class="cap"></div>`;
+ const xf = document.createElement('div'); xf.className = 'dupre-xfader';
+ xf.innerHTML = `<div class="dupre-slot"></div><div class="dupre-detent"></div><span class="dupre-end" style="left:0">${opts.aLabel || 'A'}</span><span class="dupre-end" style="right:0">${opts.bLabel || 'B'}</span><div class="dupre-cap"></div>`;
host.appendChild(xf);
+ const cap = xf.querySelector('.dupre-cap');
let pct;
const set = p => {
pct = Math.max(0, Math.min(100, p));
- xf.querySelector('.cap').style.left = pct + '%';
+ cap.style.left = pct + '%';
const v = Math.round(pct - 50);
onChange(v, (v > 0 ? '+' : '') + v);
};
@@ -1149,20 +1214,27 @@ DUPRE.crossfader = function (host, opts = {}) {
return { el: xf, get: () => Math.round(pct - 50), set };
};
-/* N08 thumbwheel — knurled edge-wheel with a windowed two-digit value */
+/* N08 thumbwheel — knurled edge-wheel with a windowed two-digit value.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (42); onChange(value, label) per step, value wrapped to
+ 0..99.
+ handle: el, get() (0..99), set(v) (wraps modulo 100).
+ CSS lives in the "thumbwheel" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.thumbwheel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const w = document.createElement('div'); w.className = 'thumbw';
- w.innerHTML = '<span class="thumbwheel"></span><span class="win"></span>';
+ const w = document.createElement('div'); w.className = 'dupre-thumbw';
+ w.innerHTML = '<span class="dupre-thumbwheel"></span><span class="dupre-win"></span>';
host.appendChild(w);
+ const win = w.querySelector('.dupre-win');
let val;
const set = v => {
val = Math.round(v);
const s = ((val % 100) + 100) % 100;
- w.querySelector('.win').textContent = String(s).padStart(2, '0');
+ win.textContent = String(s).padStart(2, '0');
onChange(s, 'value ' + s);
};
- dragDelta(w.querySelector('.thumbwheel'), () => val, set, { min: 0, max: 99, sens: 0.2 });
+ dragDelta(w.querySelector('.dupre-thumbwheel'), () => val, set, { min: 0, max: 99, sens: 0.2 });
set(opts.value !== undefined ? opts.value : 42);
return { el: w, get: () => ((val % 100) + 100) % 100, set };
};
@@ -4994,22 +5066,22 @@ const DUPRE_CSS = `
.ostep b{color:var(--cream);font-weight:700}.ostep .ev{color:var(--steel);display:block;font-size:10.5px}
/* rotary selector */
-.rotsel{position:relative;width:118px;height:74px}
-.rotsel>.dupre-knob{position:absolute;left:50%;top:20px;margin-left:-26px;cursor:pointer}
-.rotsel .pos{position:absolute;font-size:9px;color:var(--dim);transform:translate(-50%,-50%);letter-spacing:.02em}
-.rotsel .pos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
+.dupre-rotsel{position:relative;width:118px;height:74px}
+.dupre-rotsel>.dupre-knob{position:absolute;left:50%;top:20px;margin-left:-26px;cursor:pointer}
+.dupre-rotsel .dupre-pos{position:absolute;font-size:9px;color:var(--dim);transform:translate(-50%,-50%);letter-spacing:.02em}
+.dupre-rotsel .dupre-pos.dupre-on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
/* slide-rule tuner dial */
-.tuner{width:180px;height:46px;position:relative;border-radius:6px;overflow:hidden;cursor:pointer;
+.dupre-tuner{width:180px;height:46px;position:relative;border-radius:6px;overflow:hidden;cursor:pointer;
background:var(--tn-bg,linear-gradient(180deg,#191510,#0b0908));border:1px solid var(--tn-brd,#2a251c);
box-shadow:inset 0 0 20px var(--tn-glow,rgba(var(--glow-lo),.12)),inset 0 1px 0 rgba(255,255,255,.03)}
-.tuner .tick{position:absolute;top:6px;width:1px;height:11px;background:var(--tn-tick,var(--steel));transform:translateX(-50%)}
-.tuner .tick.mn{height:6px;opacity:.5}
-.tuner .mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--tn-ink,var(--steel));font-size:10px}
-.tuner .mk.on{color:var(--tn-on,var(--gold-hi));text-shadow:0 0 6px var(--tn-onglow,rgba(var(--glow-hi),.6))}
-.tuner:focus{outline:1px solid rgba(var(--glow-lo),.5);outline-offset:2px}
-.tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
-.tuner .ndl{position:absolute;top:3px;bottom:3px;width:2px;margin-left:-1px;border-radius:1px;
+.dupre-tuner .dupre-tick{position:absolute;top:6px;width:1px;height:11px;background:var(--tn-tick,var(--steel));transform:translateX(-50%)}
+.dupre-tuner .dupre-tick.dupre-mn{height:6px;opacity:.5}
+.dupre-tuner .dupre-mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--tn-ink,var(--steel));font-size:10px}
+.dupre-tuner .dupre-mk.dupre-on{color:var(--tn-on,var(--gold-hi));text-shadow:0 0 6px var(--tn-onglow,rgba(var(--glow-hi),.6))}
+.dupre-tuner:focus{outline:1px solid rgba(var(--glow-lo),.5);outline-offset:2px}
+.dupre-tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
+.dupre-tuner .dupre-ndl{position:absolute;top:3px;bottom:3px;width:2px;margin-left:-1px;border-radius:1px;
background:var(--tn-ndl,var(--fail));box-shadow:0 0 7px var(--tn-ndlglow,rgba(203,107,77,.85));transition:left .25s}
/* nixie tube */
@@ -5025,89 +5097,89 @@ const DUPRE_CSS = `
/* ===== candidate-unique CSS ===== */
/* rocker */
-.rocker{width:66px;height:40px;border-radius:7px;cursor:pointer;position:relative;
+.dupre-rocker{width:66px;height:40px;border-radius:7px;cursor:pointer;position:relative;
background:linear-gradient(180deg,#25211c,#141210);border:1px solid #34302a;
box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5);overflow:hidden}
-.rocker .half{position:absolute;left:0;right:0;height:50%;display:flex;align-items:center;justify-content:center;
+.dupre-rocker .dupre-half{position:absolute;left:0;right:0;height:50%;display:flex;align-items:center;justify-content:center;
font-size:12px;letter-spacing:.06em;color:var(--dim)}
-.rocker .top{top:0;border-bottom:1px solid #0c0b0a;background:linear-gradient(180deg,#211d19,#181513)}
-.rocker .bot{bottom:0;background:linear-gradient(180deg,#141210,#100e0c);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)}
-.rocker.on .top{background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color:var(--panel);font-weight:700;box-shadow:0 0 10px rgba(var(--glow-hi),.35)}
-.rocker.on .bot{color:var(--steel)}
-.rocker:not(.on) .top{color:var(--steel)}
-.rocker:not(.on) .bot{background:linear-gradient(180deg,#2a1512,#1c0f0d);color:var(--fail);box-shadow:inset 0 3px 5px rgba(0,0,0,.55),0 0 8px rgba(203,107,77,.25)}
+.dupre-rocker .dupre-top{top:0;border-bottom:1px solid #0c0b0a;background:linear-gradient(180deg,#211d19,#181513)}
+.dupre-rocker .dupre-bot{bottom:0;background:linear-gradient(180deg,#141210,#100e0c);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)}
+.dupre-rocker.dupre-on .dupre-top{background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color:var(--panel);font-weight:700;box-shadow:0 0 10px rgba(var(--glow-hi),.35)}
+.dupre-rocker.dupre-on .dupre-bot{color:var(--steel)}
+.dupre-rocker:not(.dupre-on) .dupre-top{color:var(--steel)}
+.dupre-rocker:not(.dupre-on) .dupre-bot{background:linear-gradient(180deg,#2a1512,#1c0f0d);color:var(--fail);box-shadow:inset 0 3px 5px rgba(0,0,0,.55),0 0 8px rgba(203,107,77,.25)}
/* transport */
-.reels{display:flex;gap:16px;align-items:center;justify-content:center;margin-bottom:2px}
-.reel{width:26px;height:26px;border-radius:50%;border:2px solid #34302a;position:relative;
+.dupre-reels{display:flex;gap:16px;align-items:center;justify-content:center;margin-bottom:2px}
+.dupre-reel{width:26px;height:26px;border-radius:50%;border:2px solid #34302a;position:relative;
background:radial-gradient(circle at 45% 40%,#211d18,#0d0b09)}
-.reel::before{content:"";position:absolute;inset:9px;border-radius:50%;border:1px solid #4a443a;background:#161310}
-.reel i{position:absolute;left:50%;top:50%;width:2px;height:11px;background:var(--steel);margin:-5.5px 0 0 -1px;transform-origin:50% 5.5px}
-.reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(var(--glow-hi),.55)}
-.reel i:nth-child(2){transform:rotate(120deg)}.reel i:nth-child(3){transform:rotate(240deg)}
-.transport{display:flex;gap:5px}
-.tbtn{font:inherit;font-size:12px;color:var(--silver);cursor:pointer;width:30px;height:26px;display:grid;place-items:center;
+.dupre-reel::before{content:"";position:absolute;inset:9px;border-radius:50%;border:1px solid #4a443a;background:#161310}
+.dupre-reel i{position:absolute;left:50%;top:50%;width:2px;height:11px;background:var(--steel);margin:-5.5px 0 0 -1px;transform-origin:50% 5.5px}
+.dupre-reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(var(--glow-hi),.55)}
+.dupre-reel i:nth-child(2){transform:rotate(120deg)}.dupre-reel i:nth-child(3){transform:rotate(240deg)}
+.dupre-transport{display:flex;gap:5px}
+.dupre-tbtn{font:inherit;font-size:12px;color:var(--silver);cursor:pointer;width:30px;height:26px;display:grid;place-items:center;
background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:6px}
-.tbtn:hover{color:var(--gold);border-color:var(--gold)}
-.tbtn.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi)}
-.tbtn.rec.on{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.dupre-tbtn:hover{color:var(--gold);border-color:var(--gold)}
+.dupre-tbtn.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi)}
+.dupre-tbtn.dupre-rec.dupre-on{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
/* radio bank */
-.radiobank{display:flex;gap:0;border:1px solid #0c0b0a;border-radius:7px;overflow:hidden;
+.dupre-radiobank{display:flex;gap:0;border:1px solid #0c0b0a;border-radius:7px;overflow:hidden;
background:#0c0b0a;padding:3px;box-shadow:inset 0 2px 4px rgba(0,0,0,.5)}
-.preset{font:inherit;font-size:11px;color:var(--dim);cursor:pointer;padding:8px 9px;border:0;border-radius:4px;
+.dupre-preset{font:inherit;font-size:11px;color:var(--dim);cursor:pointer;padding:8px 9px;border:0;border-radius:4px;
background:linear-gradient(180deg,#211d19,#161310);box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}
-.preset+.preset{margin-left:3px}
-.preset:hover{color:var(--silver)}
-.preset.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));font-weight:700;
+.dupre-preset+.dupre-preset{margin-left:3px}
+.dupre-preset:hover{color:var(--silver)}
+.dupre-preset.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));font-weight:700;
box-shadow:inset 0 2px 4px rgba(0,0,0,.4)}
/* concentric dual knob */
-.dualknob{width:64px;height:64px;position:relative}
-.dualknob .outer{width:64px;height:64px;border-radius:50%;position:absolute;inset:0;cursor:ns-resize;touch-action:none;
+.dupre-dualknob{width:64px;height:64px;position:relative}
+.dupre-dualknob .dupre-outer{width:64px;height:64px;border-radius:50%;position:absolute;inset:0;cursor:ns-resize;touch-action:none;
background:radial-gradient(circle at 40% 35%,#2a2622,#100e0c);border:1px solid #3a352c;
box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
-.dualknob .outer .tick{position:absolute;left:50%;top:3px;width:2px;height:9px;background:var(--steel);margin-left:-1px;transform-origin:50% 29px}
-.dualknob .inner{width:34px;height:34px;border-radius:50%;position:absolute;left:15px;top:15px;cursor:ns-resize;touch-action:none;
+.dupre-dualknob .dupre-outer .dupre-tick{position:absolute;left:50%;top:3px;width:2px;height:9px;background:var(--steel);margin-left:-1px;transform-origin:50% 29px}
+.dupre-dualknob .dupre-inner{width:34px;height:34px;border-radius:50%;position:absolute;left:15px;top:15px;cursor:ns-resize;touch-action:none;
background:radial-gradient(circle at 40% 35%,#37322a,#1a1713);border:1px solid #4a443a;box-shadow:0 2px 4px rgba(0,0,0,.5)}
-.dualknob .inner .ind{position:absolute;left:50%;top:3px;width:2px;height:11px;background:var(--gold-hi);margin-left:-1px;
+.dupre-dualknob .dupre-inner .dupre-ind{position:absolute;left:50%;top:3px;width:2px;height:11px;background:var(--gold-hi);margin-left:-1px;
transform-origin:50% 14px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)}
/* rotary encoder + LED ring */
-.encoder{position:relative;width:66px;height:66px;display:grid;place-items:center;cursor:ns-resize;touch-action:none}
-.encoder .led{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--wash);
+.dupre-encoder{position:relative;width:66px;height:66px;display:grid;place-items:center;cursor:ns-resize;touch-action:none}
+.dupre-encoder .dupre-led{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--wash);
left:50%;top:50%;margin:-2.5px}
-.encoder .led.on{background:var(--gold);box-shadow:0 0 5px 1px rgba(var(--glow-lo),.7)}
-.encoder .dupre-knob{width:40px;height:40px;cursor:ns-resize}
-.encoder .dupre-knob .dupre-ind{transform-origin:50% 15px;top:4px;height:12px}
+.dupre-encoder .dupre-led.dupre-on{background:var(--gold);box-shadow:0 0 5px 1px rgba(var(--glow-lo),.7)}
+.dupre-encoder .dupre-knob{width:40px;height:40px;cursor:ns-resize}
+.dupre-encoder .dupre-knob .dupre-ind{transform-origin:50% 15px;top:4px;height:12px}
/* keyed mode switch */
-.keylock{position:relative;width:110px;height:70px}
-.keylock .body{position:absolute;left:50%;top:30px;margin-left:-21px;width:42px;height:42px;border-radius:50%;
+.dupre-keylock{position:relative;width:110px;height:70px}
+.dupre-keylock .dupre-body{position:absolute;left:50%;top:30px;margin-left:-21px;width:42px;height:42px;border-radius:50%;
background:radial-gradient(circle at 42% 36%,#2a2622,#0f0d0b);border:1px solid #3a352c;cursor:pointer;
box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
-.keylock .barrel{position:absolute;left:50%;top:50%;width:5px;height:5px;margin:-2.5px;border-radius:1px;background:#0a0908;
+.dupre-keylock .dupre-barrel{position:absolute;left:50%;top:50%;width:5px;height:5px;margin:-2.5px;border-radius:1px;background:#0a0908;
box-shadow:0 0 2px rgba(0,0,0,.8)}
-.keylock .bit{position:absolute;left:50%;top:50%;width:3px;height:15px;margin:-15px 0 0 -1.5px;background:var(--gold-hi);
+.dupre-keylock .dupre-bit{position:absolute;left:50%;top:50%;width:3px;height:15px;margin:-15px 0 0 -1.5px;background:var(--gold-hi);
border-radius:1px;transform-origin:50% 100%;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .2s}
-.keylock .kpos{position:absolute;font-size:9px;color:var(--dim);letter-spacing:.05em;transform:translate(-50%,-50%)}
-.keylock .kpos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
+.dupre-keylock .dupre-kpos{position:absolute;font-size:9px;color:var(--dim);letter-spacing:.05em;transform:translate(-50%,-50%)}
+.dupre-keylock .dupre-kpos.dupre-on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
/* crossfader */
-.xfader{width:160px;height:22px;position:relative;cursor:pointer;touch-action:none}
-.xfader .slot{position:absolute;top:9px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18}
-.xfader .detent{position:absolute;top:2px;left:50%;width:1px;height:18px;background:var(--steel);margin-left:-.5px;opacity:.7}
-.xfader .end{position:absolute;top:11px;font-size:9px;color:var(--steel);transform:translateY(-50%)}
-.xfader .cap{position:absolute;top:3px;width:9px;height:16px;border-radius:2px;margin-left:-4.5px;transition:left .05s;
+.dupre-xfader{width:160px;height:22px;position:relative;cursor:pointer;touch-action:none}
+.dupre-xfader .dupre-slot{position:absolute;top:9px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18}
+.dupre-xfader .dupre-detent{position:absolute;top:2px;left:50%;width:1px;height:18px;background:var(--steel);margin-left:-.5px;opacity:.7}
+.dupre-xfader .dupre-end{position:absolute;top:11px;font-size:9px;color:var(--steel);transform:translateY(-50%)}
+.dupre-xfader .dupre-cap{position:absolute;top:3px;width:9px;height:16px;border-radius:2px;margin-left:-4.5px;transition:left .05s;
background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)}
/* thumbwheel */
-.thumbw{display:flex;align-items:center;gap:10px}
-.thumbwheel{width:34px;height:52px;border-radius:6px;cursor:ns-resize;touch-action:none;position:relative;overflow:hidden;
+.dupre-thumbw{display:flex;align-items:center;gap:10px}
+.dupre-thumbwheel{width:34px;height:52px;border-radius:6px;cursor:ns-resize;touch-action:none;position:relative;overflow:hidden;
border:1px solid #34302a;box-shadow:inset 0 0 8px rgba(0,0,0,.6);
background:repeating-linear-gradient(0deg,#0e0c0a 0 2px,#221e19 2px 3px,#2f2a23 3px 5px,#221e19 5px 6px)}
-.thumbwheel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.55),transparent 30%,transparent 70%,rgba(0,0,0,.55))}
-.thumbw .win{color:var(--cream);font-size:15px;font-weight:700;font-variant-numeric:tabular-nums;
+.dupre-thumbwheel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.55),transparent 30%,transparent 70%,rgba(0,0,0,.55))}
+.dupre-thumbw .dupre-win{color:var(--cream);font-size:15px;font-weight:700;font-variant-numeric:tabular-nums;
background:var(--well);border:1px solid #231f18;border-radius:4px;padding:3px 8px}
/* DIP bank */
@@ -5357,7 +5429,7 @@ const DUPRE_CSS = `
/* transport reels spin (was a page-side keyframe injector) */
@keyframes reelspin{to{transform:rotate(360deg)}}
-.reel.spin{animation:reelspin 2.6s linear infinite}
+.dupre-reel.dupre-spin{animation:reelspin 2.6s linear infinite}
`;
function ensureCss() {
if (document.getElementById('dupre-css') || !DUPRE_CSS) return;
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
index a242c41..e4696ad 100644
--- a/tests/gallery-probes/probe.mjs
+++ b/tests/gallery-probes/probe.mjs
@@ -1151,6 +1151,20 @@ try {
})()`);
ok('every card ID badge links to its own anchor', anchors === 'ok', anchors);
+ // Batch-3 set() hardening: out-of-range input cannot wedge an instrument.
+ // presetBank clamps; dualKnob clamps both spindles. Built on detached hosts
+ // so the gallery's own cards stay untouched.
+ const clamps = await evl(`(() => {
+ const pb = DUPRE.presetBank(document.createElement('div'));
+ pb.set(99); const hi = pb.get();
+ pb.set(-5); const lo = pb.get();
+ const dk = DUPRE.dualKnob(document.createElement('div'));
+ dk.set(500, -500); const [o, i] = dk.get();
+ return JSON.stringify({ hi, lo, o, i });
+ })()`);
+ ok('presetBank and dualKnob set() clamp out-of-range input',
+ clamps === JSON.stringify({ hi: 3, lo: 0, o: 100, i: 0 }), clamps);
+
// late exceptions from interactions
const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
ok('no exceptions after interaction', errs2.length === 0);