diff options
| -rw-r--r-- | docs/prototypes/widgets.js | 152 | ||||
| -rw-r--r-- | tests/gallery-probes/probe.mjs | 35 |
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); |
