diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-18 14:09:35 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-18 14:09:35 -0500 |
| commit | d0a0c0e7714e63f410a4615fdf0a8f266b471d42 (patch) | |
| tree | 3bca0e4484d60938e77ba2b7efc0d24781773140 | |
| parent | 0b596f1c3e98d57f23aee10931348aea39da727d (diff) | |
| download | archsetup-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.html | 4 | ||||
| -rw-r--r-- | docs/prototypes/widgets.js | 320 | ||||
| -rw-r--r-- | tests/gallery-probes/probe.mjs | 14 |
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); |
