aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/widgets.js
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-25 15:00:56 -0500
committerCraig Jennings <c@cjennings.net>2026-07-25 15:00:56 -0500
commit2c416f5361ed10cd73eb72eb5062261b2e73c18f (patch)
tree5c84c09deb2c8392544020d432ffcd0fccde79f2 /docs/prototypes/widgets.js
parentd52da959bc416e09bc0f3e54994563bd80e19c64 (diff)
downloadarchsetup-2c416f5361ed10cd73eb72eb5062261b2e73c18f.tar.gz
archsetup-2c416f5361ed10cd73eb72eb5062261b2e73c18f.zip
refactor(prototypes): merge casting widgets into Dupre kit
Diffstat (limited to 'docs/prototypes/widgets.js')
-rw-r--r--docs/prototypes/widgets.js160
1 files changed, 135 insertions, 25 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index 57ad33e..508ec3c 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -1910,56 +1910,138 @@ DUPRE.waveRegion = function (host, opts = {}) {
return { el: s, get: () => ({ s: S, e: E }), set };
};
-/* R20 drum roller selector — numbered paper drum in a window, center chip reads it.
+/* A1 multi-detent fader — a horizontal attenuator with speedbump detents.
+
+ Contract:
+ opts: value (0-100, default 68); detents (default [50]); magnet (capture
+ distance, default 3); escape (extra distance needed to leave a
+ detent, default 3); onChange(value, label).
+ handle: el, get(), set(value), parked() (detent value or null). */
+DUPRE.detentFader = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const detents = (opts.detents || [50]).slice().sort((a, b) => a - b);
+ const MAGNET = opts.magnet !== undefined ? opts.magnet : 3;
+ const ESCAPE = opts.escape !== undefined ? opts.escape : 3;
+ const el = document.createElement('span'); el.className = 'dupre-dfader';
+ el.innerHTML = '<span class="df-slot"></span><span class="df-fill"></span>';
+ const ticks = detents.map(d => {
+ const t = document.createElement('span'); t.className = 'df-tick';
+ t.style.left = `calc(8px + ${d / 100} * (100% - 16px))`;
+ el.appendChild(t); return t;
+ });
+ const cap = document.createElement('span'); cap.className = 'df-cap';
+ el.appendChild(cap); host.appendChild(el);
+
+ let v, park = null;
+ const paint = () => {
+ const left = `calc(8px + ${v / 100} * (100% - 16px))`;
+ cap.style.left = left;
+ el.querySelector('.df-fill').style.width =
+ `calc(${v / 100} * (100% - 18px))`;
+ el.classList.toggle('df-parked', park !== null);
+ ticks.forEach((t, i) =>
+ t.classList.toggle('df-park', detents[i] === park));
+ };
+ const apply = raw => {
+ raw = Math.max(0, Math.min(100, raw));
+ if (park !== null) {
+ if (Math.abs(raw - park) <= MAGNET + ESCAPE) raw = park;
+ else park = null;
+ }
+ if (park === null) {
+ const near = detents.find(d => Math.abs(raw - d) <= MAGNET);
+ if (near !== undefined) { park = near; raw = near; }
+ }
+ const nv = Math.round(raw);
+ if (nv === v && el.dataset.painted) return;
+ v = nv; el.dataset.painted = '1'; paint();
+ onChange(v, park !== null ? `${v} · detent` : String(v));
+ };
+ el.addEventListener('pointerdown', e => {
+ el.setPointerCapture(e.pointerId);
+ const pct = m => {
+ const r = el.getBoundingClientRect();
+ return ((m.clientX - r.left - 8) / (r.width - 16)) * 100;
+ };
+ apply(pct(e));
+ const move = m => apply(pct(m));
+ const up = () => {
+ el.removeEventListener('pointermove', move);
+ el.removeEventListener('pointerup', up);
+ el.removeEventListener('pointercancel', up);
+ };
+ el.addEventListener('pointermove', move);
+ el.addEventListener('pointerup', up);
+ el.addEventListener('pointercancel', up);
+ e.preventDefault();
+ });
+ v = -1; apply(opts.value !== undefined ? opts.value : 68);
+ return { el, get: () => v, set: apply, parked: () => park };
+};
+
+/* R20 drum roller selector — numbered paper drums in a window.
Contract (everything a consumer needs; no page globals touched):
opts: title (engraved header, default 'EQUALIZER'); channels (array of
- { name, v } drums, v 1-10, default HIGH/LOW pair — the layout is
- sized for two); onChange(values array, 'NAME v · NAME v') fires on
- every set, including the initial paint.
- handle: el, get() (values array), set(i, v) — v clamps 1-10; each drum
- drags vertically on its own hit strip.
+ { name, v } drums, any count >= 1, default HIGH/LOW pair);
+ min/max (default 1/10); height (default 140); onChange(values,
+ 'NAME v · ...') fires on every set, including initial paint.
+ handle: el, get() (values array), set(i, v); each drum drags vertically.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.drumRoller = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const chans = (opts.channels || [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }]).map(c => ({ name: c.name, v: c.v }));
- const s = stageSvg(host, 'rsvg', 130, 140), cy = 76, step = 17;
+ const MIN = opts.min !== undefined ? opts.min : 1;
+ const MAX = opts.max !== undefined ? opts.max : 10;
+ const chans = (opts.channels ||
+ [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }])
+ .map(c => ({ name: c.name, v: c.v }));
+ const N = chans.length;
+ const vw = Math.max(130, N * 50 + 40);
+ const vh = Math.max(100, opts.height !== undefined ? opts.height : 140);
+ const railY = 24, railH = vh - 36, winY = railY + 4, winH = railH - 8;
+ const s = stageSvg(host, 'rsvg', vw, vh);
+ const cy = winY + winH / 2, step = 17;
gradDef('thRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]);
gradDef('drPaper', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#cfc6a8'], ['.5', '#ece4c8'], ['1', '#c6bd9e']]);
gradDef('drCurve', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'rgba(0,0,0,.4)'], ['.28', 'rgba(0,0,0,0)'], ['.72', 'rgba(0,0,0,0)'], ['1', 'rgba(0,0,0,.4)']]);
const localDefs = svgEl(s, 'defs', {});
- svgEl(s, 'text', { x: 65, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'EQUALIZER';
+ if (opts.title !== '')
+ svgEl(s, 'text', { x: vw / 2, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'EQUALIZER';
const grps = [];
+ const label = () =>
+ chans.map(c => `${c.name} ${Math.round(c.v)}`).join(' · ');
const set = (i, v) => {
- v = Math.max(1, Math.min(10, v)); chans[i].v = v;
+ v = Math.max(MIN, Math.min(MAX, v)); chans[i].v = v;
const p = grps[i];
p.nums.style.transform = `translateY(${(v - p.base) * step}px)`;
p.chip.textContent = Math.round(v);
- onChange(chans.map(c => c.v), `${chans[0].name} ${Math.round(chans[0].v)} · ${chans[1].name} ${Math.round(chans[1].v)}`);
+ onChange(chans.map(c => c.v), label());
};
+ const x0 = (vw - (N - 1) * 50) / 2;
chans.forEach((ch, i) => {
- const x = 45 + i * 50, cid = uid('drClip');
- svgEl(s, 'text', { x: x + 18, y: 34, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '+';
- svgEl(s, 'text', { x: x + 18, y: 124, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '−';
- svgEl(s, 'rect', { x: x - 12, y: 24, width: 24, height: 104, rx: 4, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 });
+ const x = x0 + i * 50, cid = uid('drClip');
+ svgEl(s, 'text', { x: x + 18, y: winY + 8, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '+';
+ svgEl(s, 'text', { x: x + 18, y: winY + winH - 2, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '−';
+ svgEl(s, 'rect', { x: x - 12, y: railY, width: 24, height: railH, rx: 4, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 });
const clip = svgEl(localDefs, 'clipPath', { id: cid });
- svgEl(clip, 'rect', { x: x - 9, y: 28, width: 18, height: 96 });
- svgEl(s, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drPaper)' });
+ svgEl(clip, 'rect', { x: x - 9, y: winY, width: 18, height: winH });
+ svgEl(s, 'rect', { x: x - 9, y: winY, width: 18, height: winH, fill: 'url(#drPaper)' });
const g = svgEl(s, 'g', { 'clip-path': `url(#${cid})` });
const nums = svgEl(g, 'g', {}); nums.style.transition = 'transform .12s';
- for (let n = 1; n <= 10; n++)
+ for (let n = MIN; n <= MAX; n++)
svgEl(nums, 'text', { x, y: cy + 3 + (ch.v - n) * step, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#2b2418' }).textContent = n;
- svgEl(g, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drCurve)', 'pointer-events': 'none' });
+ svgEl(g, 'rect', { x: x - 9, y: winY, width: 18, height: winH, fill: 'url(#drCurve)', 'pointer-events': 'none' });
svgEl(s, 'rect', { x: x - 8, y: cy - 6.5, width: 16, height: 13, rx: 1.5, fill: '#1a1613', opacity: .92 });
const chip = svgEl(s, 'text', { x, y: cy + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' });
- svgEl(s, 'text', { x, y: 136, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name;
- const hit = svgEl(s, 'rect', { x: x - 14, y: 24, width: 28, height: 104, fill: 'transparent' });
+ svgEl(s, 'text', { x, y: vh - 4, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name;
+ const hit = svgEl(s, 'rect', { x: x - 14, y: railY, width: 28, height: railH, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
grps.push({ nums, chip, base: ch.v });
- dragDelta(hit, () => chans[i].v, v => set(i, v), { min: 1, max: 10, sens: .08 });
+ dragDelta(hit, () => chans[i].v, v => set(i, v),
+ { min: MIN, max: MAX, sens: (MAX - MIN) / (winH * 1.1) });
});
- set(0, chans[0].v); set(1, chans[1].v);
+ chans.forEach((c, i) => set(i, c.v));
return { el: s, get: () => chans.map(c => c.v), set };
};
@@ -2251,11 +2333,13 @@ DUPRE.guardedToggle = function (host, opts = {}) {
svgEl(lever, 'path', { d: `M ${cx - 3} ${cy} L ${cx - 2} 26 L ${cx + 2} 26 L ${cx + 3} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 });
svgEl(lever, 'rect', { x: cx - 4.5, y: 16, width: 9, height: 12, rx: 2, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 });
for (let r = 0; r < 4; r++) svgEl(lever, 'line', { x1: cx - 3.5, y1: 18.5 + r * 2.4, x2: cx + 3.5, y2: 18.5 + r * 2.4, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 });
- lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s';
+ lever.style.transformBox = 'view-box';
+ lever.style.transformOrigin = `${cx}px ${cy}px`;
+ lever.style.transition = 'transform .16s ease-in-out';
let on;
const set = v => {
on = !!v;
- lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)';
+ lever.style.transform = on ? 'rotateX(0deg)' : 'rotateX(180deg)';
lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)');
lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)');
onChange(on, on ? 'ON' : 'OFF');
@@ -6089,6 +6173,23 @@ const DUPRE_CSS = `
.dupre-patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%}
.dupre-patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round}
+/* detent fader */
+.dupre-dfader{position:relative;display:inline-block;width:230px;height:36px;cursor:ew-resize;touch-action:none}
+.dupre-dfader .df-slot{position:absolute;left:8px;right:8px;top:15px;height:6px;border-radius:4px;
+ background:var(--well,#0a0c0d);border:1px solid #0a0908;box-shadow:inset 0 1px 2px #000}
+.dupre-dfader .df-fill{position:absolute;left:9px;top:16px;height:4px;border-radius:3px;
+ background:linear-gradient(180deg,var(--amber-grad-top,#f2c76a),var(--gold,#e2a038));pointer-events:none}
+.dupre-dfader .df-tick{position:absolute;top:9px;width:2px;height:18px;border-radius:1px;
+ background:var(--steel,#969385);opacity:.55;transform:translateX(-50%);pointer-events:none}
+.dupre-dfader .df-tick.df-park{background:var(--gold-hi,#ffbe54);opacity:1;
+ box-shadow:0 0 5px rgba(var(--glow-hi,255,190,84),.7)}
+.dupre-dfader .df-cap{position:absolute;top:5px;width:12px;height:26px;border-radius:3px;transform:translateX(-50%);
+ background:linear-gradient(90deg,#7e7a70,#e8e5db 45%,#8a867c);border:1px solid #4e4a42;
+ box-shadow:0 2px 4px #000a;pointer-events:none}
+.dupre-dfader .df-cap::after{content:"";position:absolute;left:50%;top:3px;bottom:3px;width:2px;
+ transform:translateX(-50%);background:rgba(0,0,0,.35);border-radius:1px}
+.dupre-dfader.df-parked .df-cap{box-shadow:0 2px 4px #000a,0 0 7px rgba(var(--glow-hi,255,190,84),.55)}
+
/* ===== reference-batch (R) instruments — SVG-first, after period hardware ===== */
.rsvg{display:block}
.rsvg.drag{cursor:ns-resize;touch-action:none}
@@ -6136,6 +6237,15 @@ DUPRE.radarSweep.POLICY = { kind: 'screen',
DUPRE.abcKeypad.POLICY = { kind: 'screen',
why: 'The entry window is a screen like any other; its phosphor was made in several colours. (Mixed card: the keys are fixed-function and not a colour choice.)',
authentic: 'The window’s screen family. The keycap colours are functional and stay put.' };
+DUPRE.detentFader.POLICY = { kind: 'relational',
+ why: 'The gold parked detent only means anything against the quieter ordinary scale ticks.',
+ authentic: 'The detent and scale remain a contrasting pair; their material finishes and spacing may vary.' };
+DUPRE.drumRoller.POLICY = { kind: 'material',
+ why: 'Its colours describe paper, ink and the dark mechanical housing rather than application state.',
+ authentic: 'Paper and metal finishes from physical numbered drum selectors; the channel count and numeric range may vary.' };
+DUPRE.guardedToggle.POLICY = { kind: 'coded',
+ why: 'The red guard and collar mark a critical protected throw; recolouring them would erase that warning grammar.',
+ authentic: 'Red warning hardware with neutral metal lever and posts. Labels and initial position may vary.' };
Object.assign(DUPRE, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS });
window.DUPRE = DUPRE;