/* widgets.js — The Dupre Kit: retro-instrument component library (DUPRE namespace).
Classic script: load after the token :root block (tokens.json → gen_tokens.py),
before any DUPRE.* call. Works from file:// — no modules, no build step.
Contract: each gallery card in panel-widget-gallery.html is the visual +
behavioral spec its builder is judged against.
Spec: docs/specs/2026-07-12-component-generation-spec.org */
(function () {
'use strict';
const DUPRE = {};
/* ================= shared engine ================= */
const SVGNS = 'http://www.w3.org/2000/svg';
function svgEl(parent, tag, attrs) {
const e = document.createElementNS(SVGNS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
parent.appendChild(e); return e;
}
function polar(cx, cy, r, deg) {
const a = deg * Math.PI / 180;
return [cx + r * Math.sin(a), cy - r * Math.cos(a)];
}
/* ---- pointer-drag helpers (rect-ratio based, so CSS zoom is transparent) ---- */
function dragX(el, onPct) {
el.style.touchAction = 'none';
const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (e.clientX - r.left) / r.width * 100))); };
el.addEventListener('pointerdown', e => {
el.setPointerCapture(e.pointerId); at(e);
const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); };
el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault();
});
}
function dragY(el, onPct) {
el.style.touchAction = 'none';
const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (r.bottom - e.clientY) / r.height * 100))); };
el.addEventListener('pointerdown', e => {
el.setPointerCapture(e.pointerId); at(e);
const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); };
el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault();
});
}
function dragDelta(el, get, set, opts) {
opts = opts || {}; el.style.touchAction = 'none';
const min = opts.min == null ? 0 : opts.min, max = opts.max == null ? 100 : opts.max, sens = opts.sens || ((max - min) / 160);
el.addEventListener('pointerdown', e => {
if (opts.stop) e.stopPropagation(); el.setPointerCapture(e.pointerId);
const y0 = e.clientY, v0 = get();
const mv = ev => { let v = v0 + (y0 - ev.clientY) * sens; set(Math.max(min, Math.min(max, v))); };
const up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); };
el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault();
});
}
/* ---- seven-segment digit builder ---- */
const SEG = { '0': 'abcdef', '1': 'bc', '2': 'abged', '3': 'abgcd', '4': 'fbgc', '5': 'afgcd', '6': 'afgedc', '7': 'abc', '8': 'abcdefg', '9': 'abcdfg', '-': 'g', ' ': '' };
function hseg(cy) { return `5,${cy} 7,${cy - 2} 17,${cy - 2} 19,${cy} 17,${cy + 2} 7,${cy + 2}`; }
function vseg(cx, ty, by) { return `${cx},${ty} ${cx - 2},${ty + 2} ${cx - 2},${by - 2} ${cx},${by} ${cx + 2},${by - 2} ${cx + 2},${ty + 2}`; }
const SEGPTS = { a: hseg(3), g: hseg(22), d: hseg(41), f: vseg(5, 4, 21), b: vseg(19, 4, 21), e: vseg(5, 23, 40), c: vseg(19, 23, 40) };
function seg7(ch, cls) {
const lit = SEG[ch] || ''; let s = `';
}
function buildBars(el, n) { el.innerHTML = ''; for (let k = 0; k < n; k++) el.appendChild(document.createElement('i')); }
/* ---- VU law: authentic nonlinear dB scale, dB → 0..1 sweep position ---- */
const VUDB = [[-20, 0], [-10, .20], [-7, .31], [-5, .40], [-3, .50], [-2, .58], [-1, .66], [0, .75], [1, .84], [2, .91], [3, 1]];
/* interpolate a dB reading from a 0..1 sweep position */
function vuDb(t) {
let i = 0; while (i < VUDB.length - 2 && VUDB[i + 1][1] < t) i++;
const [d1, t1] = VUDB[i], [d2, t2] = VUDB[i + 1];
return d1 + (d2 - d1) * Math.max(0, Math.min(1, (t - t1) / (t2 - t1)));
}
/* ---- screen-color families: period phosphor/LCD palettes as scoped CSS vars.
Shipped literals are the fallbacks, so defaults are pixel-identical until applied. ---- */
const SCREEN_FAMS = {
green: { '--scr-hi': '#7fe0a0', '--scr-ink': '#58b87e', '--scr-dim': '#3d5c46',
'--scr-bg1': '#0a120c', '--scr-bg2': '#050c07', '--scr-bgc': '#04140a', '--scr-bge': '#020a05', '--scr-brd': '#123018',
'--scr-grat': 'rgba(111,206,51,.18)', '--scr-gratc': 'rgba(111,206,51,.35)', '--scr-glow': 'rgba(127,224,160,.8)',
'--crt-face1': '#b9d8c0', '--crt-face2': '#8bb296', '--crt-glow': '#eef7ee' },
amber: { '--scr-hi': '#ffbe54', '--scr-ink': '#e2a038', '--scr-dim': '#7d5c16',
'--scr-bg1': '#140d06', '--scr-bg2': '#0a0705', '--scr-bgc': '#140d06', '--scr-bge': '#0a0603', '--scr-brd': '#33230e',
'--scr-grat': 'rgba(226,160,56,.18)', '--scr-gratc': 'rgba(226,160,56,.35)', '--scr-glow': 'rgba(255,190,84,.8)',
'--crt-face1': '#d8cba6', '--crt-face2': '#b3a276', '--crt-glow': '#ffe7c0' },
red: { '--scr-hi': '#ff9a4c', '--scr-ink': '#e0742e', '--scr-dim': '#5c3416',
'--scr-bg1': '#140a05', '--scr-bg2': '#0c0603', '--scr-bgc': '#140a05', '--scr-bge': '#0a0502', '--scr-brd': '#331c0c',
'--scr-grat': 'rgba(224,116,46,.18)', '--scr-gratc': 'rgba(224,116,46,.35)', '--scr-glow': 'rgba(255,154,76,.8)',
'--crt-face1': '#d8b9a6', '--crt-face2': '#b39276', '--crt-glow': '#ffdcc0' },
blue: { '--scr-hi': '#cfe4ff', '--scr-ink': '#94b8d8', '--scr-dim': '#3a4c60',
'--scr-bg1': '#0a0e14', '--scr-bg2': '#05080c', '--scr-bgc': '#0a1018', '--scr-bge': '#04070c', '--scr-brd': '#16283a',
'--scr-grat': 'rgba(148,184,216,.18)', '--scr-gratc': 'rgba(148,184,216,.35)', '--scr-glow': 'rgba(207,228,255,.8)',
'--crt-face1': '#c2d2dd', '--crt-face2': '#93a8b6', '--crt-glow': '#eaf2ff' },
vfd: { '--scr-hi': '#63e6c8', '--scr-ink': '#46b89e', '--scr-dim': '#1e4a40',
'--scr-bg1': '#06100d', '--scr-bg2': '#040b09', '--scr-bgc': '#041410', '--scr-bge': '#020a08', '--scr-brd': '#0e3028',
'--scr-grat': 'rgba(70,184,158,.18)', '--scr-gratc': 'rgba(70,184,158,.35)', '--scr-glow': 'rgba(99,230,200,.8)',
'--crt-face1': '#b0d8cd', '--crt-face2': '#84ada2', '--crt-glow': '#dcfff5' },
white: { '--scr-hi': '#f2f4f2', '--scr-ink': '#c8cac8', '--scr-dim': '#5a5c5a',
'--scr-bg1': '#0b0c0b', '--scr-bg2': '#070807', '--scr-bgc': '#0d0e0d', '--scr-bge': '#060706', '--scr-brd': '#2a2c2a',
'--scr-grat': 'rgba(200,202,200,.18)', '--scr-gratc': 'rgba(200,202,200,.35)', '--scr-glow': 'rgba(242,244,242,.7)',
'--crt-face1': '#d0d2d0', '--crt-face2': '#a8aaa8', '--crt-glow': '#f7f7f7' },
};
/* ================= instrument builders =================
Every builder: DUPRE.name(host, opts) → handle. host is an empty element the
instrument renders into. opts.onChange(value, text) fires on every state change,
including the initial paint; text is the instrument's canonical readout string. */
const noop = () => {};
/* ---- the accent family ----
One named set of lit colours for the instruments whose colour IS their claim: a
chip, a lamp, a badge, a status line. "On" is good in one panel, a warning in
the next and a fault in the one after, so the colour belongs to the consumer
rather than the builder.
Deliberately NOT applied to instruments whose colour is the object rather than a
state — the nixie (the palette is explicit that neon is only ever orange), the
flip-disc's yellow, the dekatron's glow, a red needle — nor to the ones where
the colour is a standard rather than a preference: three greens on the landing
gear means down and locked, and a breaker's red/amber/green is its trip
semantics. Recolouring those doesn't restyle them, it makes them lie.
accentStyles(varName) builds a STYLES axis driving one CSS custom property, so
each consumer names its own var and the family stays single-source. */
const ACCENTS = {
amber: 'var(--gold)',
green: 'var(--pass)',
red: 'var(--fail)',
white: 'var(--cream)',
vfd: 'var(--vfd)',
};
DUPRE.accentStyles = varName => Object.fromEntries(
Object.entries(ACCENTS).map(([name, colour]) => [name, { dot: colour, vars: { [varName]: colour } }]));
/* ---- policy ----
Every instrument's colour is either the consumer's to pick or locked for a reason,
and the reason is one of these six kinds. Declared per builder (DUPRE..POLICY)
so it's a checked property rather than something the next person recalls — the
colour pass kept turning up cards where "is this one a standard?" was answered
from memory and answered wrong. The two FREE kinds get chips; the four LOCKED
ones must not, and the probe enforces the split.
Policy is a property of the INSTRUMENT, not the colour: the same vfd cyan is a free
accent on a chip and a locked emissive identity on the marquee. A few cards are
mixed (a free display with a coded red-line) and declare per element in time.
Each builder's POLICY is a record { kind, why, authentic }:
kind — one of the six below.
why — why THIS instrument is bound that way, in its own terms.
authentic — what may change and still be true to the reference (the range
that actually existed), or 'nothing' when the colour is fixed. */
DUPRE.POLICIES = {
accent: { free: true, gist: 'A lit state whose meaning varies by panel; the consumer picks from the accent family.' },
screen: { free: true, gist: 'A display whose phosphor was made in several real colours; the consumer picks from the screen family.' },
coded: { free: false, gist: 'Colour is meaning fixed by an external standard; a recolour misleads a trained operator (landing-gear three-greens, breaker trip).' },
emissive: { free: false, gist: 'Colour is what the physical source emits and no other was made; unrecognisable otherwise (nixie neon, dekatron glow).' },
relational: { free: false, gist: 'Colour means what it does only by contrast with another on the same instrument; the set is a scale or legend (VU zones, crossed needles).' },
material: { free: false, gist: 'Colour of a static physical part, not a state — nothing to parameterise as a signal (needle, brass bezel, knife blade).' },
};
/* 01 slide toggle — on/off pill. State colors ride CSS vars (--sw-*).
opts.onStyle / offStyle / offText / thumb pick a named style per axis from
DUPRE.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's
setStyle(axis, name) restyles a live instance (the gallery chips use it). */
DUPRE.slideToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const sw = document.createElement('span'); sw.className = 'switch'; host.appendChild(sw);
const setStyle = (axis, name) => {
const o = (DUPRE.slideToggle.STYLES[axis] || {})[name];
if (!o) return;
for (const [k, v] of Object.entries(o.vars)) sw.style.setProperty(k, v);
};
/* opts.preset names an intent; per-axis opts still win over it, so a caller can
take 'armed' and swap only the thumb. setPreset re-applies all four axes at
once and returns the axis map so a host UI can resync its own controls. */
const preset = DUPRE.slideToggle.PRESETS[opts.preset] || DUPRE.slideToggle.PRESETS.panel;
/* AXIS_ORDER is load-bearing, not cosmetic: onText overrides the ink that the
`on` style sets, so applying the axes in any other order silently drops it. */
const setPreset = name => {
const p = DUPRE.slideToggle.PRESETS[name];
if (!p) return null;
for (const axis of DUPRE.slideToggle.AXIS_ORDER) if (p[axis]) setStyle(axis, p[axis]);
return p;
};
const pick = { on: opts.onStyle, onText: opts.onText, off: opts.offStyle,
offText: opts.offText, thumb: opts.thumb };
for (const axis of DUPRE.slideToggle.AXIS_ORDER) setStyle(axis, pick[axis] || preset[axis]);
let on;
const set = v => { on = !!v; sw.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
sw.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: sw, get: () => on, set, setStyle, setPreset };
};
/* named presets — a combination that carries intent, which is what a consumer
actually reaches for; the STYLES axes below are how one is built. The `on`
tone does the semantic work and the thumb material backs it: a touchscreen
pill wears plastic, a run or armed switch wears metal. */
DUPRE.slideToggle.AXIS_ORDER = ['on', 'onText', 'off', 'offText', 'thumb'];
DUPRE.slideToggle.PRESETS = {
panel: { on: 'amber', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' },
run: { on: 'green', onText: 'panel', off: 'dark', offText: 'dim', thumb: 'chrome' },
armed: { on: 'red', onText: 'cream', off: 'dark', offText: 'white', thumb: 'chrome' },
caution: { on: 'warn', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' },
/* dark — neither state lights the pill; the legend alone carries the state,
green for on and Control red ("OFF marks") for off. The chrome thumb stays
the position affordance, since with both pills dark it is the only one. */
dark: { on: 'dark', onText: 'green', off: 'dark', offText: 'red', thumb: 'chrome' },
};
/* named styles per axis: dot = swatch color for pickers, vars = --sw-* overrides */
DUPRE.slideToggle.STYLES = {
on: {
amber: { dot: 'var(--gold-hi)', vars: { '--sw-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--sw-on-brd': 'var(--gold-hi)', '--sw-on-ink': 'var(--panel)' } },
green: { dot: '#8fb944', vars: { '--sw-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--sw-on-brd': '#a9c95f', '--sw-on-ink': 'var(--panel)' } },
/* red/warn make the ENGAGED state the notable one — mute, record, airplane
mode. Before them the only red was on the `off` axis, which paints the
disengaged state: the opposite claim. Palette semantics: --fail is
"muted keys", --amber-warn is "warn cells", distinct from panel amber. */
red: { dot: 'var(--fail)', vars: { '--sw-on-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-on-brd': 'var(--fail)', '--sw-on-ink': 'var(--cream)' } },
warn: { dot: 'var(--amber-warn)', vars: { '--sw-on-bg': 'linear-gradient(180deg,#f7d193,var(--amber-warn))', '--sw-on-brd': 'var(--amber-warn)', '--sw-on-ink': 'var(--panel)' } },
dark: { dot: '#242019', vars: { '--sw-on-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-on-brd': 'var(--slate)', '--sw-on-ink': 'var(--cream)' } },
},
off: {
dark: { dot: '#242019', vars: { '--sw-off-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-off-brd': 'var(--slate)' } },
red: { dot: 'var(--fail)', vars: { '--sw-off-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-off-brd': 'var(--fail)' } },
},
/* onText overrides the ink the `on` style sets. The two are not independent —
ink must contrast with its own background, which is why each `on` atom still
carries a sensible default; naming onText is how a caller says "keep the
pill, change the legend". That is what makes the `dark` preset possible:
both states unlit, the legend colour carrying the state on its own. */
onText: {
panel: { dot: 'var(--panel)', vars: { '--sw-on-ink': 'var(--panel)' } },
cream: { dot: 'var(--cream)', vars: { '--sw-on-ink': 'var(--cream)' } },
green: { dot: 'var(--sevgrn)', vars: { '--sw-on-ink': 'var(--sevgrn)' } },
},
offText: {
white: { dot: 'var(--cream)', vars: { '--sw-off-ink': 'var(--cream)' } },
/* Control red — the palette's "OFF marks" ink, so a red OFF legend is the
kit's existing grammar rather than a new claim. */
red: { dot: '#e0523a', vars: { '--sw-off-ink': '#e0523a' } },
/* steel is the kit's engraved-caption ink: lets an off toggle recede where a
panel carries many of them and eight cream OFFs would all shout equally. */
dim: { dot: 'var(--steel)', vars: { '--sw-off-ink': 'var(--steel)' } },
black: { dot: '#14110e', vars: { '--sw-off-ink': '#14110e' } },
},
thumb: {
light: { dot: '#d8d2c4', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#f6f2e8,#b0aa9a)' } },
dark: { dot: '#2c2824', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#4a453e,#16130f)' } },
chrome: { dot: '#9aa2b4', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#dfe6f2,#6d7484)' } },
brass: { dot: '#b8944a', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#e2c47a,#8a6a24)' } },
},
};
/* 02 console keys — mutually exclusive push buttons; {label, tone} per key.
tone picks the engaged look: undefined = gold (the default lit key), 'green' =
run green, 'red' = terracotta. Reading order runs safe -> live -> muted, and
LIVE takes green because that is what --pass means everywhere else in the kit
(the palette names it "run lamps, gear greens, monitor bars, LIVE lamps").
Contract (everything a consumer needs; no page globals touched):
opts: keys ([{label, tone}], default DEFAULT_KEYS); active (index; the
default set rests on LIVE, a caller's own set on its first key);
onChange(idx, label) fires on every engage.
handle: el, get() (engaged index), set(i) (clamped).
CSS lives in the "console keys" block of DUPRE_CSS; dsky and annunciator
reuse the key class for their pads. */
DUPRE.consoleKeys = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const keys = opts.keys || DUPRE.consoleKeys.DEFAULT_KEYS;
const wrap = document.createElement('span'); host.appendChild(wrap);
const btns = keys.map(k => {
const b = document.createElement('button'); b.className = 'dupre-key'; b.textContent = k.label;
wrap.appendChild(b); return b;
});
let idx;
const set = i => {
idx = Math.max(0, Math.min(keys.length - 1, i));
btns.forEach((b, j) => {
b.classList.remove('dupre-on', 'dupre-green', 'dupre-red');
if (j === idx) b.classList.add('dupre-' + (keys[idx].tone || 'on'));
});
onChange(idx, keys[idx].label);
};
btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
/* The default set engages LIVE, not the first key: it is the meaningful
resting state for a console and it shows the green. A caller supplying its
own keys gets the first one unless it says otherwise. */
set(opts.active !== undefined ? opts.active
: (keys === DUPRE.consoleKeys.DEFAULT_KEYS ? 1 : 0));
return { el: wrap, get: () => idx, set };
};
DUPRE.consoleKeys.DEFAULT_KEYS = [
{ label: 'SCAN' }, { label: 'LIVE', tone: 'green' }, { label: 'MUTED', tone: 'red' },
];
/* R57 ABC entry keypad — alphanumeric text on a faceplate.
Modelled on the industrial membrane keypad (reference: fleet/kiosk gear,
2026-07-16-abc-keypad-membrane-color.png): a 0-9 block beside A-Z laid out
ALPHABETICALLY, not QWERTY. That is the convention wherever the operator
can't be assumed to touch-type, and it is what makes this a faceplate rather
than a keyboard — R16's alphanumeric sibling.
Departures from the photo, all deliberate and all noted at their site:
- Its letters are blue. The kit has no blue control colour (blue appears
only as a screen phosphor and a jewel lens), so the letters take the
standard pale keycap and the digits a darker one — which keeps the
photo's two-tone digit/letter grouping without importing a foreign hue.
CLEAR/NO and ENTER/YES keep the reference's red and green, which are
already --fail and --pass.
- Letters left, digits right (the stainless reference's arrangement).
- DEL added, CANCEL dropped. See the layout block below for both.
Contract (everything a consumer needs; no page globals touched):
opts: max (16, buffer cap); onChange(buf, label) on every press — ENT
reports 'ENTER · ', DEL/CLR report the remaining buffer.
handle: el (focusable; keyboard per KEYS), get() (the buffer), press(k)
(any ACTIONS member: plate characters, SPC, DEL, ENT, CLR).
CSS lives in the "keypad / index-plate focus" block of DUPRE_CSS (the
faceplate itself is drawn SVG); the entry window recolours with the shared
--scr-* screen vars. */
DUPRE.abcKeypad = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const MAX = opts.max || 16;
const s = stageSvg(host, 'rsvg', 232, 226);
gradDef('abcKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#dbd8cf'], ['1', '#a29d92']]);
gradDef('abcNum', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#6f6a61'], ['1', '#464239']]);
gradDef('abcRed', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d98a6f'], ['1', 'var(--fail)']]);
gradDef('abcGrn', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#9cbf5e'], ['1', 'var(--pass)']]);
gradDef('abcAmb', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'var(--amber-grad-top)'], ['1', 'var(--gold)']]);
/* faceplate + the recessed window the legend prints into */
svgEl(s, 'rect', { x: 2, y: 2, width: 228, height: 222, rx: 7, fill: '#17140f', stroke: '#0a0908', 'stroke-width': 2 });
/* The window is a screen like the scope's or the marquee's, so it takes the
screen families through the --scr-* vars, shipped colours as the fallbacks:
nothing moves until a chip is clicked. Both the glass and the ink recolour —
a screen that changes its text and keeps its backlight isn't a screen. */
svgEl(s, 'rect', { class: 'dupre-kp-win', x: 12, y: 10, width: 208, height: 30, rx: 4,
fill: 'var(--scr-bg1, #0a0806)', stroke: 'var(--scr-brd, #2c261d)', 'stroke-width': 2 });
const disp = svgEl(s, 'text', { x: 20, y: 31, 'font-size': 14, 'letter-spacing': '.14em',
'font-family': 'var(--mono)', fill: 'var(--scr-hi, var(--gold-hi))' });
let buf = '';
/* The window shows the tail: a passphrase outruns the plate long before MAX.
Spaces are drawn as ␣ rather than as themselves. SVG collapses whitespace,
so a real space is invisible — and past the truncation boundary there are no
pad dots left for it to displace, which makes SPACE a keypress with no
feedback at all: press, see nothing, press again, and now carry two spaces
you can't see in a passphrase you can't read back. Only the DISPLAY is
substituted; buf keeps the real character. */
const show = t => t.replace(/ /g, '␣');
const render = () => {
disp.textContent = buf.length > 13 ? '‹' + show(buf.slice(-12)) : show(buf).padEnd(13, '·');
};
/* press is the allowlist, not the keydown handler above it. Filtering only in
the handler guards the web and leaves the Emacs port wide open: that port
installs KEYS into a keymap and calls press directly, with no handler in the
stack, so an unguarded press would append a stray 'F1' as literal text.
Gated on ACTIONS rather than on KEYS because they are different sets — CLR
is a real key on the plate that no keystroke maps to. */
const press = k => {
if (!DUPRE.abcKeypad.ACTIONS.has(k)) return;
if (k === 'ENT') { onChange(buf, buf ? 'ENTER · ' + buf : 'empty'); return; }
if (k === 'DEL') { buf = buf.slice(0, -1); render(); onChange(buf, buf || 'empty'); return; }
if (k === 'CLR') { buf = ''; render(); onChange(buf, 'cleared'); return; }
if (buf.length >= MAX) { onChange(buf, 'full · ' + buf); return; }
buf += (k === 'SPC' ? ' ' : k); render(); onChange(buf, buf);
};
/* [label, col, row, width-in-cols, tone].
Letters left, numbers right — the stainless reference's arrangement rather
than the membrane's. The membrane photo puts its digit block on the left,
which lands A-L in columns 3-5 while M-X starts at column 0: the alphabet
stops column-aligning with itself halfway down and the eye has to jump.
This way A-Z reads straight down the plate in one continuous block and the
digits keep their own quadrant. */
const L = [];
const DIG = ['1','2','3','4','5','6','7','8','9'];
[...'ABCDEFGHIJKL'].forEach((c, i) => L.push([c, i % 3, Math.floor(i / 3), 1, 'key']));
[...'MNOPQRSTUVWX'].forEach((c, i) => L.push([c, i % 6, 4 + Math.floor(i / 6), 1, 'key']));
/* DEL earns its own key: without a backspace the only exit from a typo is CLR,
which on a long passphrase means retyping the lot.
The three function keys read as a ladder, so the colour says what a key
costs before you read its legend: amber DEL takes one character back, red
CLR throws the whole entry away, green ENT commits it. Plain caps do
nothing you'd regret. (The stainless reference's backspace is plain steel,
but that plate has no other coloured key to be graded against.)
DEL sits in the block beside the digits and CLR is exiled to the far corner,
which is the opposite of where they started. Two reasons, pulling the same
way: DEL is the one you reach for constantly and CLR is the one you reach
for almost never, and a mis-hit on DEL costs one character where a mis-hit
on CLR costs the whole entry. Frequency and blast radius both say the safe
key gets the good spot.
The reference plate also carries CANCEL, and this one deliberately doesn't.
On that device the plate IS the whole terminal, so CANCEL has a transaction
to abandon; here the keypad is one control inside a panel that owns its own
dismiss, which left CANCEL doing exactly what CLR does. A key that means
nothing the panel doesn't already mean is a key worth removing. */
L.push(['Y', 0, 6, 1, 'key'], ['Z', 1, 6, 1, 'key'], ['SPC', 2, 6, 3, 'key'],
['CLR', 5, 6, 1, 'red']);
DIG.forEach((d, i) => L.push([d, 3 + (i % 3), Math.floor(i / 3), 1, 'num']));
L.push(['DEL', 3, 3, 1, 'amb'], ['0', 4, 3, 1, 'num'], ['ENT', 5, 3, 1, 'grn']);
const FILL = { key: 'url(#abcKey)', num: 'url(#abcNum)', red: 'url(#abcRed)', grn: 'url(#abcGrn)', amb: 'url(#abcAmb)' };
const INK = { key: '#2b2721', num: '#efeae0', red: 'var(--cream)', grn: 'var(--cream)', amb: 'var(--panel)' };
/* every function key wears a three-letter legend, so they read as one family
and each fits a single-column cap; only SPACE has the width to spell out */
const FACE = { SPC: 'SPACE' };
/* per-key rotation: a real plate is printed, not typeset — the legends sit a
fraction off true. Same trick R16 uses, keyed off the index so it is stable. */
const jitter = i => [-1.4, .9, -.6, 1.2, 0, -1.1, .7, -.9, 1.3, .4, -.5, 1][i % 12];
L.forEach(([k, c, r, w, tone], i) => {
const x = 14 + c * 35.5, y = 50 + r * 24.5, wd = w * 35.5 - 5.5;
const g = svgEl(s, 'g', {}); g.setAttribute('class', 'dupre-kp-key'); g.dataset.k = k;
g.style.cursor = 'pointer'; g.style.transition = 'transform .07s';
svgEl(g, 'rect', { x, y, width: wd, height: 20, rx: 3.5, fill: FILL[tone], stroke: '#4e4a42', 'stroke-width': 1, 'stroke-opacity': .8 });
const t = svgEl(g, 'text', { x: x + wd / 2, y: y + 14.5, 'text-anchor': 'middle',
'font-size': w > 1 ? 8 : 11, 'font-weight': 700, 'font-family': 'var(--mono)', fill: INK[tone],
transform: `rotate(${jitter(i)},${x + wd / 2},${y + 10})` });
t.textContent = FACE[k] || k;
g.addEventListener('click', () => {
g.style.transform = 'translateY(1.5px)';
setTimeout(() => { g.style.transform = ''; }, 80);
press(k);
});
});
/* Keyboard, per the README's keyboard contract. The listener is bound to the
pad's own focusable element, never to the document: a global binding would
type into this card from anywhere on a 110-card page and fight the gallery's
own Escape handler. DUPRE.slideRule is the precedent. */
s.setAttribute('class', 'rsvg dupre-kp-pad');
s.setAttribute('tabindex', '0');
s.addEventListener('click', () => s.focus());
s.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) return; /* leave shortcuts alone */
const name = e.key === ' ' ? 'Space' : (e.key.length === 1 ? e.key.toUpperCase() : e.key);
const k = DUPRE.abcKeypad.KEYS[name];
if (!k) return; /* not on the plate: let it bubble */
/* Spend preventDefault only where there is a default worth killing — Space
scrolls the page, Backspace can navigate back. Tab and Escape are never
ours: Tab is how the page stays navigable, Escape belongs to the audit
stepper, and neither reaches here anyway because they are not in KEYS. */
if (name === 'Space' || name === 'Backspace') e.preventDefault();
press(k);
});
render(); onChange('', 'type a passphrase');
return { el: s, get: () => buf, press };
};
/* The plate's keys, declared as a table so every target reads the same intent.
Deliberately not a function over a DOM event: the Emacs port installs this
into a keymap and never sees a keydown, so a function would force it to
re-derive what the instrument accepts and the two bindings would drift apart.
This is also press()'s allowlist — press appends whatever it is handed, so
without the table a stray 'F1' would land in the buffer as text. */
DUPRE.abcKeypad.KEYS = (() => {
const m = {};
for (const c of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') m[c] = c;
m.Space = 'SPC'; m.Backspace = 'DEL'; m.Enter = 'ENT';
return m;
})();
/* The plate's whole vocabulary — every argument press accepts, from any caller.
A superset of the KEYS values: CLR is on the plate but no keystroke reaches it
(Escape is the obvious candidate and belongs to the gallery's audit stepper). */
DUPRE.abcKeypad.ACTIONS = new Set([
...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 'SPC', 'DEL', 'ENT', 'CLR',
]);
/* R58 index typewriter — the oldest way to type without a keyboard.
After the AEG Mignon Model 4 (1924), the best of the index machines: one hand
walks a pointer over a printed plate of characters, the other pulls a lever,
and only the lever prints. Select and commit live on two separate controls, so
you can hunt as long as you like and nothing happens until the second hand
moves. That separation is the whole card — R57 is a keypad, this is not.
Two things kept from the Mignon and one deliberately dropped.
Kept: the plate considered the characters a keyboard skips (accents, section
mark, fractions, a full punctuation ring), and it carries BOTH cases with no
shift key, which is how a keyless machine reaches a whole character set.
Dropped: its key order. The real plate runs P U G Q / V I N A B / L D E T M,
a frequency layout you cannot read your way around — and reading the plate is
the entire interaction. Ours is alphabetical, capitals beside lowercase at the
same column offset: find the letter, then pick the case.
The layout is a table rather than drawing, because Craig has already said the
keys will be revisited and a layout welded into the geometry never is.
Contract (everything a consumer needs; no page globals touched):
opts: max (22, line cap); onChange(buf, label) on select, print and clear.
handle: el (focusable; typing selects a cell, Enter pulls the lever),
get() (the printed line), select(c), print(), press(k) (any
ACTIONS member: plate characters, PRINT, CLR), selected() (the
stylus position, null before the first select).
CSS lives in the "keypad / index-plate focus" block of DUPRE_CSS (the
machine itself is drawn SVG); the paper recolours with the shared --scr-*
screen vars. */
DUPRE.indexPlate = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const MAX = opts.max || 22;
const L = DUPRE.indexPlate.LAYOUT;
const COLS = Math.max(...L.map(r => r.length)), ROWS = L.length;
/* Width is driven by the plate, not guessed: the lever and CLR live in a gutter
to its right. Sized from the layout table so a wider plate can't slide them
back on top of the characters (they were, at 300 wide — the last column and
the PRINT legend went under the lever). */
const PX = 16, PY = 60, CW = 22, CH = 22, GUT = 46;
const PLATE_R = PX - 8 + COLS * CW + 14;
/* The gutter stack (lever, PRINT legend, CLR) is anchored to PY and needs 130px
whatever the plate does, so VH takes a floor. Without it a SHORTER table —
five rows is a plausible edit — shrinks VH until CLR rides up over the PRINT
legend and then the lever. Growth was always safe; shrink was the trap. */
const VW = PLATE_R + GUT;
const VH = Math.max(PY - 10 + ROWS * CH + 16 + 16, PY + 130);
const s = stageSvg(host, 'rsvg dupre-ix-pad', VW, VH);
gradDef('ixPlate', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#efe6c4'], ['1', '#d8caa0']]);
gradDef('ixBody', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#26221c'], ['1', '#100e0b']]);
gradDef('ixSteel', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e6e9ef'], ['1', '#8d93a1']]);
/* body, then the paper the machine prints onto */
svgEl(s, 'rect', { x: 2, y: 2, width: VW - 4, height: VH - 4, rx: 8, fill: 'url(#ixBody)', stroke: '#0a0908', 'stroke-width': 2 });
svgEl(s, 'rect', { x: 14, y: 10, width: VW - 28, height: 30, rx: 2,
fill: 'var(--scr-bg1, #efe9da)', stroke: 'var(--scr-brd, #b3a883)', 'stroke-width': 1 });
const paper = svgEl(s, 'text', { x: 22, y: 31, 'font-size': 13, 'letter-spacing': '.1em',
'font-family': 'var(--mono)', fill: 'var(--scr-hi, #241d12)' });
/* the index plate: cream, rounded, characters in printed rings like the Mignon's.
The ring area carries a halftone screen, which is what the real plate prints
its outer characters onto. */
if (!document.getElementById('ixHalf')) {
const defs = svgEl(s, 'defs', {});
const pat = svgEl(defs, 'pattern', { id: 'ixHalf', width: 4, height: 4, patternUnits: 'userSpaceOnUse' });
svgEl(pat, 'rect', { width: 4, height: 4, fill: 'url(#ixPlate)' });
svgEl(pat, 'circle', { cx: 1, cy: 1, r: 1.05, fill: '#2b2318', 'fill-opacity': .55 });
svgEl(pat, 'circle', { cx: 3, cy: 3, r: 1.05, fill: '#2b2318', 'fill-opacity': .55 });
}
svgEl(s, 'rect', { x: PX - 8, y: PY - 10, width: COLS * CW + 14, height: ROWS * CH + 16, rx: 7,
fill: 'url(#ixHalf)', stroke: '#8d8268', 'stroke-width': 1.5 });
/* the inverted blocks, drawn under the cells: a light panel for the capitals,
a dark one for the lowercase — the plate's photographic-negative trick */
const ZFILL = { caps: '#e9e0bb', lower: '#231d13' };
const zoneAt = (r, c) => {
const z = DUPRE.indexPlate.ZONES.find(z =>
r >= z.rows[0] && r <= z.rows[1] && c >= z.cols[0] && c <= z.cols[1]);
return z ? z.zone : 'ring';
};
DUPRE.indexPlate.ZONES.forEach(z => {
svgEl(s, 'rect', {
x: PX + z.cols[0] * CW - 1, y: PY + z.rows[0] * CH - 1,
width: (z.cols[1] - z.cols[0] + 1) * CW + 2, height: (z.rows[1] - z.rows[0] + 1) * CH + 2,
fill: ZFILL[z.zone] || 'none',
});
});
let sel = null, buf = '';
/* null-prototype: cells is keyed by the plate's characters, and a plain {} would
resolve select('constructor') through Object.prototype and throw. */
const cells = Object.create(null);
const render = () => { paper.textContent = (buf.length > 21 ? '‹' + buf.slice(-20) : buf.padEnd(21, ' ')).replace(/ /g, '␣'); };
/* The stylus: a cone on a short shaft, hovering over the selected cell. The
Mignon's arm reaches back to a pivot, but a full arm drawn here crosses the
plate and hides the characters the operator is trying to read — the one thing
this instrument must not do. A tip is enough to say "the pointer is here". */
const arm = svgEl(s, 'g', {}); arm.setAttribute('class', 'dupre-ix-stylus');
svgEl(arm, 'line', { x1: 0, y1: -9, x2: 0, y2: -20, stroke: 'url(#ixSteel)', 'stroke-width': 2.6 });
svgEl(arm, 'circle', { cx: 0, cy: -21, r: 3.4, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': .6 });
svgEl(arm, 'path', { d: 'M -3.6 -9 L 3.6 -9 L 0 0 Z', fill: 'url(#ixSteel)', stroke: '#41464f', 'stroke-width': .7 });
arm.style.transition = 'transform .12s';
arm.style.opacity = '0';
const select = c => {
const cell = cells[c]; if (!cell) return;
sel = c;
Object.values(cells).forEach(x => x.ring.setAttribute('stroke-opacity', '.35'));
cell.ring.setAttribute('stroke-opacity', '1');
arm.style.opacity = '1';
arm.setAttribute('transform', `translate(${cell.x},${cell.y - 9})`);
onChange(buf, 'stylus over ' + c);
};
L.forEach((row, r) => row.forEach((c, i) => {
if (!c) return;
const x = PX + i * CW + CW / 2, y = PY + r * CH + CH / 2;
/* disc and glyph invert together, by zone: dark disc + light glyph inside the
capitals block, the negative of that in the lowercase block, light-on-dark
everywhere on the ring. */
const z = zoneAt(r, i);
const DISC = { caps: '#231d13', lower: '#f2ecd6', ring: '#f2ecd6' };
const INK = { caps: '#f2ecd6', lower: '#231d13', ring: '#231d13' };
const g = svgEl(s, 'g', {}); g.setAttribute('class', 'dupre-ix-cell');
g.dataset.c = c; g.dataset.zone = z;
g.style.cursor = 'pointer';
const ring = svgEl(g, 'circle', { cx: x, cy: y, r: 8.6, fill: DISC[z],
stroke: z === 'caps' ? '#e9e0bb' : '#7a6a3e', 'stroke-width': 1.4, 'stroke-opacity': .35 });
svgEl(g, 'text', { x, y: y + 3.6, 'text-anchor': 'middle',
'font-size': /[a-z]/.test(c) || /[A-Z]/.test(c) ? 9.5 : 8.5, 'font-weight': 600,
'font-family': 'var(--mono)', fill: INK[z] }).textContent = c;
cells[c] = { ring, x, y };
g.addEventListener('click', () => press(c));
}));
s.appendChild(arm);
/* the lever: the only thing that prints. Lives in the gutter right of the plate */
const LX = PLATE_R + GUT / 2;
const lever = svgEl(s, 'g', {}); lever.setAttribute('class', 'dupre-ix-lever');
lever.style.cursor = 'pointer'; lever.style.transition = 'transform .08s';
svgEl(lever, 'rect', { x: LX - 3.5, y: PY + 6, width: 7, height: 72, rx: 3.5, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': .8 });
svgEl(lever, 'circle', { cx: LX, cy: PY + 2, r: 8, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': 1 });
svgEl(s, 'text', { x: LX, y: PY + 94, 'text-anchor': 'middle', 'font-size': 6.5, 'letter-spacing': '.1em',
'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'PRINT';
const print = () => {
if (!sel) { onChange(buf, 'no character selected'); return; }
if (buf.length >= MAX) { onChange(buf, 'line full'); return; }
buf += sel; render(); onChange(buf, buf);
lever.style.transform = 'translateY(5px)';
setTimeout(() => { lever.style.transform = ''; }, 90);
};
lever.addEventListener('click', () => press('PRINT'));
s.appendChild(lever);
/* fresh paper */
const clr = svgEl(s, 'g', {}); clr.setAttribute('class', 'dupre-ix-clear'); clr.style.cursor = 'pointer';
/* anchored to PY like the rest of the gutter stack, not to VH — mixing the two
is what let a shorter plate slide this up onto the PRINT legend */
svgEl(clr, 'rect', { x: LX - 16, y: PY + 104, width: 32, height: 18, rx: 3, fill: '#3a332a', stroke: '#5c5348', 'stroke-width': 1 });
svgEl(clr, 'text', { x: LX, y: PY + 117, 'text-anchor': 'middle', 'font-size': 7.5, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = 'CLR';
const fresh = () => { buf = ''; render(); onChange(buf, 'fresh paper'); };
clr.addEventListener('click', () => press('CLR'));
/* THE single entry. Every caller goes through it — cell clicks, the lever, CLR,
the keyboard, and any port — so the mouse cannot quietly diverge from the keys.
Binding the handlers straight to select/print/fresh worked only because press
happened to be a pure dispatcher: the moment it grows a guard or a sound, the
primary input on this card would skip it and every probe would stay green.
Gated on ACTIONS, so it selects nothing it has no cell for. */
const press = k => {
if (!DUPRE.indexPlate.ACTIONS.has(k)) return;
if (k === 'PRINT') return print();
if (k === 'CLR') return fresh();
select(k);
};
/* Keyboard, per the README's keyboard contract: bound to this element, never to
the document. Typing SELECTS and Enter pulls the lever, which is the card's
whole grammar carried onto the keys — a keypress that printed would make this
R57 with a nicer plate. Nothing is uppercased on the way in, because the plate
holds both cases: Shift picks the case, since 'a' and 'A' are different cells.
Space isn't on the plate, so it isn't ours and still scrolls the page. */
s.setAttribute('tabindex', '0');
s.addEventListener('click', () => s.focus());
s.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const k = DUPRE.indexPlate.KEYS[e.key];
if (!k) return;
e.preventDefault(); /* Enter would submit a form; a cell key has no default worth keeping */
press(k);
});
render(); onChange('', 'point, then pull');
return { el: s, get: () => buf, select, print, press, selected: () => sel };
};
/* The plate, as data. Capitals block beside lowercase at the same column offset,
so a letter and its case sit in the same row six columns apart. Digits and the
punctuation ring keep the Mignon's edges. Rewrite this table to relayout the
plate: nothing below reads it except the renderer. */
/* The Mignon Model 4's own plate, transcribed from the reference photo
(working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg).
Faithful first, iterate second — Craig's call, and the right order: the layout
has things in it worth understanding before replacing.
Two of its economies are load-bearing and easy to "fix" by accident:
- There is no 1 and no 0. You type them with lowercase l and capital O.
- J and j live out on the ring, not in the case blocks, which is why the
inversion below is positional and not "capitals are dark".
The two dashes differ: a long one top-right, a hyphen bottom-right. */
DUPRE.indexPlate.LAYOUT = [
['&','(',')',':','"','!', '?',"'",'ä','ö','ü','—'],
['§','P','F','U','G','Q', 'p','f','u','g','q',';'],
['J','V','I','N','A','B', 'v','i','n','a','b','j'],
['/','L','D','E','T','M', 'l','d','e','t','m',','],
['%','K','O','S','R','Z', 'k','o','s','r','z','='],
['¾','Y','C','H','W','X', 'y','c','h','w','x','+'],
['½','¼','2','3','4','5', '6','7','8','9','.','-'],
];
/* The plate's two inverted regions, as rectangles over LAYOUT rather than a rule
about characters. On the real plate the capitals block is dark discs on a light
panel and the lowercase block is its photographic negative, while everything on
the outer ring stays light-on-halftone — including the capital J, which is why
no case-based rule can describe this. Move a block, move its rectangle.
rows and cols are inclusive [start, end] indices into LAYOUT. */
DUPRE.indexPlate.ZONES = [
{ rows: [1, 5], cols: [1, 5], zone: 'caps' },
{ rows: [1, 5], cols: [6, 10], zone: 'lower' },
];
/* Both tables are DERIVED from the layout, never maintained beside it: relaying
the plate must not leave a keybinding aimed at a character it no longer has.
Every plate character maps to itself (no case folding — the plate has both, so
Shift does the work a shift key would), and Enter is the lever. Space is
deliberately absent: there's no space cell yet, so Space isn't ours to claim. */
DUPRE.indexPlate.KEYS = (() => {
const m = {};
for (const c of DUPRE.indexPlate.LAYOUT.flat()) if (c) m[c] = c;
m.Enter = 'PRINT';
return m;
})();
/* Every argument press accepts. A superset of the KEYS values: CLR is a real
control that no keystroke reaches, the same shape as the keypad's. */
DUPRE.indexPlate.ACTIONS = new Set([...DUPRE.indexPlate.LAYOUT.flat().filter(Boolean), 'PRINT', 'CLR']);
/* 03 horizontal fader — continuous 0-100; drag anywhere on the slot to seek.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-100, default 68); onChange(value, label) on every move.
handle: el, get(), set(v) (clamped 0-100).
CSS lives in the "fader" block of DUPRE_CSS; no other styles involved. */
DUPRE.faderH = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const f = document.createElement('div'); f.className = 'dupre-fader';
f.innerHTML = '
';
host.appendChild(f);
const fill = f.querySelector('.dupre-fill'), cap = f.querySelector('.dupre-cap');
let val;
const set = p => {
val = Math.max(0, Math.min(100, p));
fill.style.width = val + '%';
cap.style.left = val + '%';
onChange(val, 'level ' + Math.round(val));
};
dragX(f, set);
set(opts.value !== undefined ? opts.value : 68);
return { el: f, get: () => val, set };
};
/* 04 vertical fader — continuous 0-100, one channel-strip fader; compose per
channel.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-100, default 60); onChange(value, label) on every move.
handle: el, get(), set(v) (clamped 0-100).
CSS lives in the "vertical fader" block of DUPRE_CSS; no other styles
involved. */
DUPRE.faderV = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const f = document.createElement('div'); f.className = 'dupre-vfader';
f.innerHTML = '
';
host.appendChild(f);
const fill = f.querySelector('.dupre-fill'), cap = f.querySelector('.dupre-cap');
let val;
const set = p => {
val = Math.max(0, Math.min(100, p));
fill.style.height = val + '%';
cap.style.bottom = val + '%';
onChange(val, 'level ' + Math.round(val));
};
dragY(f, set);
set(opts.value !== undefined ? opts.value : 60);
return { el: f, get: () => val, set };
};
/* 05 rotary knob — drag up/down to turn, -150°..+150° sweep.
Contract (everything a consumer needs; no page globals touched):
opts: min (0), max (100), value (53); onChange(value, label) per step.
handle: el, get(), set(v) (clamped min..max).
CSS lives in the "rotary knob" block of DUPRE_CSS; rotarySelector and
encoder reuse the knob classes for their spindles. */
DUPRE.knob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const min = opts.min !== undefined ? opts.min : 0, max = opts.max !== undefined ? opts.max : 100;
const k = document.createElement('span'); k.className = 'dupre-knob';
k.innerHTML = '';
host.appendChild(k);
const ind = k.querySelector('.dupre-ind');
let val;
const set = v => {
val = Math.max(min, Math.min(max, v));
ind.style.transform = `rotate(${-150 + (val - min) / (max - min) * 300}deg)`;
onChange(val, String(Math.round(val)));
};
dragDelta(k, () => val, set, { min, max });
set(opts.value !== undefined ? opts.value : 53);
return { el: k, get: () => val, set };
};
/* 06 segmented selector — pick one of a few. opts.accent picks the lit-segment
color from DUPRE.segmented.STYLES (amber / green / red); defaults match the
stylesheet fallbacks. setStyle(axis, name) restyles a live instance.
Contract (everything a consumer needs; no page globals touched):
opts: items (labels, default TIMER/ALARM/POMO); active (0); accent
('amber', a STYLES name); onChange(idx, label) on every pick.
handle: el, get(), set(i) (clamped), setStyle(axis, name).
CSS lives in the "stepper / segmented selector" block of DUPRE_CSS; no
other styles involved. */
DUPRE.segmented = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || ['TIMER', 'ALARM', 'POMO'];
const seg = document.createElement('div'); seg.className = 'dupre-seg'; host.appendChild(seg);
const setStyle = (axis, name) => {
const o = (DUPRE.segmented.STYLES[axis] || {})[name];
if (!o) return;
for (const [k, v] of Object.entries(o.vars)) seg.style.setProperty(k, v);
};
setStyle('accent', opts.accent || 'amber');
const btns = items.map(t => { const b = document.createElement('button'); b.textContent = t; seg.appendChild(b); return b; });
let idx;
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: seg, get: () => idx, set, setStyle };
};
/* named styles per axis: dot = swatch color for pickers, vars = --seg-* overrides */
DUPRE.segmented.STYLES = {
accent: {
amber: { dot: 'var(--gold-hi)', vars: { '--seg-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--seg-on-ink': 'var(--panel)' } },
green: { dot: '#8fb944', vars: { '--seg-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--seg-on-ink': 'var(--panel)' } },
red: { dot: 'var(--fail)', vars: { '--seg-on-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--seg-on-ink': 'var(--panel)' } },
},
};
/* 07 chip toggle — inline binary inside a line of text.
Contract (everything a consumer needs; no page globals touched):
opts: label ('discoverable on'); on (true); accent ('amber', a STYLES
name); onChange(on, 'ON'|'OFF') on every flip.
handle: el, get(), set(bool), setStyle(axis, name).
CSS lives in the "chip toggle" block of DUPRE_CSS; no other styles
involved. */
DUPRE.chipToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const chip = document.createElement('span'); chip.className = 'dupre-chip';
chip.textContent = opts.label || 'discoverable on';
host.appendChild(chip);
const setStyle = (axis, name) => {
const o = (DUPRE.chipToggle.STYLES[axis] || {})[name];
if (!o) return;
for (const [k, v] of Object.entries(o.vars)) chip.style.setProperty(k, v);
};
setStyle('accent', opts.accent || 'amber');
let on;
const set = v => { on = !!v; chip.classList.toggle('dupre-on', on); onChange(on, on ? 'ON' : 'OFF'); };
chip.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: chip, get: () => on, set, setStyle };
};
/* The chip's lit colour, from the shared accent family. Gold was hardcoded, which
let the chip say exactly one thing — but "on" is good in one panel, a warning in
the next, and a fault in the one after. The colour is the claim, so it belongs
to the consumer. */
DUPRE.chipToggle.STYLES = { accent: DUPRE.accentStyles('--chip-on') };
/* 08 arm-to-fire — two-stage confirm for destructive actions: first click
arms, second fires and resets to safe.
Contract (everything a consumer needs; no page globals touched):
opts: label ('forget'); armLabel (label + '? again'); onChange(state,
label) with state 'safe' | 'armed' | 'fired'.
handle: el, get() ('armed'|'safe'), fire() (clicks through one stage).
CSS lives in the "arm-to-fire" block of DUPRE_CSS; no other styles
involved. */
DUPRE.armButton = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const label = opts.label || 'forget', armLabel = opts.armLabel || label + '? again';
const a = document.createElement('button'); a.className = 'dupre-arm'; a.textContent = label;
host.appendChild(a);
let armed = false;
a.addEventListener('click', () => {
if (!armed) { armed = true; a.classList.add('dupre-armed'); a.textContent = armLabel; onChange('armed', 'ARMED'); }
else { armed = false; a.classList.remove('dupre-armed'); a.textContent = label; onChange('fired', 'FIRED · reset'); }
});
onChange('safe', 'SAFE');
return { el: a, get: () => (armed ? 'armed' : 'safe'), fire: () => a.click() };
};
/* 09 lamp row — actionable list item: lamp + name + status, click to cycle.
states are [lampTone, label] pairs; lampTone '' | 'gold' | 'busy' | 'red'
maps onto the shared dupre-lamp classes.
Contract (everything a consumer needs; no page globals touched):
opts: name ('WH-1000XM4'); states ([[tone, label], ...]); state (0);
onChange(idx, label) on every step.
handle: el, get(), set(i) (clamped).
CSS lives in the "lamp row (list item)" block of DUPRE_CSS; the lamp
itself is the shared dupre-lamp. */
DUPRE.lampRow = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const name = opts.name || 'WH-1000XM4';
const states = opts.states || [['gold', 'tap to connect'], ['busy', 'connecting…'], ['', 'connected']];
const row = document.createElement('div'); row.className = 'dupre-lrow';
row.innerHTML = ``;
row.querySelector('b').textContent = name;
host.appendChild(row);
const lamp = row.querySelector('.dupre-lamp'), what = row.querySelector('.dupre-what');
let idx;
const set = i => {
idx = Math.max(0, Math.min(states.length - 1, i));
lamp.className = 'dupre-lamp' + (states[idx][0] ? ' dupre-' + states[idx][0] : '');
what.textContent = states[idx][1];
onChange(idx, states[idx][1]);
};
row.addEventListener('click', () => set((idx + 1) % states.length));
set(opts.state || 0);
return { el: row, get: () => idx, set };
};
/* 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]).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 = '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); return p;
});
const k = document.createElement('span'); k.className = 'dupre-knob'; k.innerHTML = ''; 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;
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));
set(opts.index !== undefined ? opts.index : 2);
return { el: rs, get: () => values[idx], set };
};
/* 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.
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];
const fmt = opts.fmt || (v => 'pos ' + v);
const X = [12, 51, 90, 129, 168];
/* expand to unit stops: every integer between adjacent numeric majors */
const stops = [];
majors.forEach((v, i) => {
stops.push({ v, x: X[i], major: true });
const b = majors[i + 1];
if (typeof v === 'number' && typeof b === 'number')
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 = '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];
if (!o) return;
for (const [k, v] of Object.entries(o.vars)) t.style.setProperty(k, v);
};
setStyle('skin', opts.skin || 'warm');
stops.forEach(s => {
t.insertAdjacentHTML('beforeend', s.major
? `${s.v}`
: ``);
});
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('.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 => {
const r = t.getBoundingClientRect();
/* rect is visual px but stop coords are layout px — divide the CSS zoom out */
const x = (e.clientX - r.left) * (t.offsetWidth / r.width);
let best = 0, bd = 1e9; stops.forEach((s, j) => { const d = Math.abs(s.x - x); if (d < bd) { bd = d; best = j; } });
set(best); t.focus();
});
t.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); set(idx - 1); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); set(idx + 1); }
});
const initV = opts.value !== undefined ? opts.value : majors[opts.index !== undefined ? opts.index : 2];
const init = stops.findIndex(s => s.v === initV);
set(init < 0 ? 0 : init);
return { el: t, get: () => stops[idx].v, set, setStyle };
};
/* named faces: dot = swatch color for pickers, vars = --tn-* overrides.
warm restates the stylesheet fallbacks so switching back is exact. */
DUPRE.slideRule.STYLES = {
skin: {
warm: { dot: 'var(--gold-hi)', vars: {
'--tn-bg': 'linear-gradient(180deg,#191510,#0b0908)', '--tn-brd': '#2a251c',
'--tn-glow': 'rgba(var(--glow-lo),.12)', '--tn-tick': 'var(--steel)', '--tn-ink': 'var(--steel)',
'--tn-on': 'var(--gold-hi)', '--tn-onglow': 'rgba(var(--glow-hi),.6)',
'--tn-ndl': 'var(--fail)', '--tn-ndlglow': 'rgba(203,107,77,.85)' } },
chrome: { dot: '#dfe6f2', vars: {
'--tn-bg': 'linear-gradient(180deg,#e9e9e5,#bcbcb6)', '--tn-brd': '#8e8e88',
'--tn-glow': 'rgba(255,255,255,.28)', '--tn-tick': '#3c3a34', '--tn-ink': '#3c3a34',
'--tn-on': '#14110e', '--tn-onglow': 'none',
'--tn-ndl': 'var(--fail)', '--tn-ndlglow': 'rgba(203,107,77,.45)' } },
black: { dot: '#1c1a18', vars: {
'--tn-bg': 'linear-gradient(180deg,#17181a,#08090a)', '--tn-brd': '#000',
'--tn-glow': 'rgba(255,255,255,.05)', '--tn-tick': '#c8cac8', '--tn-ink': '#c8cac8',
'--tn-on': '#f2f4f2', '--tn-onglow': 'rgba(242,244,242,.5)',
'--tn-ndl': '#ff3b28', '--tn-ndlglow': 'rgba(255,59,40,.9)' } },
blue: { dot: '#8fb4d8', vars: {
'--tn-bg': 'linear-gradient(180deg,#0c1520,#070c14)', '--tn-brd': '#1c2c40',
'--tn-glow': 'rgba(120,170,230,.16)', '--tn-tick': '#8fb4d8', '--tn-ink': '#8fb4d8',
'--tn-on': '#cfe4ff', '--tn-onglow': 'rgba(150,200,255,.7)',
'--tn-ndl': '#e8f2ff', '--tn-ndlglow': 'rgba(180,220,255,.9)' } },
},
};
/* 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 = 'dupre-rocker';
r.innerHTML = `${opts.onLabel || 'ON'}${opts.offLabel || 'OFF'}`;
host.appendChild(r);
let on;
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.
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;
const MODES = { rew: '⏮', play: '▶', stop: '⏹', rec: '⏺' };
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 = `
`;
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 = 'dupre-tbtn' + (m === 'rec' ? ' dupre-rec' : '');
b.textContent = MODES[m]; bar.appendChild(b); btns[m] = b;
b.addEventListener('click', () => set(m));
}
host.appendChild(wrap);
let mode;
const set = m => {
mode = m;
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('dupre-on');
onChange(mode, NAMES[mode]);
return { el: wrap, get: () => mode, set };
};
/* 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 = '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 = 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.
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 clamp = v => Math.max(0, Math.min(100, v));
const dk = document.createElement('span'); dk.className = 'dupre-dualknob';
dk.innerHTML = ``;
host.appendChild(dk);
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)`;
n.style.transform = `rotate(${-150 + iV / 100 * 300}deg)`;
onChange(oV, iV);
};
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 = 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 = clamp(a); if (b !== undefined) iV = clamp(b); paint(); } };
};
/* 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 = '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 = 'dupre-led';
d.style.left = (cx + R * Math.cos(a)) + 'px'; d.style.top = (cy + R * Math.sin(a)) + 'px';
enc.appendChild(d); leds.push(d);
}
const k = document.createElement('span'); k.className = 'dupre-knob'; k.innerHTML = ''; enc.appendChild(k);
let val;
const set = v => {
val = v;
const lit = ((Math.floor(val) % 12) + 12) % 12;
leds.forEach((l, i) => l.classList.toggle('dupre-on', i <= lit));
k.style.transform = `rotate(${val * 30}deg)`;
onChange(val, 'pos ' + Math.round(val));
};
dragDelta(enc, () => val, set, { min: 0, max: 100000, sens: 0.25 });
set(opts.value !== undefined ? opts.value : 7);
return { el: enc, get: () => val, set };
};
/* 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']).slice(0, 3);
const ANG = [-70, 0, 70], POS = [[16, 64], [50, 12], [84, 64]];
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); return p;
});
const body = document.createElement('span'); body.className = 'dupre-body';
body.innerHTML = '';
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;
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));
set(opts.index !== undefined ? opts.index : 1);
return { el: kl, get: () => idx, set };
};
/* 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 = 'dupre-xfader';
xf.innerHTML = `${opts.aLabel || 'A'}${opts.bLabel || 'B'}`;
host.appendChild(xf);
const cap = xf.querySelector('.dupre-cap');
let pct;
const set = p => {
pct = Math.max(0, Math.min(100, p));
cap.style.left = pct + '%';
const v = Math.round(pct - 50);
onChange(v, (v > 0 ? '+' : '') + v);
};
dragX(xf, set);
set(opts.value !== undefined ? opts.value : 50);
return { el: xf, get: () => Math.round(pct - 50), set };
};
/* 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 = 'dupre-thumbw';
w.innerHTML = '';
host.appendChild(w);
const win = w.querySelector('.dupre-win');
let val;
const set = v => {
val = Math.round(v);
const s = ((val % 100) + 100) % 100;
win.textContent = String(s).padStart(2, '0');
onChange(s, 'value ' + s);
};
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 };
};
/* 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 = 'dupre-dip'; host.appendChild(bank);
const sws = bits.map(on => {
const s = document.createElement('span'); s.className = 'dupre-dipsw' + (on ? ' dupre-on' : '');
s.innerHTML = ''; bank.appendChild(s); return s;
});
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('dupre-on'); upd(); }));
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.
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 = 'dupre-jog';
j.innerHTML = ``;
host.appendChild(j);
const inner = j.querySelector('.dupre-inner');
let val;
const set = v => {
val = v;
inner.style.transform = `rotate(${val * 4}deg)`;
onChange(val, 'pos ' + Math.round(val));
};
dragDelta(j, () => val, set, { min: -100000, max: 100000, sens: 0.5 });
set(opts.value !== undefined ? opts.value : 0);
return { el: j, get: () => val, set };
};
/* ---- shared SVG defs: gradients/filters referenced by url(#id) are
document-scoped, and several are used across instruments. Builders ensure the
defs they use; the first caller creates it, later calls are no-ops. ---- */
let defsRoot = null;
const defsMade = new Set();
function defsHost() {
if (!defsRoot) {
const svg = document.createElementNS(SVGNS, 'svg');
svg.setAttribute('width', 0); svg.setAttribute('height', 0);
svg.setAttribute('aria-hidden', 'true');
svg.style.position = 'absolute';
defsRoot = document.createElementNS(SVGNS, 'defs');
svg.appendChild(defsRoot);
document.body.appendChild(svg);
}
return defsRoot;
}
function def(id, make) { if (defsMade.has(id)) return; defsMade.add(id); make(defsHost(), id); }
function gradDef(id, kind, attrs, stops) {
def(id, d => {
const g = svgEl(d, kind, Object.assign({ id }, attrs));
for (const [offset, color] of stops) svgEl(g, 'stop', { offset, 'stop-color': color });
});
}
function stageSvg(host, cls, vw, vh) {
const s = document.createElementNS(SVGNS, 'svg');
s.setAttribute('class', cls); s.setAttribute('viewBox', `0 0 ${vw} ${vh}`);
s.setAttribute('width', vw); s.setAttribute('height', vh);
host.appendChild(s); return s;
}
/* instance-unique ids for defs that can't be shared (e.g. clip paths sized per instance) */
let uidN = 0;
function uid(prefix) { return prefix + '-' + (++uidN); }
/* 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;
gradDef('vernFace', 'radialGradient', { cx: '50%', cy: '45%', r: '60%' }, [['0', '#f7f2da'], ['1', '#e4dfc2']]);
gradDef('bakeKnob', 'radialGradient', { cx: '42%', cy: '36%', r: '75%' }, [['0', '#37332c'], ['1', '#060505']]);
svgEl(s, 'circle', { cx, cy, r: 72, fill: '#0c0b0a', stroke: '#262320' });
const disc = svgEl(s, 'g', {});
svgEl(disc, 'circle', { cx, cy, r: 66, fill: 'url(#vernFace)', stroke: '#b7b29a', 'stroke-width': 1 });
for (let u = 0; u <= 100; u++) {
const a = u * 3, major = u % 10 === 0, half = u % 5 === 0;
const len = major ? 10 : half ? 7 : 4.5, [x1, y1] = polar(cx, cy, 63, a), [x2, y2] = polar(cx, cy, 63 - len, a);
svgEl(disc, 'line', { x1, y1, x2, y2, stroke: '#3a3128', 'stroke-width': major ? 1.3 : half ? 1 : .7 });
if (major) {
const g = svgEl(disc, 'g', { transform: `rotate(${a},${cx},${cy})` });
svgEl(g, 'text', { x: cx, y: cy - 44, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: '#3a3128' }).textContent = u;
}
}
for (let k = 0; k < 12; k++) { const [x, y] = polar(cx, cy, 24, k * 30); svgEl(s, 'circle', { cx: x, cy: y, r: 4.8, fill: 'url(#bakeKnob)' }); }
svgEl(s, 'circle', { cx, cy, r: 24, fill: 'url(#bakeKnob)', stroke: '#000', 'stroke-width': .6 });
svgEl(s, 'circle', { cx, cy, r: 16, fill: 'url(#bakeKnob)' });
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 = 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.
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;
gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]);
gradDef('chromeG', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]);
gradDef('tipG', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]);
const lblOn = svgEl(s, 'text', { x: cx, y: 9, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' });
lblOn.textContent = opts.onLabel || 'ON';
const lblOff = svgEl(s, 'text', { x: cx, y: 87, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
lblOff.textContent = opts.offLabel || 'OFF';
const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 17 * Math.cos(a)) + ',' + (cy + 17 * Math.sin(a))); }
svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#nutG)', stroke: '#2b2822' });
svgEl(s, 'circle', { cx, cy, r: 9, fill: '#14110e', stroke: '#000' });
const lever = svgEl(s, 'g', {});
svgEl(lever, 'path', { d: `M ${cx - 3.2} ${cy} L ${cx - 1.8} 21 L ${cx + 1.8} 21 L ${cx + 3.2} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 });
svgEl(lever, 'ellipse', { cx, cy: 17, rx: 6.5, ry: 8, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 });
lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s';
let on;
const set = v => {
on = !!v;
lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)';
lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)');
lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)');
onChange(on, on ? 'ON' : 'OFF');
};
s.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: s, get: () => on, set };
};
/* 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;
gradDef('bakeSk', 'radialGradient', { cx: '42%', cy: '36%', r: '80%' }, [['0', '#312d27'], ['1', '#050404']]);
gradDef('bakeDome', 'radialGradient', { cx: '40%', cy: '32%', r: '80%' }, [['0', '#3a362f'], ['1', '#0a0908']]);
for (let i = 0; i <= 10; i++) {
const a = -135 + i * 27, major = i % 5 === 0;
const [x1, y1] = polar(cx, cy, 38, a), [x2, y2] = polar(cx, cy, major ? 45 : 43.5, a);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--steel)', 'stroke-width': major ? 1.5 : 1 });
if (major) {
const [x, y] = polar(cx, cy, 50.5, a);
svgEl(s, 'text', { x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': 8, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = i;
}
}
for (let k = 0; k < 18; k++) { const [x, y] = polar(cx, cy, 31, k * 20); svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: 'url(#bakeSk)' }); }
svgEl(s, 'circle', { cx, cy, r: 31, fill: 'url(#bakeSk)', stroke: '#000', 'stroke-width': .6 });
const idx = svgEl(s, 'line', { x1: cx, y1: cy - 30, x2: cx, y2: cy - 21, stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round' });
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 = 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. 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).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;
const native = {
silver: { caps: 'chrome', capColor: 'chrome' },
studio: { caps: 'ribbed', capColor: 'red' },
};
const nat = native[opts.style] || {};
const cur = {
panel: ST.panel[opts.panel] ? opts.panel : (ST.panel[opts.style] ? opts.style : 'silver'),
caps: ST.caps[opts.caps] ? opts.caps : (nat.caps || 'block'),
capColor: ST.capColor[opts.capColor] ? opts.capColor : (nat.capColor || 'black'),
};
gradDef('fbPlateSilver', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8d8d4'], ['.5', '#c6c6c1'], ['1', '#adada7']]);
gradDef('fbPlateStudio', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#221f1c'], ['1', '#131110']]);
gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]);
gradDef('fbCapBlack', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#2a2724'], ['1', '#171412']]);
gradDef('fbCapCream', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2ecd8'], ['1', '#cfc8ae']]);
const s = stageSvg(host, 'rsvg', 250, 112);
const y0 = 20, y1 = 92, x0 = 36, dx = (214 - x0) / (freqs.length - 1);
const yOf = db => y0 + db / 60 * (y1 - y0);
const caps = [];
const place = i => caps[i].setAttribute('transform', `translate(0,${yOf(vals[i])})`);
const drawCap = (g, kind, x) => {
const c = ST.capColor[cur.capColor];
if (kind === 'chrome') {
svgEl(g, 'rect', { x: x - 6, y: -3.25, width: 12, height: 6.5, rx: .9, fill: c.body, stroke: c.edge, 'stroke-width': .6 });
/* bare metal needs no painted stripe — chrome-on-chrome stays lineless */
if (cur.capColor !== 'chrome')
svgEl(g, 'line', { x1: x - 6, y1: 0, x2: x + 6, y2: 0, stroke: c.index, 'stroke-width': 1 });
svgEl(g, 'line', { x1: x - 6, y1: 2, x2: x + 6, y2: 2, stroke: c.rib, 'stroke-width': .5 });
} else if (kind === 'ribbed') {
svgEl(g, 'rect', { x: x - 5, y: -4, width: 10, height: 8, rx: 1, fill: c.body, stroke: c.edge, 'stroke-width': .6 });
for (const dy of [-2.4, 2.4])
svgEl(g, 'line', { x1: x - 4, y1: dy, x2: x + 4, y2: dy, stroke: c.rib, 'stroke-width': .6 });
svgEl(g, 'line', { x1: x - 5, y1: 0, x2: x + 5, y2: 0, stroke: c.index, 'stroke-width': 1.1 });
} else { /* block — tall ribbed fader after the Zaxcom Oasis; fine rib
groups above and below a mid-cap index line that marks the value */
svgEl(g, 'rect', { x: x - 4.5, y: -8, width: 9, height: 16, rx: 1.1, fill: c.body, stroke: c.edge, 'stroke-width': .6 });
for (const dy of [-6.4, -4.8, -3.2, -1.6, 1.6, 3.2, 4.8, 6.4])
svgEl(g, 'line', { x1: x - 3.7, y1: dy, x2: x + 3.7, y2: dy, stroke: c.rib, 'stroke-width': .6 });
svgEl(g, 'line', { x1: x - 4.5, y1: 0, x2: x + 4.5, y2: 0, stroke: c.index, 'stroke-width': 1.2 });
}
};
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`);
};
const render = () => {
const st = ST.panel[cur.panel];
s.replaceChildren(); caps.length = 0;
svgEl(s, 'rect', { x: 1.5, y: 1.5, width: 247, height: 109, rx: 5, fill: st.plate, stroke: st.edge, 'stroke-width': 1 });
svgEl(s, 'rect', { x: 3, y: 3, width: 244, height: 106, rx: 4, fill: 'none', stroke: st.bevel, 'stroke-width': .7 });
[[9, 9], [241, 9], [9, 103], [241, 103]].forEach(([sx, sy], k) => {
svgEl(s, 'circle', { cx: sx, cy: sy, r: 2.7, fill: st.screw, stroke: 'rgba(0,0,0,.55)', 'stroke-width': .6 });
svgEl(s, 'line', { x1: sx - 1.9, y1: sy, x2: sx + 1.9, y2: sy, stroke: 'rgba(0,0,0,.6)', 'stroke-width': .7, transform: `rotate(${25 + k * 40},${sx},${sy})` });
});
for (let db = 0; db <= 60; db += 10) {
const y = yOf(db), major = db % 20 === 0, len = major ? 4 : 2.5;
svgEl(s, 'line', { x1: 24 - len, y1: y, x2: 24, y2: y, stroke: st.dim, 'stroke-width': major ? .9 : .6 });
svgEl(s, 'line', { x1: 226, y1: y, x2: 226 + len, y2: y, stroke: st.dim, 'stroke-width': major ? .9 : .6 });
if (major) {
svgEl(s, 'text', { x: 17, y: y + 2, 'text-anchor': 'end', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: st.ink }).textContent = db;
svgEl(s, 'text', { x: 233, y: y + 2, 'text-anchor': 'start', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: st.ink }).textContent = db;
}
}
svgEl(s, 'text', { x: 20, y: 100, 'text-anchor': 'end', 'font-size': 4.6, 'font-family': 'var(--mono)', fill: st.dim }).textContent = 'dB';
freqs.forEach((f, i) => {
const x = x0 + i * dx;
svgEl(s, 'text', { x, y: 13, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', fill: st.ink }).textContent = fmtHz(f);
svgEl(s, 'line', { x1: x, y1: y0, x2: x, y2: y1, stroke: st.track, 'stroke-width': st.trackW, 'stroke-linecap': 'round' });
if (st.trackHi) svgEl(s, 'line', { x1: x - st.trackW / 2 - .35, y1: y0, x2: x - st.trackW / 2 - .35, y2: y1, stroke: st.trackHi, 'stroke-width': .7 });
const cap = svgEl(s, 'g', {});
drawCap(cap, cur.caps, x);
caps.push(cap); place(i);
const hit = svgEl(s, 'rect', { x: x - dx / 2, y: y0 - 6, width: dx, height: y1 - y0 + 12, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
dragY(hit, pct => set(i, (100 - pct) / 100 * 60));
});
};
const setStyle = (axis, name) => {
if (!ST[axis] || !ST[axis][name]) return;
cur[axis] = name; render();
};
render();
onChange(null, 'drag a band');
return { el: s, get: () => vals.slice(), set, setStyle };
};
/* named styles per axis: dot = swatch color for pickers. caps is the cap
shape; capColor is the cap finish (body + rib + index line), applicable to
any shape. */
DUPRE.filterBank.STYLES = {
panel: {
silver: { dot: '#c9c9c5', plate: 'url(#fbPlateSilver)', edge: '#8e8e88', bevel: 'rgba(255,255,255,.4)', ink: '#33332f', dim: '#5c5c56', screw: '#9c9c96', track: '#121210', trackW: 2.0, trackHi: null },
studio: { dot: '#1c1a18', plate: 'url(#fbPlateStudio)', edge: '#000', bevel: 'rgba(255,255,255,.06)', ink: '#b9b4a6', dim: '#6f6a5e', screw: '#3c3832', track: '#050505', trackW: 2.2, trackHi: 'rgba(255,255,255,.05)' },
},
caps: {
block: { dot: '#2c2824' },
ribbed: { dot: '#6f6a5e' },
chrome: { dot: '#dfe6f2' },
},
capColor: {
black: { dot: '#211e1b', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: '#f2f4f2', edge: '#000' },
red: { dot: 'var(--sevred)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--sevred)', edge: '#000' },
green: { dot: 'var(--sevgrn)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--sevgrn)', edge: '#000' },
blue: { dot: '#4f9fe0', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: '#4f9fe0', edge: '#000' },
amber: { dot: 'var(--gold-hi)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--gold-hi)', edge: '#000' },
chrome: { dot: '#dfe6f2', body: 'url(#sfChrome)', rib: 'rgba(0,0,0,.28)', index: '#14110e', edge: '#3c3a34' },
cream: { dot: '#efe9d4', body: 'url(#fbCapCream)', rib: 'rgba(0,0,0,.18)', index: '#3a3128', edge: '#8a8474' },
},
};
/* 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]];
const s = stageSvg(host, 'rsvg press', 110, 96), cx = 55, cy = 58;
gradDef('chickG', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#35312b'], ['1', '#070606']]);
const lbls = [];
items.forEach(([lbl, a]) => {
const [tx, ty] = polar(cx, cy, 42, a);
const t = svgEl(s, 'text', { x: tx, y: ty + 2.5, 'text-anchor': 'middle', 'font-size': 6.5, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
t.textContent = lbl; lbls.push(t);
const [mx1, my1] = polar(cx, cy, 33, a), [mx2, my2] = polar(cx, cy, 29, a);
svgEl(s, 'line', { x1: mx1, y1: my1, x2: mx2, y2: my2, stroke: 'var(--steel)', 'stroke-width': 1.2 });
});
svgEl(s, 'circle', { cx, cy, r: 12, fill: '#0c0b0a' });
const lever = svgEl(s, 'g', {});
svgEl(lever, 'path', { d: `M ${cx} ${cy - 29} L ${cx + 8.5} ${cy - 4} Q ${cx + 9} ${cy + 8} ${cx + 5} ${cy + 11} A 7 7 0 0 1 ${cx - 5} ${cy + 11} Q ${cx - 9} ${cy + 8} ${cx - 8.5} ${cy - 4} Z`, fill: 'url(#chickG)', stroke: '#000', 'stroke-width': .7 });
svgEl(lever, 'line', { x1: cx, y1: cy - 26, x2: cx, y2: cy + 6, stroke: '#4a463e', 'stroke-width': 1.4, 'stroke-linecap': 'round' });
lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s';
let idx;
const set = i => {
idx = ((i % items.length) + items.length) % items.length;
lever.style.transform = `rotate(${items[idx][1]}deg)`;
lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)'));
onChange(idx, items[idx][0]);
};
s.addEventListener('click', () => set(idx + 1));
set(opts.index !== undefined ? opts.index : 2);
return { el: s, get: () => idx, set };
};
/* 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;
const yOf = db => yTop + (12 - db) / 36 * (yBot - yTop);
gradDef('sfPlate', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#242019'], ['1', '#131110']]);
gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]);
gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
svgEl(s, 'rect', { x: 14, y: 8, width: 62, height: 134, rx: 6, fill: 'url(#sfPlate)', stroke: '#0a0908', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 15.5, y: 9.5, width: 59, height: 131, rx: 5, fill: 'none', stroke: 'rgba(255,255,255,.05)', 'stroke-width': 1 });
for (const sy of [16, 134]) {
svgEl(s, 'circle', { cx, cy: sy, r: 3.2, fill: 'url(#sfScrew)', stroke: '#14110e', 'stroke-width': .6 });
const [x1, y1] = polar(cx, sy, 3, 112), [x2, y2] = polar(cx, sy, 3, 292);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#221f1a', 'stroke-width': 1 });
}
svgEl(s, 'rect', { x: 40.5, y: yTop - 4, width: 9, height: yBot - yTop + 8, rx: 4, fill: '#0b0a09', stroke: '#050404' });
for (const db of [12, 8, 4, 0, -4, -8, -12, -16, -20, -24]) {
const y = yOf(db);
svgEl(s, 'line', { x1: 26, y1: y, x2: 39, y2: y, stroke: 'var(--steel)', 'stroke-width': db === 0 ? 1.3 : .8, opacity: db === 0 ? 1 : .75 });
svgEl(s, 'line', { x1: 51, y1: y, x2: 64, y2: y, stroke: 'var(--steel)', 'stroke-width': db === 0 ? 1.3 : .8, opacity: db === 0 ? 1 : .75 });
svgEl(s, 'text', { x: 23, y: y + 2.2, 'text-anchor': 'end', 'font-size': 6, 'font-family': 'var(--mono)', fill: db === 0 ? 'var(--cream)' : 'var(--steel)' }).textContent = Math.abs(db);
}
const handle = svgEl(s, 'g', {});
svgEl(handle, 'polygon', { points: '45,0 58,-5 58,5', fill: '#b9b6ae', stroke: '#5e5a52', 'stroke-width': .5 });
svgEl(handle, 'rect', { x: 58, y: -7, width: 22, height: 14, rx: 3.5, fill: 'url(#sfChrome)', stroke: '#4e4a42', 'stroke-width': .6 });
svgEl(handle, 'rect', { x: 60, y: -5.2, width: 18, height: 2.2, rx: 1, fill: 'rgba(255,255,255,.4)' });
let db;
const set = v => {
db = Math.max(-24, Math.min(12, v));
handle.setAttribute('transform', `translate(0,${30 + (12 - db) / 36 * 90})`);
onChange(db, (db > 0 ? '+' : db < 0 ? '−' : '') + Math.abs(db).toFixed(1) + ' dB');
};
dragY(s, pct => set(-24 + pct / 100 * 36));
set(opts.value !== undefined ? opts.value : -4);
return { el: s, get: () => db, set };
};
/* 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;
const sweep = v => -80 + v / 10 * 160;
gradDef('spadeKnob', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#33302a'], ['1', '#080706']]);
gradDef('spadeMetal', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e9e7e0'], ['1', '#8d897f']]);
/* engraved arc: every stroke carries a faint light twin offset below (incised relief) */
const engrave = (x1, y1, x2, y2, w) => {
svgEl(s, 'line', { x1, y1: y1 + .8, x2, y2: y2 + .8, stroke: 'rgba(255,255,255,.13)', 'stroke-width': w });
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#050404', 'stroke-width': w });
};
for (let i = 0; i <= 40; i++) {
const v = i / 4, a = sweep(v), major = i % 4 === 0;
const [x1, y1] = polar(cx, cy, major ? 50 : 52, a), [x2, y2] = polar(cx, cy, 58, a);
engrave(x1, y1, x2, y2, major ? 1.6 : .9);
if (major) {
const [nx, ny] = polar(cx, cy, 65, a);
svgEl(s, 'text', { x: nx, y: ny + 3.2, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: 'rgba(255,255,255,.13)' }).textContent = v;
svgEl(s, 'text', { x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: '#0a0908' }).textContent = v;
}
}
const grp = svgEl(s, 'g', {});
svgEl(grp, 'path', { d: `M ${cx - 2} ${cy - 28} L ${cx - 4.5} ${cy - 42} L ${cx} ${cy - 54} L ${cx + 4.5} ${cy - 42} L ${cx + 2} ${cy - 28} Z`, fill: 'url(#spadeMetal)', stroke: '#55524a', 'stroke-width': .6 });
svgEl(grp, 'circle', { cx, cy, r: 30, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .8 });
svgEl(grp, 'circle', { cx, cy, r: 28.5, fill: 'none', stroke: '#0a0908', 'stroke-width': 3, 'stroke-dasharray': '2.2 2.2' });
svgEl(grp, 'circle', { cx, cy, r: 22, fill: 'url(#spadeKnob)' });
svgEl(grp, 'ellipse', { cx: cx - 8, cy: cy - 9, rx: 8, ry: 5, fill: 'rgba(255,255,255,.08)', transform: `rotate(-30,${cx - 8},${cy - 9})` });
let val;
const set = v => {
val = Math.max(0, Math.min(10, v));
grp.setAttribute('transform', `rotate(${-80 + val / 10 * 160},70,82)`);
onChange(val, val.toFixed(1));
};
dragDelta(s, () => val, set, { min: 0, max: 10, sens: .05 });
set(opts.value !== undefined ? opts.value : 8.3);
return { el: s, get: () => val, set };
};
/* 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]];
const s = stageSvg(host, 'rsvg', 190, 110), cx = 62, cy = 62;
const sweep = t => -70 + t * 140;
gradDef('mbFace', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#eee5c8'], ['1', '#dcd2ae']]);
gradDef('spadeKnob', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#33302a'], ['1', '#080706']]);
svgEl(s, 'rect', { x: 4, y: 4, width: 182, height: 102, rx: 12, fill: '#171412', stroke: '#060505' });
svgEl(s, 'rect', { x: 9, y: 9, width: 172, height: 92, rx: 9, fill: 'url(#mbFace)', stroke: '#0a0908' });
const INK = '#2c2318';
const rings = [];
[18, 26, 34, 42].forEach(r => {
const ring = svgEl(s, 'g', {});
const [x1, y1] = polar(cx, cy, r, -70), [x2, y2] = polar(cx, cy, r, 70);
svgEl(ring, 'path', { d: `M ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2}`, fill: 'none', stroke: INK, 'stroke-width': .9 });
for (let i = 0; i <= 8; i++) {
const a = sweep(i / 8), [tx1, ty1] = polar(cx, cy, r, a), [tx2, ty2] = polar(cx, cy, r + 3, a);
svgEl(ring, 'line', { x1: tx1, y1: ty1, x2: tx2, y2: ty2, stroke: INK, 'stroke-width': .8 });
}
rings.push(ring);
});
const needle = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 46, stroke: '#1a1613', 'stroke-width': 1.6, 'stroke-linecap': 'round' });
svgEl(s, 'circle', { cx, cy, r: 7, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 });
svgEl(s, 'text', { x: 14, y: 100, 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'MEGACYCLES';
/* bandspread dial (click = band select) */
const bx = 142, by = 56;
for (let i = 0; i < 12; i++) { const a = i * 30, [x1, y1] = polar(bx, by, 24, a), [x2, y2] = polar(bx, by, 21, a); svgEl(s, 'line', { x1, y1, x2, y2, stroke: INK, 'stroke-width': .8 }); }
svgEl(s, 'circle', { cx: bx, cy: by, r: 17, fill: 'none', stroke: INK, 'stroke-width': .7, opacity: .5 });
const spread = svgEl(s, 'line', { x1: bx, y1: by, x2: bx, y2: by - 22, stroke: '#1a1613', 'stroke-width': 1.4, 'stroke-linecap': 'round' });
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 = 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 + 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';
dragDelta(dragHit, () => val, v => set(v, band), { min: 0, max: 100 });
const clickHit = svgEl(s, 'rect', { x: 112, y: 9, width: 69, height: 92, fill: 'transparent' });
clickHit.style.cursor = 'pointer';
clickHit.addEventListener('click', () => set(val, (band + 1) % ranges.length));
set(opts.value !== undefined ? opts.value : 45, opts.band !== undefined ? opts.band : 2);
return { el: s, get: () => [val, band], set };
};
/* R16 entry keypad — worn keys feed the amber display; lamps watch state.
Contract (everything a consumer needs; no page globals touched):
opts: onChange(buffer, text) fires on every keypress and on the initial
paint ('', 'enter a code'); ✓ reports 'OK · ' (or 'empty')
then clears, ✗ reports 'cleared' and flashes the lower lamp.
handle: el, get() (the buffer string, up to 6 digits), press(key) — any
of '0'-'9', '✓', '✗'; digits past 6 are ignored. The upper lamp
lights while the buffer is non-empty.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.entryKeypad = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 150, 190);
gradDef('kpKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#dbd8cf'], ['1', '#a29d92']]);
gradDef('kpAmber', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'var(--amber-grad-top)'], ['1', 'var(--amber-grad-mid)']]);
const lamp = cy => svgEl(s, 'circle', { cx: 13, cy, r: 6.5, fill: '#3a0f0a', stroke: '#14100e', 'stroke-width': 1.5 });
const lampTop = lamp(20), lampBot = lamp(38);
svgEl(s, 'rect', { x: 26, y: 8, width: 116, height: 32, rx: 5, fill: '#0a0806', stroke: '#2c261d', 'stroke-width': 2 });
const disp = svgEl(s, 'text', { x: 84, y: 31, 'text-anchor': 'middle', 'font-size': 16, 'letter-spacing': '.22em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' });
svgEl(s, 'rect', { x: 24, y: 48, width: 118, height: 138, rx: 8, fill: '#1a1714', stroke: '#0a0908', 'stroke-width': 1.5 });
let buf = '';
const render = () => {
disp.textContent = buf.padEnd(6, '–');
lampTop.setAttribute('fill', buf ? 'var(--jewel-r)' : '#3a0f0a');
};
const press = k => {
if (k === '✓') { onChange(buf, buf ? 'OK · ' + buf : 'empty'); buf = ''; render(); return; }
if (k === '✗') {
buf = ''; render(); onChange(buf, 'cleared');
lampBot.setAttribute('fill', 'var(--jewel-r)');
setTimeout(() => lampBot.setAttribute('fill', '#3a0f0a'), 350); return;
}
if (/^[0-9]$/.test(k) && buf.length < 6) { buf += k; render(); onChange(buf, buf); }
};
const KEYS = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['✓', '0', '✗']];
const jitter = [-2, 1, -1, 2, 0, -2, 1, -1, 2, 0, -1, 1];
KEYS.forEach((row, r) => row.forEach((k, c) => {
const x = 47 + c * 36, y = 68 + r * 32, amber = (k === '✓' || k === '✗');
const g = svgEl(s, 'g', {}); g.style.cursor = 'pointer'; g.style.transition = 'transform .07s';
svgEl(g, 'rect', { x: x - 14, y: y - 11, width: 28, height: 24, rx: 4, fill: amber ? 'url(#kpAmber)' : 'url(#kpKey)', stroke: '#4e4a42', 'stroke-width': 1, 'stroke-opacity': .8 });
svgEl(g, 'text', { x, y: y + 6, 'text-anchor': 'middle', 'font-size': 13, 'font-weight': 700, 'font-family': 'var(--mono)', fill: k === '✓' ? '#3f5a1f' : k === '✗' ? '#7a2a1a' : '#2b2721', transform: `rotate(${jitter[r * 3 + c]},${x},${y})` }).textContent = k;
g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); press(k); });
}));
render(); onChange('', 'enter a code');
return { el: s, get: () => buf, press };
};
/* R18 thumb-slide attenuator pair — lit numeral strip, cream side tab.
Contract (everything a consumer needs; no page globals touched):
opts: channels (array of { name, v } strips, v 0-100, default BLEND/MIX
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 0-100; each strip
drags vertically on its own hit rect, and the numerals within 5
of the value light up.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.thumbSlide = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const chans = (opts.channels || [{ name: 'BLEND', v: 74 }, { name: 'MIX', v: 92 }]).map(c => ({ name: c.name, v: c.v }));
const s = stageSvg(host, 'rsvg', 130, 150), y0 = 22, y1 = 122;
gradDef('thRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]);
gradDef('thTab', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2efe6'], ['1', '#c6c1b3']]);
gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
const parts = [];
const set = (i, v) => {
v = Math.max(0, Math.min(100, Math.round(v))); chans[i].v = v;
const p = parts[i];
p.tab.setAttribute('transform', `translate(0,${28 + (100 - v) * 0.88})`);
p.nums.forEach(t => {
const on = Math.abs(parseInt(t.textContent) - v) <= 5;
t.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--gold)'); t.setAttribute('opacity', on ? '1' : '.55');
});
onChange(chans.map(c => c.v), `${chans[0].name} ${chans[0].v} · ${chans[1].name} ${chans[1].v}`);
};
chans.forEach((ch, i) => {
const x = 40 + i * 50;
svgEl(s, 'rect', { x: x - 9, y: y0 - 10, width: 18, height: y1 - y0 + 20, rx: 3, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 });
for (const sy of [y0 - 6, y1 + 6]) {
svgEl(s, 'circle', { cx: x, cy: sy, r: 2.4, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 });
svgEl(s, 'line', { x1: x - 1.8, y1: sy - 1, x2: x + 1.8, y2: sy + 1, stroke: '#14110e', 'stroke-width': .8 });
}
svgEl(s, 'rect', { x: x - 5, y: y0, width: 10, height: y1 - y0, fill: '#0d0a08', stroke: '#050404' });
const nums = [];
for (let n = 0; n <= 10; n++) {
const val = 100 - n * 10, y = y0 + 6 + n * (y1 - y0 - 12) / 10;
const t = svgEl(s, 'text', { x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .55 });
t.textContent = val; nums.push(t);
}
const tab = svgEl(s, 'g', {});
svgEl(tab, 'rect', { x: x + 7, y: -6, width: 8, height: 12, rx: 2, fill: 'url(#thTab)', stroke: '#7a766a', 'stroke-width': .6 });
svgEl(tab, 'rect', { x: x + 8.2, y: -1, width: 5.6, height: 1.6, fill: 'rgba(0,0,0,.25)' });
svgEl(s, 'text', { x, y: 145, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name;
const hit = svgEl(s, 'rect', { x: x - 12, y: y0 - 10, width: 30, height: y1 - y0 + 20, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
parts.push({ tab, nums });
dragY(hit, pct => set(i, pct));
});
set(0, chans[0].v); set(1, chans[1].v);
return { el: s, get: () => chans.map(c => c.v), set };
};
/* R19 waveform region editor — monochrome LCD, draggable S/E flags.
Contract (everything a consumer needs; no page globals touched):
opts: start / end (region bounds in percent, defaults 22 / 76);
onChange({ s, e }, 'S n% · E n%') fires on every set, including
the initial paint.
handle: el, get() ({ s, e }), set(s, e) — s clamps 0-96, e clamps 4-100,
and e is kept at least 4 above s; dragging moves whichever flag
is nearer the pointer.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; the LCD inks read the --scr-* screen tokens with hex
fallbacks. */
DUPRE.waveRegion = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 190, 100), x0 = 10, x1 = 180, yMid = 48, N = 85;
svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 98, rx: 6, fill: 'var(--scr-bg1,#0b0c0b)', stroke: 'var(--scr-brd,#2a2c2a)', 'stroke-width': 2 });
svgEl(s, 'text', { x: 12, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C1:START';
svgEl(s, 'text', { x: 78, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C2:OFF';
svgEl(s, 'text', { x: 136, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C3:END';
svgEl(s, 'line', { x1: x0, y1: yMid, x2: x1, y2: yMid, stroke: '#3a3c3a', 'stroke-width': .6, 'stroke-dasharray': '1.5 2' });
const bars = [];
for (let i = 0; i < N; i++) {
const x = x0 + (i / (N - 1)) * (x1 - x0);
const env = Math.abs(Math.sin(i * 0.19)) * Math.abs(Math.sin(i * 0.045)) * (i % 17 < 11 ? 1 : .25);
const h = Math.max(1.2, env * 26);
bars.push(svgEl(s, 'rect', { x: x - 0.8, y: yMid - h, width: 1.6, height: h * 2, fill: 'var(--scr-dim,#5a5c5a)' }));
}
const flag = lbl => {
const g = svgEl(s, 'g', {});
svgEl(g, 'line', { x1: 0, y1: 18, x2: 0, y2: 78, stroke: 'var(--scr-hi,#f2f4f2)', 'stroke-width': 1 });
svgEl(g, 'rect', { x: lbl === 'S' ? 0 : -8, y: 70, width: 8, height: 8, fill: 'var(--scr-hi,#f2f4f2)' });
svgEl(g, 'text', { x: lbl === 'S' ? 4 : -4, y: 76.5, 'text-anchor': 'middle', 'font-size': 6.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = lbl;
return g;
};
const flagS = flag('S'), flagE = flag('E');
svgEl(s, 'rect', { x: 10, y: 84, width: 78, height: 11, fill: 'var(--scr-hi,#e8eae8)' });
svgEl(s, 'text', { x: 14, y: 92.5, 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = 'ENC:ZOOM(1x)';
svgEl(s, 'text', { x: 96, y: 92.5, 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'A-3 M:[S]';
svgEl(s, 'rect', { x: 148, y: 84, width: 32, height: 11, fill: 'var(--scr-hi,#e8eae8)' });
svgEl(s, 'text', { x: 164, y: 92.5, 'text-anchor': 'middle', 'font-size': 6.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = 'MENU';
s.style.cursor = 'ew-resize';
let S, E;
const set = (sv, ev) => {
S = Math.max(0, Math.min(96, sv)); E = Math.max(4, Math.min(100, ev));
if (E < S + 4) E = S + 4;
const xOf = p => x0 + p / 100 * (x1 - x0);
flagS.setAttribute('transform', `translate(${xOf(S)},0)`);
flagE.setAttribute('transform', `translate(${xOf(E)},0)`);
bars.forEach((b, i) => {
const p = i / (bars.length - 1) * 100;
b.setAttribute('fill', (p >= S && p <= E) ? 'var(--scr-hi,#f2f4f2)' : 'var(--scr-dim,#5a5c5a)');
});
onChange({ s: S, e: E }, `S ${Math.round(S)}% · E ${Math.round(E)}%`);
};
dragX(s, pct => {
/* move whichever flag is nearer the pointer */
if (Math.abs(pct - S) <= Math.abs(pct - E)) set(Math.min(pct, E - 4), E);
else set(S, Math.max(pct, S + 4));
});
set(opts.start !== undefined ? opts.start : 22, opts.end !== undefined ? opts.end : 76);
return { el: s, get: () => ({ s: S, e: E }), set };
};
/* R20 drum roller selector — numbered paper drum in a window, center chip reads it.
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.
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;
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';
const grps = [];
const set = (i, v) => {
v = Math.max(1, Math.min(10, 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)}`);
};
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 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)' });
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++)
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(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' });
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 });
});
set(0, chans[0].v); set(1, chans[1].v);
return { el: s, get: () => chans.map(c => c.v), set };
};
/* R21 LED program row — exclusive select, the LED above the key carries the state.
Contract (everything a consumer needs; no page globals touched):
opts: items (program names, default 8 reverb programs — the layout is
sized for about eight); label (engraved caption, default
'PROGRAM'); index (initial selection — default 5 for the default
set, first item for a caller's own set); onChange(index,
'n · name') fires on every set, including the initial paint.
handle: el, get() (selected index), set(i) — i clamps to the items
range; exactly one LED is lit at a time.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.ledRow = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || ['Sm Hall B', 'VocPlate', 'Lg Hall B', 'Chamber', 'ParcPlate', 'Sm Hall A', 'Room A', 'Const Plate'];
const s = stageSvg(host, 'rsvg', 190, 58);
gradDef('lrKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#2b2823'], ['1', '#171512']]);
const leds = [];
let idx;
const set = i => {
idx = Math.max(0, Math.min(items.length - 1, i));
leds.forEach((l, k) => {
const on = k === idx;
l.setAttribute('fill', on ? 'var(--jewel-r)' : '#3a0f0a');
l.setAttribute('style', on ? 'filter:drop-shadow(0 0 3px rgba(255,91,69,.8))' : '');
});
onChange(idx, `${idx + 1} · ${items[idx]}`);
};
items.forEach((_, i) => {
const x = 16 + i * 22.6;
svgEl(s, 'text', { x, y: 9, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = i + 1;
leds.push(svgEl(s, 'circle', { cx: x, cy: 17, r: 2.4, fill: '#3a0f0a' }));
const g = svgEl(s, 'g', {}); g.style.cursor = 'pointer'; g.style.transition = 'transform .07s';
svgEl(g, 'rect', { x: x - 8.5, y: 24, width: 17, height: 22, rx: 2.5, fill: 'url(#lrKey)', stroke: '#0a0908', 'stroke-width': 1 });
svgEl(g, 'rect', { x: x - 6.5, y: 26.5, width: 13, height: 3, rx: 1.5, fill: 'rgba(255,255,255,.06)' });
g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); set(i); });
});
svgEl(s, 'text', { x: 95, y: 56, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'PROGRAM';
set(opts.index !== undefined ? opts.index : opts.items ? 0 : 5);
return { el: s, get: () => idx, set };
};
/* R22 three-position slide — chrome pill between detents, honest LED pair.
Contract (everything a consumer needs; no page globals touched):
opts: positions (three detent labels, default A / AB / B — the detents
are fixed at three); title (engraved header, default
'BASIC · VARIATION'); index (initial detent, default 1);
onChange(index, label) fires on every set, including the initial
paint.
handle: el, get() (detent index), set(i) — i clamps to the detents;
clicking the stage snaps the pill to the nearest detent, and the
LED pair reports A-side / B-side engagement (both lit at AB).
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.pillSlide = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const positions = opts.positions || ['A', 'AB', 'B'];
const s = stageSvg(host, 'rsvg press', 110, 64), detX = [34, 55, 76];
gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]);
svgEl(s, 'text', { x: 55, y: 10, 'text-anchor': 'middle', 'font-size': 5.5, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'BASIC · VARIATION';
svgEl(s, 'rect', { x: 20, y: 16, width: 70, height: 14, rx: 7, fill: '#0b0a09', stroke: '#000', 'stroke-width': 1 });
svgEl(s, 'rect', { x: 21, y: 17, width: 68, height: 5, rx: 2.5, fill: 'rgba(255,255,255,.04)' });
const pill = svgEl(s, 'rect', { x: -13, y: 18.5, width: 26, height: 9, rx: 4.5, fill: 'url(#sfChrome)', stroke: '#4e4a42', 'stroke-width': .6 });
pill.style.transition = 'transform .12s';
const lbls = positions.map((lbl, i) => {
const t = svgEl(s, 'text', { x: detX[i], y: 40, 'text-anchor': 'middle', 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--dim)' });
t.textContent = lbl; return t;
});
svgEl(s, 'rect', { x: 32, y: 45, width: 46, height: 11, rx: 2, fill: '#141210', stroke: '#060505' });
const leds = [44, 66].map(x => svgEl(s, 'circle', { cx: x, cy: 50.5, r: 2.4, fill: '#3a0f0a' }));
let idx;
const set = i => {
idx = Math.max(0, Math.min(positions.length - 1, i));
pill.setAttribute('transform', `translate(${detX[idx]},0)`);
lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)'));
const lit = [idx === 0 || idx === 1, idx === 2 || idx === 1];
leds.forEach((l, k) => l.setAttribute('fill', lit[k] ? 'var(--jewel-r)' : '#3a0f0a'));
onChange(idx, positions[idx]);
};
s.addEventListener('click', e => {
const r = s.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width * 110;
set(x < 45 ? 0 : x < 66 ? 1 : 2);
});
set(opts.index !== undefined ? opts.index : 1);
return { el: s, get: () => idx, set };
};
/* R23 spun-aluminum knob — machined rings in a knurled grip, red index.
Contract (everything a consumer needs; no page globals touched):
opts: label (engraved caption, default 'SPEED'); value (initial 0-100,
default 62); onChange(value, 'n%') fires on every set, including
the initial paint.
handle: el, get() (value), set(v) — v clamps 0-100; the whole stage
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.spunKnob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52;
gradDef('spunFace', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#e8e6e0'], ['.7', '#b3b0a8'], ['1', '#8a877e']]);
for (let k = 0; k < 8; k++) { const [x, y] = polar(cx, cy, 45, -135 + k * 38.6); svgEl(s, 'circle', { cx: x, cy: y, r: 1.3, fill: 'var(--steel)' }); }
for (let k = 0; k < 22; k++) { const [x, y] = polar(cx, cy, 36, k * 16.36); svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: '#14110e' }); }
svgEl(s, 'circle', { cx, cy, r: 36, fill: '#1a1714', stroke: '#000', 'stroke-width': .8 });
svgEl(s, 'circle', { cx, cy, r: 30, fill: 'url(#spunFace)', stroke: '#6e6b63', 'stroke-width': .8 });
for (const r of [24, 18, 12, 6])
svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: r % 12 === 0 ? 'rgba(0,0,0,.12)' : 'rgba(255,255,255,.3)', 'stroke-width': .6 });
const idx = svgEl(s, 'line', { x1: cx, y1: cy - 28, x2: cx, y2: cy - 10, stroke: 'var(--fail)', 'stroke-width': 2.6, 'stroke-linecap': 'round' });
svgEl(s, 'ellipse', { cx: cx - 9, cy: cy - 11, rx: 9, ry: 5, fill: 'rgba(255,255,255,.28)', transform: `rotate(-32,${cx - 9},${cy - 11})` });
svgEl(s, 'text', { x: cx, y: 103, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'SPEED';
let val;
const set = v => {
val = Math.max(0, Math.min(100, v));
idx.setAttribute('transform', `rotate(${-135 + val / 100 * 270},${cx},${cy})`);
onChange(val, Math.round(val) + '%');
};
dragDelta(s, () => val, set, { min: 0, max: 100 });
set(opts.value !== undefined ? opts.value : 62);
return { el: s, get: () => val, set };
};
/* R24 stomp switch + jewel — press to engage, the jewel reports.
Contract (everything a consumer needs; no page globals touched):
opts: on (initial state, default false); onChange(on, 'ENGAGED' /
'bypass') fires on every set, including the initial paint.
handle: el, get() (boolean), set(v) — coerced to boolean; clicking the
stage toggles, the dome dips, and the amber jewel glows while
engaged.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients and the avGlow filter register in the shared defs
plate. */
DUPRE.stompSwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55;
gradDef('stompDome', 'radialGradient', { cx: '40%', cy: '30%', r: '80%' }, [['0', '#f4f2ec'], ['.55', '#b9b6ae'], ['1', '#6e6b63']]);
gradDef('stompJewel', 'radialGradient', { cx: '38%', cy: '30%', r: '80%' }, [['0', '#ffe9b0'], ['.45', 'var(--jewel-a)'], ['1', '#5c3d0c']]);
def('avGlow', d => {
const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
});
const glow = svgEl(s, 'circle', { cx, cy: 22, r: 14, fill: 'rgba(255,180,58,.35)', filter: 'url(#avGlow)', opacity: 0 });
const jewel = svgEl(s, 'circle', { cx, cy: 22, r: 9, fill: '#241f1b', stroke: '#0a0908', 'stroke-width': 1.5 });
for (let k = 0; k < 6; k++) {
const a1 = k * 60, a2 = k * 60 + 30;
const [x1, y1] = polar(cx, 22, 9, a1), [x2, y2] = polar(cx, 22, 9, a2);
svgEl(s, 'path', { d: `M ${cx} 22 L ${x1} ${y1} A 9 9 0 0 1 ${x2} ${y2} Z`, fill: k % 2 ? 'rgba(255,255,255,.09)' : 'rgba(0,0,0,.10)', 'pointer-events': 'none' });
}
for (let k = 0; k < 20; k++) { const [x, y] = polar(cx, 72, 25, k * 18); svgEl(s, 'circle', { cx: x, cy: y, r: 2.4, fill: '#14110e' }); }
svgEl(s, 'circle', { cx, cy: 72, r: 25, fill: '#1a1714', stroke: '#000', 'stroke-width': .8 });
const dome = svgEl(s, 'g', {}); dome.style.transition = 'transform .08s';
svgEl(dome, 'circle', { cx, cy: 72, r: 19, fill: 'url(#stompDome)', stroke: '#55524a', 'stroke-width': .8 });
svgEl(dome, 'ellipse', { cx: cx - 6, cy: 64, rx: 8, ry: 4.5, fill: 'rgba(255,255,255,.5)', transform: `rotate(-24,${cx - 6},64)` });
let on;
const set = v => {
on = !!v;
jewel.setAttribute('fill', on ? 'url(#stompJewel)' : '#241f1b');
glow.setAttribute('opacity', on ? '1' : '0');
onChange(on, on ? 'ENGAGED' : 'bypass');
};
s.addEventListener('click', () => { dome.style.transform = 'translateY(1.5px)'; setTimeout(() => dome.style.transform = '', 90); set(!on); });
set(opts.on !== undefined ? opts.on : false);
return { el: s, get: () => on, set };
};
/* R27 winged gain selector — red T-bar over a dot ring, stepped detents.
Contract (everything a consumer needs; no page globals touched):
opts: steps (dB values, one detent each, default 12 from -80 to +10);
index (initial detent, default 7); onChange(db, '+n dB') fires on
every set with the step's dB value, including the initial paint.
handle: el, get() (detent index, not the dB value), set(i) — i rounds
and clamps to the steps range; the whole stage drags vertically
between detents.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.wingSelector = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const steps = opts.steps || [-80, -70, -60, -50, -40, -30, -20, -10, -5, 0, 5, 10];
const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52;
gradDef('wingRed', 'radialGradient', { cx: '40%', cy: '32%', r: '80%' }, [['0', '#d98a6f'], ['.6', 'var(--fail)'], ['1', '#6e2415']]);
steps.forEach((db, i) => {
const a = -135 + i / (steps.length - 1) * 270;
const [dx, dy] = polar(cx, cy, 38, a);
svgEl(s, 'circle', { cx: dx, cy: dy, r: 1.5, fill: 'var(--steel)' });
if (i % 2 === 1 || db === 0 || db === 10 || db === -80) {
const [tx, ty] = polar(cx, cy, 46.5, a);
svgEl(s, 'text', { x: tx, y: ty + 2.2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = db > 0 ? '+' + db : db;
}
});
const grp = svgEl(s, 'g', {}); grp.style.transition = 'transform .09s';
svgEl(grp, 'rect', { x: cx - 6.5, y: cy - 30, width: 13, height: 60, rx: 6, fill: 'url(#wingRed)', stroke: '#4e150a', 'stroke-width': .8 });
svgEl(grp, 'circle', { cx, cy, r: 15, fill: 'url(#wingRed)', stroke: '#4e150a', 'stroke-width': .8 });
svgEl(grp, 'line', { x1: cx, y1: cy - 28, x2: cx, y2: cy - 18, stroke: 'var(--cream)', 'stroke-width': 2, 'stroke-linecap': 'round' });
svgEl(grp, 'ellipse', { cx: cx - 4, cy: cy - 8, rx: 5, ry: 3, fill: 'rgba(255,255,255,.18)', transform: `rotate(-30,${cx - 4},${cy - 8})` });
let idx;
const set = v => {
idx = Math.max(0, Math.min(steps.length - 1, Math.round(v)));
grp.style.transform = `rotate(${-135 + idx / (steps.length - 1) * 270}deg)`;
grp.style.transformOrigin = `${cx}px ${cy}px`;
const db = steps[idx];
onChange(db, (db > 0 ? '+' : '') + db + ' dB');
};
dragDelta(s, () => idx, set, { min: 0, max: steps.length - 1, sens: .05 });
set(opts.index !== undefined ? opts.index : 7);
return { el: s, get: () => idx, set };
};
/* R28 rotary disc switch — the whole disc turns between heavy positions.
Contract (everything a consumer needs; no page globals touched):
opts: positions (array of [label, angle°] stops, default OFF / ON /
COMBINE); index (initial stop, default 1); onChange(index, label)
fires on every set, including the initial paint.
handle: el, get() (stop index), set(i) — i clamps to the stops; clicking
the stage advances to the next stop, wrapping.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.discSwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const positions = opts.positions || [['OFF', -45], ['ON', 45], ['COMBINE', 135]];
const s = stageSvg(host, 'rsvg press', 110, 116), cx = 55, cy = 58;
gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]);
gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
svgEl(s, 'circle', { cx, cy, r: 48, fill: '#1a1714', stroke: '#060505', 'stroke-width': 1.5 });
for (let k = 0; k < 4; k++) {
const [x, y] = polar(cx, cy, 44, 45 + k * 90);
svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 });
}
const lbls = positions.map(([lbl, a]) => {
const [tx, ty] = polar(cx, cy, 42, a);
const t = svgEl(s, 'text', { x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 5.8, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
t.textContent = lbl; return t;
});
svgEl(s, 'circle', { cx, cy, r: 36, fill: 'url(#discRed)', stroke: '#4e150a', 'stroke-width': 1 });
const grp = svgEl(s, 'g', {}); grp.style.transition = 'transform .15s'; grp.style.transformOrigin = `${cx}px ${cy}px`;
svgEl(grp, 'rect', { x: cx - 27, y: cy - 8, width: 54, height: 16, rx: 8, fill: 'url(#discRed)', stroke: '#8f3520', 'stroke-width': 1 });
svgEl(grp, 'rect', { x: cx - 25, y: cy - 6.5, width: 50, height: 4, rx: 2, fill: 'rgba(255,255,255,.16)' });
svgEl(grp, 'line', { x1: cx + 20, y1: cy, x2: cx + 26, y2: cy, stroke: 'var(--cream)', 'stroke-width': 2.5, 'stroke-linecap': 'round' });
svgEl(s, 'ellipse', { cx: cx - 12, cy: cy - 14, rx: 12, ry: 6, fill: 'rgba(255,255,255,.10)', transform: `rotate(-28,${cx - 12},${cy - 14})` });
let idx;
const set = i => {
idx = Math.max(0, Math.min(positions.length - 1, i));
grp.style.transform = `rotate(${positions[idx][1]}deg)`;
lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)'));
onChange(idx, positions[idx][0]);
};
s.addEventListener('click', () => set((idx + 1) % positions.length));
set(opts.index !== undefined ? opts.index : 1);
return { el: s, get: () => idx, set };
};
/* R29 guarded toggle — guard posts + red collar mark the critical throw.
Contract (everything a consumer needs; no page globals touched):
opts: on (initial state, default true); onLabel / offLabel (engraved
legends, defaults ON / OFF); onChange(on, label) fires on every
set, including the initial paint.
handle: el, get() (boolean), set(v) — coerced to boolean; clicking the
stage throws the lever, and the active legend carries the gold
ink.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.guardedToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52;
gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]);
gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]);
gradDef('chromeG', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]);
gradDef('tipG', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]);
const lblOn = svgEl(s, 'text', { x: cx, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' });
lblOn.textContent = opts.onLabel || 'ON';
const lblOff = svgEl(s, 'text', { x: cx, y: 96, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
lblOff.textContent = opts.offLabel || 'OFF';
svgEl(s, 'circle', { cx, cy, r: 13, fill: 'url(#discRed)', stroke: '#4e150a', 'stroke-width': 1 });
const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 11 * Math.cos(a)) + ',' + (cy + 11 * Math.sin(a))); }
svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#nutG)', stroke: '#2b2822' });
svgEl(s, 'circle', { cx, cy, r: 6, fill: '#14110e', stroke: '#000' });
for (const gx of [16, 74]) {
svgEl(s, 'rect', { x: gx - 6, y: cy - 24, width: 12, height: 48, rx: 6, fill: 'url(#nutG)', stroke: '#2b2822', 'stroke-width': 1 });
svgEl(s, 'rect', { x: gx - 4, y: cy - 21, width: 3.5, height: 42, rx: 2, fill: 'rgba(255,255,255,.14)' });
}
const lever = svgEl(s, 'g', {});
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';
let on;
const set = v => {
on = !!v;
lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)';
lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)');
lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)');
onChange(on, on ? 'ON' : 'OFF');
};
s.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: s, get: () => on, set };
};
/* R32 mechanical timer dial — dial rotates under a fixed index; wind by drag, stop by the red knob.
Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set.
Contract (everything a consumer needs; no page globals touched):
opts: minutes (initial wind, clamps 0-60, default 0);
onChange(minutes, 'T-N MIN'|'OFF'|'STOP · OFF'|'DING · OFF') fires
on every set, including the initial paint.
handle: el, get() (minutes remaining), set(m) — m clamps 0-60; the dial
face drags, the red knob clicks to 0.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.timerDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 158, 132), cx = 62, cy = 58;
gradDef('mtFace', 'radialGradient', { cx: '50%', cy: '42%', r: '75%' }, [['0', '#282320'], ['1', '#14110e']]);
gradDef('mtRed', 'radialGradient', { cx: '38%', cy: '32%', r: '80%' }, [['0', '#e0523a'], ['1', '#8f2416']]);
gradDef('mtHub', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#c9c4b8'], ['1', '#6e685c']]);
/* bezel + knurled coin edge */
svgEl(s, 'circle', { cx, cy, r: 50, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 47.5, fill: 'none', stroke: '#3a352c', 'stroke-width': 4, 'stroke-dasharray': '2 1.6' });
svgEl(s, 'circle', { cx, cy, r: 44, fill: 'url(#mtFace)', stroke: '#0a0908', 'stroke-width': 1 });
/* rotating scale: OFF at 0, minutes climb clockwise at 4.5 deg/min (60 at 270) */
const rot = svgEl(s, 'g', {});
for (let m = 0; m <= 60; m += 5) {
const major = m % 20 === 0;
const [x1, y1] = polar(cx, cy, 40, m * 4.5), [x2, y2] = polar(cx, cy, major ? 34 : 37, m * 4.5);
svgEl(rot, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': major ? 1.2 : .55, opacity: major ? .9 : .55 });
}
for (const m of [20, 40, 60]) {
const [x, y] = polar(cx, cy, 27, m * 4.5);
svgEl(rot, 'text', { x, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = String(m);
}
const [ox, oy] = polar(cx, cy, 27, 0);
svgEl(rot, 'text', { x: ox, y: oy + 3, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#e0523a' }).textContent = 'OFF';
const [mx, my] = polar(cx, cy, 16, 180);
svgEl(rot, 'text', { x: mx, y: my + 2.5, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'MINUTES';
/* hub with screw slot */
svgEl(s, 'circle', { cx, cy, r: 9, fill: 'url(#mtHub)', stroke: '#3c382f', 'stroke-width': 1 });
svgEl(s, 'line', { x1: cx - 5.5, y1: cy, x2: cx + 5.5, y2: cy, stroke: '#4a4438', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 2.2, fill: '#8f897b' });
/* fixed red index at top + winding-direction arrow */
svgEl(s, 'line', { x1: cx, y1: cy - 44, x2: cx, y2: cy - 32, stroke: '#e0523a', 'stroke-width': 1.6 });
svgEl(s, 'path', { d: 'M 122 14 A 26 26 0 0 1 136 26', fill: 'none', stroke: 'var(--steel)', 'stroke-width': 1.6 });
svgEl(s, 'polygon', { points: '136,26 130.5,24.5 134.5,19.5', fill: 'var(--steel)' });
/* engraved plate captions */
const cap = (t, y) => svgEl(s, 'text', { x: 4, y, 'font-size': 6.2, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = t;
cap('PUSH TO STOP', 118); cap('TURN TO START', 128);
let min;
const set = m => {
min = Math.max(0, Math.min(60, m));
rot.setAttribute('transform', `rotate(${(-min * 4.5).toFixed(2)},${cx},${cy})`);
onChange(min, min > 0.5 ? 'T-' + Math.round(min) + ' MIN' : 'OFF');
};
/* dial drag surface (under the knob in DOM so the knob wins the overlap) */
const hit = svgEl(s, 'circle', { cx, cy, r: 50, fill: 'transparent' });
hit.style.cursor = 'grab';
dragDelta(hit, () => min, set, { min: 0, max: 60 });
/* red stop knob: fluted edge, domed center */
const knob = svgEl(s, 'g', {});
svgEl(knob, 'circle', { cx: 126, cy: 104, r: 20, fill: 'url(#mtRed)', stroke: '#5c150c', 'stroke-width': 1.5 });
svgEl(knob, 'circle', { cx: 126, cy: 104, r: 18, fill: 'none', stroke: 'rgba(0,0,0,.4)', 'stroke-width': 3.4, 'stroke-dasharray': '3 2.4' });
svgEl(knob, 'circle', { cx: 126, cy: 104, r: 8.5, fill: 'url(#mtRed)', stroke: 'rgba(0,0,0,.3)', 'stroke-width': .8 });
svgEl(knob, 'ellipse', { cx: 121, cy: 98, rx: 6, ry: 3.4, fill: 'rgba(255,255,255,.18)', transform: 'rotate(-28,121,98)' });
knob.style.cursor = 'pointer';
knob.addEventListener('click', () => { set(0); onChange(0, 'STOP · OFF'); });
set(opts.minutes !== undefined ? opts.minutes : 0);
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => { if (min > 0) { set(min - 1); if (min === 0) onChange(0, 'DING · OFF'); } }, 1000);
return { el: s, get: () => min, set };
};
/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press.
Contract (everything a consumer needs; no page globals touched):
opts: onChange({x, y, dir}, 'DIR · x N y N') fires on every press and
once at the initial paint (dir null, origin).
handle: el, get() ({x, y} cursor position). No set — the cursor is a
relative accumulator; position only moves by presses.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.rockerPad = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55;
gradDef('rk4Pad', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#33302b'], ['1', '#141210']]);
gradDef('rk4Arr', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#e9e4d6'], ['1', '#9d988b']]);
gradDef('rk4Nub', 'radialGradient', { cx: '38%', cy: '30%', r: '85%' }, [['0', '#4a453e'], ['1', '#0d0c0a']]);
/* recessed plate + rubber pad */
svgEl(s, 'rect', { x: 3, y: 3, width: 104, height: 104, rx: 18, fill: '#211e1a', stroke: '#0c0b09', 'stroke-width': 1.5 });
svgEl(s, 'circle', { cx, cy, r: 45, fill: '#0f0d0b' });
svgEl(s, 'circle', { cx, cy, r: 41, fill: 'url(#rk4Pad)', stroke: '#060505', 'stroke-width': 1 });
/* four arrows, pointing outward */
const ARR = { up: '55,22 45,36 65,36', down: '55,88 45,74 65,74', left: '22,55 36,45 36,65', right: '88,55 74,45 74,65' };
const arrows = {};
for (const [dir, pts] of Object.entries(ARR))
arrows[dir] = svgEl(s, 'polygon', { points: pts, fill: 'url(#rk4Arr)', stroke: '#3c382f', 'stroke-width': .7 });
/* center pivot nub */
svgEl(s, 'circle', { cx, cy, r: 7, fill: 'url(#rk4Nub)', stroke: '#060505', 'stroke-width': .8 });
svgEl(s, 'ellipse', { cx: cx - 2, cy: cy - 2.5, rx: 2.6, ry: 1.7, fill: 'rgba(255,255,255,.25)' });
/* quadrant hit zones (drawn last so they win), press flash + tracked position */
let rx = 0, ry = 0;
const press = (dir, dx, dy) => {
rx += dx; ry += dy;
onChange({ x: rx, y: ry, dir }, dir.toUpperCase() + ' · x ' + rx + ' y ' + ry);
arrows[dir].setAttribute('fill', 'var(--gold-hi)');
setTimeout(() => arrows[dir].setAttribute('fill', 'url(#rk4Arr)'), 160);
};
const zone = (dir, pts, dx, dy) => {
const z = svgEl(s, 'polygon', { points: pts, fill: 'transparent' });
z.style.cursor = 'pointer'; z.addEventListener('click', () => press(dir, dx, dy));
};
zone('up', `${cx},${cy} 20,20 90,20`, 0, 1);
zone('down', `${cx},${cy} 20,90 90,90`, 0, -1);
zone('left', `${cx},${cy} 20,20 20,90`, -1, 0);
zone('right', `${cx},${cy} 90,20 90,90`, 1, 0);
onChange({ x: 0, y: 0, dir: null }, 'x 0 y 0');
return { el: s, get: () => ({ x: rx, y: ry }) };
};
/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state.
Contract (everything a consumer needs; no page globals touched):
opts: position ('A'|'B'|'C'|'D', default 'C'; anything else falls back
to 'C'); onChange(pos, 'POS Q') fires on every set, including the
initial paint.
handle: el, get() (current quadrant letter), set(q) — invalid quadrants
are ignored; quadrant click zones select directly.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.fourWayToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
gradDef('fw4Chrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e8e6e0'], ['.5', '#9a968c'], ['1', '#55524a']]);
gradDef('fw4Ball', 'radialGradient', { cx: '36%', cy: '30%', r: '80%' }, [['0', '#f2f0ea'], ['.55', '#8e8a80'], ['1', '#3c3a34']]);
/* printed panel graphics: axes, circle, diagonal square, labels */
svgEl(s, 'line', { x1: 8, y1: cy, x2: 122, y2: cy, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .7 });
svgEl(s, 'line', { x1: cx, y1: 8, x2: cx, y2: 122, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .7 });
svgEl(s, 'text', { x: cx + 2, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'Y';
svgEl(s, 'text', { x: 116, y: cy - 4, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'X';
svgEl(s, 'circle', { cx, cy, r: 42, fill: 'none', stroke: 'var(--steel)', 'stroke-width': .9, opacity: .8 });
const QUAD = { A: 315, B: 45, C: 135, D: 225 };
const diag = Object.values(QUAD).map(a => polar(cx, cy, 42, a));
svgEl(s, 'polygon', { points: diag.map(p => p.join(',')).join(' '), fill: 'none', stroke: 'var(--steel)', 'stroke-width': .8, opacity: .8 });
const LAMP_AT = { A: [14, 14], B: [116, 14], C: [116, 116], D: [14, 116] };
const lamps = {};
for (const [q, a] of Object.entries(QUAD)) {
const [lx, ly] = polar(cx, cy, 42, a);
const [px, py] = LAMP_AT[q];
svgEl(s, 'line', { x1: lx, y1: ly, x2: px, y2: py, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .5 });
svgEl(s, 'circle', { cx: lx, cy: ly, r: 6.5, fill: '#14110e', stroke: 'var(--steel)', 'stroke-width': .9 });
svgEl(s, 'text', { x: lx, y: ly + 2.8, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = q;
svgEl(s, 'circle', { cx: px, cy: py, r: 5.5, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 });
lamps[q] = svgEl(s, 'circle', { cx: px, cy: py, r: 3.6, fill: '#3a3426' });
}
/* bezel + boot */
svgEl(s, 'circle', { cx, cy, r: 20, fill: '#0d0c0a', stroke: '#2a2722', 'stroke-width': 1.5 });
svgEl(s, 'circle', { cx, cy, r: 15, fill: '#17140f', stroke: '#060505', 'stroke-width': 1 });
/* lever: shaft + grip rotate to the selected diagonal; ball pivot stays centered */
const lever = svgEl(s, 'g', {});
svgEl(lever, 'polygon', { points: `${cx - 3},${cy} ${cx + 3},${cy} ${cx + 2},${cy - 26} ${cx - 2},${cy - 26}`, fill: 'url(#fw4Chrome)', stroke: '#3c3a34', 'stroke-width': .6 });
svgEl(lever, 'rect', { x: cx - 3.4, y: cy - 40, width: 6.8, height: 16, rx: 3, fill: 'url(#fw4Chrome)', stroke: '#3c3a34', 'stroke-width': .6 });
svgEl(s, 'circle', { cx, cy, r: 8.5, fill: 'url(#fw4Ball)', stroke: '#26241f', 'stroke-width': .8 });
svgEl(s, 'ellipse', { cx: cx - 2.6, cy: cy - 3, rx: 2.8, ry: 1.9, fill: 'rgba(255,255,255,.5)' });
let pos = null;
const set = q => {
if (!Object.hasOwn(QUAD, q)) return;
pos = q;
lever.setAttribute('transform', `rotate(${QUAD[q]},${cx},${cy})`);
for (const k of Object.keys(QUAD)) {
lamps[k].setAttribute('fill', k === q ? 'var(--jewel-a)' : '#3a3426');
lamps[k].setAttribute('filter', k === q ? 'drop-shadow(0 0 4px rgba(255,180,58,.8))' : 'none');
}
onChange(pos, 'POS ' + q);
};
/* quadrant click zones (diagonal quadrants, drawn last) */
const zone = (q, pts) => {
const z = svgEl(s, 'polygon', { points: pts, fill: 'transparent' });
z.style.cursor = 'pointer'; z.addEventListener('click', () => set(q));
};
zone('A', `${cx},${cy} ${cx},5 5,5 5,${cy}`);
zone('B', `${cx},${cy} ${cx},5 125,5 125,${cy}`);
zone('C', `${cx},${cy} 125,${cy} 125,125 ${cx},125`);
zone('D', `${cx},${cy} ${cx},125 5,125 5,${cy}`);
set(Object.hasOwn(QUAD, opts.position || '') ? opts.position : 'C');
return { el: s, get: () => pos, set };
};
/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many.
Contract (everything a consumer needs; no page globals touched):
opts: rows (source labels, default 5-row synth set); cols (destination
labels, default 6-col set; the layout is sized for 5x6); pins
(initial 'ROW>COL' keys — keys naming no intersection are
dropped); onChange(pins array, 'N routes') fires on every seat or
pull, including the initial paint.
handle: el, get() ('ROW>COL' keys array). No set — pins seat and pull
by intersection click.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.pinMatrix = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV'];
const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD'];
/* a legible default patch; keys that name no intersection are dropped */
const pins = new Set((opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA'])
.filter(k => { const [r, c] = k.split('>'); return ROWS.includes(r) && COLS.includes(c) && k === r + '>' + c; }));
const s = stageSvg(host, 'rsvg press', 160, 110);
const X0 = 42, Y0 = 30, DX = 19, DY = 15.5;
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
COLS.forEach((c, j) => svgEl(s, 'text', { x: X0 + j * DX, y: 16, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = c);
ROWS.forEach((r, i) => svgEl(s, 'text', { x: 34, y: Y0 + i * DY + 2, 'text-anchor': 'end', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = r);
ROWS.forEach((r, i) => COLS.forEach((c, j) => {
const cx = X0 + j * DX, cy = Y0 + i * DY, key = r + '>' + c;
svgEl(s, 'circle', { cx, cy, r: 3.4, fill: '#0a0908', stroke: '#2c2820', 'stroke-width': 1 });
const cap = svgEl(s, 'circle', { cx, cy, r: 4.4, fill: 'var(--gold)', stroke: '#7d5c16', 'stroke-width': 1, opacity: pins.has(key) ? 1 : 0, filter: 'drop-shadow(0 1px 2px rgba(0,0,0,.6))' });
const hit = svgEl(s, 'circle', { cx, cy, r: 8, fill: 'transparent' });
hit.style.cursor = 'pointer';
hit.addEventListener('click', () => {
const on = !pins.has(key);
if (on) pins.add(key); else pins.delete(key);
cap.setAttribute('opacity', on ? 1 : 0);
onChange([...pins], (on ? r + ' → ' + c : 'pulled ' + r + ' → ' + c) + ' · ' + pins.size + ' routes');
});
}));
onChange([...pins], pins.size + ' routes');
return { el: s, get: () => [...pins] };
};
/* R38 dead-man button — state exists only while the pointer holds it down.
Contract (everything a consumer needs; no page globals touched):
opts: caption (plate legend, default 'HOLD TO RUN'); label (button
face, default 'RUN'); onChange(held, 'RUNNING N.Ns'|'SAFE...')
fires on press, release, every 100 ms while held, and the
initial paint.
handle: el, get() (true while held). No set — by design the state
exists only under a live pointer.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.deadMan = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 120, 110), cx = 60, cy = 58;
gradDef('dmBtn', 'radialGradient', { cx: '40%', cy: '32%', r: '85%' }, [['0', '#3a3631'], ['1', '#16130f']]);
svgEl(s, 'rect', { x: 4, y: 4, width: 112, height: 102, rx: 12, fill: '#211e1a', stroke: '#0c0b09', 'stroke-width': 1.5 });
svgEl(s, 'text', { x: cx, y: 18, 'text-anchor': 'middle', 'font-size': 6.2, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.caption || 'HOLD TO RUN';
const lamp = svgEl(s, 'circle', { cx: 104, cy: 16, r: 4.5, fill: '#1e2a12' });
const ring = svgEl(s, 'circle', { cx, cy, r: 33, fill: 'none', stroke: '#2c2820', 'stroke-width': 3 });
const btn = svgEl(s, 'g', {});
svgEl(btn, 'circle', { cx, cy, r: 28, fill: 'url(#dmBtn)', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(btn, 'circle', { cx, cy, r: 22, fill: 'none', stroke: 'rgba(255,255,255,.07)', 'stroke-width': 1 });
svgEl(btn, 'text', { x: cx, y: cy + 2.5, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--silver)' }).textContent = opts.label || 'RUN';
btn.style.cursor = 'pointer';
let t0 = null, iv = null;
const stop = () => {
if (t0 === null) return;
const held = ((Date.now() - t0) / 1000).toFixed(1);
t0 = null; clearInterval(iv);
btn.removeAttribute('transform');
ring.setAttribute('stroke', '#2c2820'); ring.removeAttribute('filter');
lamp.setAttribute('fill', '#1e2a12'); lamp.removeAttribute('filter');
onChange(false, 'SAFE (held ' + held + 's)');
};
btn.addEventListener('pointerdown', e => {
btn.setPointerCapture(e.pointerId);
t0 = Date.now();
btn.setAttribute('transform', 'translate(0,1.5)');
ring.setAttribute('stroke', 'var(--gold)'); ring.setAttribute('filter', 'drop-shadow(0 0 5px rgba(var(--glow-lo),.7))');
lamp.setAttribute('fill', 'var(--pass)'); lamp.setAttribute('filter', 'drop-shadow(0 0 4px rgba(116,147,47,.8))');
onChange(true, 'RUNNING 0.0s');
iv = setInterval(() => { if (t0 !== null) onChange(true, 'RUNNING ' + ((Date.now() - t0) / 1000).toFixed(1) + 's'); }, 100);
e.preventDefault();
});
btn.addEventListener('pointerup', stop);
btn.addEventListener('pointercancel', stop);
onChange(false, 'SAFE');
return { el: s, get: () => t0 !== null };
};
/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns.
Spin animation is reduced-motion-gated; clicks during a spin are ignored.
Contract (everything a consumer needs; no page globals touched):
opts: onChange(dialed, dialed) fires when each digit's return spin
lands, plus once at the initial paint ('dial a number').
handle: el, get() (dialed string, last 10 digits kept). No set — digits
only arrive through the dial.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.telephoneDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 63;
const DIGITS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
const angOf = i => (60 - i * 30 + 360) % 360; /* 1 at 60cw, counterclockwise to 0 at 150 */
const STOP = 105;
gradDef('tdWheel', 'radialGradient', { cx: '42%', cy: '36%', r: '85%' }, [['0', '#8f8a7e'], ['1', '#4a463e']]);
svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1e1a16', stroke: '#0a0908', 'stroke-width': 1 });
/* rotating finger wheel: ring + hole openings (dark wells punched in the metal) */
const wheel = svgEl(s, 'g', {});
svgEl(wheel, 'circle', { cx, cy, r: 46, fill: 'none', stroke: 'url(#tdWheel)', 'stroke-width': 17, opacity: .95 });
DIGITS.forEach((_, i) => {
const [x, y] = polar(cx, cy, 38, angOf(i));
svgEl(wheel, 'circle', { cx: x, cy: y, r: 7.2, fill: '#1e1a16', stroke: '#26221c', 'stroke-width': 1.4 });
});
/* fixed digits ON TOP so they read through the holes (they stay put while the wheel spins) */
DIGITS.forEach((d, i) => {
const [x, y] = polar(cx, cy, 38, angOf(i));
svgEl(s, 'text', { x, y: y + 3, 'text-anchor': 'middle', 'font-size': 8.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = d;
});
svgEl(s, 'circle', { cx, cy, r: 16, fill: 'url(#tdWheel)', stroke: '#26221c', 'stroke-width': 1 });
/* finger stop */
const [fx, fy] = polar(cx, cy, 52, STOP);
svgEl(s, 'rect', { x: fx - 2.5, y: fy - 8, width: 5, height: 16, rx: 2, fill: '#b8b2a4', stroke: '#3c382f', 'stroke-width': 1, transform: `rotate(${STOP},${fx},${fy})` });
let dialed = '', spinning = false;
const setW = a => wheel.setAttribute('transform', `rotate(${a},${cx},${cy})`);
const finish = d => { dialed = (dialed + d).slice(-10); onChange(dialed, dialed); spinning = false; };
DIGITS.forEach((d, i) => {
const [x, y] = polar(cx, cy, 38, angOf(i));
const hit = svgEl(s, 'circle', { cx: x, cy: y, r: 8.5, fill: 'transparent' });
hit.style.cursor = 'pointer';
hit.addEventListener('click', () => {
if (spinning) return; spinning = true;
const R = (STOP - angOf(i) + 360) % 360;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { setW(R); setTimeout(() => { setW(0); finish(d); }, 160); return; }
const t0 = performance.now(), Tf = R / 300 * 1000, Tb = R / 450 * 1000;
const step = t => {
const el = t - t0;
if (el < Tf) { setW(R * el / Tf); requestAnimationFrame(step); }
else if (el < Tf + 80) { setW(R); requestAnimationFrame(step); }
else if (el < Tf + 80 + Tb) { setW(R * (1 - (el - Tf - 80) / Tb)); requestAnimationFrame(step); }
else { setW(0); finish(d); }
};
requestAnimationFrame(step);
});
});
onChange('', 'dial a number');
return { el: s, get: () => dialed };
};
/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step
(a tripped handle clicks to off, then to on).
Contract (everything a consumer needs; no page globals touched):
opts: names (breaker labels, default MAIN/PUMP/LAMP/AUX — the layout is
sized for four); onChange(states array, 'N/M closed[ · NAME
TRIPPED]') fires on every click or trip, including the initial
paint. The first three breakers start on, the rest off.
handle: el, get() (per-breaker 'on'|'off'|'tripped' array). No set —
breakers move by handle click and the TRIP button.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.breakerPanel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const NAMES = opts.names || ['MAIN', 'PUMP', 'LAMP', 'AUX'];
const s = stageSvg(host, 'rsvg press', 160, 110);
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
const brk = [];
const draw = () => {
let closed = 0, trip = null;
brk.forEach(b => {
if (b.state === 'on') closed++; if (b.state === 'tripped') trip = b.name;
b.lamp.setAttribute('fill', b.state === 'on' ? 'var(--pass)' : '#1e2a12');
b.lamp.setAttribute('filter', b.state === 'on' ? 'drop-shadow(0 0 3px rgba(116,147,47,.8))' : 'none');
b.handle.setAttribute('y', b.state === 'on' ? 40 : b.state === 'tripped' ? 57 : 74);
b.collar.setAttribute('opacity', b.state === 'tripped' ? 1 : 0);
});
onChange(brk.map(b => b.state), closed + '/' + NAMES.length + ' closed' + (trip ? ' · ' + trip + ' TRIPPED' : ''));
};
NAMES.forEach((name, i) => {
const x = 14 + i * 30;
svgEl(s, 'rect', { x, y: 36, width: 20, height: 52, rx: 4, fill: '#14110e', stroke: '#2c2820', 'stroke-width': 1 });
const lamp = svgEl(s, 'circle', { cx: x + 10, cy: 26, r: 4, fill: '#1e2a12' });
const collar = svgEl(s, 'rect', { x: x + 4, y: 69, width: 12, height: 5, fill: 'var(--amber-warn)', opacity: 0 });
const handle = svgEl(s, 'rect', { x: x + 4, y: 40, width: 12, height: 14, rx: 2, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
svgEl(s, 'text', { x: x + 10, y: 98, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = name;
const hit = svgEl(s, 'rect', { x, y: 20, width: 20, height: 80, fill: 'transparent' });
hit.style.cursor = 'pointer';
const b = { name, state: i < 3 ? 'on' : 'off', lamp, collar, handle };
hit.addEventListener('click', () => { b.state = b.state === 'on' ? 'off' : b.state === 'off' ? 'on' : 'off'; draw(); });
brk.push(b);
});
const tb = svgEl(s, 'g', {});
svgEl(tb, 'rect', { x: 132, y: 40, width: 22, height: 16, rx: 4, fill: 'var(--fail)', stroke: '#5c150c', 'stroke-width': 1 });
svgEl(tb, 'text', { x: 143, y: 50.5, 'text-anchor': 'middle', 'font-size': 6, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = 'TRIP';
tb.style.cursor = 'pointer';
tb.addEventListener('click', () => { for (let i = brk.length - 1; i >= 0; i--) if (brk[i].state === 'on') { brk[i].state = 'tripped'; break; } draw(); });
draw();
return { el: s, get: () => brk.map(b => b.state) };
};
/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb.
Contract (everything a consumer needs; no page globals touched):
opts: onChange(vals {prog, verb, noun}, status text) fires on ENTR,
RSET, OPR ERR, and the initial paint. Grammar: VERB/NOUN arm a
window, digits fill it (2 max), ENTR commits — V35 runs the lamp
test, V16 N36 sets PROG 16 (monitor clock), a bare-verb commit
with under 2 digits flashes OPR ERR.
handle: el, get() ({prog, verb, noun} copy).
CSS lives in the "DSKY" block of DUPRE_CSS; keys reuse the shared
.dupre-key face and digits render as shared .seg7 glyphs. */
DUPRE.dsky = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const LAMPS = ['UPLINK', 'TEMP', 'GIMBAL', 'PROG', 'RESTART', 'OPR ERR'];
const el = document.createElement('div'); el.className = 'dupre-dsky'; host.appendChild(el);
el.innerHTML =
`
${LAMPS.map(l => `
${l}
`).join('')}
` +
`
` +
`
PROG
` +
`
VERB
` +
`
NOUN
` +
`
`;
const win = w => el.querySelector(`[data-w="${w}"]`);
const setWin = (w, txt) => { win(w).querySelector('.dupre-dsky-wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); };
const lampEl = l => el.querySelector(`[data-l="${l}"]`);
let mode = null, vals = { prog: ' ', verb: ' ', noun: ' ' }, entry = '';
const KEYS = ['VERB', 'NOUN', 'CLR', 'ENTR', 'RSET', '7', '8', '9', '4', '5', '6', '1', '2', '3', '0'];
const pad = el.querySelector('.dupre-dsky-pad');
const hot = () => { el.querySelectorAll('.dupre-dsky-win').forEach(w => w.classList.toggle('dupre-hot', w.dataset.w === mode)); };
const oprErr = () => { lampEl('OPR ERR').classList.add('dupre-on'); setTimeout(() => lampEl('OPR ERR').classList.remove('dupre-on'), 1200); onChange(vals, 'OPR ERR'); };
const commit = () => {
if (vals.verb.trim().length < 2) { oprErr(); return; }
const v = vals.verb, n = vals.noun.trim();
if (v === '35') {
LAMPS.forEach(l => lampEl(l).classList.add('dupre-on'));
setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('dupre-on')), 1400);
onChange(vals, 'V35 · lamp test');
}
else if (v === '16' && n === '36') { vals.prog = '16'; setWin('prog', vals.prog); onChange(vals, 'V16 N36 · monitor clock'); }
else onChange(vals, 'V' + v + (n ? ' N' + n : '') + ' ENTR');
vals.verb = ' '; vals.noun = ' '; entry = ''; mode = null; hot();
};
KEYS.forEach(k => {
const b = document.createElement('button'); b.className = 'dupre-key'; b.textContent = k;
b.addEventListener('click', () => {
if (k === 'VERB' || k === 'NOUN') { mode = k.toLowerCase(); entry = ''; vals[mode] = ' '; setWin(mode, ' '); hot(); }
else if (k === 'CLR') { if (mode) { vals[mode] = ' '; entry = ''; setWin(mode, ' '); } }
else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('dupre-on')); onChange(vals, 'RSET'); }
else if (k === 'ENTR') commit();
else if (/\d/.test(k)) {
if (!mode) { oprErr(); return; }
entry = (entry + k).slice(0, 2); vals[mode] = entry.padEnd(2, ' '); setWin(mode, vals[mode]);
}
});
pad.appendChild(b);
});
vals.prog = '00'; setWin('prog', vals.prog); setWin('verb', ' '); setWin('noun', ' ');
onChange(vals, 'VERB ## ENTR');
return { el, get: () => ({ ...vals }) };
};
/* R42 cam-timer program drum — the program is a ring; the pointer self-advances through it.
Runs its own reduced-motion-gated 2 s step interval once started.
Contract (everything a consumer needs; no page globals touched):
opts: steps (ring labels, index 0 is OFF, default 12-step wash
program — the layout is sized for 12); colors (label→wedge
fill map, default wash palette); position (initial step, clamps
to the ring, default 0); onChange(pos, 'OFF'|'STEP N ·
LABEL'|'CYCLE DONE · OFF') fires on every set, including the
initial paint.
handle: el, get() (step index), set(i) — i clamps to the ring; any
click advances one step.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.camTimer = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const STEPS = opts.steps || ['OFF', 'FILL', 'WASH', 'WASH', 'WASH', 'RINSE', 'RINSE', 'DRAIN', 'SPIN', 'SPIN', 'FLUFF', 'COOL'];
const COLS = opts.colors || { OFF: '#3a3426', FILL: '#54677d', WASH: '#d29638', RINSE: '#46b89e', DRAIN: '#969385', SPIN: '#cb6b4d', FLUFF: '#c9b98a', COOL: '#7c99b0' };
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
STEPS.forEach((st, i) => {
const a0 = i * 30 - 90, a1 = a0 + 30;
const p0o = polar(cx, cy, 52, a0 + 90), p1o = polar(cx, cy, 52, a1 + 90), p0i = polar(cx, cy, 38, a0 + 90), p1i = polar(cx, cy, 38, a1 + 90);
svgEl(s, 'path', { d: `M ${p0i[0]} ${p0i[1]} L ${p0o[0]} ${p0o[1]} A 52 52 0 0 1 ${p1o[0]} ${p1o[1]} L ${p1i[0]} ${p1i[1]} A 38 38 0 0 0 ${p0i[0]} ${p0i[1]} Z`, fill: COLS[st], opacity: .75, stroke: '#0a0908', 'stroke-width': .8 });
const [tx, ty] = polar(cx, cy, 45, i * 30 + 15);
svgEl(s, 'text', { x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.4, 'font-weight': 700, 'font-family': 'var(--mono)', fill: st === 'OFF' ? 'var(--cream)' : '#14110e' }).textContent = st === 'OFF' ? 'OFF' : st[0];
});
svgEl(s, 'circle', { cx, cy, r: 34, fill: '#211e1a', stroke: '#0a0908', 'stroke-width': 1.5 });
STEPS.forEach((st, i) => {
if (i === 0) return; const [lx, ly] = polar(cx, cy, 27, i * 30 + 15);
svgEl(s, 'text', { x: lx, y: ly + 1.8, 'text-anchor': 'middle', 'font-size': 4.2, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = st;
});
svgEl(s, 'circle', { cx, cy, r: 17, fill: '#14110e', stroke: '#2c2820', 'stroke-width': 1.5 });
const ptr = svgEl(s, 'polygon', { points: `${cx - 2.5},${cy} ${cx + 2.5},${cy} ${cx},${cy - 31}`, fill: 'var(--gold-hi)', filter: 'drop-shadow(0 0 3px rgba(var(--glow-lo),.7))' });
svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
let pos;
const set = i => {
pos = Math.max(0, Math.min(STEPS.length - 1, i | 0));
ptr.setAttribute('transform', `rotate(${pos * 30 + 15},${cx},${cy})`);
onChange(pos, pos === 0 ? 'OFF' : 'STEP ' + pos + ' · ' + STEPS[pos]);
};
s.style.cursor = 'pointer';
s.addEventListener('click', () => set((pos + 1) % STEPS.length));
set(opts.position !== undefined ? opts.position : 0);
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => { if (pos > 0) { const n = (pos + 1) % STEPS.length; set(n); if (n === 0) onChange(0, 'CYCLE DONE · OFF'); } }, 2000);
return { el: s, get: () => pos, set };
};
/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw.
Contract (everything a consumer needs; no page globals touched):
opts: closed (initial state, coerced to boolean, default true);
onChange(closed, 'CLOSED · live'|'OPEN · visibly dead') fires on
every set, including the initial paint.
handle: el, get() (true when closed), set(v) — coerced to boolean; any
click toggles.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.knifeSwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 110);
gradDef('ksCu', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d09a5c'], ['1', '#8a5a2a']]);
const HX = 32, HY = 74; /* hinge pivot */
svgEl(s, 'rect', { x: 6, y: 56, width: 118, height: 44, rx: 6, fill: '#22262a', stroke: '#0c0d0e', 'stroke-width': 1.5 });
const lamp = svgEl(s, 'circle', { cx: 116, cy: 22, r: 4.5, fill: '#1e2a12' });
svgEl(s, 'text', { x: 116, y: 36, 'text-anchor': 'middle', 'font-size': 4.8, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'LIVE';
/* hinge post (left) and jaw clip (right), both on the base */
svgEl(s, 'rect', { x: HX - 6, y: HY - 6, width: 12, height: 16, rx: 2, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .8 });
svgEl(s, 'rect', { x: 96, y: HY - 9, width: 5, height: 18, rx: 1.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .7 });
svgEl(s, 'rect', { x: 104, y: HY - 9, width: 5, height: 18, rx: 1.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .7 });
/* blade + upright handle, hinged at (HX,HY) */
const blade = svgEl(s, 'g', {});
svgEl(blade, 'rect', { x: HX, y: HY - 3, width: 71, height: 6, rx: 2.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .8 });
svgEl(blade, 'rect', { x: 88, y: HY - 26, width: 7, height: 26, rx: 3, fill: '#14110e', stroke: '#000', 'stroke-width': 1 });
svgEl(blade, 'circle', { cx: 91.5, cy: HY - 26, r: 5, fill: '#14110e', stroke: '#000', 'stroke-width': 1 });
svgEl(s, 'circle', { cx: HX, cy: HY, r: 3, fill: '#5c3a18' });
blade.style.transformBox = 'view-box'; blade.style.transformOrigin = `${HX}px ${HY}px`;
blade.style.transition = 'transform .28s ease';
let closed;
const set = v => {
closed = !!v;
blade.style.transform = closed ? 'rotate(0deg)' : 'rotate(-46deg)';
lamp.setAttribute('fill', closed ? 'var(--pass)' : '#1e2a12');
lamp.setAttribute('filter', closed ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' : 'none');
onChange(closed, closed ? 'CLOSED · live' : 'OPEN · visibly dead');
};
s.style.cursor = 'pointer';
s.addEventListener('click', () => set(!closed));
set(opts.closed !== undefined ? opts.closed : true);
return { el: s, get: () => closed, set };
};
/* R49 decade box — four skirted knobs, one digit each; the value is their sum.
Contract (everything a consumer needs; no page globals touched):
opts: digits (array of four 0-9 digits, most-significant first, default
[3, 5, 0, 0] — missing entries read 0, values clamp to 0-9);
onChange(total, 'N,NNN Ω') fires on every redraw, including the
initial paint.
handle: el, get() (the summed value; digits weight x1000/x100/x10/x1).
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; no gradients registered. */
DUPRE.decadeBox = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const MUL = [1000, 100, 10, 1], LBL = ['x1000', 'x100', 'x10', 'x1'];
const digs = MUL.map((_, i) => Math.round(Math.max(0, Math.min(9, +(opts.digits || [3, 5, 0, 0])[i] || 0))));
const s = stageSvg(host, 'rsvg', 160, 100);
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
const wins = [], knobs = [];
const total = () => digs.reduce((a, d, i) => a + d * MUL[i], 0);
const draw = () => {
wins.forEach((w, i) => w.textContent = String(digs[i]));
knobs.forEach((k, i) => k.setAttribute('transform', `rotate(${digs[i] * 36},${28 + i * 35},58)`));
onChange(total(), total().toLocaleString('en-US') + ' Ω');
};
MUL.forEach((_, i) => {
const cx = 28 + i * 35, cy = 58;
svgEl(s, 'rect', { x: cx - 8, y: 16, width: 16, height: 12, rx: 2, fill: '#0a0806', stroke: '#2c2820', 'stroke-width': 1 });
wins.push(svgEl(s, 'text', { x: cx, y: 25.5, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }));
svgEl(s, 'circle', { cx, cy, r: 15, fill: '#211e1a', stroke: '#0a0908', 'stroke-width': 1.5 }); /* skirt */
for (let t = 0; t < 10; t++) {
const [x1, y1] = polar(cx, cy, 14, t * 36), [x2, y2] = polar(cx, cy, 12, t * 36);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--steel)', 'stroke-width': .6, opacity: .7 });
}
const k = svgEl(s, 'g', {});
svgEl(k, 'circle', { cx, cy, r: 10, fill: '#2c2824', stroke: '#060505', 'stroke-width': 1 });
svgEl(k, 'line', { x1: cx, y1: cy - 9, x2: cx, y2: cy - 4, stroke: 'var(--gold-hi)', 'stroke-width': 1.8 });
knobs.push(k);
svgEl(s, 'text', { x: cx, y: 88, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = LBL[i];
const hit = svgEl(s, 'circle', { cx, cy, r: 16, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
dragDelta(hit, () => digs[i], v => { digs[i] = Math.round(Math.max(0, Math.min(9, v))); draw(); }, { min: 0, max: 9, sens: 9 / 70 });
});
draw();
return { el: s, get: () => total() };
};
/* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle.
Contract (everything a consumer needs; no page globals touched):
opts: onChange(state, text) fires on every transition — 'ready',
'armed' (one palm down, 0.5s window), 'running' (1.5s cycle),
'fault' (window expired or same hand twice); initial fire 'ready'.
handle: el, press(side) — side is 'L' or 'R' only, anything else is
ignored; presses during a running cycle are ignored.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; no gradients registered. */
DUPRE.twoHandSafety = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 160, 100);
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
const runLamp = svgEl(s, 'circle', { cx: 80, cy: 24, r: 5.5, fill: '#1e2a12' });
svgEl(s, 'text', { x: 80, y: 40, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'CYCLE';
const mkBtn = (cx, lbl) => {
const g = svgEl(s, 'g', {});
svgEl(g, 'ellipse', { cx, cy: 70, rx: 19, ry: 6, fill: '#0d0b09' });
const cap = svgEl(g, 'circle', { cx, cy: 62, r: 16, fill: '#8f2416', stroke: '#5c150c', 'stroke-width': 1.5 });
svgEl(g, 'ellipse', { cx: cx - 5, cy: 56, rx: 6, ry: 3.4, fill: 'rgba(255,255,255,.22)' });
svgEl(s, 'text', { x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = lbl;
g.style.cursor = 'pointer'; return { g, cap };
};
const L = mkBtn(32, 'LEFT PALM'), R = mkBtn(128, 'RIGHT PALM');
let armed = null, armT = null, busy = false;
const flash = cap => { cap.setAttribute('fill', '#e0523a'); setTimeout(() => cap.setAttribute('fill', '#8f2416'), 200); };
const fault = () => { armed = null; clearTimeout(armT); onChange('fault', 'TIE-DOWN FAULT · release and retry'); };
const press = side => {
if (side !== 'L' && side !== 'R') return;
if (busy) return;
if (armed === null) {
armed = side; flash(side === 'L' ? L.cap : R.cap);
onChange('armed', side + ' armed · other hand within 0.5s');
armT = setTimeout(fault, 500); return;
}
if (armed === side) { fault(); return; } /* same hand twice = tie-down */
clearTimeout(armT); armed = null; busy = true;
flash(side === 'L' ? L.cap : R.cap);
runLamp.setAttribute('fill', 'var(--pass)');
runLamp.setAttribute('filter', 'drop-shadow(0 0 5px rgba(116,147,47,.8))');
onChange('running', 'CYCLE RUNNING');
setTimeout(() => {
runLamp.setAttribute('fill', '#1e2a12'); runLamp.removeAttribute('filter');
busy = false; onChange('ready', 'ready');
}, 1500);
};
L.g.addEventListener('click', () => press('L'));
R.g.addEventListener('click', () => press('R'));
onChange('ready', 'ready');
return { el: s, press };
};
/* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker.
Contract (everything a consumer needs; no page globals touched):
opts: loops (array of key labels; the default 8-loop set also gets a
legible demo state — FD + A/G monitored, GNC talking — while a
caller's own set starts all-idle); onChange(states, text) fires
on every click and the initial paint — states is an array of
'0' idle / '1' monitored / '2' talking, one per loop.
handle: el, get() (the states array). Click cycles idle → monitored →
talking → idle; talk is exclusive across keys. Activity
flicker is display-side and honors prefers-reduced-motion.
CSS lives in the "voice-loop keyset" block of DUPRE_CSS; no other styles
involved. */
DUPRE.voiceLoop = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const LOOPS = opts.loops || ['FD', 'GNC', 'ECOM', 'SURG', 'A/G', 'NET1', 'NET2', 'PAO'];
const el = document.createElement('div'); el.className = 'dupre-vloop'; host.appendChild(el);
const keys = LOOPS.map(l => {
const k = document.createElement('div'); k.className = 'dupre-vk';
k.innerHTML = l + ''; k.dataset.state = '0'; el.appendChild(k); return k;
});
if (!opts.loops) {
/* a legible default: FD + A/G monitored, GNC talking */
keys[0].dataset.state = '1'; keys[0].classList.add('dupre-mon');
keys[4].dataset.state = '1'; keys[4].classList.add('dupre-mon');
keys[1].dataset.state = '2'; keys[1].classList.add('dupre-mon', 'dupre-tlk');
}
const refresh = () => {
const mon = keys.filter(k => k.dataset.state !== '0').length;
const tlk = keys.findIndex(k => k.dataset.state === '2');
onChange(keys.map(k => k.dataset.state), mon === 0 ? 'all loops dropped' : mon + ' monitored' + (tlk >= 0 ? ' · talk ' + LOOPS[tlk] : ''));
};
keys.forEach(k => k.addEventListener('click', () => {
const st = k.dataset.state;
if (st === '0') { k.dataset.state = '1'; k.classList.add('dupre-mon'); }
else if (st === '1') {
keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('dupre-tlk'); } });
k.dataset.state = '2'; k.classList.add('dupre-tlk');
}
else { k.dataset.state = '0'; k.classList.remove('dupre-mon', 'dupre-tlk', 'dupre-act'); }
refresh();
}));
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => {
const t = performance.now() / 1000;
keys.forEach((k, i) => {
if (k.dataset.state === '0') { k.classList.remove('dupre-act'); return; }
k.classList.toggle('dupre-act', Math.sin(t * (1.1 + i * 0.37) + i * 2.1) > 0.55);
});
}, 300);
refresh();
return { el, get: () => keys.map(k => k.dataset.state) };
};
/* ---- meters & gauges ----
Tick contract: the page owns the clock and the signal; live meters expose
value-driven handles (set/push) that repaint synchronously and fire
onChange(value, text) like every other builder. Display-side state that
belongs to the instrument (peak-hold, history buffers) lives in here. */
/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-100, default 50); onChange(value, 'value N%') fires on
every set, including the initial paint.
handle: el, get() (current value), set(v) — no clamping beyond the
drag helper's 0-100; the needle maps value linearly to ±60°.
CSS lives in the "needle gauge" block of DUPRE_CSS; no other styles involved. */
DUPRE.needleGauge = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 50;
const el = document.createElement('div'); el.className = 'dupre-gauge';
el.innerHTML = `
0%
`;
host.appendChild(el);
const ndl = el.querySelector('.dupre-ndl'), num = el.querySelector('.dupre-gv span');
function set(nv) {
v = nv;
ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`;
num.textContent = Math.round(v);
onChange(v, 'value ' + Math.round(v) + '%');
}
dragDelta(el, () => v, set, { min: 0, max: 100 });
set(v);
return { el, get: () => v, set };
};
/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both.
Contract (everything a consumer needs; no page globals touched):
opts: bars (cells per channel, default 16); onChange([l, r], 'L n · R n')
fires on every set (values 0-1, readout in percent).
handle: el, get() ([l, r] last set), set(l, r). Peak-hold is display-side
state owned in here: each channel's peak cell decays 0.4 cells
per set() call, so the page's tick cadence is the decay clock
(the tick contract — the page owns the clock and the signal).
CSS lives in the "segmented VU / LED bar" block of DUPRE_CSS; no other
styles involved. */
DUPRE.vuPair = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const n = opts.bars || 16;
const el = document.createElement('div'); el.className = 'dupre-vu';
el.innerHTML = `
L
R
`;
host.appendChild(el);
const bars = el.querySelectorAll('.dupre-vubar');
bars.forEach(b => buildBars(b, n));
const pkL = { v: 0 }, pkR = { v: 0 };
function paint(bar, l, pk) {
const b = bar.children, lit = Math.round(l * n);
pk.v = Math.max(lit, (pk.v || 0) - 0.4); const p = Math.round(pk.v);
for (let k = 0; k < n; k++) {
let c = k < lit ? (k >= n - 2 ? 'dupre-clip' : k >= n - 4 ? 'dupre-hot' : 'dupre-on') : '';
if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'dupre-peak';
b[k].className = c;
}
}
let lv = 0, rv = 0;
function set(l, r) {
lv = l; rv = r;
paint(bars[0], l, pkL); paint(bars[1], r, pkR);
onChange([l, r], 'L ' + Math.round(l * 100) + ' · R ' + Math.round(r * 100));
}
return { el, get: () => [lv, rv], set };
};
/* 12 mini 4-bar signal — compact activity meter; set(level).
Contract (everything a consumer needs; no page globals touched):
opts: onChange(level, 'N%') fires on every set (level 0-1, clamped;
readout in percent). No initial fire — the page's tick drives it
(the tick contract — the page owns the clock and the signal).
handle: el, get() (last level set), set(level). Bars light bottom-up:
dupre-on, third bar dupre-hot, top bar dupre-clip.
CSS lives in the "mini 4-bar signal" block of DUPRE_CSS; no other styles
involved. */
DUPRE.miniSig = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const el = document.createElement('span'); el.className = 'dupre-sig';
el.innerHTML = '';
host.appendChild(el);
let v = 0;
function set(l) {
v = Math.max(0, Math.min(1, l));
const b = el.children, lit = Math.round(v * 4);
for (let k = 0; k < 4; k++) b[k].className = k < lit ? (k >= 3 ? 'dupre-clip' : k >= 2 ? 'dupre-hot' : 'dupre-on') : '';
onChange(v, Math.round(v * 100) + '%');
}
return { el, get: () => v, set };
};
/* 13 signal ladder — stepped 0-4 strength; click cycles.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-4, default 3); onChange(v, 'v/4') fires on every set,
including the initial paint.
handle: el, get() (current step), set(v) — bars at or below v light
gold; click advances (v + 1) % 5.
CSS lives in the "signal ladder" block of DUPRE_CSS; the lit/unlit bar
colours are inline (--gold / --wash), no other styles involved. */
DUPRE.signalLadder = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 3;
const el = document.createElement('span'); el.className = 'dupre-ladder';
el.innerHTML = '';
host.appendChild(el);
function set(nv) {
v = nv;
const bars = el.children;
for (let i = 0; i < bars.length; i++) bars[i].style.background = i < v ? 'var(--gold)' : 'var(--wash)';
onChange(v, v + '/4');
}
el.onclick = () => set((v + 1) % 5);
set(v);
return { el, get: () => v, set };
};
/* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-100, default 50); warnAt (threshold, default 20 — below
it the fill tints dupre-warn); onChange(value, 'N%') fires on
every set, including the initial paint.
handle: el, get() (current value), set(v) — clamped to 0-100; drag
along the bar sets by pointer position.
CSS lives in the "linear progress / fuel bar" block of DUPRE_CSS; no
other styles involved. */
DUPRE.fuelBar = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const warnAt = opts.warnAt ?? 20;
let v = opts.value ?? 50;
const el = document.createElement('div'); el.className = 'dupre-bar';
el.innerHTML = '';
host.appendChild(el);
const fill = el.querySelector('span');
function set(p) {
v = Math.max(0, Math.min(100, p));
fill.style.width = v + '%';
el.classList.toggle('dupre-warn', v < warnAt);
onChange(v, Math.round(v) + '%');
}
dragX(el, set);
set(v);
return { el, get: () => v, set };
};
/* 15 radial ring — percentage donut; drag up/down to set.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-100, default 68); onChange(value, 'N%') fires on every
set, including the initial paint.
handle: el, get() (current value), set(v) — clamped to 0-100; the fill
is a conic gradient driven by the element-scoped --p property.
CSS lives in the "radial ring" block of DUPRE_CSS; no other styles
involved. */
DUPRE.radialRing = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 68;
const el = document.createElement('span'); el.className = 'dupre-ring';
el.innerHTML = '';
host.appendChild(el);
const num = el.querySelector('b');
function set(nv) {
v = Math.max(0, Math.min(100, nv));
el.style.setProperty('--p', v);
num.textContent = Math.round(v);
onChange(v, Math.round(v) + '%');
}
dragDelta(el, () => v, set, { min: 0, max: 100 });
set(v);
return { el, get: () => v, set };
};
/* 16 sparkline — rolling history trace; push(v) appends a sample, fill(v) levels it.
Contract (everything a consumer needs; no page globals touched):
opts: samples (history length, default 40, floor 2 — the trace needs
two points); value (initial level, default 0.5); onChange(last,
'N') fires on every repaint (samples 0-1, clamped on entry;
readout 0-100). No initial fire — the page's tick drives it (the
tick contract — the page owns the clock and the signal). The
history buffer is display-side state owned in here.
handle: el, get() (newest sample), push(v) (append, oldest drops),
fill(v) (level the whole buffer).
CSS lives in the "sparkline" block of DUPRE_CSS; the trace colour is an
inline stroke, no other styles involved. */
DUPRE.sparkline = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const n = Math.max(2, opts.samples || 40);
const clamp = x => Math.max(0, Math.min(1, x));
const hist = Array.from({ length: n }, () => clamp(opts.value ?? 0.5));
const el = document.createElement('span'); el.className = 'dupre-spark';
el.innerHTML = '';
host.appendChild(el);
const line = el.querySelector('polyline');
function paint() {
line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 170},${44 - v * 40 - 2}`).join(' '));
onChange(hist[n - 1], String(Math.round(hist[n - 1] * 100)));
}
function push(v) { hist.push(clamp(v)); hist.shift(); paint(); }
function fill(v) { hist.fill(clamp(v)); paint(); }
return { el, get: () => hist[n - 1], push, fill };
};
/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1.
Contract (everything a consumer needs; no page globals touched):
opts: amp (initial amplitude for the readout, default 0.6);
onChange(amp, 'amp N%') fires on every set, including the
initial paint (a flat centre line).
handle: el, get() (last amp), set(samples, amp) — samples in -1..1,
clamped per sample; fewer than two samples draws the flat
centre line. Amp is readout-only; the trace height comes from
the samples themselves.
CSS lives in the "waveform strip" block of DUPRE_CSS; the trace colour
is an inline stroke, no other styles involved. */
DUPRE.waveStrip = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const el = document.createElement('span'); el.className = 'dupre-wave';
el.innerHTML = '';
host.appendChild(el);
const path = el.querySelector('path');
const clamp = x => Math.max(-1, Math.min(1, x));
let amp = 0;
function set(samples, a) {
amp = a;
let d = 'M0 19';
if (samples.length < 2) d += ' L170 19';
else for (let i = 0; i < samples.length; i++)
d += ` L${(i / (samples.length - 1) * 170).toFixed(1)} ${(19 + clamp(samples[i]) * 14).toFixed(1)}`;
path.setAttribute('d', d);
onChange(amp, 'amp ' + Math.round(amp * 100) + '%');
}
set([], opts.amp ?? 0.6);
return { el, get: () => amp, set };
};
/* N11 oscilloscope — sampled phosphor trace; set(samples, vpp) with samples in -1..1.
Contract (everything a consumer needs; no page globals touched):
opts: onChange(vpp, 'Vpp N') fires on every set (readout is vpp x100).
No initial fire — the page's tick drives it (the tick contract —
the page owns the clock and the signal).
handle: el, get() (last vpp), set(samples, vpp) — samples in -1..1,
clamped per sample; fewer than two samples clears the trace.
Vpp is readout-only; the trace comes from the samples.
CSS lives in the "oscilloscope" block of DUPRE_CSS; the screen-family
--scr-* vars retint it, with the original green as fallback. */
DUPRE.scope = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const el = document.createElement('span'); el.className = 'dupre-scope';
el.innerHTML = '';
host.appendChild(el);
const line = el.querySelector('polyline');
const clamp = x => Math.max(-1, Math.min(1, x));
let vpp = 0;
function set(samples, v) {
vpp = v;
const n = samples.length;
line.setAttribute('points', n < 2 ? '' :
samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + clamp(s) * 22).toFixed(1)}`).join(' '));
onChange(vpp, 'Vpp ' + Math.round(vpp * 100));
}
return { el, get: () => vpp, set };
};
/* N12 spectrum / EQ bars — set(values) paints one column per band.
Contract (everything a consumer needs; no page globals touched):
opts: bands (columns, default 11); cells (segments per column, default
9); onChange(values, 'peak N%') fires on every set (values 0-1
per band, clamped; a missing band reads 0). No initial fire —
the page's tick drives it (the tick contract — the page owns the
clock and the signal).
handle: el, get() (last values array), set(values). Cells light
bottom-up: dupre-on, top three dupre-hot, topmost dupre-clip.
CSS lives in the "spectrum / EQ" block of DUPRE_CSS; no other styles
involved. */
DUPRE.eqBars = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const bands = opts.bands || 11, cells = opts.cells || 9;
const el = document.createElement('span'); el.className = 'dupre-eq';
host.appendChild(el);
for (let b = 0; b < bands; b++) {
const band = document.createElement('span'); band.className = 'dupre-band';
for (let s = 0; s < cells; s++) band.appendChild(document.createElement('i'));
el.appendChild(band);
}
let vals = [];
function set(values) {
vals = values;
let peak = 0;
for (let b = 0; b < bands; b++) {
const col = el.children[b].children, val = Math.max(0, Math.min(1, values[b] || 0)), lit = Math.round(val * cells);
peak = Math.max(peak, val);
for (let k = 0; k < cells; k++) {
let c = '';
if (k < lit) c = (k >= cells - 1 ? 'dupre-clip' : k >= cells - 3 ? 'dupre-hot' : 'dupre-on');
col[k].className = c;
}
}
onChange(vals, 'peak ' + Math.round(peak * 100) + '%');
}
return { el, get: () => vals, set };
};
/* N13 crossed-needle meter — one drive value, FWD and RFL needles cross.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-100, default 55); onChange(value, 'FWD n · RFL n') fires
on every set — RFL derives from the drive value (0.68×), the way a
crossed-needle SWR face couples the two.
handle: el, get() (current value), set(v) — clamped to 0-100; drag on
the face sets by vertical delta.
CSS lives in the "crossed-needle" block of DUPRE_CSS; no other styles
involved. */
DUPRE.crossNeedle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 55;
const el = document.createElement('div'); el.className = 'dupre-crossm';
el.innerHTML = `
FWDRFL
`;
host.appendChild(el);
const face = el.querySelector('.dupre-face'), nA = el.querySelector('.dupre-fwd'), nB = el.querySelector('.dupre-rfl');
function set(nv) {
v = Math.max(0, Math.min(100, nv));
const fwd = v, rfl = v * 0.68;
nA.style.transform = `rotate(${-42 + fwd / 100 * 84}deg)`;
nB.style.transform = `rotate(${42 - rfl / 100 * 84}deg)`;
onChange(v, 'FWD ' + Math.round(fwd) + ' · RFL ' + Math.round(rfl));
}
dragDelta(face, () => v, set, { min: 0, max: 100 });
set(v);
return { el, get: () => v, set };
};
/* N14 thermometer column — mercury rises with the value.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-100, default 58); onChange(value, 'N°') fires on every
set — the readout maps 0-100 onto the printed 30-90° scale.
handle: el, get() (current value), set(v) — clamped to 0-100; drag
anywhere on the column sets by vertical delta.
CSS lives in the "thermometer" block of DUPRE_CSS; no other styles
involved. */
DUPRE.thermometer = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 58;
const el = document.createElement('div'); el.className = 'dupre-thermo';
el.innerHTML = `
906030
`;
host.appendChild(el);
const fill = el.querySelector('.dupre-thermo-fill');
function set(nv) {
v = Math.max(0, Math.min(100, nv));
fill.style.height = v + '%';
onChange(v, Math.round(30 + v / 100 * 60) + '°');
}
dragDelta(el, () => v, set, { min: 0, max: 100 });
set(v);
return { el, get: () => v, set };
};
/* N15 bourdon pressure gauge — needle over a printed arc with a redline.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-100, default 45); onChange(value, 'N PSI') fires on every
set — the readout maps 0-100 onto a 0-160 PSI scale.
handle: el, get() (current value), set(v) — clamped to 0-100, the
needle sweeps -60°..+60°; drag anywhere on the dial sets by
vertical delta. The printed arc and redline are the builder's
own inline SVG.
CSS lives in the "bourdon" block of DUPRE_CSS; no other styles involved. */
DUPRE.bourdon = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 45;
const el = document.createElement('div'); el.className = 'dupre-bourdon';
el.innerHTML = `
PSI
`;
host.appendChild(el);
const ndl = el.querySelector('.dupre-bourdon-ndl');
function set(nv) {
v = Math.max(0, Math.min(100, nv));
ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`;
onChange(v, Math.round(v / 100 * 160) + ' PSI');
}
dragDelta(el, () => v, set, { min: 0, max: 100 });
set(v);
return { el, get: () => v, set };
};
/* N16 strip-chart recorder — scrolling history, pen rides the newest value.
Contract (everything a consumer needs; no page globals touched):
opts: samples (history length, default 60, floor 2); value (initial
level 0-1, default 0.5); onChange(current, 'N') fires on every
paint (readout in percent). Values clamp to 0-1 at paint.
handle: el, get() (newest value), push(v) appends one sample and
scrolls, set(samples, current) replaces the whole trace (a
short or missing array back-fills 0.5).
CSS lives in the "strip-chart" block of DUPRE_CSS; no other styles
involved. */
DUPRE.stripChart = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const n = Math.max(2, opts.samples || 60);
const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
const el = document.createElement('span'); el.className = 'dupre-strip';
el.innerHTML = '';
host.appendChild(el);
const line = el.querySelector('polyline'), pen = el.querySelector('.dupre-pen');
const clamp = x => Math.max(0, Math.min(1, x));
const yPx = v => 59 - clamp(v) * 56;
function paint(current) {
line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 176},${yPx(v).toFixed(1)}`).join(' '));
pen.style.top = yPx(current) + 'px';
onChange(current, String(Math.round(clamp(current) * 100)));
}
function push(v) { hist.push(v); hist.shift(); paint(v); }
function set(samples, current) {
if (!Array.isArray(samples)) samples = [];
for (let i = 0; i < n; i++) hist[i] = samples[i] ?? 0.5;
paint(current ?? hist[n - 1]);
}
return { el, get: () => hist[n - 1], push, set };
};
/* N17 correlation meter — needle rests at 0 and swings ±; drag left/right.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-100, default 58 — 50 is the 0 rest point); onChange(corr,
'±x.xx') fires on every set with the CORRELATION (-1..+1 mapped
from the 0-100 position), not the raw position.
handle: el, get() (current 0-100 position), set(p) — clamped to 0-100;
drag left/right sets by pointer position.
CSS lives in the "correlation" block of DUPRE_CSS; no other styles
involved. */
DUPRE.corrMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let p = opts.value ?? 58;
const el = document.createElement('div'); el.className = 'dupre-corr';
el.innerHTML = `
−10+1
`;
host.appendChild(el);
const ndl = el.querySelector('.dupre-corr-ndl');
function set(np) {
p = Math.max(0, Math.min(100, np));
const v = (p - 50) / 50;
ndl.style.transform = `rotate(${v * 38}deg)`;
onChange(v, (v >= 0 ? '+' : '') + v.toFixed(2));
}
dragX(el, set);
set(p);
return { el, get: () => p, set };
};
/* N18 battery-cell gauge — charge as discrete cells, the low end warns.
Contract (everything a consumer needs; no page globals touched):
opts: cells (count, default 8, floor 1); warnAt (threshold, default 25 —
at or below it lit cells tint dupre-warn); value (0-100, default
62); onChange(value, 'N%') fires on every set.
handle: el, get() (current value), set(v) — clamped to 0-100; drag
left/right sets by pointer position. Lit cells carry dupre-on.
CSS lives in the "battery" block of DUPRE_CSS; no other styles involved. */
DUPRE.battCells = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const cells = Math.max(1, opts.cells || 8), warnAt = opts.warnAt ?? 25;
let p = opts.value ?? 62;
const el = document.createElement('div'); el.className = 'dupre-batt';
el.innerHTML = '';
host.appendChild(el);
const cc = el.querySelector('.dupre-cells');
for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'dupre-cell'; cc.appendChild(c); }
function set(np) {
p = Math.max(0, Math.min(100, np));
const lit = Math.round(p / 100 * cells);
for (let i = 0; i < cells; i++) cc.children[i].className = 'dupre-cell' + (i < lit ? ' dupre-on' : '') + ((p <= warnAt && i < lit) ? ' dupre-warn' : '');
onChange(p, Math.round(p) + '%');
}
dragX(el, set);
set(p);
return { el, get: () => p, set };
};
/* R01 moving-coil VU — pivot below the face, authentic nonlinear dB scale;
ballistics belong to the signal owner.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-1.02, default .35 — clamped, the needle paints from it
at build); onChange(t, '+x.x VU') fires on every set, no initial
fire — the readout maps t through the shared VU law (VUDB/vuDb),
the needle caps at t=1.
handle: el, get() (current t), set(t) — clamped to 0-1.02; positions
instantly, no ballistics. No drag — display-only.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; the glass gradient and face clip are per-instance defs
entries. */
DUPRE.mcVu = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 150, 96);
const cx = 75, cy = 112, sweep = t => -43 + t * 86;
const glassId = uid('vuGlass'), clipId = uid('vuFace');
const defs = svgEl(s, 'defs', {});
const g1 = svgEl(defs, 'linearGradient', { id: glassId, x1: 0, y1: 0, x2: 0, y2: 1 });
svgEl(g1, 'stop', { offset: '0', 'stop-color': 'rgba(255,255,255,.13)' });
svgEl(g1, 'stop', { offset: '.35', 'stop-color': 'rgba(255,255,255,0)' });
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3 });
svgEl(s, 'rect', { x: .5, y: .5, width: 149, height: 95, rx: 7, fill: '#16130f', stroke: '#060505' });
svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: 'var(--cream)' });
const face = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const arcPt = t => polar(cx, cy, 58, sweep(t));
const [ax1, ay1] = arcPt(0), [ax2, ay2] = arcPt(1);
svgEl(face, 'path', { d: `M ${ax1} ${ay1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.2 });
const [rx1, ry1] = arcPt(.75);
svgEl(face, 'path', { d: `M ${rx1} ${ry1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3.5 });
for (const [db, t] of VUDB) {
const a = sweep(t), [x1, y1] = polar(cx, cy, 55, a), [x2, y2] = polar(cx, cy, 63, a);
svgEl(face, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1.1 });
}
for (const [db, t] of VUDB) {
if (![-20, -10, -5, -3, 0, 1, 2, 3].includes(db)) continue;
const [x, y] = polar(cx, cy, 69, sweep(t));
svgEl(face, 'text', {
x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.8, 'font-family': 'var(--mono)',
fill: db > 0 ? 'var(--fail)' : '#3a3128'
}).textContent = Math.abs(db);
}
svgEl(face, 'text', {
x: 75, y: 78, 'text-anchor': 'middle', 'font-size': 11, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#3a3128'
}).textContent = 'VU';
let t = Math.max(0, Math.min(1.02, opts.value ?? .35));
const needle = svgEl(face, 'line', {
x1: cx, y1: cy, x2: cx, y2: cy - 62, stroke: '#1a1613', 'stroke-width': 1.6,
transform: `rotate(${sweep(Math.min(1, t))},${cx},${cy})`
});
svgEl(face, 'circle', { cx, cy: 88, r: 9, fill: '#16130f' });
svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: `url(#${glassId})` });
function set(nt) {
t = Math.max(0, Math.min(1.02, nt));
needle.setAttribute('transform', `rotate(${sweep(Math.min(1, t))},${cx},${cy})`);
const db = vuDb(Math.min(1, t));
onChange(t, (db > 0 ? '+' : '') + db.toFixed(1) + ' VU');
}
return { el: s, get: () => t, set };
};
/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-100, default 50); onChange(value, '+x.x dB') fires on
every set — the readout maps the linear value through the shared
VU law (VUDB/vuDb), like every VU-family meter.
handle: el, get() (current value), set(v) — the needle sweeps -40°..+40°
over the clamped 0-1 position.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; the face clip is a per-instance defs entry. */
DUPRE.roundMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
const cx = 55, fy = 54, py = 74, sweep = t => -40 + t * 80;
const clipId = uid('rndFace');
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'circle', { cx, cy: fy, r: 40 });
svgEl(s, 'circle', { cx, cy: fy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy: fy, r: 40, fill: 'var(--cream)' });
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const pt = (r, t) => polar(cx, py, r, sweep(t));
const [ax1, ay1] = pt(44, 0), [ax2, ay2] = pt(44, 1);
svgEl(g, 'path', { d: `M ${ax1} ${ay1} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.1 });
const [rzx, rzy] = pt(44, .75);
svgEl(g, 'path', { d: `M ${rzx} ${rzy} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3 });
for (const [db, t] of VUDB) {
const [x1, y1] = pt(41.5, t), [x2, y2] = pt(47, t);
svgEl(g, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1 });
}
for (const [db, t] of VUDB) {
if (![-20, -10, -5, -3, 0, 3].includes(db)) continue;
const [x, y] = pt(52, t);
svgEl(g, 'text', {
x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)',
fill: db > 0 ? 'var(--fail)' : '#3a3128'
}).textContent = Math.abs(db);
}
svgEl(g, 'text', {
x: cx, y: fy + 14, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#3a3128'
}).textContent = 'dB';
const needle = svgEl(g, 'line', {
x1: cx, y1: py, x2: cx, y2: py - 46, stroke: '#1a1613', 'stroke-width': 1.5,
transform: `rotate(0,${cx},${py})`
});
svgEl(g, 'circle', { cx, cy: py, r: 5, fill: '#141110' });
svgEl(s, 'ellipse', { cx: cx - 8, cy: fy - 22, rx: 20, ry: 9, fill: 'rgba(255,255,255,.07)', transform: `rotate(-18,${cx - 8},${fy - 22})` });
let v = opts.value ?? 50;
function set(nv) {
v = nv;
const t = Math.max(0, Math.min(1, v / 100));
needle.setAttribute('transform', `rotate(${-40 + t * 80},${cx},${py})`);
const db = vuDb(t);
onChange(v, (db > 0 ? '+' : '') + db.toFixed(1) + ' dB');
}
dragDelta(s, () => v, set, { min: 0, max: 100 });
set(v);
return { el: s, get: () => v, set };
};
/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-100, default 50); onChange(value, readout) fires on
every set — the readout says 'MIN' at ≤2, 'MAX' at ≥98, 'N%'
between.
handle: el, get() (current value), set(v) — clamped to 0-100, the
pointer sweeps -55°..+55°; drag anywhere sets by vertical
delta.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; the ring and dome gradients register in the shared defs
plate. */
DUPRE.chromeMinMax = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
const cx = 55, cy = 58;
gradDef('chrRing', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#eceef0'], ['.5', '#9aa0a8'], ['1', '#54585e']]);
gradDef('chrDome', 'radialGradient', { cx: '48%', cy: '20%', r: '85%' }, [['0', '#e6e8ec'], ['1', '#82878f']]);
svgEl(s, 'circle', { cx, cy: 54, r: 47, fill: 'url(#chrRing)' });
svgEl(s, 'circle', { cx, cy: 54, r: 40, fill: '#101214', stroke: '#2c2f33', 'stroke-width': 1 });
svgEl(s, 'path', { d: `M 21 60 A 34 34 0 0 1 89 60 Z`, fill: 'url(#chrDome)', stroke: '#55595f', 'stroke-width': .5 });
for (const r of [14, 22, 30])
svgEl(s, 'path', { d: `M ${cx - r} 60 A ${r} ${r} 0 0 1 ${cx + r} 60`, fill: 'none', stroke: 'rgba(255,255,255,.13)', 'stroke-width': .7 });
const ptr = svgEl(s, 'line', { x1: cx, y1: cy - 10, x2: cx, y2: cy - 31, stroke: '#17191b', 'stroke-width': 2.2, 'stroke-linecap': 'round' });
svgEl(s, 'circle', { cx, cy, r: 6, fill: '#23262a' });
for (let k = 0; k < 8; k++) {
const [x, y] = polar(cx, cy, 6, k * 45);
svgEl(s, 'circle', { cx: x, cy: y, r: 1.3, fill: '#23262a' });
}
svgEl(s, 'circle', { cx, cy, r: 1.6, fill: '#0c0d0e' });
svgEl(s, 'text', {
x: 24, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em',
'font-family': 'var(--mono)', fill: '#d7dade'
}).textContent = 'MIN';
svgEl(s, 'text', {
x: 86, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em',
'font-family': 'var(--mono)', fill: '#d7dade'
}).textContent = 'MAX';
svgEl(s, 'text', {
x: cx, y: 84, 'text-anchor': 'middle', 'font-size': 7, 'font-style': 'italic',
'font-family': 'var(--mono)', fill: '#787c82'
}).textContent = 'dupre.';
svgEl(s, 'ellipse', { cx: 47, cy: 34, rx: 22, ry: 9, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-16,47,34)' });
let v = opts.value ?? 50;
function set(nv) {
v = Math.max(0, Math.min(100, nv));
ptr.setAttribute('transform', `rotate(${-55 + v / 100 * 110},${cx},${cy})`);
onChange(v, v <= 2 ? 'MIN' : v >= 98 ? 'MAX' : Math.round(v) + '%');
}
dragDelta(s, () => v, set, { min: 0, max: 100 });
set(v);
return { el: s, get: () => v, set };
};
/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-100, default 52); onChange(value, 'x.x ×100 rpm') fires
on every set — the readout maps 0-100 onto 0-8 ×100 RPM.
handle: el, get() (current value), set(v) — clamped to 0-100, the
needle sweeps -135°..+135°; drag anywhere sets by vertical
delta.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; the needle-glow filter registers in the shared defs plate. */
DUPRE.aviationGauge = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
const cx = 55, cy = 52, sweep = t => -135 + t * 270;
def('avGlow', d => {
const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
});
svgEl(s, 'circle', { cx, cy, r: 47, fill: '#1b1917', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 41, fill: '#0a0908', stroke: '#3a352c', 'stroke-width': 1 });
const zone = (t1, t2, color) => {
const [x1, y1] = polar(cx, cy, 37, sweep(t1)), [x2, y2] = polar(cx, cy, 37, sweep(t2));
svgEl(s, 'path', { d: `M ${x1} ${y1} A 37 37 0 ${(t2 - t1) > .5 ? 1 : 0} 1 ${x2} ${y2}`, fill: 'none', stroke: color, 'stroke-width': 3, opacity: .85 });
};
zone(.15, .6, 'var(--pass)'); zone(.6, .85, 'var(--gold)'); zone(.85, 1, 'var(--fail)');
for (let i = 0; i <= 40; i++) {
const t = i / 40, major = i % 5 === 0, a = sweep(t);
const [x1, y1] = polar(cx, cy, 40, a), [x2, y2] = polar(cx, cy, major ? 32 : 36, a);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--gold)', 'stroke-width': major ? 1.6 : .8, opacity: major ? 1 : .7 });
if (major) {
const [nx, ny] = polar(cx, cy, 25, a);
svgEl(s, 'text', {
x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 6.5, 'font-family': 'var(--mono)',
fill: 'var(--gold-hi)'
}).textContent = i / 5;
}
}
svgEl(s, 'text', {
x: cx, y: cy + 16, 'text-anchor': 'middle', 'font-size': 5.5, 'letter-spacing': '.14em',
'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .8
}).textContent = '×100 RPM';
const needle = svgEl(s, 'g', {});
svgEl(needle, 'line', {
x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 4,
'stroke-linecap': 'round', opacity: .5, filter: 'url(#avGlow)'
});
svgEl(needle, 'line', {
x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 2.2,
'stroke-linecap': 'round'
});
svgEl(s, 'circle', { cx, cy, r: 5, fill: '#1b1917', stroke: '#3a352c' });
svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 24, rx: 18, ry: 8, fill: 'rgba(255,255,255,.05)', transform: `rotate(-18,${cx - 10},${cy - 24})` });
let v = opts.value ?? 52;
function set(nv) {
v = Math.max(0, Math.min(100, nv));
needle.setAttribute('transform', `rotate(${-135 + v / 100 * 270},${cx},${cy})`);
onChange(v, (v / 100 * 8).toFixed(1) + ' ×100 rpm');
}
dragDelta(s, () => v, set, { min: 0, max: 100 });
set(v);
return { el: s, get: () => v, set };
};
/* R13 edgewise strip meter — compressed log scale, the bar rides the edge.
Contract (everything a consumer needs; no page globals touched):
opts: value (fraction of the window, .08-.94, default .62); onChange(
frac, '−x.x dB') fires on every set — the readout interpolates
the compressed 0..−40 dB scale printed on the face.
handle: el, get() (current fraction), set(f) — clamped to .08-.94 (the
printed scale's span); drag up/down sets by pointer position.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; the face gradient is a per-instance defs entry. */
DUPRE.edgeMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const SCALE = [[0, .08], [3, .26], [6, .44], [12, .62], [20, .78], [40, .94]];
const s = stageSvg(host, 'rsvg drag', 70, 130);
const wy0 = 12, wy1 = 118;
const faceId = uid('edgeFace');
const defs = svgEl(s, 'defs', {});
const pg = svgEl(defs, 'linearGradient', { id: faceId, x1: 0, y1: 0, x2: 1, y2: 0 });
svgEl(pg, 'stop', { offset: '0', 'stop-color': '#efe6ca' });
svgEl(pg, 'stop', { offset: '1', 'stop-color': '#d9cfae' });
svgEl(s, 'rect', { x: 8, y: 6, width: 54, height: 118, rx: 8, fill: '#171412', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: wy1 - wy0, fill: `url(#${faceId})`, stroke: '#0a0908' });
const yOf = f => wy0 + f * (wy1 - wy0);
for (const [db, f] of SCALE) {
const y = yOf(f);
svgEl(s, 'line', { x1: 19, y1: y, x2: 40, y2: y, stroke: '#3a3128', 'stroke-width': 1 });
svgEl(s, 'text', {
x: 52, y: y + 2.4, 'text-anchor': 'end', 'font-size': 7, 'font-family': 'var(--mono)',
fill: '#3a3128'
}).textContent = db;
}
for (let i = 0; i < SCALE.length - 1; i++) {
const y = yOf((SCALE[i][1] + SCALE[i + 1][1]) / 2);
svgEl(s, 'line', { x1: 19, y1: y, x2: 32, y2: y, stroke: '#3a3128', 'stroke-width': .6, opacity: .7 });
}
const bar = svgEl(s, 'polygon', { points: '56,0 24,-1.4 24,1.4', fill: '#1a1613' });
svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: 24, fill: 'rgba(255,255,255,.08)' });
let frac = opts.value ?? .62;
function set(f) {
frac = Math.max(.08, Math.min(.94, f));
bar.setAttribute('transform', `translate(0,${wy0 + frac * (wy1 - wy0)})`);
let i = 0; while (i < SCALE.length - 2 && SCALE[i + 1][1] < frac) i++;
const [d1, f1] = SCALE[i], [d2, f2] = SCALE[i + 1];
const db = d1 + (d2 - d1) * Math.max(0, Math.min(1, (frac - f1) / (f2 - f1)));
onChange(frac, '−' + db.toFixed(1) + ' dB');
}
dragY(s, pct => set(.08 + (100 - pct) / 100 * .86));
set(frac);
return { el: s, get: () => frac, set };
};
/* R17 round CRT scope — pale phosphor face; the trace is the instrument's own
animation, so it lives here with its reduced-motion gate.
Contract (everything a consumer needs; no page globals touched):
opts: onChange(amp, 'Vpp N') fires on every animation tick, including
the initial paint — the demo waveform is instrument-owned.
handle: el, tick() — one animation step (the reduced-motion path paints
once and never ticks again on its own).
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate, except the
face gradient, which reads screen-family vars from this instrument's
subtree and so lives in a local defs. */
DUPRE.roundCrt = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 110, 104);
const cx = 55, cy = 52;
const faceId = uid('rcrtFace'), clipId = uid('rcrtClip');
gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
def('avGlow', d => {
const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
});
/* the face gradient reads screen-family vars from this instrument's subtree,
so it must live in the local defs, not the shared def sink */
const defs = svgEl(s, 'defs', {});
const ph = svgEl(defs, 'radialGradient', { id: faceId, cx: '50%', cy: '44%', r: '70%' });
svgEl(ph, 'stop', { offset: '0', 'stop-color': 'var(--crt-face1,#b9d8c0)' });
svgEl(ph, 'stop', { offset: '1', 'stop-color': 'var(--crt-face2,#8bb296)' });
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'circle', { cx, cy, r: 38 });
svgEl(s, 'circle', { cx, cy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 });
for (let k = 0; k < 4; k++) {
const [x, y] = polar(cx, cy, 43, 45 + k * 90);
svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 });
}
svgEl(s, 'circle', { cx, cy, r: 38, fill: `url(#${faceId})` });
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
for (let i = -4; i <= 4; i++) {
svgEl(g, 'line', { x1: cx + i * 9, y1: cy - 38, x2: cx + i * 9, y2: cy + 38, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 });
svgEl(g, 'line', { x1: cx - 38, y1: cy + i * 9, x2: cx + 38, y2: cy + i * 9, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 });
}
const glow = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: 'var(--crt-glow,#eef7ee)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' });
const trace = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: '#f4fcf4', 'stroke-width': 1.3 });
svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 16, rx: 20, ry: 10, fill: 'rgba(255,255,255,.10)', transform: `rotate(-20,${cx - 10},${cy - 16})` });
let tph = 0;
function tick() {
tph += 0.12;
const amp = 8 + 3 * Math.sin(tph * 0.35);
let pts = '';
for (let x = -38; x <= 38; x += 2) {
const y = Math.sin(x * 0.55 + tph * 2.5) * amp;
pts += `${cx + x},${(cy + y).toFixed(1)} `;
}
trace.setAttribute('points', pts.trim());
glow.setAttribute('points', pts.trim());
onChange(amp, 'Vpp ' + Math.round(amp * 2 * 4.5));
}
tick();
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 80);
return { el: s, tick };
};
/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag.
Contract (everything a consumer needs; no page globals touched):
opts: bank (initial degrees, -60..60, default 0); pitch (initial
degrees, -20..20, default 0); onChange({bank, pitch}, 'BANK …
· PITCH …') fires on every set, including the initial paint.
handle: el, get() ({bank, pitch}), set(bank, pitch) — both clamp to
their ranges; the stage itself is a 2D drag surface.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.attitudeIndicator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 130, 130);
const cx = 65, cy = 65;
const clipId = uid('aiClip');
gradDef('aiSky', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#54677d'], ['1', '#3a4c60']]);
gradDef('aiGnd', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#5c4630'], ['1', '#3c2e1e']]);
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'circle', { cx, cy, r: 48 });
svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const ball = svgEl(g, 'g', {});
svgEl(ball, 'rect', { x: cx - 90, y: cy - 180, width: 180, height: 180, fill: 'url(#aiSky)' });
svgEl(ball, 'rect', { x: cx - 90, y: cy, width: 180, height: 180, fill: 'url(#aiGnd)' });
svgEl(ball, 'line', { x1: cx - 90, y1: cy, x2: cx + 90, y2: cy, stroke: '#f3e7c5', 'stroke-width': 1.4 });
for (const p of [-20, -10, 10, 20]) {
const y = cy - p * 1.6, w = p % 20 === 0 ? 16 : 10;
svgEl(ball, 'line', { x1: cx - w, y1: y, x2: cx + w, y2: y, stroke: '#f3e7c5', 'stroke-width': .8, opacity: .85 });
svgEl(ball, 'text', {
x: cx + w + 3, y: y + 2, 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: '#f3e7c5', opacity: .85
}).textContent = String(Math.abs(p));
}
/* fixed bank scale + pointer + miniature aircraft */
for (const b of [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60]) {
const [x1, y1] = polar(cx, cy, 47, b), [x2, y2] = polar(cx, cy, b % 30 === 0 ? 42 : 44, b);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': b === 0 ? 1.6 : .9, opacity: .9 });
}
svgEl(s, 'polygon', { points: `${cx - 3.5},${cy - 38} ${cx + 3.5},${cy - 38} ${cx},${cy - 44}`, fill: 'var(--gold-hi)' });
svgEl(s, 'path', {
d: `M ${cx - 22} ${cy} L ${cx - 7} ${cy} L ${cx - 4} ${cy + 4} M ${cx + 22} ${cy} L ${cx + 7} ${cy} L ${cx + 4} ${cy + 4}`,
fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round'
});
svgEl(s, 'circle', { cx, cy, r: 2, fill: 'var(--gold-hi)' });
let bank = 0, pitch = 0;
function set(b, p) {
bank = Math.max(-60, Math.min(60, b)); pitch = Math.max(-20, Math.min(20, p));
ball.setAttribute('transform', `rotate(${(-bank).toFixed(1)},${cx},${cy}) translate(0,${(pitch * 1.6).toFixed(1)})`);
onChange({ bank, pitch }, 'BANK ' + (bank < 0 ? 'L' : bank > 0 ? 'R' : '') + Math.abs(Math.round(bank)) +
' · PITCH ' + (pitch >= 0 ? '+' : '-') + String(Math.abs(Math.round(pitch))).padStart(2, '0'));
}
s.style.touchAction = 'none'; s.style.cursor = 'move';
s.addEventListener('pointerdown', e => {
s.setPointerCapture(e.pointerId);
const x0 = e.clientX, y0 = e.clientY, b0 = bank, p0 = pitch;
const mv = ev => set(b0 + (ev.clientX - x0) * 0.4, p0 + (ev.clientY - y0) * 0.25);
const up = () => { s.removeEventListener('pointermove', mv); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); };
s.addEventListener('pointermove', mv); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up); e.preventDefault();
});
set(opts.bank ?? 0, opts.pitch ?? 0);
return { el: s, get: () => ({ bank, pitch }), set };
};
/* R44 heading bug + servo needle — drag parks the command; the needle chases
with honest servo lag (instrument-owned animation, reduced-motion gated).
Contract (everything a consumer needs; no page globals touched):
opts: value (initial commanded heading in degrees, default 90 —
normalized to 0..360); onChange({cmd, act}, 'CMD … · ACT …')
fires on every set and on every servo step.
handle: el, get() ({cmd, act}), set(v) — v normalizes to 0..360;
dragging the stage moves the command bug, the needle chases.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.headingBug = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 130, 130);
const cx = 65, cy = 65;
svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 });
for (let d = 0; d < 360; d += 10) {
const maj = d % 30 === 0;
const [x1, y1] = polar(cx, cy, 48, d), [x2, y2] = polar(cx, cy, maj ? 42 : 45, d);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': maj ? 1 : .5, opacity: maj ? .9 : .5 });
}
for (let d = 0; d < 360; d += 30) {
const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : String(d / 10);
const [x, y] = polar(cx, cy, 35, d);
svgEl(s, 'text', {
x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': d % 90 === 0 ? 8 : 6.4, 'font-weight': 700,
'font-family': 'var(--mono)', fill: d % 90 === 0 ? 'var(--cream)' : 'var(--steel)'
}).textContent = lbl;
}
svgEl(s, 'line', { x1: cx, y1: cy - 56, x2: cx, y2: cy - 48, stroke: 'var(--fail)', 'stroke-width': 2 });
const bug = svgEl(s, 'path', {
d: `M ${cx - 6} ${cy - 49} L ${cx - 6} ${cy - 44} L ${cx - 2.5} ${cy - 44} L ${cx} ${cy - 47} L ${cx + 2.5} ${cy - 44} L ${cx + 6} ${cy - 44} L ${cx + 6} ${cy - 49} Z`,
fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': .7
});
const needle = svgEl(s, 'g', {});
svgEl(needle, 'polygon', {
points: `${cx - 2},${cy + 10} ${cx + 2},${cy + 10} ${cx + 1.2},${cy - 40} ${cx},${cy - 44} ${cx - 1.2},${cy - 40}`,
fill: '#bfc4d0', stroke: '#4a4e58', 'stroke-width': .6
});
svgEl(s, 'circle', { cx, cy, r: 4.5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
let cmd = ((opts.value ?? 90) % 360 + 360) % 360, act = cmd;
const draw = () => {
bug.setAttribute('transform', `rotate(${cmd.toFixed(1)},${cx},${cy})`);
needle.setAttribute('transform', `rotate(${act.toFixed(1)},${cx},${cy})`);
onChange({ cmd, act }, 'CMD ' + String(Math.round((cmd % 360 + 360) % 360)).padStart(3, '0') +
' · ACT ' + String(Math.round((act % 360 + 360) % 360)).padStart(3, '0'));
};
function set(v) { cmd = (v % 360 + 360) % 360; draw(); }
dragDelta(s, () => cmd, set, { min: -100000, max: 100000, sens: 1.2 });
draw();
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => {
const diff = ((cmd - act + 540) % 360) - 180;
if (Math.abs(diff) < 0.4) { if (act !== cmd) { act = cmd; draw(); } return; }
act = (act + diff * 0.07 + 360) % 360; draw();
}, 80);
return { el: s, get: () => ({ cmd, act }), set };
};
/* R53 circular chart recorder — a day per revolution; the pen draws the cycle
(instrument-owned clock, reduced-motion paints the full day once); click for fresh paper.
Contract (everything a consumer needs; no page globals touched):
opts: onChange(hour, 'HH:MM · N%') fires on every pen step — the demo
day-cycle signal is instrument-owned.
handle: el, get() (current hour, 0..24), reset() — fresh paper, pen to
00:00; clicking the stage does the same.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.chartRecorder = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 130);
const cx = 65, cy = 65;
svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 54, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': .8 });
for (const r of [20, 31, 42])
svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: '#b8ae98', 'stroke-width': .5 });
for (let h = 0; h < 24; h++) {
const a = h * 15;
const [x1, y1] = polar(cx, cy, 54, a), [x2, y2] = polar(cx, cy, h % 6 === 0 ? 12 : 48, a);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#b8ae98', 'stroke-width': h % 6 === 0 ? .6 : .35, opacity: .8 });
if (h % 6 === 0) {
const [tx, ty] = polar(cx, cy, 50, a + 5);
svgEl(s, 'text', {
x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: '#6e6552'
}).textContent = String(h).padStart(2, '0');
}
}
const trace = svgEl(s, 'path', { d: '', fill: 'none', stroke: '#8f2416', 'stroke-width': 1.1, 'stroke-linejoin': 'round' });
const pen = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 54, stroke: '#4a463e', 'stroke-width': 1 });
const penDot = svgEl(s, 'circle', { cx, cy: cy - 30, r: 2, fill: '#8f2416' });
svgEl(s, 'circle', { cx, cy, r: 4, fill: '#4a463e' });
const val = h => 52 + 30 * Math.sin(h / 24 * 2 * Math.PI - 2.1) + 12 * Math.sin(h / 24 * 6 * Math.PI) + 4 * Math.sin(h * 2.7);
const rOf = v => 12 + Math.max(0, Math.min(100, v)) * 0.36;
let pts = [], hour = 0;
const draw = () => {
const a = hour * 15, r = rOf(val(hour));
pts.push(polar(cx, cy, r, a));
if (pts.length > 1) trace.setAttribute('d', 'M ' + pts.map(p => p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' L '));
pen.setAttribute('transform', `rotate(${a},${cx},${cy})`);
const [px, py] = polar(cx, cy, r, a);
penDot.setAttribute('cx', px); penDot.setAttribute('cy', py);
onChange(hour, String(Math.floor(hour)).padStart(2, '0') + ':' + (hour % 1 >= 0.5 ? '30' : '00') + ' · ' + Math.round(val(hour)) + '%');
};
const reset = () => { pts = []; hour = 0; trace.setAttribute('d', ''); draw(); };
s.style.cursor = 'pointer';
s.addEventListener('click', reset);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
for (hour = 0; hour < 24; hour += 0.25) draw();
hour = 23.75;
} else {
reset();
setInterval(() => { hour += 0.25; if (hour >= 24) { pts = []; hour = 0; } draw(); }, 375);
}
return { el: s, get: () => hour, reset };
};
/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive.
Contract (everything a consumer needs; no page globals touched):
opts: value (initial units, 5..35 on the printed RPM x100 scale,
default 24); onChange(value, 'RPM N') fires on every set,
including the initial paint.
handle: el, get() (units), set(v) — v clamps 5..35; the stage 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.verticalTape = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 70, 130);
const CX = 38, CY = 65;
const MIN = 5, MAX = 35, PPU = 5.5; /* rpm x100, px per unit */
const clipId = uid('vtClip');
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4 });
svgEl(s, 'rect', { x: 2, y: 2, width: 66, height: 126, rx: 7, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4, fill: '#0d0c0a', stroke: '#2c2820', 'stroke-width': 1 });
const tapeG = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const tape = svgEl(tapeG, 'g', {});
for (let u = MIN; u <= MAX; u++) {
const y = -u * PPU; /* higher value further up the tape */
const maj = u % 5 === 0;
svgEl(tape, 'line', { x1: maj ? 20 : 26, y1: y, x2: 34, y2: y, stroke: 'var(--cream)', 'stroke-width': maj ? 1.1 : .55, opacity: maj ? .95 : .6 });
if (maj) svgEl(tape, 'text', {
x: 48, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700,
'font-family': 'var(--mono)', fill: 'var(--cream)'
}).textContent = String(u);
}
/* fixed index: side pointer + line across the window at center height */
svgEl(s, 'polygon', { points: `8,${CY} 15,${CY - 4} 15,${CY + 4}`, fill: 'var(--gold-hi)' });
svgEl(s, 'line', { x1: 15, y1: CY, x2: 62, y2: CY, stroke: 'var(--gold-hi)', 'stroke-width': 1, opacity: .85 });
svgEl(s, 'text', {
x: CX, y: 126, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.1em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'RPM x100';
let v = opts.value ?? 24;
function set(nv) {
v = Math.max(MIN, Math.min(MAX, nv));
tape.setAttribute('transform', `translate(0,${(CY + v * PPU).toFixed(1)})`);
onChange(v, 'RPM ' + Math.round(v * 100));
}
s.style.cursor = 'ns-resize';
dragDelta(s, () => v, set, { min: MIN, max: MAX, sens: (MAX - MIN) / 140 });
set(v);
return { el: s, get: () => v, set };
};
/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles.
Contract (everything a consumer needs; no page globals touched):
opts: fuel (initial, 0..4, default 2.4); oil (initial, 0..4, default
3.1); onChange([fuel, oil], 'FUEL n.n · OIL n.n') fires on every
set, including the initial paint.
handle: el, get() ([fuel, oil]), set(fuel, oil) — both clamp 0..4;
each half of the stage drags its own needle vertically.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.twinNeedle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 120, 110);
const cx = 60, cy = 62, R = 44;
svgEl(s, 'circle', { cx, cy, r: 52, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 46, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 });
/* mirrored arcs: left = FUEL (0 at bottom-left up to 4 at top), right = OIL */
const angL = v => -170 + v / 4 * 160; /* 0..4 -> -170..-10 (left side, cw from top) */
const angR = v => 170 - v / 4 * 160; /* 0..4 -> 170..10 (right side) */
for (let v = 0; v <= 4; v++) {
for (const ang of [angL(v), angR(v)]) {
const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 5, ang);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': 1, opacity: .9 });
const [tx, ty] = polar(cx, cy, R - 11, ang);
svgEl(s, 'text', {
x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)',
fill: 'var(--cream)'
}).textContent = String(v);
}
}
for (let v = 0.5; v < 4; v += 0.5) {
if (v % 1 === 0) continue;
for (const ang of [angL(v), angR(v)]) {
const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 3, ang);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': .5, opacity: .55 });
}
}
svgEl(s, 'text', {
x: cx - 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'FUEL';
svgEl(s, 'text', {
x: cx + 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'OIL';
svgEl(s, 'text', {
x: cx, y: cy - 26, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.06em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'kg/cm2';
const needle = color => {
const g = svgEl(s, 'g', {});
svgEl(g, 'polygon', {
points: `${cx - 1.6},${cy + 8} ${cx + 1.6},${cy + 8} ${cx + 1},${cy - R + 8} ${cx},${cy - R + 4} ${cx - 1},${cy - R + 8}`,
fill: color, stroke: 'rgba(0,0,0,.4)', 'stroke-width': .5
}); return g;
};
const nF = needle('#e0523a'), nO = needle('#bfc4d0');
svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
let vF, vO;
const draw = () => {
nF.setAttribute('transform', `rotate(${angL(vF).toFixed(1)},${cx},${cy})`);
nO.setAttribute('transform', `rotate(${angR(vO).toFixed(1)},${cx},${cy})`);
onChange([vF, vO], 'FUEL ' + vF.toFixed(1) + ' · OIL ' + vO.toFixed(1));
};
function set(f, o) { vF = Math.max(0, Math.min(4, f)); vO = Math.max(0, Math.min(4, o)); draw(); }
/* each half is its own drag surface */
const half = (x, get, setV) => {
const hit = svgEl(s, 'rect', { x, y: 10, width: 60, height: 104, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
dragDelta(hit, get, v => { setV(Math.max(0, Math.min(4, v))); draw(); }, { min: 0, max: 4, sens: 4 / 110 });
};
half(0, () => vF, v => vF = v);
half(60, () => vO, v => vO = v);
set(opts.fuel ?? 2.4, opts.oil ?? 3.1);
return { el: s, get: () => [vF, vO], set };
};
/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts.
Contract (everything a consumer needs; no page globals touched):
opts: temp (initial °F, 40..100, default 72); humidity (initial %,
0..100, default 45); onChange([temp, humidity], 'NF · N% RH ·
VERDICT') fires on every set, including the initial paint.
handle: el, get() ([temp, humidity]), set(temp, humidity) — both clamp
to their scales; the left half of the stage drags temp, the
right half humidity.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.comfortMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 130, 122);
const cx = 65, cy = 60;
const clipId = uid('cmClip');
gradDef('cmBrass', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8b25c'], ['.5', '#a07c30'], ['1', '#6e5218']]);
const defs = svgEl(s, 'defs', {});
svgEl(s, 'circle', { cx, cy, r: 56, fill: 'url(#cmBrass)', stroke: '#4a3610', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 53, fill: 'none', stroke: '#7a5c1e', 'stroke-width': 2.5, 'stroke-dasharray': '2 1.4' });
svgEl(s, 'circle', { cx, cy, r: 49, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': 1 });
const PT = [38, 96], PH = [92, 96]; /* temp pivot (BL), humidity pivot (BR) */
const angT = t => 105 - (t - 40) / 60 * 70; /* 40..100F -> bearing 105..35 from PT */
const angH = h => -105 + h / 100 * 70; /* 0..100% -> bearing -105..-35 from PH */
/* scale ticks along each sweep, near the rim */
for (let t = 40; t <= 100; t += 10) {
const a = angT(t);
const [x1, y1] = polar(PT[0], PT[1], 86, a), [x2, y2] = polar(PT[0], PT[1], 82, a);
if (Math.hypot(x1 - cx, y1 - cy) < 48) {
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': t % 20 === 0 ? 1 : .5 });
if (t % 20 === 0) {
const [tx, ty] = polar(PT[0], PT[1], 78, a);
svgEl(s, 'text', {
x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: '#3c382f'
}).textContent = String(t);
}
}
}
for (let h = 0; h <= 100; h += 10) {
const a = angH(h);
const [x1, y1] = polar(PH[0], PH[1], 86, a), [x2, y2] = polar(PH[0], PH[1], 82, a);
if (Math.hypot(x1 - cx, y1 - cy) < 48) {
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': h % 20 === 0 ? 1 : .5 });
if (h % 20 === 0) {
const [tx, ty] = polar(PH[0], PH[1], 78, a);
svgEl(s, 'text', {
x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: '#3c382f'
}).textContent = String(h);
}
}
}
/* printed zone verdicts; the active one goes red */
const zones = {};
const zone = (key, txt, x, y, rot, size) => {
zones[key] = svgEl(s, 'text', {
x, y, 'text-anchor': 'middle',
'font-size': size || 5.2, 'font-weight': 700, 'letter-spacing': '.06em', 'font-family': 'var(--mono)',
fill: '#8f897b', transform: `rotate(${rot},${x},${y})`
}); zones[key].textContent = txt;
};
zone('humid', 'TOO HUMID', 42, 34, -32);
zone('warm', 'TOO WARM', 89, 34, 32);
zone('right', 'JUST RIGHT', 65, 58, 0, 6.2);
zone('dry', 'TOO DRY', 38, 74, 28);
zone('cold', 'TOO COLD', 93, 74, -28);
svgEl(s, 'text', {
x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.6, 'letter-spacing': '.18em',
'font-family': 'var(--mono)', fill: '#3c382f'
}).textContent = 'WEATHER STATION';
/* needles: temp from bottom-left, humidity from bottom-right — clipped to the face */
const nclip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(nclip, 'circle', { cx, cy, r: 49 });
const ng = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const needle = () => {
const g = svgEl(ng, 'g', {});
svgEl(g, 'line', { x1: 0, y1: 0, x2: 0, y2: -84, stroke: '#c23a28', 'stroke-width': 1.6, 'stroke-linecap': 'round' });
svgEl(g, 'circle', { cx: 0, cy: 0, r: 2.6, fill: '#8f2416' }); return g;
};
const nT = needle(), nH = needle();
nT.setAttribute('transform', `translate(${PT[0]},${PT[1]})`);
nH.setAttribute('transform', `translate(${PH[0]},${PH[1]})`);
let vT, vH;
const verdict = () => vT > 78 ? 'warm' : vT < 62 ? 'cold' : vH > 60 ? 'humid' : vH < 30 ? 'dry' : 'right';
const draw = () => {
nT.setAttribute('transform', `translate(${PT[0]},${PT[1]}) rotate(${angT(vT).toFixed(1)})`);
nH.setAttribute('transform', `translate(${PH[0]},${PH[1]}) rotate(${angH(vH).toFixed(1)})`);
const v = verdict();
for (const k of Object.keys(zones)) zones[k].setAttribute('fill', k === v ? '#c23a28' : '#8f897b');
onChange([vT, vH], Math.round(vT) + 'F · ' + Math.round(vH) + '% RH · ' + zones[v].textContent);
};
function set(t, h) { vT = Math.max(40, Math.min(100, t)); vH = Math.max(0, Math.min(100, h)); draw(); }
const half = (x, get, setV, min, max) => {
const hit = svgEl(s, 'rect', { x, y: 8, width: 65, height: 106, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
dragDelta(hit, get, v => { setV(Math.max(min, Math.min(max, v))); draw(); }, { min, max, sens: (max - min) / 110 });
};
half(0, () => vT, v => vT = v, 40, 100);
half(65, () => vH, v => vH = v, 0, 100);
set(opts.temp ?? 72, opts.humidity ?? 45);
return { el: s, get: () => [vT, vH], set };
};
/* ================= indicators & readouts ================= */
/* 18 status lamps — one lamp per health state; click any lamp to cycle it.
Contract (everything a consumer needs; no page globals touched):
opts: states (array of state indices, default [0,1,2,3,4] — one lamp
per entry; 0 ok · 1 engaged · 2 fault · 3 off · 4 busy);
onChange(states array copy, state name) fires per change and once
at build with a count summary.
handle: el, get() (states copy), set(i, st) — st wraps modulo 5.
CSS lives in the "shared primitives" .dupre-lamp block of DUPRE_CSS
(shared with the output well's step lamps); no other styles involved. */
DUPRE.statusLamps = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const CLS = ['dupre-lamp', 'dupre-lamp dupre-gold', 'dupre-lamp dupre-red', 'dupre-lamp dupre-off', 'dupre-lamp dupre-busy'];
const NAMES = ['ok', 'engaged', 'fault', 'off', 'busy'];
const states = (opts.states || [0, 1, 2, 3, 4]).slice();
const wrap = document.createElement('span');
wrap.style.cssText = 'display:inline-flex;gap:9px;align-items:center';
const lamps = states.map((st, i) => {
const l = document.createElement('span'); l.className = CLS[st]; l.style.cursor = 'pointer';
l.addEventListener('click', () => set(i, states[i] + 1));
wrap.appendChild(l); return l;
});
host.appendChild(wrap);
function set(i, st) {
states[i] = ((st % CLS.length) + CLS.length) % CLS.length;
lamps[i].className = CLS[states[i]];
onChange(states.slice(), NAMES[states[i]]);
}
onChange(states.slice(), states.length === 5 ? 'five states' : states.length + ' states');
return { el: wrap, get: () => states.slice(), set };
};
/* 19 badges — labelled flags; click a badge to cycle its variant.
Contract (everything a consumer needs; no page globals touched):
opts: items (array of [label, variant] pairs, variant 0 gold · 1 red ·
2 ghost, default a three-badge demo set); onChange(variants array
copy, label) fires per change and once at build with a count
summary.
handle: el, get() (variants copy), set(i, v) — v wraps modulo 3.
CSS lives in the .dupre-badge block of DUPRE_CSS; no other styles
involved. */
DUPRE.badges = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const CLS = ['dupre-badge', 'dupre-badge dupre-red', 'dupre-badge dupre-ghost'];
const norm = v => (((v | 0) % CLS.length) + CLS.length) % CLS.length;
const items = opts.items || [['TUNNEL', 0], ['LOW BATT', 1], ['2.4G', 2]];
const vars = items.map(it => norm(it[1]));
const wrap = document.createElement('span');
const els = items.map(([txt], i) => {
const b = document.createElement('span'); b.className = CLS[vars[i]]; b.textContent = txt; b.style.cursor = 'pointer';
b.addEventListener('click', () => set(i, vars[i] + 1));
wrap.appendChild(b);
if (i < items.length - 1) wrap.appendChild(document.createTextNode(' '));
return b;
});
host.appendChild(wrap);
function set(i, v) {
vars[i] = norm(v);
els[i].className = CLS[vars[i]];
onChange(vars.slice(), items[i][0]);
}
onChange(vars.slice(), items.length === 3 ? 'three variants' : items.length + ' variants');
return { el: wrap, get: () => vars.slice(), set };
};
/* 20 tabular readout — mm:ss countdown; builder owns the state, the page
drives tick() on its own clock (tick contract); click pauses/resumes.
Contract (everything a consumer needs; no page globals touched):
opts: secs (initial seconds, wraps modulo 3600, default 24:10); run
(initial running state, default true); unit (caption under the
digits, default 'timer'); onChange(secs, 'running'/'paused')
fires on every set, including the initial paint.
handle: el, get() (secs), set(v) — v wraps modulo 3600; tick() — one
second down when running (the page's clock drives it); clicking
the digits pauses/resumes.
CSS lives in the .dupre-readout block of DUPRE_CSS; no other styles
involved. */
DUPRE.tabularReadout = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const wrap = document.createElement('div'); wrap.style.textAlign = 'center';
wrap.innerHTML = '';
const out = wrap.querySelector('.dupre-readout');
wrap.querySelector('.dupre-unit').textContent = opts.unit || 'timer';
host.appendChild(wrap);
let secs, run = opts.run !== undefined ? !!opts.run : true;
const draw = () => {
out.textContent = String(Math.floor(secs / 60)).padStart(2, '0') + ':' + String(secs % 60).padStart(2, '0');
onChange(secs, run ? 'running' : 'paused');
};
const set = v => { secs = ((v % 3600) + 3600) % 3600; draw(); };
out.addEventListener('click', () => { run = !run; draw(); });
set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10);
return { el: wrap, get: () => secs, set, tick: () => { if (run) set(secs - 1); } };
};
/* 21 engraved label — hairline-flanked caps label with a count; click bumps 1-9.
Contract (everything a consumer needs; no page globals touched):
opts: label (caption text, default 'outputs'); count (initial count,
default 3); onChange(count, 'count N') fires on every set,
including the initial paint.
handle: el, get() (count), set(v); clicking the label cycles the count
1..9.
CSS lives in the .dupre-engrave block of DUPRE_CSS; no other styles
involved. */
DUPRE.engravedLabel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const e = document.createElement('span'); e.className = 'dupre-engrave';
e.append(opts.label || 'outputs');
const c = document.createElement('span'); c.className = 'dupre-cnt'; e.appendChild(c);
host.appendChild(e);
let n;
const set = v => { n = v; c.textContent = '· ' + n; onChange(n, 'count ' + n); };
e.addEventListener('click', () => set(n % 9 + 1));
set(opts.count !== undefined ? opts.count : 3);
return { el: e, get: () => n, set };
};
/* 22 output well — streaming step log, lamp per step; click streams the next
demo step; push([lampCls, name, evidence]) appends programmatically */
/* 22 output well — a scrolling log of status steps; click streams the next demo
step, oldest rows scroll off once the well is full.
Contract (everything a consumer needs; no page globals touched):
opts: seed (initial rows, default two); steps (the click-cycle demo pool);
keep (max visible rows before the oldest scrolls off, default 5).
Each row is [tone, label, detail]; tone is '' | 'gold' | 'red' and
lights the row's dupre-lamp accent. onChange(row|null, caption) fires
on every push and once at build with a hint.
handle: el, push(row) — appends a row and trims the well to keep. Click
advances through steps and pushes the next.
CSS lives in the "output well" block of DUPRE_CSS; the row lamp is the shared
.dupre-lamp primitive tinted by the tone accent. */
DUPRE.outputWell = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const seed = opts.seed || [['', 'Link', 'wlp170s0 · @Hyatt'], ['gold', 'DNS', 'resolving…']];
const steps = opts.steps || [['gold', 'Probe', '8.8.8.8 …'], ['', 'Gateway', '10.0.0.1 ok'], ['', 'DNS', '1.1.1.1 ok'], ['red', 'Retry', 'timeout']];
const keep = opts.keep || 5;
const w = document.createElement('div'); w.className = 'dupre-owell'; host.appendChild(w);
const add = s => {
const d = document.createElement('div'); d.className = 'dupre-ostep';
d.innerHTML = `${s[1]}${s[2]}`;
w.appendChild(d); while (w.children.length > keep) w.removeChild(w.firstChild);
};
seed.forEach(add);
const push = s => { add(s); onChange(s, '+ ' + s[1]); };
let i = 0;
w.addEventListener('click', () => { i = (i + 1) % steps.length; push(steps[i]); });
onChange(null, 'click to stream');
return { el: w, push };
};
/* 23 toast — one-line transient confirmation; click fires the next demo
message; fire(msg) shows any message with the fade-in.
Contract (everything a consumer needs; no page globals touched):
opts: msgs (the demo message pool, cycled by click); text (initial
message, default the last pool entry); onChange(n, 'fired n') fires
on each click and once at build with a hint.
handle: el, fire(msg) — shows any message with the opacity fade-in; the
page can drive it with its own text. Click cycles the demo pool.
CSS lives in the "toast" block of DUPRE_CSS; no other styles involved. */
DUPRE.toast = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const msgs = opts.msgs || ['link up · 300 Mbps', 'bt paired — WH-1000XM4', 'profile switched', 'joined @Hyatt_WiFi — saved'];
const t = document.createElement('span'); t.className = 'dupre-toastw';
t.textContent = opts.text || msgs[msgs.length - 1];
host.appendChild(t);
const fire = msg => {
t.textContent = msg; t.style.opacity = '0';
requestAnimationFrame(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '1'; });
};
let n = 0;
t.addEventListener('click', () => { n++; fire(msgs[n % msgs.length]); onChange(n, 'fired ' + n); });
onChange(0, 'click to fire');
return { el: t, fire };
};
/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments.
Contract (everything a consumer needs; no page globals touched):
opts: digits (tube count, default 2); value (initial, default 8);
onChange(value, zero-padded string) fires on every set.
handle: el, get() (current value), set(v) — wraps modulo 10^digits;
leading-zero tubes go dark rather than showing 0, like the
hardware.
CSS lives in the "nixie tube" block of DUPRE_CSS; no other styles involved. */
DUPRE.nixie = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const digits = opts.digits || 2;
const max = Math.pow(10, digits);
const nx = document.createElement('span'); nx.className = 'dupre-nixie';
for (let i = 0; i < digits; i++) {
const tu = document.createElement('span'); tu.className = 'dupre-tube'; tu.innerHTML = ''; nx.appendChild(tu);
}
host.appendChild(nx);
let v;
const set = x => {
v = ((x % max) + max) % max;
const s = String(v).padStart(digits, '0');
[...nx.children].forEach((tu, i) => {
tu.classList.toggle('dupre-off', i < digits - 1 && v < Math.pow(10, digits - 1 - i));
tu.querySelector('b').textContent = s[i];
});
onChange(v, s);
};
nx.addEventListener('click', () => set(v + 1));
set(opts.value !== undefined ? opts.value : 8);
return { el: nx, get: () => v, set };
};
/* N20 split-flap — an honest Solari mechanism. Each cell owns a stack of
flaps in `chars` order (data, like R58's LAYOUT) and can only advance one
flap at a time, so a changed reading cascades through intermediates and
cells arrive staggered by travel distance. setText() mid-cascade re-aims
the running cells; animate:false (the page's reduced-motion gate)
collapses every move to an instant jump. One flip is two half-panel
animations: the current top falls (rotateX 0 -> -180) while the next
bottom lands (180 -> 0), backfaces hidden, crease preserved. First paint
is silent (no cascade on load), like the original. The page drives next()
on its own cadence.
Contract (everything a consumer needs; no page globals touched):
opts: rows (1) x cells (4) grid; chars (flap order, default space+A-Z+0-9);
flapMs (100, per-cell rate jittered 0.8x-1.35x so letters finish
at different times, base mutable via setFlapMs); animate (true);
skin ('paper'|'white'|'dark'|'light', card default paper); font ('helv'|'mono', card default helv);
words (the demo pool, at least rows+1 entries: next() sends every
row to a different random word, mutually distinct; set(i) pins the
top row for determinism); onChange(idx|-1, top word) fires at
command time. get() is the last set() index, -1 after a scramble.
handle: el, get(), set(i), next(), setText(multi-line string),
reading() (displayed grid, rows joined with newline),
setStyle(axis, name), chars, flapMs()/setFlapMs(ms) (base flap
rate, clamped 20-400), onSettle(cb) — cb(reading) fires once per
command when every cell has arrived (dwell hook).
CSS lives in the "split-flap" block of DUPRE_CSS; no other styles involved. */
DUPRE.splitFlap = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const words = opts.words || ['DNS ', 'LINK', 'SYNC', 'WIFI', 'SCAN'];
const animate = opts.animate !== undefined ? !!opts.animate : true;
const rows = opts.rows || 1;
const cells = opts.cells || 4;
const chars = opts.chars || ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const clampMs = ms => Math.min(400, Math.max(20, ms | 0));
let flapMs = clampMs(opts.flapMs || 100);
const ci = ch => Math.max(0, chars.indexOf(ch));
const fit = (str, w) => String(str).padEnd(w, ' ').slice(0, w);
const wordAt = k => words[((k % words.length) + words.length) % words.length];
// Four stacked half-panels per cell, painting order = stacking order:
// top-next (revealed as the current top falls), bottom-current (covered as
// the next bottom lands), top-current (the falling flap), bottom-next (the
// landing flap, resting folded away at rotateX(180) with its back hidden).
const st = []; // row-major cell states
const settleCbs = []; if (opts.onSettle) settleCbs.push(opts.onSettle);
let announced = true; // no announcement for the silent first paint
let idx = 0; // last set() index; -1 after a scramble
let lastGrid = ''; // the last commanded grid — the demo picks against
// commands, not in-flight glyphs mid-cascade
const allSettled = () => st.every(S => !S.running && S.cur === S.target);
const fitGrid = str => Array.from({ length: rows },
(_, r) => fit(String(str).split('\n')[r] || '', cells)).join('\n');
const reading = () => Array.from({ length: rows },
(_, r) => st.slice(r * cells, (r + 1) * cells).map(S => chars[S.cur]).join('')).join('\n');
const makeCell = () => {
const d = document.createElement('span'); d.className = 'flapd';
d.innerHTML = '';
st.push({
cur: 0, target: 0, running: false,
// Each cell's mechanism has its own character: a fixed per-cell jitter
// (0.8x-1.35x of the base rate) so letters finish at genuinely
// different times, beyond what travel distance alone staggers.
jitter: 0.8 + 0.55 * Math.random(),
ftn: d.querySelector('.ftn'), fbc: d.querySelector('.fbc'),
ftc: d.querySelector('.ftc'), fbn: d.querySelector('.fbn'),
});
return d;
};
const f = document.createElement('span'); f.className = 'flap' + (rows > 1 ? ' flap-rows' : '');
for (let r = 0; r < rows; r++) {
const line = rows > 1 ? document.createElement('span') : f;
if (rows > 1) { line.className = 'flapline'; f.appendChild(line); }
for (let c = 0; c < cells; c++) line.appendChild(makeCell());
}
host.appendChild(f);
// One cell's cascade: flip one place through the charset until the (live)
// target is reached. A retarget while running just moves the goalpost.
const run = S => {
if (S.running) return;
S.running = true;
(async () => {
while (S.cur !== S.target) {
const nk = (S.cur + 1) % chars.length;
S.ftn.textContent = chars[nk]; S.fbn.textContent = chars[nk];
if (animate) {
try {
const flip = { duration: Math.round(flapMs * S.jitter), easing: 'ease-in' };
await Promise.all([
S.ftc.animate([{ transform: 'rotateX(0deg)' }, { transform: 'rotateX(-180deg)' }], flip).finished,
S.fbn.animate([{ transform: 'rotateX(180deg)' }, { transform: 'rotateX(0deg)' }], flip).finished,
]);
} catch { break; /* cancelled (card torn down mid-flip) */ }
}
S.ftc.textContent = S.fbc.textContent = chars[nk];
S.cur = nk;
}
S.running = false;
maybeSettle();
})();
};
// Announce once per command, when the whole board has arrived. Consumers
// use it to dwell on the settled reading before commanding the next update.
const maybeSettle = () => {
if (announced || !allSettled()) return;
announced = true;
const r = reading();
settleCbs.forEach(cb => cb(r));
};
// Map a multi-line string onto the grid; missing lines/cells pad with space.
const gridTargets = str => {
const lines = String(str).split('\n');
return st.map((S, i) => ci(fit(lines[(i / cells) | 0] || '', cells)[i % cells]));
};
const setText = str => {
announced = false;
lastGrid = fitGrid(str);
// Assign every target before running any cell: a cell that finishes
// synchronously (animate:false) must not see its neighbours' stale,
// already-satisfied targets and announce a partial board as settled.
gridTargets(str).forEach((k, i) => { st[i].target = k; });
st.forEach(run);
maybeSettle(); // covers a command that changes nothing
};
const silent = str => {
lastGrid = fitGrid(str);
gridTargets(str).forEach((k, i) => {
const S = st[i]; S.cur = S.target = k;
S.ftn.textContent = S.fbc.textContent = S.ftc.textContent = S.fbn.textContent = chars[k];
});
};
// The words demo: every advance sends each row to a completely different
// word, drawn at random from the pool; rows stay mutually distinct. set(i)
// pins the top row (deterministic callers, tests); next() scrambles all.
const rowWords = () => lastGrid.split('\n');
const pickNew = taken => {
const open = words.filter(w => !taken.includes(fit(w, cells)));
return fit(open[(Math.random() * open.length) | 0] || wordAt(0), cells);
};
const gridFor = top => {
const out = [];
const prev = rowWords();
for (let r = 0; r < rows; r++)
out.push(r === 0 && top !== null ? fit(top, cells) : pickNew(out.concat(prev[r])));
return out.join('\n');
};
const set = i => { idx = ((i % words.length) + words.length) % words.length; setText(gridFor(wordAt(idx))); onChange(idx, wordAt(idx).trim()); };
const scramble = () => { idx = -1; const g = gridFor(null); setText(g); onChange(-1, g.split('\n')[0].trim()); };
f.addEventListener('click', scramble);
// construction axes (skin, font): swap this axis's classes per STYLES.
// Polarity and typeface are construction, not accent — no colour policy.
const setStyle = (axis, name) => {
const ax = DUPRE.splitFlap.STYLES[axis]; const o = ax && ax[name];
if (!o) return;
Object.values(ax).forEach(v => { if (v.cls) f.classList.remove(v.cls); });
if (o.cls) f.classList.add(o.cls);
};
if (opts.skin) setStyle('skin', opts.skin);
if (opts.font) setStyle('font', opts.font);
// first paint: consecutive pool words, silent (no cascade on load)
silent(Array.from({ length: rows }, (_, r) => fit(wordAt(r), cells)).join('\n'));
return {
el: f, get: () => idx, set, next: scramble, setStyle,
setText, chars, reading, onSettle: cb => settleCbs.push(cb),
flapMs: () => flapMs, setFlapMs: ms => { flapMs = clampMs(ms); },
};
};
DUPRE.splitFlap.STYLES = {
// board polarity + ink: dark board with the kit's cream ink (default),
// dark board with true-white ink, or the inverse ivory board
// dot: the board colour; the gallery card letters each chip in the skin's
// ink so the swatch is a miniature flap cell, not an ambiguous colour dot
skin: {
paper: { cls: 'flap-paper', dot: '#ffffff' },
white: { cls: 'flap-white', dot: '#141210' },
dark: { cls: '', dot: '#141210' },
light: { cls: 'flap-light', dot: '#f3e7c5' },
},
// typeface: the kit's Berkeley Mono, or the grotesque real boards wore
font: {
helv: { cls: 'flap-helv', dot: '#0d0c0b' },
mono: { cls: '', dot: '#0d0c0b' },
},
};
/* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick();
click adds a minute.
Contract (everything a consumer needs; no page globals touched):
opts: secs (initial seconds, default 24:10); onChange(secs, 'mm:ss')
fires on every set.
handle: el, get() (current seconds), set(v) — wraps modulo 3600;
tick() steps one second down (the tick contract — the page owns
the clock); click adds 60.
CSS lives in the "seven-segment" block of DUPRE_CSS; the digit glyphs come
from the shared seg7() engine helper (.seg7, shared with the DSKY). */
DUPRE.sevenSeg = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const sv = document.createElement('span'); sv.className = 'dupre-seven'; host.appendChild(sv);
let secs;
const set = v => {
secs = ((v % 3600) + 3600) % 3600;
const mm = String(Math.floor(secs / 60)).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0');
sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '' + seg7(ss[0]) + seg7(ss[1]);
onChange(secs, mm + ':' + ss);
};
sv.addEventListener('click', () => set(secs + 60));
set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10);
return { el: sv, get: () => secs, set, tick: () => set(secs - 1) };
};
/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message.
Contract (everything a consumer needs; no page globals touched):
opts: msgs (message pool, default demo set); width (window px, 176);
onChange(msg index, 'msg i/n') fires on click-cycle and once at
build.
handle: el, get() (current message index), tick() — advances the scroll
1.1px and wraps when the text clears the window (the tick
contract — the page owns the clock).
CSS lives in the "VFD marquee" block of DUPRE_CSS; no other styles involved. */
DUPRE.vfdMarquee = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const msgs = opts.msgs || ['ARCHSETUP · NET OK · BT 2 · SND 62%', 'WIFI @Hyatt · 300 Mbps · VPN UP', 'BATTERY 84% · DISK 61% · TEMP 47C'];
const W = opts.width || 176;
const m = document.createElement('span'); m.className = 'dupre-vfdm';
m.innerHTML = '';
const t = m.querySelector('.dupre-txt'); t.textContent = msgs[0];
host.appendChild(m);
let mi = 0, x = W;
m.addEventListener('click', () => {
mi = (mi + 1) % msgs.length; t.textContent = msgs[mi]; x = W;
onChange(mi, 'msg ' + (mi + 1) + '/' + msgs.length);
});
onChange(0, 'scrolling');
return {
el: m, get: () => mi,
tick: () => { x -= 1.1; if (x < -t.offsetWidth) x = W; t.style.left = x + 'px'; }
};
};
/* N23 annunciator — named alarm grid with the raise → MSTR CAUTION → ACK → RESET
lifecycle; TEST proves the bulbs then restores the board.
Contract (everything a consumer needs; no page globals touched):
opts: cells (array of [label, state] where state is 0 ok · 1 warn ·
2 fault; defaults to a six-cell demo). onChange fires on every
transition: (count, summary) after a refresh, (index, 'label → name')
after a cell change, (null, 'lamp test') during TEST.
handle: el, get() (per-cell state array 0/1/2), set(i, st) — st clamps to
the 0..2 lens range at build AND at set, so an out-of-range caller
can't paint an undefined class. A new warn/fault re-arms the
MSTR CAUTION flasher; ACK stops it, RESET clears the board.
CSS lives in the "annunciator" block of DUPRE_CSS; the ACK/TEST/RESET buttons
are the shared .dupre-key primitive; the board uses the kit-wide dupre-warn
modifier and MSTR CAUTION the kit-wide dupre-on. */
DUPRE.annunciator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const CLS = ['dupre-acell', 'dupre-acell dupre-warn', 'dupre-acell dupre-fault'];
const NAMES = ['ok', 'warn', 'fault'];
const clampSt = st => Math.max(0, Math.min(CLS.length - 1, st | 0));
const cells = opts.cells || [['SYNC', 0], ['LOW BATT', 1], ['LINK', 0], ['NO DNS', 2], ['VPN', 0], ['DISK', 0]];
const wrap = document.createElement('div'); wrap.className = 'dupre-annwrap';
const grid = document.createElement('div'); grid.className = 'dupre-annun'; wrap.appendChild(grid);
const bar = document.createElement('div'); bar.className = 'dupre-annbar';
const mc = document.createElement('span'); mc.className = 'dupre-mc'; mc.textContent = 'MSTR CAUTION'; bar.appendChild(mc);
['ACK', 'TEST', 'RESET'].forEach(a => {
const b = document.createElement('button'); b.className = 'dupre-key'; b.dataset.k = a; b.textContent = a; bar.appendChild(b);
});
wrap.appendChild(bar); host.appendChild(wrap);
const els = cells.map(([label, st]) => {
const c = document.createElement('span'); c.className = CLS[clampSt(st)]; c.textContent = label; grid.appendChild(c); return c;
});
let acked = false;
const active = () => grid.querySelectorAll('.dupre-warn,.dupre-fault').length;
const refresh = () => {
const n = active();
mc.classList.toggle('dupre-on', n > 0); mc.classList.toggle('dupre-fl', n > 0 && !acked);
onChange(n, n === 0 ? 'clear' : n + ' active · ' + (acked ? 'ACK' : 'UNACK'));
};
els.forEach((c, i) => c.addEventListener('click', () => {
let j = CLS.indexOf(c.className.trim()); if (j < 0) j = 0; j = (j + 1) % CLS.length; c.className = CLS[j];
if (j > 0) acked = false; /* a new alarm re-arms the flasher */
refresh(); onChange(j, cells[i][0] + ' → ' + NAMES[j]);
}));
bar.querySelectorAll('.dupre-key').forEach(k => k.addEventListener('click', () => {
const a = k.dataset.k;
if (a === 'ACK') { if (active() > 0) acked = true; refresh(); }
else if (a === 'RESET') { els.forEach(c => c.className = 'dupre-acell'); acked = false; refresh(); }
else if (a === 'TEST') {
const prev = els.map(c => c.className);
els.forEach(c => c.className = 'dupre-acell dupre-fault');
mc.classList.add('dupre-on', 'dupre-fl'); onChange(null, 'lamp test');
setTimeout(() => { els.forEach((c, i) => c.className = prev[i]); refresh(); }, 1100);
}
}));
refresh();
return {
el: wrap, get: () => els.map(c => Math.max(0, CLS.indexOf(c.className.trim()))),
set: (i, st) => { els[i].className = CLS[clampSt(st)]; if (clampSt(st) > 0) acked = false; refresh(); }
};
};
/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark.
Contract (everything a consumer needs; no page globals touched):
opts: states (array, one jewel per entry: 0 red · 1 amber · 2 green ·
-1 dark; default [0,1,2,-1]); onChange(state, name) fires per
change and once at build with a hint.
handle: el, get() (states copy), set(i, st) — st clamps to the lens
range, negatives dark the jewel. State lives in here; the DOM
is a paint of it (was the reverse — the old builder kept state
only in classList/--jc, so nothing could read or drive it).
CSS lives in the "jewel" block of DUPRE_CSS; no other styles involved. */
DUPRE.jewels = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const cols = ['var(--jewel-r)', 'var(--jewel-a)', 'var(--jewel-g)'];
const NAMES = ['red', 'amber', 'green'];
const states = (opts.states || [0, 1, 2, -1]).slice(); /* index into cols; -1 = dark */
const wrap = document.createElement('span');
wrap.style.cssText = 'display:inline-flex;gap:10px;align-items:center';
const paint = i => {
const st = states[i], j = els[i];
j.className = 'dupre-jewel' + (st < 0 ? ' dupre-dim' : '');
if (st >= 0) j.style.setProperty('--jc', cols[st]); else j.style.removeProperty('--jc');
};
function set(i, st) {
states[i] = st < 0 ? -1 : Math.min(st, cols.length - 1);
paint(i);
onChange(states[i], states[i] < 0 ? 'dark' : NAMES[states[i]]);
}
const els = states.map((_, i) => {
const j = document.createElement('span');
j.addEventListener('click', () => set(i, states[i] >= cols.length - 1 ? -1 : states[i] + 1));
wrap.appendChild(j); return j;
});
host.appendChild(wrap);
states.forEach((_, i) => paint(i));
onChange(null, 'click to cycle');
return { el: wrap, get: () => states.slice(), set };
};
/* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit.
Contract (everything a consumer needs; no page globals touched):
opts: wheels (digit count, default 6); redFrom (index from which the
high-order wheels wear the red band, default 4); value (initial,
default 471300); onChange(value, zero-padded string) fires on set.
handle: el, get() (current total), set(total) — rounds fractional input
and wraps modulo 10^wheels, then rolls each wheel to its digit.
CSS lives in the "tape counter" block of DUPRE_CSS; no other styles involved. */
DUPRE.tapeCounter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const wheels = opts.wheels || 6, redFrom = opts.redFrom !== undefined ? opts.redFrom : 4;
const ct = document.createElement('span'); ct.className = 'dupre-counter'; host.appendChild(ct);
for (let idx = 0; idx < wheels; idx++) {
const w = document.createElement('span'); w.className = 'dupre-cwheel' + (idx >= redFrom ? ' dupre-redw' : '');
const col = document.createElement('span'); col.className = 'dupre-col';
for (let n = -1; n <= 10; n++) { const sp = document.createElement('span'); sp.textContent = ((n + 10) % 10); col.appendChild(sp); }
w.appendChild(col); ct.appendChild(w);
}
const max = Math.pow(10, wheels);
let v;
const set = x => {
v = ((Math.round(x) % max) + max) % max;
const s = String(v).padStart(wheels, '0');
[...ct.children].forEach((w, i) => { w.querySelector('.dupre-col').style.top = (-(+s[i] + 1) * 34) + 'px'; });
onChange(v, s);
};
set(opts.value !== undefined ? opts.value : 471300);
return { el: ct, get: () => v, set };
};
/* N26 analog clock — engraved ticks + three hands; the page owns the time source
and drives set(h, m, s); silent until the first set, like the live original.
Contract (everything a consumer needs; no page globals touched):
opts: onChange([h, m, s], 'hh:mm:ss') fires on every set.
handle: el, set(h, m, s) — the minute hand carries a seconds fraction
and the hour hand a minutes fraction, so the face reads like a
real movement. Display-only: no state to get, no clock of its
own (the tick contract — the page owns the time source).
CSS lives in the "analog clock" block of DUPRE_CSS; no other styles involved. */
DUPRE.analogClock = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const cl = document.createElement('span'); cl.className = 'dupre-clock';
for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'dupre-tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); }
cl.insertAdjacentHTML('beforeend', '');
host.appendChild(cl);
const hh = cl.querySelector('.dupre-hh'), mh = cl.querySelector('.dupre-mh'), sh = cl.querySelector('.dupre-sh');
const set = (h, m, s) => {
sh.style.transform = `rotate(${s * 6}deg)`;
mh.style.transform = `rotate(${m * 6 + s * 0.1}deg)`;
hh.style.transform = `rotate(${(h % 12) * 30 + m * 0.5}deg)`;
onChange([h, m, s], String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'));
};
return { el: cl, set };
};
/* N27 frequency-dial scale — printed log axis, marks crowd low and spread high;
drag the pointer to tune.
Contract (everything a consumer needs; no page globals touched):
opts: width (px, default 184); marks (labeled major marks, log-spaced,
default 0.5..20); unit (band label, default 'MHz'); value (initial
pointer percent 0..100, default 45); onChange(freq, 'nn unit') fires
on set and drag.
handle: el, get() (pointer percent 0..100), set(pct) — clamps to 0..100
and maps to a frequency along the log axis. dragX drives it.
CSS lives in the "frequency-dial scale" block of DUPRE_CSS; the ticks and
marks reuse the shared scoped dupre-tick/dupre-mk names (like the tuner and
dualknob), and the unit label is dupre-fs-band (dupre-band is the EQ's). */
DUPRE.freqScale = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const W = opts.width || 184;
const marks = opts.marks || [0.5, 1, 2, 3, 5, 7, 10, 14, 20];
const unit = opts.unit || 'MHz';
const lo = Math.log10(marks[0]), hi = Math.log10(marks[marks.length - 1]);
const px = m => 6 + ((Math.log10(m) - lo) / (hi - lo)) * (W - 12);
const fs = document.createElement('span'); fs.className = 'dupre-freqscale';
const band = document.createElement('span'); band.className = 'dupre-fs-band'; band.textContent = unit; fs.appendChild(band);
marks.forEach(m => {
const tk = document.createElement('span'); tk.className = 'dupre-tick'; tk.style.left = px(m) + 'px'; tk.style.height = '11px'; fs.appendChild(tk);
const mk = document.createElement('span'); mk.className = 'dupre-mk'; mk.style.left = px(m) + 'px'; mk.textContent = m; fs.appendChild(mk);
});
for (let m = marks[0]; m <= marks[marks.length - 1]; m += (m < 2 ? 0.25 : m < 10 ? 1 : 2)) {
const tk = document.createElement('span'); tk.className = 'dupre-tick'; tk.style.left = px(m) + 'px'; tk.style.height = '6px'; tk.style.opacity = '.5'; fs.appendChild(tk);
}
const ptr = document.createElement('span'); ptr.className = 'dupre-fptr'; fs.appendChild(ptr);
host.appendChild(fs);
let pct;
const set = p => {
pct = Math.max(0, Math.min(100, p));
ptr.style.left = (6 + pct / 100 * (W - 12)) + 'px';
const f = Math.pow(10, lo + (pct / 100) * (hi - lo));
onChange(f, (f < 10 ? f.toFixed(1) : Math.round(f)) + ' ' + unit);
};
dragX(fs, set);
set(opts.value !== undefined ? opts.value : 45);
return { el: fs, get: () => pct, set };
};
/* N28 patch bay — jack grid with SVG cables; click one jack then another to
patch or unpatch the pair; redraws itself on window resize.
Contract (everything a consumer needs; no page globals touched):
opts: rows (default 2), cols (default 4); conns (initial cables as
'a-b' jack-index strings, default ['0-5', '2-7']); onChange(conns
array, summary) fires on every patch/unpatch and once at build.
handle: el, get() (conns copy), set(conns) — replaces the patch set and
redraws the cables. Click one jack then another to patch or
unpatch; the SVG cables re-solve on window resize.
CSS lives in the "patch-bay" block of DUPRE_CSS; a live jack wears the
kit-wide dupre-hot modifier. */
DUPRE.patchBay = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const rows = opts.rows || 2, cols = opts.cols || 4;
const patch = document.createElement('div'); patch.className = 'dupre-patch';
const jacks = [];
for (let r = 0; r < rows; r++) {
const rowEl = document.createElement('div'); rowEl.className = 'dupre-row';
for (let c = 0; c < cols; c++) { const j = document.createElement('span'); j.className = 'dupre-jack'; j.dataset.id = (r * cols + c); rowEl.appendChild(j); jacks.push(j); }
patch.appendChild(rowEl);
}
const svg = document.createElementNS(SVGNS, 'svg'); patch.appendChild(svg);
host.appendChild(patch);
let conns = (opts.conns || ['0-5', '2-7']).slice(); let pending = null;
const key = (a, b) => Math.min(a, b) + '-' + Math.max(a, b);
const draw = () => {
while (svg.firstChild) svg.removeChild(svg.firstChild);
const pr = patch.getBoundingClientRect();
jacks.forEach(j => j.classList.remove('dupre-hot'));
conns.forEach(pair => {
const [a, b] = pair.split('-').map(Number);
const ra = jacks[a].getBoundingClientRect(), rb = jacks[b].getBoundingClientRect();
const x1 = ra.left - pr.left + ra.width / 2, y1 = ra.top - pr.top + ra.height / 2;
const x2 = rb.left - pr.left + rb.width / 2, y2 = rb.top - pr.top + rb.height / 2;
const p = document.createElementNS(SVGNS, 'path');
const my = Math.max(y1, y2) + 16; p.setAttribute('d', `M${x1} ${y1} C${x1} ${my} ${x2} ${my} ${x2} ${y2}`); svg.appendChild(p);
jacks[a].classList.add('dupre-hot'); jacks[b].classList.add('dupre-hot');
});
onChange(conns.slice(), conns.length ? conns.map(c => { const [a, b] = c.split('-'); return a + '↔' + b; }).join(' ') : 'no cables');
};
jacks.forEach(j => {
j.addEventListener('click', () => {
const id = +j.dataset.id;
if (pending == null) { pending = id; j.classList.add('dupre-sel'); return; }
if (pending === id) { pending = null; j.classList.remove('dupre-sel'); return; }
const k = key(pending, id); const i = conns.indexOf(k); if (i >= 0) conns.splice(i, 1); else conns.push(k);
jacks[pending].classList.remove('dupre-sel'); pending = null; draw();
});
});
draw(); window.addEventListener('resize', draw);
return { el: patch, get: () => conns.slice(), set: c => { conns = c.slice(); draw(); } };
};
/* R10 data matrix readout — a page of labeled amber fields; click cycles pages;
a page marked live re-renders on the builder's own 1s clock (reduced-motion gated).
Contract (everything a consumer needs; no page globals touched):
opts: pages (array of [name, rowsFn, live?] tuples, default SYS/NET/TIME;
rowsFn() returns the three text rows, and a truthy third element
marks a page that re-renders on the 1s clock); page (initial index,
default 0); onChange(index, 'page NAME') fires on set.
handle: el, get() (page index), set(i) — wraps modulo the page count.
Click cycles pages; a live page re-renders on the builder's own
1s clock (reduced-motion gated).
SVG-built: styling is inline attributes plus the shared .rsvg stage block of
DUPRE_CSS; the glow filter registers in the shared defs plate, but the
screen-family background gradient lives in this instrument's local defs (it
reads --scr-* vars from its own subtree). */
DUPRE.dataMatrix = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const pages = opts.pages || [
['SYS', () => ['CPU 42 MEM 61', 'DSK 58 TMP 47C', 'ARCH 6.18 LTS']],
['NET', () => ['WIFI @HYATT 300M', 'VPN UP DNS OK', 'BT 2 PAIRED']],
['TIME', () => {
const d = new Date(), p = n => String(n).padStart(2, '0');
return [`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`,
`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`,
'UP 14D 06:12'];
}, true]];
const s = stageSvg(host, 'rsvg press', 190, 74);
def('dmxGlow', d => {
const fl = svgEl(d, 'filter', { id: 'dmxGlow', x: '-30%', y: '-30%', width: '160%', height: '160%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.1 });
});
/* the background gradient reads screen-family vars from this instrument's subtree,
so it lives in the local defs, not the shared def sink */
const bgId = uid('dmxBg');
const defs = svgEl(s, 'defs', {});
const bg = svgEl(defs, 'linearGradient', { id: bgId, x1: 0, y1: 0, x2: 0, y2: 1 });
svgEl(bg, 'stop', { offset: '0', 'stop-color': 'var(--scr-bg1,#140d06)' });
svgEl(bg, 'stop', { offset: '1', 'stop-color': 'var(--scr-bg2,#0a0705)' });
svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 72, rx: 5, fill: `url(#${bgId})`, stroke: '#2c261d' });
const lines = [];
for (let r = 0; r < 3; r++) {
const glow = svgEl(s, 'text', {
x: 12, y: 24 + r * 18, 'font-size': 9, 'letter-spacing': '.15em',
'font-family': 'var(--mono)', fill: 'var(--scr-hi,var(--gold-hi))', opacity: .6, filter: 'url(#dmxGlow)'
});
const crisp = svgEl(s, 'text', {
x: 12, y: 24 + r * 18, 'font-size': 9, 'letter-spacing': '.15em',
'font-family': 'var(--mono)', fill: 'var(--scr-hi,var(--gold-hi))'
});
lines.push([glow, crisp]);
}
let pi;
const set = i => {
pi = ((i % pages.length) + pages.length) % pages.length;
const [name, rowsFn] = pages[pi];
rowsFn().forEach((txt, r) => { lines[r][0].textContent = txt; lines[r][1].textContent = txt; });
onChange(pi, 'page ' + name);
};
s.addEventListener('click', () => set(pi + 1));
set(opts.page || 0);
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => { if (pages[pi][2]) set(pi); }, 1000);
return { el: s, get: () => pi, set };
};
/* R11 warning flag window — the striped mechanical flag slides into the window
when the condition trips; click trips and clears it.
Contract (everything a consumer needs; no page globals touched):
opts: label (window legend, default 'VIB'); on (initial tripped state,
default false); onChange(on, 'FLAG'|'clear') fires on set.
handle: el, get() (tripped bool), set(v) — slides the striped flag into
the window when truthy, retracts it when not. Click toggles.
SVG-built: styling is inline attributes plus the shared .rsvg stage block of
DUPRE_CSS; the barber-stripe pattern registers in the shared defs plate, the
window clip in this instrument's local defs. */
DUPRE.warningFlag = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 120, 64);
def('barber', d => {
const pat = svgEl(d, 'pattern', { id: 'barber', width: 10, height: 10, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' });
svgEl(pat, 'rect', { x: 0, y: 0, width: 10, height: 10, fill: '#17130c' });
svgEl(pat, 'rect', { x: 0, y: 0, width: 5, height: 10, fill: 'var(--gold)' });
});
const clipId = uid('flagWin');
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'rect', { x: 14, y: 20, width: 56, height: 24 });
svgEl(s, 'rect', { x: 11, y: 17, width: 62, height: 30, rx: 3, fill: '#0a0908', stroke: '#2c261d', 'stroke-width': 2 });
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const stripes = svgEl(g, 'rect', { x: 14, y: 20, width: 56, height: 24, fill: 'url(#barber)' });
stripes.style.transition = 'transform .18s'; stripes.style.transform = 'translateX(-58px)';
svgEl(s, 'text', {
x: 92, y: 35, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.14em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = opts.label || 'VIB';
let on;
const set = v => { on = !!v; stripes.style.transform = on ? 'translateX(0)' : 'translateX(-58px)'; onChange(on, on ? 'FLAG' : 'clear'); };
s.addEventListener('click', () => set(!on));
set(opts.on || false);
return { el: s, get: () => on, set };
};
/* R25 fourteen-segment display — the starburst alphanumeric that spells words;
click cycles the word.
Contract (everything a consumer needs; no page globals touched):
opts: words (the four-letter word pool cycled by click, default
ZOOM/ECHO/TAPE/…; letters outside the segment MAP render blank);
index (initial, default 0); onChange(index, word) fires on set.
handle: el, get() (word index), set(i) — wraps modulo the pool and lights
the fourteen-segment cells to spell the word. Click cycles.
SVG-built: styling is inline attributes plus the shared .rsvg stage block of
DUPRE_CSS; the segments are filled inline (lit vs --sevoff), no shared defs. */
DUPRE.seg14 = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const words = opts.words || ['ZOOM', 'ECHO', 'TAPE', 'MOOD', 'HALL', 'COMP'];
const MAP = {
A: ['a', 'b', 'c', 'e', 'f', 'g1', 'g2'], C: ['a', 'd', 'e', 'f'],
D: ['a', 'b', 'c', 'd', 'i', 'l'], E: ['a', 'd', 'e', 'f', 'g1', 'g2'], H: ['b', 'c', 'e', 'f', 'g1', 'g2'],
L: ['d', 'e', 'f'], M: ['b', 'c', 'e', 'f', 'h', 'j'], O: ['a', 'b', 'c', 'd', 'e', 'f'],
P: ['a', 'b', 'e', 'f', 'g1', 'g2'], T: ['a', 'i', 'l'], Z: ['a', 'd', 'j', 'k']
};
const s = stageSvg(host, 'rsvg press', 130, 54);
svgEl(s, 'rect', { x: 1, y: 1, width: 128, height: 52, rx: 5, fill: '#0a0f08', stroke: '#1c2a16', 'stroke-width': 2 });
/* segment shapes in a 22x36 local box */
const TH = 2.6, W = 22, H = 36, mid = H / 2;
const segs = {
a: [[2, 0], [W - 2, 0], [W - 4, TH], [4, TH]],
d: [[2, H], [W - 2, H], [W - 4, H - TH], [4, H - TH]],
f: [[0, 2], [TH, 4], [TH, mid - 2], [0, mid - 1]],
e: [[0, H - 2], [TH, H - 4], [TH, mid + 2], [0, mid + 1]],
b: [[W, 2], [W - TH, 4], [W - TH, mid - 2], [W, mid - 1]],
c: [[W, H - 2], [W - TH, H - 4], [W - TH, mid + 2], [W, mid + 1]],
g1: [[2, mid], [4, mid - TH / 2], [W / 2 - 1, mid - TH / 2], [W / 2 - 1, mid + TH / 2], [4, mid + TH / 2]],
g2: [[W - 2, mid], [W - 4, mid - TH / 2], [W / 2 + 1, mid - TH / 2], [W / 2 + 1, mid + TH / 2], [W - 4, mid + TH / 2]],
i: [[W / 2 - TH / 2, 3], [W / 2 + TH / 2, 3], [W / 2 + TH / 2, mid - 2], [W / 2 - TH / 2, mid - 2]],
l: [[W / 2 - TH / 2, H - 3], [W / 2 + TH / 2, H - 3], [W / 2 + TH / 2, mid + 2], [W / 2 - TH / 2, mid + 2]],
h: [[3, 3], [5.5, 3], [W / 2 - 2, mid - 3], [W / 2 - 4.5, mid - 3]],
j: [[W - 3, 3], [W - 5.5, 3], [W / 2 + 2, mid - 3], [W / 2 + 4.5, mid - 3]],
k: [[3, H - 3], [5.5, H - 3], [W / 2 - 2, mid + 3], [W / 2 - 4.5, mid + 3]],
m: [[W - 3, H - 3], [W - 5.5, H - 3], [W / 2 + 2, mid + 3], [W / 2 + 4.5, mid + 3]]
};
const cells = [];
for (let d = 0; d < 4; d++) {
const x0 = 13 + d * 28, y0 = 9, cell = {};
for (const k in segs) {
cell[k] = svgEl(s, 'polygon', { points: segs[k].map(p => `${x0 + p[0]},${y0 + p[1]}`).join(' '), fill: 'var(--sevoff)' });
}
cells.push(cell);
}
let wi;
const set = i => {
wi = ((i % words.length) + words.length) % words.length;
const word = words[wi].padEnd(4, ' ');
cells.forEach((cell, d) => {
const lit = MAP[word[d]] || [];
for (const k in cell) {
const on = lit.includes(k);
cell[k].setAttribute('fill', on ? 'var(--sevgrn)' : 'var(--sevoff)');
cell[k].setAttribute('style', on ? 'filter:drop-shadow(0 0 2.5px rgba(87,211,87,.6))' : '');
}
});
onChange(wi, words[wi]);
};
s.addEventListener('click', () => set(wi + 1));
set(opts.index || 0);
return { el: s, get: () => wi, set };
};
/* R26 response graph — log-frequency axes with a draggable amber peak; 2D drag
places the peak in both axes at once.
Contract (everything a consumer needs; no page globals touched):
opts: fc (initial center frequency Hz, default 1200); gain (initial dB,
default 6.5); onChange([fc, gain], 'f · ±g dB') fires on set and drag.
handle: el, get() ([fc, gain]), set(f, g) — fc clamps 32..16000 Hz, gain
clamps ±12 dB; draws the bell curve and moves the peak handle. A
2D drag on the plot places the peak in both axes at once.
SVG-built: styling is inline attributes plus the shared .rsvg stage block of
DUPRE_CSS; the amber glow filter registers in the shared defs plate. */
DUPRE.responseGraph = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 190, 110);
const RG = { x0: 30, x1: 182, y0: 8, y1: 86, fLo: Math.log10(32), fHi: Math.log10(16000), db: 12 };
def('avGlow', d => {
const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
});
svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 108, rx: 5, fill: '#0d1410', stroke: '#22301f', 'stroke-width': 2 });
const FREQS = [32, 64, 128, 250, 500, 1000, 2000, 4000, 8000, 16000];
const xOf = f => RG.x0 + (Math.log10(f) - RG.fLo) / (RG.fHi - RG.fLo) * (RG.x1 - RG.x0);
const yOf = db => RG.y0 + (RG.db - db) / (2 * RG.db) * (RG.y1 - RG.y0);
FREQS.forEach(f => {
const x = xOf(f);
svgEl(s, 'line', { x1: x, y1: RG.y0, x2: x, y2: RG.y1, stroke: '#22301f', 'stroke-width': .6 });
svgEl(s, 'text', {
x, y: RG.y1 + 9, 'text-anchor': 'middle', 'font-size': 4.8, 'font-family': 'var(--mono)',
fill: 'var(--steel)'
}).textContent = f < 1000 ? f : (f / 1000) + 'k';
});
for (const db of [12, 6, 0, -6, -12]) {
const y = yOf(db);
svgEl(s, 'line', { x1: RG.x0, y1: y, x2: RG.x1, y2: y, stroke: '#22301f', 'stroke-width': db === 0 ? 1 : .6 });
svgEl(s, 'text', {
x: RG.x0 - 4, y: y + 2, 'text-anchor': 'end', 'font-size': 4.8, 'font-family': 'var(--mono)',
fill: 'var(--steel)'
}).textContent = (db > 0 ? '+' : '') + db;
}
const glowLine = svgEl(s, 'polyline', { points: '', fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' });
const curve = svgEl(s, 'polyline', { points: '', fill: 'none', stroke: 'var(--gold)', 'stroke-width': 1.6 });
const handle = svgEl(s, 'circle', { r: 4, fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': 1 });
handle.style.cursor = 'move';
let fc, gain;
const set = (f, g) => {
fc = Math.max(32, Math.min(16000, f)); gain = Math.max(-12, Math.min(12, g));
const sigma = 0.28; let pts = '';
for (let i = 0; i <= 76; i++) {
const lf = RG.fLo + i / 76 * (RG.fHi - RG.fLo);
const db = gain * Math.exp(-Math.pow(lf - Math.log10(fc), 2) / (2 * sigma * sigma));
pts += `${xOf(Math.pow(10, lf)).toFixed(1)},${yOf(db).toFixed(1)} `;
}
curve.setAttribute('points', pts.trim()); glowLine.setAttribute('points', pts.trim());
handle.setAttribute('cx', xOf(fc)); handle.setAttribute('cy', yOf(gain));
const fLbl = fc < 1000 ? Math.round(fc) + ' Hz' : (fc / 1000).toFixed(1) + ' kHz';
onChange([fc, gain], `${fLbl} · ${gain >= 0 ? '+' : ''}${gain.toFixed(1)} dB`);
};
/* 2D drag: the plot places the peak in both axes at once */
s.style.touchAction = 'none';
s.addEventListener('pointerdown', e => {
s.setPointerCapture(e.pointerId);
const move = ev => {
const r = s.getBoundingClientRect(), sx = 190 / r.width, sy = 110 / r.height;
const px = (ev.clientX - r.left) * sx, py = (ev.clientY - r.top) * sy;
const lf = RG.fLo + Math.max(0, Math.min(1, (px - RG.x0) / (RG.x1 - RG.x0))) * (RG.fHi - RG.fLo);
const g = RG.db - Math.max(0, Math.min(1, (py - RG.y0) / (RG.y1 - RG.y0))) * 2 * RG.db;
set(Math.pow(10, lf), g);
};
move(e);
const up = () => { s.removeEventListener('pointermove', move); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); };
s.addEventListener('pointermove', move); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up);
e.preventDefault();
});
set(opts.fc !== undefined ? opts.fc : 1200, opts.gain !== undefined ? opts.gain : 6.5);
return { el: s, get: () => [fc, gain], set };
};
/* R30 telegraph indicator — the pointer names the active state on labeled
sectors, engine-telegraph style; click steps the state */
DUPRE.telegraphIndicator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const states = opts.states || ['OK', 'TEST', 'BUSY', 'STOP', 'ATTN', 'FAULT'];
const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55, N = states.length;
svgEl(s, 'circle', { cx, cy, r: 50, fill: '#171412', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 43, fill: 'var(--cream)', stroke: '#b7b29a', 'stroke-width': 1 });
states.forEach((st, i) => {
const a = i * 360 / N - 90 + 180 / N;
const [lx1, ly1] = polar(cx, cy, 9, i * 360 / N), [lx2, ly2] = polar(cx, cy, 43, i * 360 / N);
svgEl(s, 'line', { x1: lx1, y1: ly1, x2: lx2, y2: ly2, stroke: '#2b2418', 'stroke-width': 1 });
const g = svgEl(s, 'g', { transform: `rotate(${a + 90},${cx},${cy})` });
svgEl(g, 'text', {
x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 7, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#2b2418'
}).textContent = st;
});
const ptr = svgEl(s, 'g', {}); ptr.style.transition = 'transform .18s'; ptr.style.transformOrigin = `${cx}px ${cy}px`;
svgEl(ptr, 'polygon', { points: `${cx - 2.5},${cy} ${cx},${cy - 38} ${cx + 2.5},${cy}`, fill: 'var(--fail)', stroke: '#7a2a1a', 'stroke-width': .5 });
svgEl(ptr, 'rect', { x: cx + 1, y: cy - 16, width: 10, height: 7, rx: 1, fill: 'var(--gold)', stroke: '#7d5c16', 'stroke-width': .6 });
svgEl(s, 'circle', { cx, cy, r: 8, fill: '#171412', stroke: '#000' });
let si;
const set = i => {
si = ((i % N) + N) % N;
ptr.style.transform = `rotate(${si * 360 / N + 180 / N}deg)`;
onChange(si, states[si]);
};
s.addEventListener('click', () => set(si + 1));
set(opts.index || 0);
return { el: s, get: () => si, set };
};
/* R31 radar sweep — the beam circles the bearing ring on the builder's own
clock (reduced-motion gated), contacts bloom as it passes; click marks the bearing */
DUPRE.radarSweep = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const contacts = opts.contacts || [[60, .55], [205, .7], [318, .4]];
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
const clipId = uid('radarClip');
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'circle', { cx, cy, r: 46 });
svgEl(s, 'circle', { cx, cy, r: 62, fill: '#1a1714', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 52, fill: 'var(--scr-bg1,#120b05)', stroke: 'var(--scr-brd,#3a2a12)', 'stroke-width': 1 });
for (let d = 0; d < 360; d += 30) {
const [x1, y1] = polar(cx, cy, 51, d), [x2, y2] = polar(cx, cy, 47, d);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--scr-ink,var(--gold))', 'stroke-width': 1, opacity: .8 });
const g = svgEl(s, 'g', { transform: `rotate(${d},${cx},${cy})` });
svgEl(g, 'text', {
x: cx, y: cy - 47.5, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: 'var(--scr-ink,var(--gold))', opacity: .9
}).textContent = String(d).padStart(3, '0');
}
for (let d = 0; d < 360; d += 10) {
if (d % 30 === 0) continue;
const [x1, y1] = polar(cx, cy, 51, d), [x2, y2] = polar(cx, cy, 49, d);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--scr-ink,var(--gold))', 'stroke-width': .6, opacity: .5 });
}
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
svgEl(g, 'circle', { cx, cy, r: 46, fill: 'var(--scr-bg2,#0d0803)' });
for (const r of [15, 30, 45])
svgEl(g, 'circle', { cx, cy, r, fill: 'none', stroke: 'var(--scr-ink,var(--gold))', 'stroke-opacity': .18, 'stroke-width': .7 });
const sweep = svgEl(g, 'g', {});
for (let t = 0; t < 5; t++) {
const a0 = -14 + t * 2.8, a1 = a0 + 2.8;
const [x1, y1] = polar(cx, cy, 46, a0), [x2, y2] = polar(cx, cy, 46, a1);
svgEl(sweep, 'path', {
d: `M ${cx} ${cy} L ${x1} ${y1} A 46 46 0 0 1 ${x2} ${y2} Z`,
fill: 'var(--scr-ink,var(--gold))', 'fill-opacity': (0.04 + t * 0.045).toFixed(3)
});
}
svgEl(sweep, 'line', {
x1: cx, y1: cy, x2: cx, y2: cy - 46, stroke: 'var(--scr-hi,var(--gold-hi))', 'stroke-width': 1.4,
transform: `rotate(0,${cx},${cy})`, opacity: .95
});
const blips = contacts.map(([bd, rf]) => {
const [x, y] = polar(cx, cy, 46 * rf, bd);
return svgEl(g, 'circle', { cx: x, cy: y, r: 2.2, fill: 'var(--scr-hi,var(--gold-hi))', opacity: 0 });
});
svgEl(s, 'circle', { cx, cy, r: 3, fill: 'var(--scr-ink,var(--gold))', opacity: .8 });
const mark = svgEl(s, 'text', {
x: cx, y: 124, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
});
mark.textContent = 'BEARING —';
let a = opts.bearing !== undefined ? opts.bearing : 210;
const tick = () => {
a = (a + 2.6) % 360;
sweep.setAttribute('transform', `rotate(${a},${cx},${cy})`);
contacts.forEach(([bd], i) => {
const d = (a - bd + 360) % 360; /* degrees since beam passed */
blips[i].setAttribute('opacity', d < 200 ? Math.max(0, .95 - d / 200) : 0);
});
mark.textContent = mark.textContent.startsWith('MARK') ? mark.textContent :
'BEARING ' + String(Math.round(a) % 360).padStart(3, '0');
};
s.addEventListener('click', () => {
const b = String(Math.round(a) % 360).padStart(3, '0');
mark.textContent = 'MARK ' + b;
onChange(a, 'mark ' + b);
});
tick(); onChange(a, 'sweeping');
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 50);
return { el: s, get: () => a, tick };
};
/* R35 day-date disc calendar — coaxial printed discs rotate so today reads
under the fixed hand; starts on today; click rolls midnight forward.
Contract (everything a consumer needs; no page globals touched):
opts: date (1-31) and day (0-6, SUN=0), both defaulting to today;
onChange([date, day], 'DAY d') fires on every set, including the
initial paint.
handle: el, get() ([date, day]), set(d, w) — rotates both discs so the
commanded pair reads under the fixed hand; click advances date
and weekday together like a midnight rollover.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.dayDateCal = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65, HAND = 135;
gradDef('ddFace', 'radialGradient', { cx: '46%', cy: '40%', r: '75%' }, [['0', '#efe9da'], ['1', '#cfc8b6']]);
/* movement plate + white calendar disc */
svgEl(s, 'circle', { cx, cy, r: 62, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
for (let k = 0; k < 5; k++) {
const [x, y] = polar(cx, cy, 58, 20 + k * 72);
svgEl(s, 'circle', { cx: x, cy: y, r: 1.8, fill: '#060505' });
}
svgEl(s, 'circle', { cx, cy, r: 54, fill: 'url(#ddFace)', stroke: '#8f897b', 'stroke-width': .8 });
/* date ring: 31 radial numerals on its own group */
const dateG = svgEl(s, 'g', {});
for (let d = 1; d <= 31; d++) {
const a = (d - 1) * (360 / 31);
const g = svgEl(dateG, 'g', { transform: `rotate(${a},${cx},${cy})` });
svgEl(g, 'text', {
x: cx, y: cy - 46, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#14110e'
}).textContent = String(d);
}
/* inner weekday disc: 7 names twice around, separated by a hairline ring */
svgEl(s, 'circle', { cx, cy, r: 38, fill: 'none', stroke: '#8f897b', 'stroke-width': .7 });
const dayG = svgEl(s, 'g', {});
for (let i = 0; i < 14; i++) {
const a = i * (360 / 14);
const g = svgEl(dayG, 'g', { transform: `rotate(${a},${cx},${cy})` });
svgEl(g, 'text', {
x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 6.8, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#14110e'
}).textContent = DAYS[i % 7];
}
/* fixed yellow hand from hub toward the read index, and the hub */
const [hx, hy] = polar(cx, cy, 44, HAND);
svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: '#e8cf4a', 'stroke-width': 3, 'stroke-linecap': 'round' });
svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 });
svgEl(s, 'circle', { cx, cy, r: 6, fill: '#b8b2a4', stroke: '#5c574c', 'stroke-width': 1.2 });
svgEl(s, 'circle', { cx, cy, r: 2.4, fill: '#7a7466' });
s.style.cursor = 'pointer';
let date, day;
const set = (d, w) => {
date = d; day = w;
dateG.setAttribute('transform', `rotate(${(HAND - (d - 1) * (360 / 31)).toFixed(2)},${cx},${cy})`);
dayG.setAttribute('transform', `rotate(${(HAND - w * (360 / 14)).toFixed(2)},${cx},${cy})`);
onChange([d, w], DAYS[w] + ' ' + d);
};
s.addEventListener('click', () => set(date % 31 + 1, (day + 1) % 7));
const now = new Date();
set(opts.date !== undefined ? opts.date : now.getDate(),
opts.day !== undefined ? opts.day : now.getDay());
return { el: s, get: () => [date, day], set };
};
/* R36 LED dot matrix — 8x8 paintable bitmap behind a tinted window; click dots
to paint; starts on the reference K */
DUPRE.dotMatrix = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const N = 8, STEP = 11, X0 = 10.5, Y0 = 10.5;
const glyph = opts.glyph || ['10000100', '10001000', '10010000', '11100000', '10100000', '10010000', '10001000', '10000100'];
const s = stageSvg(host, 'rsvg press', 108, 108);
svgEl(s, 'rect', { x: 2, y: 2, width: 104, height: 104, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 6, y: 6, width: 96, height: 96, rx: 5, fill: '#0d0505', stroke: '#241010', 'stroke-width': 1 });
let lit = 0;
const paint = (d, on) => {
d.on = on;
d.el.setAttribute('fill', on ? '#ff4a30' : '#2a1210');
d.el.setAttribute('filter', on ? 'drop-shadow(0 0 3.5px rgba(255,74,48,.85))' : 'none');
};
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2, cy: Y0 + r * STEP + STEP / 2, r: 4.1, fill: '#2a1210' });
el.style.cursor = 'pointer';
const d = { el, on: false };
paint(d, glyph[r][c] === '1'); if (d.on) lit++;
el.addEventListener('click', () => {
paint(d, !d.on); lit += d.on ? 1 : -1;
onChange(lit, lit + ' / 64 lit');
});
}
onChange(lit, lit + ' / 64 lit');
return { el: s, get: () => lit };
};
/* R45 flip-disc tile array — bistable mechanical pixels; scaleX flip with the
color swap at the midpoint; click a disc to flip it */
DUPRE.flipDisc = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const COLS = 7, ROWS = 5, STEP = 14, X0 = 13, Y0 = 13;
const glyph = opts.glyph || ['0000100', '0000010', '1111111', '0000010', '0000100']; /* arrow right */
const s = stageSvg(host, 'rsvg press', 120, 92);
svgEl(s, 'rect', { x: 2, y: 2, width: 116, height: 88, rx: 7, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
let lit = 0;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2 - 2, cy: Y0 + r * STEP + STEP / 2 - 2, r: 5.6, stroke: '#060505', 'stroke-width': .8 });
el.style.transformBox = 'fill-box'; el.style.transformOrigin = 'center'; el.style.transition = 'transform .11s ease-in';
el.style.cursor = 'pointer';
const d = { el, on: glyph[r][c] === '1' };
el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); if (d.on) lit++;
el.addEventListener('click', () => {
d.on = !d.on; lit += d.on ? 1 : -1;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); }
else {
el.style.transform = 'scaleX(0)';
setTimeout(() => { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); el.style.transform = 'scaleX(1)'; }, 115);
}
onChange(lit, lit + ' discs showing');
});
}
onChange(lit, lit + ' discs showing');
return { el: s, get: () => lit };
};
/* R46 dekatron counting ring — each click pulses the neon glow one cathode
around; the wrap flashes the carry dot */
DUPRE.dekatron = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 120, 120), cx = 60, cy = 60;
svgEl(s, 'circle', { cx, cy, r: 52, fill: '#171310', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 46, fill: '#0d0a08', stroke: '#2a221c', 'stroke-width': 1 });
svgEl(s, 'ellipse', { cx: cx - 14, cy: cy - 18, rx: 20, ry: 11, fill: 'rgba(255,255,255,.05)', transform: `rotate(-24,${cx - 14},${cy - 18})` });
const dots = [];
for (let i = 0; i < 10; i++) {
const [x, y] = polar(cx, cy, 34, i * 36);
svgEl(s, 'text', {
x: polar(cx, cy, 43, i * 36)[0], y: polar(cx, cy, 43, i * 36)[1] + 2, 'text-anchor': 'middle', 'font-size': 5.2,
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = String(i);
dots.push(svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: '#3a2a20' }));
}
const carry = svgEl(s, 'circle', { cx, cy: cy - 6, r: 3, fill: '#2a1c14' });
svgEl(s, 'text', {
x: cx, y: cy + 8, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.12em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'CARRY';
let pos = opts.count || 0, carries = 0;
const draw = () => {
dots.forEach((d, i) => {
const on = i === pos;
d.setAttribute('fill', on ? '#ff9a4c' : '#3a2a20');
d.setAttribute('filter', on ? 'drop-shadow(0 0 5px rgba(255,154,76,.9))' : 'none');
});
onChange(pos, 'COUNT ' + pos + (carries ? ' · CARRY x' + carries : ''));
};
s.style.cursor = 'pointer';
s.addEventListener('click', () => {
pos = (pos + 1) % 10;
if (pos === 0) {
carries++; carry.setAttribute('fill', '#ff9a4c');
carry.setAttribute('filter', 'drop-shadow(0 0 4px rgba(255,154,76,.9))');
setTimeout(() => { carry.setAttribute('fill', '#2a1c14'); carry.removeAttribute('filter'); }, 220);
}
draw();
});
draw();
return { el: s, get: () => pos };
};
/* R47 landing gear indicator — three greens or nothing; the lever cycles
down → transit (amber pulse) → up */
DUPRE.gearIndicator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 140, 110);
svgEl(s, 'rect', { x: 2, y: 2, width: 136, height: 106, rx: 9, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
const POS = [['NOSE', 48, 28], ['L', 24, 74], ['R', 72, 74]];
const lamps = {};
POS.forEach(([n, x, y]) => {
svgEl(s, 'circle', { cx: x, cy: y, r: 9, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1.5 });
lamps[n] = svgEl(s, 'circle', { cx: x, cy: y, r: 6.2, fill: '#16240f' });
svgEl(s, 'text', {
x, y: y + 18, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = n;
});
/* gear lever: slot + wheel-shaped knob */
svgEl(s, 'rect', { x: 106, y: 22, width: 8, height: 66, rx: 4, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 });
const lever = svgEl(s, 'g', {});
svgEl(lever, 'rect', { x: 107.5, y: 36, width: 5, height: 26, fill: '#8f8a7e' });
svgEl(lever, 'circle', { cx: 110, cy: 32, r: 8, fill: '#26221c', stroke: '#4a463e', 'stroke-width': 2.4 });
svgEl(lever, 'circle', { cx: 110, cy: 32, r: 3, fill: '#4a463e' });
svgEl(s, 'text', {
x: 110, y: 100, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'GEAR';
let state = opts.state || 'down', busy = false;
const paint = () => {
const c = state === 'down' ? 'var(--pass)' : state === 'transit' ? 'var(--amber-warn)' : '#16240f';
for (const n of Object.keys(lamps)) {
lamps[n].setAttribute('fill', c);
lamps[n].setAttribute('filter', state === 'down' ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' :
state === 'transit' ? 'drop-shadow(0 0 4px rgba(var(--glow-lo),.7))' : 'none');
lamps[n].style.animation = state === 'transit' ? 'pulse var(--pulse-rate) ease-in-out infinite' : 'none';
}
lever.setAttribute('transform', state === 'up' ? 'translate(0,-8)' : state === 'transit' ? 'translate(0,-4)' : '');
onChange(state, state === 'down' ? 'GEAR DOWN · 3 GREEN' : state === 'up' ? 'GEAR UP' : 'IN TRANSIT');
};
s.style.cursor = 'pointer';
s.addEventListener('click', () => {
if (busy) return;
const target = state === 'down' ? 'up' : 'down';
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { state = target; paint(); return; }
busy = true; state = 'transit'; paint();
setTimeout(() => { state = target; busy = false; paint(); }, 1500);
});
paint();
return { el: s, get: () => state };
};
/* R52 blinkenlights front panel — address/data lamps ripple with a live
pseudo-PC (builder-owned clock, reduced-motion gated) that folds in the
switch register; flip the SR bits and the pattern changes */
DUPRE.blinkenlights = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 170, 100);
svgEl(s, 'rect', { x: 2, y: 2, width: 166, height: 96, rx: 8, fill: '#3a1420', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 8, y: 8, width: 154, height: 62, rx: 5, fill: '#14090e' });
const mkRow = (y, lbl) => {
const row = [];
svgEl(s, 'text', {
x: 12, y: y + 2.6, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)',
fill: '#b08a96'
}).textContent = lbl;
for (let i = 0; i < 12; i++) row.push(svgEl(s, 'circle', { cx: 46 + i * 10, cy: y, r: 3, fill: '#2a1210' }));
return row;
};
const addr = mkRow(24, 'ADDR'), data = mkRow(46, 'DATA');
const paint = (row, val) => row.forEach((d, i) => {
const on = (val >> (11 - i)) & 1;
d.setAttribute('fill', on ? '#ffb43a' : '#2a1210');
d.setAttribute('filter', on ? 'drop-shadow(0 0 3px rgba(255,180,58,.8))' : 'none');
});
/* switch register: 8 mini toggles */
let sr = opts.sr !== undefined ? opts.sr : 0b10100101;
const toggles = [];
for (let i = 0; i < 8; i++) {
const x = 46 + i * 14;
svgEl(s, 'rect', { x: x - 3, y: 76, width: 6, height: 16, rx: 3, fill: '#0d0b09', stroke: '#4a2432', 'stroke-width': 1 });
const t = svgEl(s, 'circle', { cx: x, cy: 80, r: 4, fill: '#8f8a7e', stroke: '#3c382f', 'stroke-width': 1 });
t.style.cursor = 'pointer';
const hit = svgEl(s, 'rect', { x: x - 7, y: 72, width: 14, height: 24, fill: 'transparent' });
hit.style.cursor = 'pointer';
hit.addEventListener('click', () => { sr ^= (1 << (7 - i)); draw(); });
toggles.push(t);
}
svgEl(s, 'text', {
x: 12, y: 83, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)',
fill: '#b08a96'
}).textContent = 'SR';
let pc = 0o1234;
const draw = () => {
toggles.forEach((t, i) => t.setAttribute('cy', ((sr >> (7 - i)) & 1) ? 76 : 84));
onChange(sr, 'SR ' + sr.toString(8).padStart(3, '0') + ' (octal)');
};
const tick = () => { pc = ((pc * 5 + sr + 1) & 0xFFF); paint(addr, pc); paint(data, (pc ^ (pc << 3) ^ sr) & 0xFFF); };
draw(); tick();
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 250);
return { el: s, get: () => sr, tick };
};
/* ---- instrument CSS: injected once, grows as builders move in ---- */
/* instrument-internal CSS (moved from the gallery