aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/widgets.js
diff options
context:
space:
mode:
Diffstat (limited to 'docs/prototypes/widgets.js')
-rw-r--r--docs/prototypes/widgets.js6142
1 files changed, 6142 insertions, 0 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
new file mode 100644
index 0000000..57ad33e
--- /dev/null
+++ b/docs/prototypes/widgets.js
@@ -0,0 +1,6142 @@
+/* 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 = `<svg class="seg7 ${cls || ''}" viewBox="0 0 24 44">`;
+ for (const k of 'abcdefg') {
+ const on = lit.includes(k);
+ const col = on ? (cls === 'red' ? 'var(--sevred)' : 'var(--sevgrn)') : 'var(--sevoff)';
+ s += `<polygon points="${SEGPTS[k]}" fill="${col}"/>`;
+ }
+ return s + '</svg>';
+}
+
+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.<name>.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 · <buf>', 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 = '<div class="dupre-slot"><div class="dupre-fill"></div></div><div class="dupre-cap"></div>';
+ 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 = '<div class="dupre-slot"><div class="dupre-fill"></div></div><div class="dupre-cap"></div>';
+ 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 = '<span class="dupre-ind"></span>';
+ 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 = `<span class="dupre-lamp"></span><span class="dupre-who"><b></b></span><span class="dupre-what"></span>`;
+ 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 = '<span class="dupre-ind"></span>'; rs.appendChild(k);
+ host.appendChild(rs);
+ const ind = k.querySelector('.dupre-ind');
+ let idx;
+ const set = i => {
+ idx = ((i % values.length) + values.length) % values.length;
+ 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
+ ? `<span class="dupre-tick" style="left:${s.x}px"></span><span class="dupre-mk" style="left:${s.x}px">${s.v}</span>`
+ : `<span class="dupre-tick dupre-mn" style="left:${s.x}px"></span>`);
+ });
+ const ndl = document.createElement('span'); ndl.className = '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 = `<span class="dupre-half dupre-top">${opts.onLabel || 'ON'}</span><span class="dupre-half dupre-bot">${opts.offLabel || 'OFF'}</span>`;
+ host.appendChild(r);
+ let on;
+ const set = v => { on = !!v; r.classList.toggle('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 = `<div class="dupre-reels"><span class="dupre-reel dupre-spin"><i></i><i></i><i></i></span><span class="dupre-reel dupre-spin"><i></i><i></i><i></i></span></div><div class="dupre-transport"></div>`;
+ const bar = wrap.querySelector('.dupre-transport');
+ const reels = wrap.querySelectorAll('.dupre-reel');
+ const btns = {};
+ for (const m of Object.keys(MODES)) {
+ const b = document.createElement('button'); b.className = '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 = `<span class="dupre-outer"><span class="dupre-tick" style="transform:rotate(-120deg)"></span><span class="dupre-tick" style="transform:rotate(120deg)"></span></span><span class="dupre-inner"><span class="dupre-ind"></span></span>`;
+ host.appendChild(dk);
+ const o = dk.querySelector('.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 = '<span class="dupre-ind"></span>'; 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 = '<span class="dupre-bit"></span><span class="dupre-barrel"></span>';
+ kl.appendChild(body); host.appendChild(kl);
+ const bit = body.querySelector('.dupre-bit');
+ let idx;
+ const set = i => {
+ idx = ((i % items.length) + items.length) % items.length;
+ 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 = `<div class="dupre-slot"></div><div class="dupre-detent"></div><span class="dupre-end" style="left:0">${opts.aLabel || 'A'}</span><span class="dupre-end" style="right:0">${opts.bLabel || 'B'}</span><div class="dupre-cap"></div>`;
+ host.appendChild(xf);
+ const cap = xf.querySelector('.dupre-cap');
+ let pct;
+ const set = p => {
+ pct = Math.max(0, Math.min(100, p));
+ 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 = '<span class="dupre-thumbwheel"></span><span class="dupre-win"></span>';
+ host.appendChild(w);
+ const win = w.querySelector('.dupre-win');
+ let val;
+ const set = v => {
+ val = Math.round(v);
+ const s = ((val % 100) + 100) % 100;
+ 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 = '<i></i>'; 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 = `<span class="dupre-shuttle" style="--sh:40deg"></span><span class="dupre-inner"><span class="dupre-dimple"></span></span>`;
+ host.appendChild(j);
+ const inner = j.querySelector('.dupre-inner');
+ let val;
+ const set = v => {
+ val = v;
+ 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 · <code>' (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 =
+ `<div class="dupre-dsky-lamps">${LAMPS.map(l => `<div class="dupre-dsky-sl" data-l="${l}">${l}</div>`).join('')}</div>` +
+ `<div class="dupre-dsky-right"><div class="dupre-dsky-wins">` +
+ `<div class="dupre-dsky-win" data-w="prog"><span class="dupre-dsky-wl">PROG</span><span class="dupre-dsky-wd"></span></div>` +
+ `<div class="dupre-dsky-win" data-w="verb"><span class="dupre-dsky-wl">VERB</span><span class="dupre-dsky-wd"></span></div>` +
+ `<div class="dupre-dsky-win" data-w="noun"><span class="dupre-dsky-wl">NOUN</span><span class="dupre-dsky-wd"></span></div>` +
+ `</div><div class="dupre-dsky-pad"></div></div>`;
+ 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 + '<span class="dupre-vk-bar"></span>'; 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 = `<div class="dupre-dial"><div class="dupre-arc"></div>
+ <div class="dupre-tk" style="transform:rotate(-60deg)"></div><div class="dupre-tk" style="transform:rotate(0)"></div><div class="dupre-tk" style="transform:rotate(60deg)"></div>
+ <div class="dupre-ndl"></div><div class="dupre-hub"></div></div><div class="dupre-gv"><span>0</span>%</div>`;
+ 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 = `<div class="dupre-vurow"><span class="dupre-ch">L</span><span class="dupre-vubar"></span></div>
+ <div class="dupre-vurow"><span class="dupre-ch">R</span><span class="dupre-vubar"></span></div>`;
+ 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 = '<i></i><i></i><i></i><i></i>';
+ 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 = '<i></i><i></i><i></i><i></i>';
+ 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 = '<span></span>';
+ 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 = '<b></b>';
+ 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 = '<svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg>';
+ 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 = '<svg viewBox="0 0 170 38" preserveAspectRatio="none"><path fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg>';
+ 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 = '<span class="dupre-grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>';
+ 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 = `<div class="dupre-face"><div class="dupre-crossm-arc"></div><div class="dupre-fwd"></div><div class="dupre-rfl"></div></div>
+ <div class="dupre-lbl"><span>FWD</span><span>RFL</span></div>`;
+ 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 = `<div class="dupre-scale"><span>90</span><span>60</span><span>30</span></div>
+ <div class="dupre-wrapcol"><div class="dupre-thermo-tube"><div class="dupre-thermo-fill"></div></div><div class="dupre-bulb"></div></div>`;
+ 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 = `<svg viewBox="0 0 82 82"><path d="M14 62 A34 34 0 0 1 68 62" fill="none" stroke="#2c2f32" stroke-width="3"/>
+ <path d="M52 22 A34 34 0 0 1 68 62" fill="none" stroke="#cb6b4d" stroke-width="3"/></svg>
+ <div class="dupre-bourdon-ndl"></div><div class="dupre-bourdon-hub"></div><div class="dupre-bourdon-cap">PSI</div>`;
+ 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 = '<span class="dupre-rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline/></svg><span class="dupre-pen" style="top:31px"></span>';
+ 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 = `<div class="dupre-corr-face"><div class="dupre-corr-arc"></div><div class="dupre-zero"></div><div class="dupre-corr-ndl"></div></div>
+ <div class="dupre-corr-lbl"><span>−1</span><span>0</span><span>+1</span></div>`;
+ 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 = '<div class="dupre-cells"></div><span class="dupre-nub"></span>';
+ 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 = '<div class="dupre-readout"></div><span class="dupre-unit"></span>';
+ 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 = `<span class="dupre-lamp${s[0] ? ' dupre-' + s[0] : ''}"></span><span><b>${s[1]}</b><span class="dupre-ev">${s[2]}</span></span>`;
+ 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 = '<b></b>'; 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 = '<b class="fh ftn"></b><b class="fh fbc"></b><b class="fh ftc"></b><b class="fh fbn"></b>';
+ 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]) + '<span class="dupre-colon"><i></i><i></i></span>' + 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 = '<span class="dupre-txt"></span><span class="dupre-mesh"></span>';
+ 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', '<span class="dupre-hh"></span><span class="dupre-mh"></span><span class="dupre-sh"></span><span class="dupre-pin"></span>');
+ 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 <style> block; gallery keeps page furniture) */
+const DUPRE_CSS = `
+/* ---- shared primitives ---- */
+.dupre-lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.dupre-lamp.dupre-gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)}
+.dupre-lamp.dupre-red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.dupre-lamp.dupre-off{background:var(--wash);box-shadow:none}
+.dupre-lamp.dupre-busy{background:var(--gold);animation:pulse var(--pulse-rate) ease-in-out infinite}
+/* standard pulse: 1s ease-in-out — the norm for every pulsing / flashing element */
+@keyframes pulse{50%{opacity:.25}}
+
+/* slide toggle — after the iPod-hold-switch pill: dome thumb, revealed state engraving.
+ Flex layout with fixed px only: absolute insets and stretched widths misrender under stage zoom. */
+.switch{display:inline-flex;vertical-align:middle;align-items:center;justify-content:space-between;
+ width:52px;height:23px;border-radius:12px;padding:2px 7px 2px 2px;cursor:pointer;
+ background:var(--sw-off-bg,linear-gradient(180deg,#15130f,#242019));
+ border:1px solid var(--sw-off-brd,var(--slate));
+ box-shadow:inset 0 1.5px 3px rgba(0,0,0,.55)}
+.switch::before{content:"OFF";order:2;font-size:7px;letter-spacing:.06em;font-weight:700;
+ font-family:var(--mono);color:var(--sw-off-ink,var(--cream))}
+.switch::after{content:"";order:1;width:17px;height:17px;border-radius:50%;
+ background:var(--sw-thumb,radial-gradient(circle at 35% 28%,#f6f2e8,#b0aa9a));
+ box-shadow:0 1px 2.5px rgba(0,0,0,.65),inset 0 1px 0 rgba(255,255,255,.25)}
+.switch.on{background:var(--sw-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold)));
+ border-color:var(--sw-on-brd,var(--gold-hi));
+ padding:2px 2px 2px 7px;box-shadow:inset 0 1.5px 3px rgba(0,0,0,.3)}
+.switch.on::before{content:"ON";order:1;color:var(--sw-on-ink,var(--panel))}
+.switch.on::after{order:2}
+.switch.red{background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail);padding:2px 2px 2px 7px}
+.switch.red::before{content:"OFF";order:1;color:var(--cream)}
+.switch.red::after{order:2}
+
+/* keypad / index-plate focus — both take keys, so each must show when it is
+ the one listening: an unlit focus state means typing vanishes into a card
+ you thought was live.
+ :focus, not :focus-visible — Chrome won't match :focus-visible on a
+ mouse-driven focus of a non-text element, and clicking the plate IS how it
+ gets focus here, so the ring would have appeared only when tabbed to. */
+.dupre-kp-pad,.dupre-ix-pad{outline:none}
+.dupre-kp-pad:focus,.dupre-ix-pad:focus{outline:2px solid var(--gold-hi);outline-offset:3px;border-radius:9px}
+
+/* console keys */
+.dupre-key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:8px 12px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.dupre-key:hover{color:var(--gold);border-color:var(--gold)}
+.dupre-key:active{transform:translateY(1px)}
+.dupre-key.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi);font-weight:700}
+.dupre-key.dupre-green{color:var(--cream);background:linear-gradient(180deg,#9cbf5e,var(--pass));border-color:var(--pass);font-weight:700}
+.dupre-key.dupre-red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+
+/* chip toggle */
+.dupre-chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px}
+.dupre-chip.dupre-on{color:var(--chip-on,var(--gold));border-color:var(--chip-on,var(--gold))}
+
+.dupre-badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px}
+.dupre-badge.dupre-red{background:var(--fail);color:var(--cream)}
+.dupre-badge.dupre-ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+
+/* fader */
+.dupre-fader{width:150px;height:16px;position:relative;cursor:pointer;touch-action:none}
+.dupre-fader .dupre-slot{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.dupre-fader .dupre-fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold))}
+.dupre-fader .dupre-cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px;
+ background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)}
+/* vertical fader */
+.dupre-vfader{width:16px;height:64px;position:relative;cursor:pointer;touch-action:none}
+.dupre-vfader .dupre-slot{position:absolute;left:6px;top:0;bottom:0;width:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.dupre-vfader .dupre-fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,var(--amber-grad-bot),var(--gold))}
+.dupre-vfader .dupre-cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px;
+ background:linear-gradient(90deg,var(--amber-grad-mid),var(--amber-grad-top));border:1px solid var(--amber-edge)}
+
+/* rotary knob */
+.dupre-knob{width:52px;height:52px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none;
+ background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c;
+ box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
+.dupre-knob .dupre-ind{position:absolute;left:50%;top:5px;width:2px;height:16px;background:var(--gold-hi);
+ margin-left:-1px;transform-origin:50% 21px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)}
+
+/* needle gauge */
+.dupre-gauge{width:96px;cursor:ns-resize;touch-action:none}
+.dupre-gauge .dupre-dial{position:relative;height:48px;overflow:hidden}
+.dupre-gauge .dupre-arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%}
+.dupre-gauge .dupre-tk{position:absolute;left:50%;top:1px;width:1.5px;height:8px;margin-left:-.75px;background:var(--steel);transform-origin:50% 47px}
+.dupre-gauge .dupre-ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(0deg);border-radius:2px;box-shadow:0 0 6px rgba(var(--glow-hi),.5);
+ transition:transform .12s cubic-bezier(.3,1.3,.5,1)}
+.dupre-gauge .dupre-hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)}
+.dupre-gauge .dupre-gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums}
+
+/* segmented VU / LED bar */
+.dupre-vu{width:170px;display:flex;flex-direction:column;gap:5px}
+.dupre-vurow{display:flex;align-items:center;gap:7px}
+.dupre-vurow .dupre-ch{color:var(--steel);font-size:.6rem;width:8px}
+.dupre-vubar{flex:1;display:flex;gap:2px;height:9px}
+.dupre-vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3}
+.dupre-vubar i.dupre-on{opacity:1;background:var(--pass)}.dupre-vubar i.dupre-hot{opacity:1;background:var(--gold)}
+.dupre-vubar i.dupre-clip{opacity:1;background:var(--fail)}.dupre-vubar i.dupre-peak{outline:1px solid var(--gold-hi);outline-offset:-1px}
+
+/* mini 4-bar signal */
+.dupre-sig{display:flex;align-items:flex-end;gap:2px;height:18px}
+.dupre-sig i{width:4px;background:var(--wash);border-radius:1px}
+.dupre-sig i:nth-child(1){height:5px}.dupre-sig i:nth-child(2){height:9px}.dupre-sig i:nth-child(3){height:13px}.dupre-sig i:nth-child(4){height:17px}
+.dupre-sig i.dupre-on{background:var(--pass)}.dupre-sig i.dupre-hot{background:var(--gold)}.dupre-sig i.dupre-clip{background:var(--fail)}
+
+/* signal ladder (wifi bars) */
+.dupre-ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px;cursor:pointer}
+.dupre-ladder i{width:5px;background:var(--wash);border-radius:1px}
+.dupre-ladder i:nth-child(1){height:6px}.dupre-ladder i:nth-child(2){height:10px}
+.dupre-ladder i:nth-child(3){height:14px}.dupre-ladder i:nth-child(4){height:18px}
+
+/* linear progress / fuel bar */
+.dupre-bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none}
+.dupre-bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold));border-radius:6px}
+.dupre-bar.dupre-warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+
+/* radial ring */
+.dupre-ring{width:60px;height:60px;border-radius:50%;cursor:ns-resize;touch-action:none;
+ background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);
+ display:grid;place-items:center;position:relative}
+.dupre-ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)}
+.dupre-ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums}
+
+/* tabular readout */
+.dupre-readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em;cursor:pointer}
+.dupre-readout small{color:var(--dim);font-size:12px;font-weight:400}
+.dupre-unit{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px}
+
+/* sparkline */
+.dupre-spark{width:170px;height:44px}
+.dupre-spark svg{display:block;width:100%;height:100%}
+
+/* lamp row (list item) */
+.dupre-lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px}
+.dupre-lrow:hover{background:var(--wash)}
+.dupre-lrow .dupre-who{color:var(--silver)}.dupre-lrow .dupre-who b{color:var(--cream)}
+.dupre-lrow .dupre-what{margin-left:auto;color:var(--dim);font-size:11px}
+
+/* arm-to-fire */
+.dupre-arm{font:inherit;font-size:11.5px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;
+ border-radius:8px;padding:7px 12px}
+.dupre-arm.dupre-armed{background:rgba(203,107,77,.12);border-color:var(--fail);color:var(--fail)}
+
+/* stepper / segmented selector */
+.dupre-seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden}
+.dupre-seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer}
+.dupre-seg button:last-child{border-right:0}
+.dupre-seg button.dupre-on{background:var(--seg-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold)));color:var(--seg-on-ink,var(--panel));font-weight:700}
+
+/* engraved section label */
+.dupre-engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer;
+ display:flex;align-items:center;gap:9px}
+.dupre-engrave::before,.dupre-engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.dupre-engrave::before{max-width:10px}
+.dupre-engrave .dupre-cnt{color:var(--dim);letter-spacing:.1em;text-transform:none}
+
+/* waveform strip */
+.dupre-wave{width:170px;height:38px}
+.dupre-wave svg{width:100%;height:100%;display:block}
+
+/* toast */
+.dupre-toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px;cursor:pointer}
+
+/* output well (log step) */
+.dupre-owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px;cursor:pointer}
+.dupre-ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0}
+.dupre-ostep .dupre-lamp{margin-top:3px;width:7px;height:7px}
+.dupre-ostep b{color:var(--cream);font-weight:700}.dupre-ostep .dupre-ev{color:var(--steel);display:block;font-size:10.5px}
+
+/* rotary selector */
+.dupre-rotsel{position:relative;width:118px;height:74px}
+.dupre-rotsel>.dupre-knob{position:absolute;left:50%;top:20px;margin-left:-26px;cursor:pointer}
+.dupre-rotsel .dupre-pos{position:absolute;font-size:9px;color:var(--dim);transform:translate(-50%,-50%);letter-spacing:.02em}
+.dupre-rotsel .dupre-pos.dupre-on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
+
+/* slide-rule tuner dial */
+.dupre-tuner{width:180px;height:46px;position:relative;border-radius:6px;overflow:hidden;cursor:pointer;
+ background:var(--tn-bg,linear-gradient(180deg,#191510,#0b0908));border:1px solid var(--tn-brd,#2a251c);
+ box-shadow:inset 0 0 20px var(--tn-glow,rgba(var(--glow-lo),.12)),inset 0 1px 0 rgba(255,255,255,.03)}
+.dupre-tuner .dupre-tick{position:absolute;top:6px;width:1px;height:11px;background:var(--tn-tick,var(--steel));transform:translateX(-50%)}
+.dupre-tuner .dupre-tick.dupre-mn{height:6px;opacity:.5}
+.dupre-tuner .dupre-mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--tn-ink,var(--steel));font-size:10px}
+.dupre-tuner .dupre-mk.dupre-on{color:var(--tn-on,var(--gold-hi));text-shadow:0 0 6px var(--tn-onglow,rgba(var(--glow-hi),.6))}
+.dupre-tuner:focus{outline:1px solid rgba(var(--glow-lo),.5);outline-offset:2px}
+.dupre-tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
+.dupre-tuner .dupre-ndl{position:absolute;top:3px;bottom:3px;width:2px;margin-left:-1px;border-radius:1px;
+ background:var(--tn-ndl,var(--fail));box-shadow:0 0 7px var(--tn-ndlglow,rgba(203,107,77,.85));transition:left .25s}
+
+/* nixie tube */
+.dupre-nixie{display:inline-flex;gap:5px;cursor:pointer}
+.dupre-nixie .dupre-tube{width:30px;height:44px;border-radius:5px;position:relative;overflow:hidden;
+ background:radial-gradient(circle at 50% 38%,#241a12,#0b0807);border:1px solid #2c261d;
+ display:grid;place-items:center;box-shadow:inset 0 0 12px rgba(0,0,0,.6)}
+.dupre-nixie .dupre-tube b{font-size:26px;font-weight:400;color:#ff9a3c;
+ text-shadow:0 0 6px rgba(255,140,50,.85),0 0 15px rgba(255,120,40,.45)}
+.dupre-nixie .dupre-tube.dupre-off b{color:#3a2a1c;text-shadow:none}
+.dupre-nixie .dupre-tube::after{content:"";position:absolute;inset:0;pointer-events:none;
+ background:linear-gradient(180deg,rgba(255,255,255,.06),transparent 32%)}
+
+/* ===== candidate-unique CSS ===== */
+/* rocker */
+.dupre-rocker{width:66px;height:40px;border-radius:7px;cursor:pointer;position:relative;
+ background:linear-gradient(180deg,#25211c,#141210);border:1px solid #34302a;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5);overflow:hidden}
+.dupre-rocker .dupre-half{position:absolute;left:0;right:0;height:50%;display:flex;align-items:center;justify-content:center;
+ font-size:12px;letter-spacing:.06em;color:var(--dim)}
+.dupre-rocker .dupre-top{top:0;border-bottom:1px solid #0c0b0a;background:linear-gradient(180deg,#211d19,#181513)}
+.dupre-rocker .dupre-bot{bottom:0;background:linear-gradient(180deg,#141210,#100e0c);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)}
+.dupre-rocker.dupre-on .dupre-top{background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color:var(--panel);font-weight:700;box-shadow:0 0 10px rgba(var(--glow-hi),.35)}
+.dupre-rocker.dupre-on .dupre-bot{color:var(--steel)}
+.dupre-rocker:not(.dupre-on) .dupre-top{color:var(--steel)}
+.dupre-rocker:not(.dupre-on) .dupre-bot{background:linear-gradient(180deg,#2a1512,#1c0f0d);color:var(--fail);box-shadow:inset 0 3px 5px rgba(0,0,0,.55),0 0 8px rgba(203,107,77,.25)}
+
+/* transport */
+.dupre-reels{display:flex;gap:16px;align-items:center;justify-content:center;margin-bottom:2px}
+.dupre-reel{width:26px;height:26px;border-radius:50%;border:2px solid #34302a;position:relative;
+ background:radial-gradient(circle at 45% 40%,#211d18,#0d0b09)}
+.dupre-reel::before{content:"";position:absolute;inset:9px;border-radius:50%;border:1px solid #4a443a;background:#161310}
+.dupre-reel i{position:absolute;left:50%;top:50%;width:2px;height:11px;background:var(--steel);margin:-5.5px 0 0 -1px;transform-origin:50% 5.5px}
+.dupre-reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(var(--glow-hi),.55)}
+.dupre-reel i:nth-child(2){transform:rotate(120deg)}.dupre-reel i:nth-child(3){transform:rotate(240deg)}
+.dupre-transport{display:flex;gap:5px}
+.dupre-tbtn{font:inherit;font-size:12px;color:var(--silver);cursor:pointer;width:30px;height:26px;display:grid;place-items:center;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:6px}
+.dupre-tbtn:hover{color:var(--gold);border-color:var(--gold)}
+.dupre-tbtn.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi)}
+.dupre-tbtn.dupre-rec.dupre-on{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+
+/* radio bank */
+.dupre-radiobank{display:flex;gap:0;border:1px solid #0c0b0a;border-radius:7px;overflow:hidden;
+ background:#0c0b0a;padding:3px;box-shadow:inset 0 2px 4px rgba(0,0,0,.5)}
+.dupre-preset{font:inherit;font-size:11px;color:var(--dim);cursor:pointer;padding:8px 9px;border:0;border-radius:4px;
+ background:linear-gradient(180deg,#211d19,#161310);box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}
+.dupre-preset+.dupre-preset{margin-left:3px}
+.dupre-preset:hover{color:var(--silver)}
+.dupre-preset.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));font-weight:700;
+ box-shadow:inset 0 2px 4px rgba(0,0,0,.4)}
+
+/* concentric dual knob */
+.dupre-dualknob{width:64px;height:64px;position:relative}
+.dupre-dualknob .dupre-outer{width:64px;height:64px;border-radius:50%;position:absolute;inset:0;cursor:ns-resize;touch-action:none;
+ background:radial-gradient(circle at 40% 35%,#2a2622,#100e0c);border:1px solid #3a352c;
+ box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
+.dupre-dualknob .dupre-outer .dupre-tick{position:absolute;left:50%;top:3px;width:2px;height:9px;background:var(--steel);margin-left:-1px;transform-origin:50% 29px}
+.dupre-dualknob .dupre-inner{width:34px;height:34px;border-radius:50%;position:absolute;left:15px;top:15px;cursor:ns-resize;touch-action:none;
+ background:radial-gradient(circle at 40% 35%,#37322a,#1a1713);border:1px solid #4a443a;box-shadow:0 2px 4px rgba(0,0,0,.5)}
+.dupre-dualknob .dupre-inner .dupre-ind{position:absolute;left:50%;top:3px;width:2px;height:11px;background:var(--gold-hi);margin-left:-1px;
+ transform-origin:50% 14px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)}
+
+/* rotary encoder + LED ring */
+.dupre-encoder{position:relative;width:66px;height:66px;display:grid;place-items:center;cursor:ns-resize;touch-action:none}
+.dupre-encoder .dupre-led{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--wash);
+ left:50%;top:50%;margin:-2.5px}
+.dupre-encoder .dupre-led.dupre-on{background:var(--gold);box-shadow:0 0 5px 1px rgba(var(--glow-lo),.7)}
+.dupre-encoder .dupre-knob{width:40px;height:40px;cursor:ns-resize}
+.dupre-encoder .dupre-knob .dupre-ind{transform-origin:50% 15px;top:4px;height:12px}
+
+/* keyed mode switch */
+.dupre-keylock{position:relative;width:110px;height:70px}
+.dupre-keylock .dupre-body{position:absolute;left:50%;top:30px;margin-left:-21px;width:42px;height:42px;border-radius:50%;
+ background:radial-gradient(circle at 42% 36%,#2a2622,#0f0d0b);border:1px solid #3a352c;cursor:pointer;
+ box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
+.dupre-keylock .dupre-barrel{position:absolute;left:50%;top:50%;width:5px;height:5px;margin:-2.5px;border-radius:1px;background:#0a0908;
+ box-shadow:0 0 2px rgba(0,0,0,.8)}
+.dupre-keylock .dupre-bit{position:absolute;left:50%;top:50%;width:3px;height:15px;margin:-15px 0 0 -1.5px;background:var(--gold-hi);
+ border-radius:1px;transform-origin:50% 100%;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .2s}
+.dupre-keylock .dupre-kpos{position:absolute;font-size:9px;color:var(--dim);letter-spacing:.05em;transform:translate(-50%,-50%)}
+.dupre-keylock .dupre-kpos.dupre-on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
+
+/* crossfader */
+.dupre-xfader{width:160px;height:22px;position:relative;cursor:pointer;touch-action:none}
+.dupre-xfader .dupre-slot{position:absolute;top:9px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18}
+.dupre-xfader .dupre-detent{position:absolute;top:2px;left:50%;width:1px;height:18px;background:var(--steel);margin-left:-.5px;opacity:.7}
+.dupre-xfader .dupre-end{position:absolute;top:11px;font-size:9px;color:var(--steel);transform:translateY(-50%)}
+.dupre-xfader .dupre-cap{position:absolute;top:3px;width:9px;height:16px;border-radius:2px;margin-left:-4.5px;transition:left .05s;
+ background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)}
+
+/* thumbwheel */
+.dupre-thumbw{display:flex;align-items:center;gap:10px}
+.dupre-thumbwheel{width:34px;height:52px;border-radius:6px;cursor:ns-resize;touch-action:none;position:relative;overflow:hidden;
+ border:1px solid #34302a;box-shadow:inset 0 0 8px rgba(0,0,0,.6);
+ background:repeating-linear-gradient(0deg,#0e0c0a 0 2px,#221e19 2px 3px,#2f2a23 3px 5px,#221e19 5px 6px)}
+.dupre-thumbwheel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.55),transparent 30%,transparent 70%,rgba(0,0,0,.55))}
+.dupre-thumbw .dupre-win{color:var(--cream);font-size:15px;font-weight:700;font-variant-numeric:tabular-nums;
+ background:var(--well);border:1px solid #231f18;border-radius:4px;padding:3px 8px}
+
+/* DIP bank */
+.dupre-dip{display:flex;gap:3px;padding:6px 7px;background:#0f1a2a;border:1px solid #24344a;border-radius:5px;
+ box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
+.dupre-dipsw{width:12px;height:26px;background:#0a1220;border-radius:2px;position:relative;cursor:pointer;border:1px solid #1c2c42}
+.dupre-dipsw i{position:absolute;left:1px;right:1px;height:11px;border-radius:1px;background:linear-gradient(180deg,#eae4d2,#b4ad98);
+ bottom:1px;transition:bottom .12s,top .12s}
+.dupre-dipsw.dupre-on i{bottom:auto;top:1px;background:linear-gradient(180deg,var(--amber-grad-top),var(--gold))}
+
+/* jog / shuttle */
+.dupre-jog{position:relative;width:74px;height:74px;cursor:ns-resize;touch-action:none}
+.dupre-jog .dupre-shuttle{position:absolute;inset:0;border-radius:50%;border:6px solid #1a1713;
+ background:conic-gradient(from -90deg,#3a352c 0 var(--sh,40deg),#141210 0 360deg);
+ box-shadow:inset 0 0 8px rgba(0,0,0,.6)}
+.dupre-jog .dupre-inner{position:absolute;inset:12px;border-radius:50%;transition:transform .05s linear;
+ background:radial-gradient(circle at 42% 36%,#2c2822,#100e0c);border:1px solid #3a352c;box-shadow:0 2px 5px rgba(0,0,0,.6)}
+.dupre-jog .dupre-dimple{position:absolute;left:50%;top:7px;width:8px;height:8px;margin-left:-4px;border-radius:50%;
+ background:radial-gradient(circle at 40% 35%,#151210,#000);box-shadow:inset 0 1px 2px rgba(0,0,0,.9)}
+
+/* oscilloscope — screen-family vars with the original green as fallback */
+.dupre-scope{width:176px;height:78px;border-radius:6px;position:relative;overflow:hidden;
+ background:radial-gradient(circle at 50% 45%,var(--scr-bgc,#04140a),var(--scr-bge,#020a05));
+ border:1px solid var(--scr-brd,#123018);
+ box-shadow:inset 0 0 16px rgba(0,0,0,.7)}
+.dupre-scope .dupre-grat{position:absolute;inset:0;opacity:.5;
+ background-image:linear-gradient(var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px),linear-gradient(90deg,var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px);
+ background-size:22px 19.5px}
+.dupre-scope .dupre-grat::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:var(--scr-gratc,rgba(111,206,51,.35))}
+.dupre-scope svg{position:absolute;inset:0;width:100%;height:100%}
+.dupre-scope polyline{fill:none;stroke:var(--scr-hi,var(--phos));stroke-width:1.6;filter:drop-shadow(0 0 3px var(--scr-glow,rgba(127,224,160,.8)))}
+
+/* voice-loop keyset */
+.dupre-vloop{display:grid;grid-template-columns:repeat(4,52px);gap:4px}
+.dupre-vk{display:flex;flex-direction:column;align-items:stretch;gap:4px;font-size:7px;letter-spacing:.05em;
+ text-align:center;padding:6px 4px 4px;cursor:pointer;
+ color:var(--dim);background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-radius:4px;
+ font-family:var(--mono)}
+.dupre-vk .dupre-vk-bar{height:3px;width:40px;align-self:center;border-radius:2px;background:#16240f}
+.dupre-vk.dupre-mon{color:var(--silver)}
+.dupre-vk.dupre-mon .dupre-vk-bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
+.dupre-vk.dupre-mon.dupre-act .dupre-vk-bar{background:#a8d84a;box-shadow:0 0 7px rgba(150,200,60,.9)}
+.dupre-vk.dupre-tlk{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));
+ border-color:var(--gold-hi);font-weight:700}
+.dupre-vk.dupre-tlk .dupre-vk-bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
+
+/* spectrum / EQ */
+.dupre-eq{display:flex;align-items:flex-end;gap:3px;height:60px}
+.dupre-eq .dupre-band{width:9px;display:flex;flex-direction:column-reverse;gap:2px;height:100%}
+.dupre-eq .dupre-band i{height:5px;border-radius:1px;background:var(--wash);opacity:.3}
+.dupre-eq .dupre-band i.dupre-on{opacity:1;background:var(--pass)}
+.dupre-eq .dupre-band i.dupre-hot{opacity:1;background:var(--gold)}
+.dupre-eq .dupre-band i.dupre-clip{opacity:1;background:var(--fail)}
+
+/* crossed-needle */
+.dupre-crossm{width:120px}
+.dupre-crossm .dupre-face{position:relative;height:56px;overflow:hidden;cursor:ns-resize;touch-action:none}
+.dupre-crossm .dupre-crossm-arc{position:absolute;inset:2px 4px -56px;border:2px solid var(--wash);border-radius:50%}
+.dupre-crossm .dupre-fwd{position:absolute;left:14px;bottom:2px;width:2px;height:52px;background:var(--gold-hi);transform-origin:50% 100%;
+ border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)}
+.dupre-crossm .dupre-rfl{position:absolute;right:14px;bottom:2px;width:2px;height:52px;background:var(--fail);transform-origin:50% 100%;
+ border-radius:2px;box-shadow:0 0 5px rgba(203,107,77,.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)}
+.dupre-crossm .dupre-lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
+
+/* thermometer */
+.dupre-thermo{display:flex;align-items:flex-end;gap:6px;height:74px;cursor:ns-resize;touch-action:none}
+.dupre-thermo .dupre-thermo-tube{width:12px;height:64px;border-radius:6px 6px 0 0;position:relative;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.dupre-thermo .dupre-thermo-fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#a35a3f,var(--fail))}
+.dupre-thermo .dupre-bulb{width:20px;height:20px;border-radius:50%;background:var(--fail);position:absolute;left:-4px;bottom:-9px;
+ box-shadow:0 0 6px rgba(203,107,77,.5)}
+.dupre-thermo .dupre-scale{display:flex;flex-direction:column;justify-content:space-between;height:64px;font-size:9px;color:var(--steel)}
+.dupre-thermo .dupre-wrapcol{position:relative;padding-bottom:9px}
+
+/* bourdon */
+.dupre-bourdon{width:82px;height:82px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none;
+ background:radial-gradient(circle at 48% 42%,#1d1a16,#0c0b09);border:2px solid #2c261d;
+ box-shadow:inset 0 2px 5px rgba(0,0,0,.6)}
+.dupre-bourdon svg{position:absolute;inset:0}
+.dupre-bourdon-ndl{position:absolute;left:50%;top:50%;width:2px;height:32px;margin:-32px 0 0 -1px;background:var(--gold-hi);
+ transform-origin:50% 100%;border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .12s cubic-bezier(.3,1.2,.5,1)}
+.dupre-bourdon-hub{position:absolute;left:50%;top:50%;width:8px;height:8px;margin:-4px;border-radius:50%;background:var(--gold)}
+.dupre-bourdon-cap{position:absolute;left:0;right:0;bottom:12px;text-align:center;font-size:8px;letter-spacing:.16em;color:var(--steel)}
+
+/* strip-chart */
+.dupre-strip{width:176px;height:62px;border-radius:5px;position:relative;overflow:hidden;
+ background:#0c0e0f;border:1px solid #231f18}
+.dupre-strip .dupre-rule{position:absolute;inset:0;opacity:.4;
+ background-image:linear-gradient(90deg,rgba(150,147,133,.14) 1px,transparent 1px);background-size:16px 100%}
+.dupre-strip svg{position:absolute;inset:0;width:100%;height:100%}
+.dupre-strip polyline{fill:none;stroke:var(--gold);stroke-width:1.4}
+.dupre-strip .dupre-pen{position:absolute;right:2px;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold-hi);
+ box-shadow:0 0 6px rgba(var(--glow-hi),.8);transition:top .08s linear}
+
+/* correlation */
+.dupre-corr{width:150px;cursor:pointer;touch-action:none}
+.dupre-corr-face{position:relative;height:44px;overflow:hidden}
+.dupre-corr-arc{position:absolute;inset:0 0 -44px;border:2px solid var(--wash);border-radius:50%}
+.dupre-corr .dupre-zero{position:absolute;left:50%;top:2px;width:1px;height:10px;background:var(--gold);margin-left:-.5px}
+.dupre-corr-ndl{position:absolute;left:50%;bottom:0;width:2px;height:38px;background:var(--gold-hi);transform-origin:50% 100%;
+ border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .1s ease-out}
+.dupre-corr-lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
+
+/* battery */
+.dupre-batt{display:flex;align-items:center;cursor:pointer;touch-action:none}
+.dupre-batt .dupre-cells{display:flex;gap:2px;padding:3px;border:1px solid #34302a;border-radius:4px;background:#0d0f10}
+.dupre-batt .dupre-cell{width:12px;height:26px;border-radius:1px;background:var(--wash);opacity:.35}
+.dupre-batt .dupre-cell.dupre-on{opacity:1;background:linear-gradient(180deg,var(--pass),#5c7526)}
+.dupre-batt .dupre-cell.dupre-warn.dupre-on{background:linear-gradient(180deg,var(--fail),#a04a34)}
+.dupre-batt .dupre-nub{width:4px;height:12px;background:#34302a;border-radius:0 2px 2px 0}
+
+/* split-flap — four clipped half-panels per cell; the fold happens on
+ .ftc (falls) and .fbn (lands), driven by the builder's WAAPI flips */
+.flap{display:inline-flex;gap:4px;cursor:pointer}
+.flapd{width:30px;height:42px;border-radius:5px;position:relative;
+ background:linear-gradient(180deg,#211d18,#141210);border:1px solid #34302a;box-shadow:0 2px 4px rgba(0,0,0,.5);
+ perspective:220px;transform-style:preserve-3d}
+.flapd .fh{position:absolute;inset:0;display:grid;place-items:center;font-size:24px;color:var(--cream);
+ text-shadow:0 1px 2px rgba(0,0,0,.7);backface-visibility:hidden;transform-origin:50% 50%}
+.flapd .ftn,.flapd .ftc{clip-path:inset(0 0 50% 0)}
+.flapd .fbc,.flapd .fbn{clip-path:inset(50% 0 0 0)}
+.flapd .fbn{transform:rotateX(180deg)}
+.flapd::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;z-index:5;
+ background:rgba(0,0,0,.65);box-shadow:0 1px 0 rgba(255,255,255,.03)}
+/* multi-row board: column of flap lines */
+.flap-rows{flex-direction:column;gap:5px}
+.flapline{display:inline-flex;gap:4px}
+/* white skin — the dark board with true-white ink (the default ink is cream) */
+.flap-white .flapd .fh{color:#fff}
+/* helvetica — the grotesque face real Solari boards wore */
+.flap-helv .flapd .fh{font-family:Helvetica,'Helvetica Neue',Arial,sans-serif;font-weight:600}
+/* paper skin — true-white cards, dark lettering */
+.flap-paper .flapd{background:linear-gradient(180deg,#ffffff,#e6e6e3);border-color:#b6b6b2}
+.flap-paper .flapd .fh{color:#17181a;text-shadow:0 1px 0 rgba(255,255,255,.5)}
+.flap-paper .flapd::after{background:rgba(0,0,0,.28);box-shadow:0 1px 0 rgba(255,255,255,.35)}
+/* light skin — the inverse board: ivory flaps, near-black ink, softer crease */
+.flap-light .flapd{background:linear-gradient(180deg,#f7eed3,#dccfa8);border-color:#a89c7e}
+.flap-light .flapd .fh{color:#1b1813;text-shadow:0 1px 0 rgba(255,255,255,.45)}
+.flap-light .flapd::after{background:rgba(0,0,0,.3);box-shadow:0 1px 0 rgba(255,255,255,.3)}
+
+/* seven-segment */
+.dupre-seven{display:inline-flex;gap:6px;padding:6px 9px;border-radius:6px;background:#0a0806;border:1px solid #231f18;cursor:pointer;
+ box-shadow:inset 0 0 10px rgba(0,0,0,.6)}
+.seg7{width:22px;height:40px;filter:drop-shadow(0 0 3px rgba(87,211,87,.55))}
+.seg7.red{filter:drop-shadow(0 0 3px rgba(226,84,63,.55))}
+.dupre-seven .dupre-colon{align-self:center;display:flex;flex-direction:column;gap:9px}
+.dupre-seven .dupre-colon i{width:4px;height:4px;border-radius:50%;background:var(--sevgrn);box-shadow:0 0 4px rgba(87,211,87,.7)}
+
+/* DSKY verb/noun panel */
+.dupre-dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px}
+.dupre-dsky-lamps{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px}
+.dupre-dsky-sl{font-size:6.5px;letter-spacing:.04em;display:flex;align-items:center;justify-content:center;text-align:center;
+ background:#141210;color:#5c574c;border-radius:2px;border:1px solid #26221c}
+.dupre-dsky-sl.dupre-on{background:linear-gradient(180deg,var(--amber-warn),var(--gold));color:var(--panel);font-weight:700;
+ box-shadow:0 0 6px rgba(var(--glow-lo),.5)}
+.dupre-dsky-right{display:flex;flex-direction:column;gap:6px}
+.dupre-dsky-wins{display:flex;gap:8px}
+.dupre-dsky-win{display:flex;flex-direction:column;align-items:center;gap:2px}
+.dupre-dsky-wl{font-size:6px;letter-spacing:.14em;color:var(--steel)}
+.dupre-dsky-wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px}
+.dupre-dsky-wd .seg7{width:13px;height:24px}
+.dupre-dsky-win.dupre-hot .dupre-dsky-wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)}
+.dupre-dsky-pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px}
+.dupre-dsky-pad .dupre-key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em}
+
+/* VFD marquee */
+.dupre-vfdm{width:176px;height:34px;border-radius:5px;overflow:hidden;position:relative;cursor:pointer;
+ background:linear-gradient(180deg,#04100e,#020807);border:1px solid #123028;box-shadow:inset 0 0 12px rgba(0,0,0,.6)}
+.dupre-vfdm .dupre-txt{position:absolute;top:50%;transform:translateY(-50%);white-space:nowrap;font-size:15px;letter-spacing:.22em;
+ color:var(--vfd);text-shadow:0 0 6px rgba(99,230,200,.65)}
+.dupre-vfdm .dupre-mesh{position:absolute;inset:0;pointer-events:none;opacity:.35;
+ background-image:radial-gradient(rgba(0,0,0,.6) 40%,transparent 41%);background-size:3px 3px}
+
+/* annunciator */
+.dupre-annwrap{display:flex;flex-direction:column;gap:6px}
+.dupre-annbar{display:flex;gap:5px;align-items:center}
+.dupre-mc{font-size:7px;letter-spacing:.08em;padding:4px 8px;border-radius:3px;background:#141210;color:#5c574c;
+ border:1px solid #26221c;text-align:center}
+.dupre-mc.dupre-on{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream);font-weight:700;
+ box-shadow:0 0 8px rgba(203,107,77,.5)}
+.dupre-mc.dupre-on.dupre-fl{animation:pulse var(--pulse-rate) ease-in-out infinite}
+.dupre-annbar .dupre-key{padding:3px 7px;font-size:7.5px;border-radius:4px}
+.dupre-annun{display:grid;grid-template-columns:repeat(3,1fr);gap:3px}
+.dupre-acell{font-size:8.5px;letter-spacing:.08em;text-align:center;color:var(--dim);padding:6px 4px;border-radius:3px;cursor:pointer;
+ background:#141210;border:1px solid #262320;line-height:1.2}
+.dupre-acell.dupre-warn{color:var(--panel);background:linear-gradient(180deg,var(--amber-warn),var(--gold));font-weight:700;box-shadow:0 0 8px rgba(var(--glow-lo),.4)}
+.dupre-acell.dupre-fault{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));font-weight:700;box-shadow:0 0 8px rgba(203,107,77,.4);animation:pulse var(--pulse-rate) ease-in-out infinite}
+
+/* jewel */
+.dupre-jewel{width:24px;height:24px;border-radius:50%;position:relative;cursor:pointer;
+ background:radial-gradient(circle at 36% 30%,rgba(255,255,255,.75),var(--jc) 42%,#3a0d08 100%);
+ box-shadow:0 0 10px 1px var(--jc),inset 0 -2px 3px rgba(0,0,0,.5)}
+.dupre-jewel::after{content:"";position:absolute;inset:0;border-radius:50%;
+ background:conic-gradient(from 0deg,rgba(255,255,255,.12) 0 22deg,transparent 22deg 45deg,rgba(0,0,0,.18) 45deg 67deg,transparent 67deg 90deg);
+ background-repeat:repeat}
+.dupre-jewel.dupre-dim{background:radial-gradient(circle at 36% 30%,rgba(120,120,120,.3),#241f1b 55%,#0e0c0a);box-shadow:inset 0 -2px 3px rgba(0,0,0,.5)}
+
+/* tape counter */
+.dupre-counter{display:inline-flex;gap:2px;padding:4px;background:#0c0b0a;border:1px solid #2c261d;border-radius:4px;
+ box-shadow:inset 0 1px 3px rgba(0,0,0,.6)}
+.dupre-cwheel{width:18px;height:34px;border-radius:2px;position:relative;overflow:hidden;
+ background:linear-gradient(180deg,#efe9d6,#c7c0ac);box-shadow:inset 0 0 3px rgba(0,0,0,.3)}
+.dupre-cwheel .dupre-col{position:absolute;left:0;right:0;text-align:center;color:#1a1613;font-size:18px;font-weight:700;line-height:34px;
+ transition:top .35s cubic-bezier(.4,1.4,.5,1)}
+.dupre-cwheel .dupre-col span{display:block;height:34px}
+.dupre-cwheel::before,.dupre-cwheel::after{content:"";position:absolute;left:0;right:0;height:9px;z-index:2;pointer-events:none}
+.dupre-cwheel::before{top:0;background:linear-gradient(180deg,rgba(0,0,0,.4),transparent)}
+.dupre-cwheel::after{bottom:0;background:linear-gradient(0deg,rgba(0,0,0,.4),transparent)}
+.dupre-counter .dupre-redw{background:linear-gradient(180deg,#e7b46a,#cf9440)}
+
+/* analog clock */
+.dupre-clock{width:78px;height:78px;border-radius:50%;position:relative;
+ background:radial-gradient(circle at 46% 40%,#1c1915,#0c0b09);border:2px solid #2c261d;box-shadow:inset 0 2px 5px rgba(0,0,0,.6)}
+.dupre-clock .dupre-tk{position:absolute;left:50%;top:4px;width:1.5px;height:6px;background:var(--steel);margin-left:-.75px;transform-origin:50% 35px}
+.dupre-clock .dupre-hh{position:absolute;left:50%;top:50%;width:3px;height:20px;margin:-20px 0 0 -1.5px;background:var(--cream);
+ transform-origin:50% 100%;border-radius:2px}
+.dupre-clock .dupre-mh{position:absolute;left:50%;top:50%;width:2px;height:28px;margin:-28px 0 0 -1px;background:var(--silver);
+ transform-origin:50% 100%;border-radius:2px}
+.dupre-clock .dupre-sh{position:absolute;left:50%;top:50%;width:1px;height:30px;margin:-30px 0 0 -.5px;background:var(--gold-hi);
+ transform-origin:50% 100%;box-shadow:0 0 4px rgba(var(--glow-hi),.5)}
+.dupre-clock .dupre-pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold)}
+
+/* frequency-dial scale */
+.dupre-freqscale{width:184px;height:44px;position:relative;border-radius:5px;overflow:hidden;cursor:pointer;touch-action:none;
+ background:linear-gradient(180deg,#191510,#0b0908);border:1px solid #2a251c;
+ box-shadow:inset 0 0 16px rgba(var(--glow-lo),.1)}
+.dupre-freqscale .dupre-tick{position:absolute;top:5px;width:1px;background:var(--steel);transform:translateX(-50%)}
+.dupre-freqscale .dupre-mk{position:absolute;bottom:6px;transform:translateX(-50%);color:var(--steel);font-size:9px}
+.dupre-freqscale .dupre-fs-band{position:absolute;bottom:2px;left:6px;color:var(--gold);font-size:8px;letter-spacing:.18em}
+.dupre-freqscale .dupre-fptr{position:absolute;top:3px;bottom:14px;width:2px;margin-left:-1px;border-radius:1px;
+ background:var(--fail);box-shadow:0 0 7px rgba(203,107,77,.85)}
+
+/* patch-bay */
+.dupre-patch{padding:7px 9px;background:#141210;border:1px solid #2c261d;border-radius:5px;position:relative}
+.dupre-patch .dupre-row{display:flex;gap:9px}
+.dupre-patch .dupre-row+.dupre-row{margin-top:9px}
+.dupre-patch .dupre-jack{width:13px;height:13px;border-radius:50%;cursor:pointer;background:radial-gradient(circle at 40% 35%,#3a352c,#0a0908 70%);
+ border:1px solid #4a443a;box-shadow:inset 0 1px 2px rgba(0,0,0,.8)}
+.dupre-patch .dupre-jack.dupre-hot{background:radial-gradient(circle at 40% 35%,var(--amber-edge),#1a1408 70%)}
+.dupre-patch .dupre-jack.dupre-sel{border-color:var(--gold-hi);box-shadow:0 0 6px 1px rgba(var(--glow-hi),.7)}
+.dupre-patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%}
+.dupre-patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round}
+
+/* ===== reference-batch (R) instruments — SVG-first, after period hardware ===== */
+.rsvg{display:block}
+.rsvg.drag{cursor:ns-resize;touch-action:none}
+.rsvg.press{cursor:pointer}
+
+/* transport reels spin (was a page-side keyframe injector) */
+@keyframes reelspin{to{transform:rotate(360deg)}}
+.dupre-reel.dupre-spin{animation:reelspin 2.6s linear infinite}
+`;
+function ensureCss() {
+ if (document.getElementById('dupre-css') || !DUPRE_CSS) return;
+ const st = document.createElement('style'); st.id = 'dupre-css'; st.textContent = DUPRE_CSS;
+ document.head.appendChild(st);
+}
+if (document.head) ensureCss();
+else document.addEventListener('DOMContentLoaded', ensureCss);
+
+/* Policy classification (see DUPRE.POLICIES). This is the colour pass's worklist made
+ explicit: every card gets a record here as we review it — the kind it's bound
+ by, why in its own terms, and the range it may vary and stay authentic. This
+ round covers the cards already touched; the rest are unclassified on purpose,
+ the review still to do card by card, not a memory dump to fill now. Assigned
+ after the builders exist, in one place, so it reads as a catalogue. */
+DUPRE.slideToggle.POLICY = { kind: 'accent',
+ why: 'On means good in one panel and a fault in the next, so the lit colour is the consumer’s claim, not the instrument’s.',
+ authentic: 'The on-colour from the accent family (red, amber, green, white, vfd), plus the pill and thumb finishes. The switch form stays.' };
+DUPRE.segmented.POLICY = { kind: 'accent',
+ why: 'The active segment signals a mode whose meaning depends on the panel it sits in.',
+ authentic: 'The accent colour of the active segment. The segment count and labels are the deployment’s, not a colour choice.' };
+DUPRE.chipToggle.POLICY = { kind: 'accent',
+ why: 'A filter chip’s on-state reads as good, warning or fault entirely by where it is used.',
+ authentic: 'The lit colour from the accent family. The chip keeps its inline weight and dotted underline.' };
+DUPRE.dataMatrix.POLICY = { kind: 'screen',
+ why: 'LED and LCD dot-matrix modules were sold in several emitter colours, so no one colour is definitive.',
+ authentic: 'The screen family (amber, green, red, blue, vfd, white) — each a panel that was actually built. Not an arbitrary hue.' };
+DUPRE.roundCrt.POLICY = { kind: 'screen',
+ why: 'CRT phosphors were manufactured in more than one colour (P1 green, P3 amber, white), so the trace colour is a real choice.',
+ authentic: 'The screen family, limited to phosphors that existed. The face tint follows the phosphor.' };
+DUPRE.waveRegion.POLICY = { kind: 'screen',
+ why: 'A backlit editor LCD was made in several tints; the ink colour is the panel’s, not fixed.',
+ authentic: 'The screen family. The waveform and region handles recolour with the screen, staying legible on it.' };
+DUPRE.radarSweep.POLICY = { kind: 'screen',
+ why: 'PPI radar scopes ran amber and green phosphors both; neither is the one true colour.',
+ authentic: 'The screen family, phosphors that shipped. The sweep and afterglow track the chosen screen.' };
+DUPRE.abcKeypad.POLICY = { kind: 'screen',
+ why: 'The entry window is a screen like any other; its phosphor was made in several colours. (Mixed card: the keys are fixed-function and not a colour choice.)',
+ authentic: 'The window’s screen family. The keycap colours are functional and stay put.' };
+
+Object.assign(DUPRE, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS });
+window.DUPRE = DUPRE;
+})();