aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/prototypes/widgets.js152
-rw-r--r--tests/gallery-probes/probe.mjs35
2 files changed, 144 insertions, 43 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index 4789763..64a0283 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -1239,32 +1239,46 @@ DUPRE.thumbwheel = function (host, opts = {}) {
return { el: w, get: () => ((val % 100) + 100) % 100, set };
};
-/* N09 DIP-switch bank — hard flags, up is on; readout is the binary word */
+/* N09 DIP-switch bank — hard flags, up is on; readout is the binary word.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: bits (array of booleans, one switch each, default 6 switches);
+ onChange(word, label) — both are the binary word string ('101100').
+ handle: el, get() (the word string), set(w) — a word string; position i
+ goes up on '1', down on any other character.
+ CSS lives in the "DIP bank" block of DUPRE_CSS; no other styles involved. */
DUPRE.dipBank = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const bits = opts.bits || [true, false, true, true, false, false];
- const bank = document.createElement('span'); bank.className = 'dip'; host.appendChild(bank);
+ const bank = document.createElement('span'); bank.className = 'dupre-dip'; host.appendChild(bank);
const sws = bits.map(on => {
- const s = document.createElement('span'); s.className = 'dipsw' + (on ? ' on' : '');
+ const s = document.createElement('span'); s.className = 'dupre-dipsw' + (on ? ' dupre-on' : '');
s.innerHTML = '<i></i>'; bank.appendChild(s); return s;
});
- const word = () => sws.map(x => x.classList.contains('on') ? '1' : '0').join('');
+ const word = () => sws.map(x => x.classList.contains('dupre-on') ? '1' : '0').join('');
const upd = () => onChange(word(), word());
- sws.forEach(s => s.addEventListener('click', () => { s.classList.toggle('on'); upd(); }));
+ sws.forEach(s => s.addEventListener('click', () => { s.classList.toggle('dupre-on'); upd(); }));
upd();
- return { el: bank, get: word, set: w => { sws.forEach((s, i) => s.classList.toggle('on', w[i] === '1')); upd(); } };
+ return { el: bank, get: word, set: w => { sws.forEach((s, i) => s.classList.toggle('dupre-on', w[i] === '1')); upd(); } };
};
-/* N10 jog / shuttle wheel — scrub fine; position accumulates without limit */
+/* N10 jog / shuttle wheel — scrub fine; position accumulates without limit.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (initial position, default 0); onChange(v, label) on every
+ move.
+ handle: el, get() (the position), set(v) — unclamped by design; the jog
+ is a relative scrub, not an absolute dial (drag spans ±100000).
+ CSS lives in the "jog / shuttle" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.jogWheel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const j = document.createElement('span'); j.className = 'jog';
- j.innerHTML = `<span class="shuttle" style="--sh:40deg"></span><span class="inner"><span class="dimple"></span></span>`;
+ const j = document.createElement('span'); j.className = 'dupre-jog';
+ j.innerHTML = `<span class="dupre-shuttle" style="--sh:40deg"></span><span class="dupre-inner"><span class="dupre-dimple"></span></span>`;
host.appendChild(j);
+ const inner = j.querySelector('.dupre-inner');
let val;
const set = v => {
val = v;
- j.querySelector('.inner').style.transform = `rotate(${val * 4}deg)`;
+ inner.style.transform = `rotate(${val * 4}deg)`;
onChange(val, 'pos ' + Math.round(val));
};
dragDelta(j, () => val, set, { min: -100000, max: 100000, sens: 0.5 });
@@ -1306,7 +1320,12 @@ function stageSvg(host, cls, vw, vh) {
let uidN = 0;
function uid(prefix) { return prefix + '-' + (++uidN); }
-/* R02 calibrated vernier dial — the disc turns under a fixed hairline */
+/* R02 calibrated vernier dial — the disc turns under a fixed hairline.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 42.0); onChange(v, label) on every move.
+ handle: el, get(), set(v) (clamped 0-100).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.vernierDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 150, 150), cx = 75, cy = 75;
@@ -1330,13 +1349,19 @@ DUPRE.vernierDial = function (host, opts = {}) {
svgEl(s, 'ellipse', { cx: 69, cy: 66, rx: 7, ry: 4, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-32,69,66)' });
svgEl(s, 'line', { x1: cx, y1: 6, x2: cx, y2: 22, stroke: 'var(--fail)', 'stroke-width': 2.2, 'stroke-linecap': 'round' });
let val;
- const set = v => { val = v; disc.setAttribute('transform', `rotate(${-v * 3},75,75)`); onChange(val, val.toFixed(1)); };
+ const set = v => { val = Math.max(0, Math.min(100, v)); disc.setAttribute('transform', `rotate(${-val * 3},75,75)`); onChange(val, val.toFixed(1)); };
dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.15 });
set(opts.value !== undefined ? opts.value : 42.0);
return { el: s, get: () => val, set };
};
-/* R03 bat-handle toggle — chrome lever throws between lit legends */
+/* R03 bat-handle toggle — chrome lever throws between lit legends.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: on (initial state, default true); onLabel / offLabel (legends,
+ default 'ON' / 'OFF'); onChange(on, label) on every throw.
+ handle: el, get() (boolean), set(v) (coerced to boolean).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.batToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 70, 90), cx = 35, cy = 44;
@@ -1367,7 +1392,13 @@ DUPRE.batToggle = function (host, opts = {}) {
return { el: s, get: () => on, set };
};
-/* R04 bakelite fluted knob — scallop skirt over a printed 0-10 arc */
+/* R04 bakelite fluted knob — scallop skirt over a printed 0-10 arc.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100 internally, default 63; the arc prints it as 0-10);
+ onChange(v, label) on every move, label as 'x.x / 10'.
+ handle: el, get(), set(v) (clamped 0-100).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.flutedKnob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 54;
@@ -1388,25 +1419,32 @@ DUPRE.flutedKnob = function (host, opts = {}) {
svgEl(s, 'circle', { cx, cy, r: 19, fill: 'url(#bakeDome)' });
svgEl(s, 'ellipse', { cx: 49, cy: 46, rx: 6, ry: 3.5, fill: 'rgba(255,255,255,.14)', transform: 'rotate(-28,49,46)' });
let val;
- const set = v => { val = v; idx.setAttribute('transform', `rotate(${v * 2.7 - 135},55,54)`); onChange(val, (val / 10).toFixed(1) + ' / 10'); };
+ const set = v => { val = Math.max(0, Math.min(100, v)); idx.setAttribute('transform', `rotate(${val * 2.7 - 135},55,54)`); onChange(val, (val / 10).toFixed(1) + ' / 10'); };
dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.25 });
set(opts.value !== undefined ? opts.value : 63);
return { el: s, get: () => val, set };
};
-/* R05 filter slider bank — a dense fader wall on a real faceplate. Period skins
- as constructor opts backed by DUPRE.filterBank.STYLES, three independent axes:
- panel (silver hi-fi aluminum / studio black), caps — the cap SHAPE
- (chrome T / short ribbed / tall block fader), and capColor — the cap
- FINISH (black white-index / color stripes / chrome / cream). opts.style
- picks a native trio (silver+chrome+chrome, studio+ribbed+red);
- opts.panel / opts.caps / opts.capColor override an axis; setStyle(axis, name)
- restyles live (values kept). After the Pioneer SG-9500, the Technics
- SH-8065, and the Zaxcom Oasis block faders. */
+/* R05 filter slider bank — a dense fader wall on a real faceplate. After the
+ Pioneer SG-9500, the Technics SH-8065, and the Zaxcom Oasis block faders.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: freqs (band centers in Hz, default 12 bands); values (initial dB
+ cuts 0-60, padded/truncated to freqs); onChange({band, hz, db},
+ label) per move. Period skins via DUPRE.filterBank.STYLES, three
+ independent axes: panel (silver hi-fi aluminum / studio black),
+ caps — the cap SHAPE (chrome T / short ribbed / tall block fader),
+ capColor — the cap FINISH (black white-index / color stripes /
+ chrome / cream). opts.style picks a native trio (silver+chrome+
+ chrome, studio+ribbed+red); opts.panel / opts.caps / opts.capColor
+ override an axis.
+ handle: el, get() (dB array copy), set(i, db) (band clamped, dB clamped
+ 0-60), setStyle(axis, name) — restyles live, values kept.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.filterBank = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const freqs = opts.freqs || [78, 113, 163, 235, 339, 487, 701, 1010, 1450, 2090, 3010, 4340];
- const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48, 38, 26, 44, 32]).slice(0, freqs.length);
+ const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48, 38, 26, 44, 32]).slice(0, freqs.length).map(v => Math.max(0, Math.min(60, v)));
while (vals.length < freqs.length) vals.push(30);
const fmtHz = f => f < 1000 ? f : (f / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
const ST = DUPRE.filterBank.STYLES;
@@ -1452,6 +1490,7 @@ DUPRE.filterBank = function (host, opts = {}) {
}
};
const set = (i, db) => {
+ i = Math.max(0, Math.min(freqs.length - 1, i));
db = Math.max(0, Math.min(60, db)); vals[i] = db; place(i);
onChange({ band: i, hz: freqs[i], db }, `${fmtHz(freqs[i])} Hz · −${Math.round(db)} dB`);
};
@@ -1519,7 +1558,14 @@ DUPRE.filterBank.STYLES = {
},
};
-/* R06 chicken-head selector — tapered lever aims at the position */
+/* R06 chicken-head selector — tapered lever aims at the position.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: items ([label, angleDeg] pairs, default OFF/LO/MID/HI); index
+ (initial position, default 2); onChange(index, label) per step.
+ Click advances one position, wrapping.
+ handle: el, get() (the index), set(i) (wraps modulo items.length).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.chickenHead = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || [['OFF', -60], ['LO', -20], ['MID', 20], ['HI', 60]];
@@ -1550,7 +1596,12 @@ DUPRE.chickenHead = function (host, opts = {}) {
return { el: s, get: () => idx, set };
};
-/* R12 chrome slot fader — engraved dB scale, chrome T-handle in a screwed plate */
+/* R12 chrome slot fader — engraved dB scale, chrome T-handle in a screwed plate.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (dB, default -4); onChange(db, label) on every move.
+ handle: el, get() (the dB), set(v) (clamped -24..+12).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.slotFader = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 90, 150), cx = 45, yTop = 30, yBot = 120;
@@ -1587,7 +1638,12 @@ DUPRE.slotFader = function (host, opts = {}) {
return { el: s, get: () => db, set };
};
-/* R14 spade-pointer tuning knob — engraved relief arc, knurl ring turns with it */
+/* R14 spade-pointer tuning knob — engraved relief arc, knurl ring turns with it.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-10, default 8.3); onChange(v, label) on every move.
+ handle: el, get(), set(v) (clamped 0-10).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.spadeKnob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 140, 124), cx = 70, cy = 82;
@@ -1626,7 +1682,16 @@ DUPRE.spadeKnob = function (host, opts = {}) {
return { el: s, get: () => val, set };
};
-/* R15 multi-band dial — nested arcs, one needle; the bandspread dial selects the ring */
+/* R15 multi-band dial — nested arcs, one needle; the bandspread dial selects the ring.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: ranges ([lo, hi] Mc pairs, one ring each, default 4 bands); value
+ (0-100 across the ring, default 45); band (initial ring index,
+ default 2); onChange({band, mc}, label) on every change. Dragging
+ the main dial tunes; clicking the bandspread dial cycles bands.
+ handle: el, get() ([value, band]), set(v, b) (value clamped 0-100, band
+ clamped to ranges; omit b to keep the current band).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.multiBandDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const ranges = opts.ranges || [[0.54, 1.6], [1.6, 5.1], [5.1, 15.5], [15.5, 30.5]];
@@ -1659,13 +1724,14 @@ DUPRE.multiBandDial = function (host, opts = {}) {
svgEl(s, 'circle', { cx: bx, cy: by, r: 6, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 });
svgEl(s, 'text', { x: bx, y: 96, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'BANDSPREAD';
let val, band;
- const set = (v, b) => {
- val = Math.max(0, Math.min(100, v)); band = b;
+ const set = (v, b = band) => {
+ val = Math.max(0, Math.min(100, v));
+ band = Math.max(0, Math.min(ranges.length - 1, b));
needle.setAttribute('transform', `rotate(${-70 + val / 100 * 140},62,62)`);
- spread.setAttribute('transform', `rotate(${-45 + b * 30},142,56)`);
- rings.forEach((g, i) => g.setAttribute('opacity', i === b ? '1' : '.4'));
- const [lo, hi] = ranges[b]; const mc = lo + val / 100 * (hi - lo);
- onChange({ band: b, mc }, `B${b + 1} · ${mc.toFixed(2)} Mc`);
+ spread.setAttribute('transform', `rotate(${-45 + band * 30},142,56)`);
+ rings.forEach((g, i) => g.setAttribute('opacity', i === band ? '1' : '.4'));
+ const [lo, hi] = ranges[band]; const mc = lo + val / 100 * (hi - lo);
+ onChange({ band, mc }, `B${band + 1} · ${mc.toFixed(2)} Mc`);
};
const dragHit = svgEl(s, 'rect', { x: 9, y: 9, width: 100, height: 92, fill: 'transparent' });
dragHit.style.cursor = 'ns-resize';
@@ -5183,21 +5249,21 @@ const DUPRE_CSS = `
background:var(--well);border:1px solid #231f18;border-radius:4px;padding:3px 8px}
/* DIP bank */
-.dip{display:flex;gap:3px;padding:6px 7px;background:#0f1a2a;border:1px solid #24344a;border-radius:5px;
+.dupre-dip{display:flex;gap:3px;padding:6px 7px;background:#0f1a2a;border:1px solid #24344a;border-radius:5px;
box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
-.dipsw{width:12px;height:26px;background:#0a1220;border-radius:2px;position:relative;cursor:pointer;border:1px solid #1c2c42}
-.dipsw i{position:absolute;left:1px;right:1px;height:11px;border-radius:1px;background:linear-gradient(180deg,#eae4d2,#b4ad98);
+.dupre-dipsw{width:12px;height:26px;background:#0a1220;border-radius:2px;position:relative;cursor:pointer;border:1px solid #1c2c42}
+.dupre-dipsw i{position:absolute;left:1px;right:1px;height:11px;border-radius:1px;background:linear-gradient(180deg,#eae4d2,#b4ad98);
bottom:1px;transition:bottom .12s,top .12s}
-.dipsw.on i{bottom:auto;top:1px;background:linear-gradient(180deg,var(--amber-grad-top),var(--gold))}
+.dupre-dipsw.dupre-on i{bottom:auto;top:1px;background:linear-gradient(180deg,var(--amber-grad-top),var(--gold))}
/* jog / shuttle */
-.jog{position:relative;width:74px;height:74px;cursor:ns-resize;touch-action:none}
-.jog .shuttle{position:absolute;inset:0;border-radius:50%;border:6px solid #1a1713;
+.dupre-jog{position:relative;width:74px;height:74px;cursor:ns-resize;touch-action:none}
+.dupre-jog .dupre-shuttle{position:absolute;inset:0;border-radius:50%;border:6px solid #1a1713;
background:conic-gradient(from -90deg,#3a352c 0 var(--sh,40deg),#141210 0 360deg);
box-shadow:inset 0 0 8px rgba(0,0,0,.6)}
-.jog .inner{position:absolute;inset:12px;border-radius:50%;transition:transform .05s linear;
+.dupre-jog .dupre-inner{position:absolute;inset:12px;border-radius:50%;transition:transform .05s linear;
background:radial-gradient(circle at 42% 36%,#2c2822,#100e0c);border:1px solid #3a352c;box-shadow:0 2px 5px rgba(0,0,0,.6)}
-.jog .dimple{position:absolute;left:50%;top:7px;width:8px;height:8px;margin-left:-4px;border-radius:50%;
+.dupre-jog .dupre-dimple{position:absolute;left:50%;top:7px;width:8px;height:8px;margin-left:-4px;border-radius:50%;
background:radial-gradient(circle at 40% 35%,#151210,#000);box-shadow:inset 0 1px 2px rgba(0,0,0,.9)}
/* oscilloscope — screen-family vars with the original green as fallback */
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
index e4696ad..87197d2 100644
--- a/tests/gallery-probes/probe.mjs
+++ b/tests/gallery-probes/probe.mjs
@@ -1165,6 +1165,41 @@ try {
ok('presetBank and dualKnob set() clamp out-of-range input',
clamps === JSON.stringify({ hi: 3, lo: 0, o: 100, i: 0 }), clamps);
+ // Batch-4 set() hardening, same detached-host shape: vernierDial and
+ // flutedKnob clamp 0-100; filterBank clamps the band index (an out-of-range
+ // band lands on the last, it doesn't throw); multiBandDial clamps the band
+ // to its rings and keeps the current band when set() omits it.
+ const clamps4 = await evl(`(() => {
+ const vd = DUPRE.vernierDial(document.createElement('div'));
+ vd.set(500); const vHi = vd.get();
+ vd.set(-5); const vLo = vd.get();
+ const fk = DUPRE.flutedKnob(document.createElement('div'));
+ fk.set(500); const fHi = fk.get();
+ const fb = DUPRE.filterBank(document.createElement('div'));
+ fb.set(99, 30); const fbLast = fb.get()[11];
+ const mb = DUPRE.multiBandDial(document.createElement('div'));
+ mb.set(50, 99); const [, bHi] = mb.get();
+ mb.set(70); const [v2, bKeep] = mb.get();
+ return JSON.stringify({ vHi, vLo, fHi, fbLast, bHi, v2, bKeep });
+ })()`);
+ ok('batch-4 set() clamps: vernier/fluted 0-100, filterBank + multiBand band index',
+ clamps4 === JSON.stringify({ vHi: 100, vLo: 0, fHi: 100, fbLast: 30, bHi: 3, v2: 70, bKeep: 3 }), clamps4);
+
+ // The batch-4 rename holds end to end: a detached dipBank round-trips its
+ // word through the dupre- prefixed switch classes, and jogWheel's cached
+ // inner disc actually rotates on set.
+ const b4dom = await evl(`(() => {
+ const db = DUPRE.dipBank(document.createElement('div'));
+ db.set('101');
+ const jw = DUPRE.jogWheel(document.createElement('div'));
+ jw.set(10);
+ const inner = jw.el.querySelector('.dupre-inner');
+ return JSON.stringify({ word: db.get(), sw: db.el.querySelectorAll('.dupre-dipsw').length,
+ rot: inner ? inner.style.transform : 'missing' });
+ })()`);
+ ok('dipBank word round-trips and jogWheel rotates via dupre- classes',
+ b4dom === JSON.stringify({ word: '101000', sw: 6, rot: 'rotate(40deg)' }), b4dom);
+
// late exceptions from interactions
const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
ok('no exceptions after interaction', errs2.length === 0);