/* widgets.js — The Dupre Kit: retro-instrument component library (DUPRE namespace).
Classic script: load after the token :root block (tokens.json → gen_tokens.py),
before any DUPRE.* call. Works from file:// — no modules, no build step.
Contract: each gallery card in panel-widget-gallery.html is the visual +
behavioral spec its builder is judged against.
Spec: docs/specs/2026-07-12-component-generation-spec.org */
(function () {
'use strict';
const DUPRE = {};
/* ================= shared engine ================= */
const SVGNS = 'http://www.w3.org/2000/svg';
function svgEl(parent, tag, attrs) {
const e = document.createElementNS(SVGNS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
parent.appendChild(e); return e;
}
function polar(cx, cy, r, deg) {
const a = deg * Math.PI / 180;
return [cx + r * Math.sin(a), cy - r * Math.cos(a)];
}
/* ---- pointer-drag helpers (rect-ratio based, so CSS zoom is transparent) ---- */
function dragX(el, onPct) {
el.style.touchAction = 'none';
const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (e.clientX - r.left) / r.width * 100))); };
el.addEventListener('pointerdown', e => {
el.setPointerCapture(e.pointerId); at(e);
const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); };
el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault();
});
}
function dragY(el, onPct) {
el.style.touchAction = 'none';
const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (r.bottom - e.clientY) / r.height * 100))); };
el.addEventListener('pointerdown', e => {
el.setPointerCapture(e.pointerId); at(e);
const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); };
el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault();
});
}
function dragDelta(el, get, set, opts) {
opts = opts || {}; el.style.touchAction = 'none';
const min = opts.min == null ? 0 : opts.min, max = opts.max == null ? 100 : opts.max, sens = opts.sens || ((max - min) / 160);
el.addEventListener('pointerdown', e => {
if (opts.stop) e.stopPropagation(); el.setPointerCapture(e.pointerId);
const y0 = e.clientY, v0 = get();
const mv = ev => { let v = v0 + (y0 - ev.clientY) * sens; set(Math.max(min, Math.min(max, v))); };
const up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); };
el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault();
});
}
/* ---- seven-segment digit builder ---- */
const SEG = { '0': 'abcdef', '1': 'bc', '2': 'abged', '3': 'abgcd', '4': 'fbgc', '5': 'afgcd', '6': 'afgedc', '7': 'abc', '8': 'abcdefg', '9': 'abcdfg', '-': 'g', ' ': '' };
function hseg(cy) { return `5,${cy} 7,${cy - 2} 17,${cy - 2} 19,${cy} 17,${cy + 2} 7,${cy + 2}`; }
function vseg(cx, ty, by) { return `${cx},${ty} ${cx - 2},${ty + 2} ${cx - 2},${by - 2} ${cx},${by} ${cx + 2},${by - 2} ${cx + 2},${ty + 2}`; }
const SEGPTS = { a: hseg(3), g: hseg(22), d: hseg(41), f: vseg(5, 4, 21), b: vseg(19, 4, 21), e: vseg(5, 23, 40), c: vseg(19, 23, 40) };
function seg7(ch, cls) {
const lit = SEG[ch] || ''; let s = `';
}
function buildBars(el, n) { el.innerHTML = ''; for (let k = 0; k < n; k++) el.appendChild(document.createElement('i')); }
/* ---- VU law: authentic nonlinear dB scale, dB → 0..1 sweep position ---- */
const VUDB = [[-20, 0], [-10, .20], [-7, .31], [-5, .40], [-3, .50], [-2, .58], [-1, .66], [0, .75], [1, .84], [2, .91], [3, 1]];
/* interpolate a dB reading from a 0..1 sweep position */
function vuDb(t) {
let i = 0; while (i < VUDB.length - 2 && VUDB[i + 1][1] < t) i++;
const [d1, t1] = VUDB[i], [d2, t2] = VUDB[i + 1];
return d1 + (d2 - d1) * Math.max(0, Math.min(1, (t - t1) / (t2 - t1)));
}
/* ---- screen-color families: period phosphor/LCD palettes as scoped CSS vars.
Shipped literals are the fallbacks, so defaults are pixel-identical until applied. ---- */
const SCREEN_FAMS = {
green: { '--scr-hi': '#7fe0a0', '--scr-ink': '#58b87e', '--scr-dim': '#3d5c46',
'--scr-bg1': '#0a120c', '--scr-bg2': '#050c07', '--scr-bgc': '#04140a', '--scr-bge': '#020a05', '--scr-brd': '#123018',
'--scr-grat': 'rgba(111,206,51,.18)', '--scr-gratc': 'rgba(111,206,51,.35)', '--scr-glow': 'rgba(127,224,160,.8)',
'--crt-face1': '#b9d8c0', '--crt-face2': '#8bb296', '--crt-glow': '#eef7ee' },
amber: { '--scr-hi': '#ffbe54', '--scr-ink': '#e2a038', '--scr-dim': '#7d5c16',
'--scr-bg1': '#140d06', '--scr-bg2': '#0a0705', '--scr-bgc': '#140d06', '--scr-bge': '#0a0603', '--scr-brd': '#33230e',
'--scr-grat': 'rgba(226,160,56,.18)', '--scr-gratc': 'rgba(226,160,56,.35)', '--scr-glow': 'rgba(255,190,84,.8)',
'--crt-face1': '#d8cba6', '--crt-face2': '#b3a276', '--crt-glow': '#ffe7c0' },
red: { '--scr-hi': '#ff9a4c', '--scr-ink': '#e0742e', '--scr-dim': '#5c3416',
'--scr-bg1': '#140a05', '--scr-bg2': '#0c0603', '--scr-bgc': '#140a05', '--scr-bge': '#0a0502', '--scr-brd': '#331c0c',
'--scr-grat': 'rgba(224,116,46,.18)', '--scr-gratc': 'rgba(224,116,46,.35)', '--scr-glow': 'rgba(255,154,76,.8)',
'--crt-face1': '#d8b9a6', '--crt-face2': '#b39276', '--crt-glow': '#ffdcc0' },
blue: { '--scr-hi': '#cfe4ff', '--scr-ink': '#94b8d8', '--scr-dim': '#3a4c60',
'--scr-bg1': '#0a0e14', '--scr-bg2': '#05080c', '--scr-bgc': '#0a1018', '--scr-bge': '#04070c', '--scr-brd': '#16283a',
'--scr-grat': 'rgba(148,184,216,.18)', '--scr-gratc': 'rgba(148,184,216,.35)', '--scr-glow': 'rgba(207,228,255,.8)',
'--crt-face1': '#c2d2dd', '--crt-face2': '#93a8b6', '--crt-glow': '#eaf2ff' },
vfd: { '--scr-hi': '#63e6c8', '--scr-ink': '#46b89e', '--scr-dim': '#1e4a40',
'--scr-bg1': '#06100d', '--scr-bg2': '#040b09', '--scr-bgc': '#041410', '--scr-bge': '#020a08', '--scr-brd': '#0e3028',
'--scr-grat': 'rgba(70,184,158,.18)', '--scr-gratc': 'rgba(70,184,158,.35)', '--scr-glow': 'rgba(99,230,200,.8)',
'--crt-face1': '#b0d8cd', '--crt-face2': '#84ada2', '--crt-glow': '#dcfff5' },
white: { '--scr-hi': '#f2f4f2', '--scr-ink': '#c8cac8', '--scr-dim': '#5a5c5a',
'--scr-bg1': '#0b0c0b', '--scr-bg2': '#070807', '--scr-bgc': '#0d0e0d', '--scr-bge': '#060706', '--scr-brd': '#2a2c2a',
'--scr-grat': 'rgba(200,202,200,.18)', '--scr-gratc': 'rgba(200,202,200,.35)', '--scr-glow': 'rgba(242,244,242,.7)',
'--crt-face1': '#d0d2d0', '--crt-face2': '#a8aaa8', '--crt-glow': '#f7f7f7' },
};
/* ================= instrument builders =================
Every builder: DUPRE.name(host, opts) → handle. host is an empty element the
instrument renders into. opts.onChange(value, text) fires on every state change,
including the initial paint; text is the instrument's canonical readout string. */
const noop = () => {};
/* ---- the accent family ----
One named set of lit colours for the instruments whose colour IS their claim: a
chip, a lamp, a badge, a status line. "On" is good in one panel, a warning in
the next and a fault in the one after, so the colour belongs to the consumer
rather than the builder.
Deliberately NOT applied to instruments whose colour is the object rather than a
state — the nixie (the palette is explicit that neon is only ever orange), the
flip-disc's yellow, the dekatron's glow, a red needle — nor to the ones where
the colour is a standard rather than a preference: three greens on the landing
gear means down and locked, and a breaker's red/amber/green is its trip
semantics. Recolouring those doesn't restyle them, it makes them lie.
accentStyles(varName) builds a STYLES axis driving one CSS custom property, so
each consumer names its own var and the family stays single-source. */
const ACCENTS = {
amber: 'var(--gold)',
green: 'var(--pass)',
red: 'var(--fail)',
white: 'var(--cream)',
vfd: 'var(--vfd)',
};
DUPRE.accentStyles = varName => Object.fromEntries(
Object.entries(ACCENTS).map(([name, colour]) => [name, { dot: colour, vars: { [varName]: colour } }]));
/* ---- policy ----
Every instrument's colour is either the consumer's to pick or locked for a reason,
and the reason is one of these six kinds. Declared per builder (DUPRE..POLICY)
so it's a checked property rather than something the next person recalls — the
colour pass kept turning up cards where "is this one a standard?" was answered
from memory and answered wrong. The two FREE kinds get chips; the four LOCKED
ones must not, and the probe enforces the split.
Policy is a property of the INSTRUMENT, not the colour: the same vfd cyan is a free
accent on a chip and a locked emissive identity on the marquee. A few cards are
mixed (a free display with a coded red-line) and declare per element in time.
Each builder's POLICY is a record { kind, why, authentic }:
kind — one of the six below.
why — why THIS instrument is bound that way, in its own terms.
authentic — what may change and still be true to the reference (the range
that actually existed), or 'nothing' when the colour is fixed. */
DUPRE.POLICIES = {
accent: { free: true, gist: 'A lit state whose meaning varies by panel; the consumer picks from the accent family.' },
screen: { free: true, gist: 'A display whose phosphor was made in several real colours; the consumer picks from the screen family.' },
coded: { free: false, gist: 'Colour is meaning fixed by an external standard; a recolour misleads a trained operator (landing-gear three-greens, breaker trip).' },
emissive: { free: false, gist: 'Colour is what the physical source emits and no other was made; unrecognisable otherwise (nixie neon, dekatron glow).' },
relational: { free: false, gist: 'Colour means what it does only by contrast with another on the same instrument; the set is a scale or legend (VU zones, crossed needles).' },
material: { free: false, gist: 'Colour of a static physical part, not a state — nothing to parameterise as a signal (needle, brass bezel, knife blade).' },
};
/* 01 slide toggle — on/off pill. State colors ride CSS vars (--sw-*).
opts.onStyle / offStyle / offText / thumb pick a named style per axis from
DUPRE.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's
setStyle(axis, name) restyles a live instance (the gallery chips use it). */
DUPRE.slideToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const sw = document.createElement('span'); sw.className = 'switch'; host.appendChild(sw);
const setStyle = (axis, name) => {
const o = (DUPRE.slideToggle.STYLES[axis] || {})[name];
if (!o) return;
for (const [k, v] of Object.entries(o.vars)) sw.style.setProperty(k, v);
};
/* opts.preset names an intent; per-axis opts still win over it, so a caller can
take 'armed' and swap only the thumb. setPreset re-applies all four axes at
once and returns the axis map so a host UI can resync its own controls. */
const preset = DUPRE.slideToggle.PRESETS[opts.preset] || DUPRE.slideToggle.PRESETS.panel;
/* AXIS_ORDER is load-bearing, not cosmetic: onText overrides the ink that the
`on` style sets, so applying the axes in any other order silently drops it. */
const setPreset = name => {
const p = DUPRE.slideToggle.PRESETS[name];
if (!p) return null;
for (const axis of DUPRE.slideToggle.AXIS_ORDER) if (p[axis]) setStyle(axis, p[axis]);
return p;
};
const pick = { on: opts.onStyle, onText: opts.onText, off: opts.offStyle,
offText: opts.offText, thumb: opts.thumb };
for (const axis of DUPRE.slideToggle.AXIS_ORDER) setStyle(axis, pick[axis] || preset[axis]);
let on;
const set = v => { on = !!v; sw.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
sw.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: sw, get: () => on, set, setStyle, setPreset };
};
/* named presets — a combination that carries intent, which is what a consumer
actually reaches for; the STYLES axes below are how one is built. The `on`
tone does the semantic work and the thumb material backs it: a touchscreen
pill wears plastic, a run or armed switch wears metal. */
DUPRE.slideToggle.AXIS_ORDER = ['on', 'onText', 'off', 'offText', 'thumb'];
DUPRE.slideToggle.PRESETS = {
panel: { on: 'amber', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' },
run: { on: 'green', onText: 'panel', off: 'dark', offText: 'dim', thumb: 'chrome' },
armed: { on: 'red', onText: 'cream', off: 'dark', offText: 'white', thumb: 'chrome' },
caution: { on: 'warn', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' },
/* dark — neither state lights the pill; the legend alone carries the state,
green for on and Control red ("OFF marks") for off. The chrome thumb stays
the position affordance, since with both pills dark it is the only one. */
dark: { on: 'dark', onText: 'green', off: 'dark', offText: 'red', thumb: 'chrome' },
};
/* named styles per axis: dot = swatch color for pickers, vars = --sw-* overrides */
DUPRE.slideToggle.STYLES = {
on: {
amber: { dot: 'var(--gold-hi)', vars: { '--sw-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--sw-on-brd': 'var(--gold-hi)', '--sw-on-ink': 'var(--panel)' } },
green: { dot: '#8fb944', vars: { '--sw-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--sw-on-brd': '#a9c95f', '--sw-on-ink': 'var(--panel)' } },
/* red/warn make the ENGAGED state the notable one — mute, record, airplane
mode. Before them the only red was on the `off` axis, which paints the
disengaged state: the opposite claim. Palette semantics: --fail is
"muted keys", --amber-warn is "warn cells", distinct from panel amber. */
red: { dot: 'var(--fail)', vars: { '--sw-on-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-on-brd': 'var(--fail)', '--sw-on-ink': 'var(--cream)' } },
warn: { dot: 'var(--amber-warn)', vars: { '--sw-on-bg': 'linear-gradient(180deg,#f7d193,var(--amber-warn))', '--sw-on-brd': 'var(--amber-warn)', '--sw-on-ink': 'var(--panel)' } },
dark: { dot: '#242019', vars: { '--sw-on-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-on-brd': 'var(--slate)', '--sw-on-ink': 'var(--cream)' } },
},
off: {
dark: { dot: '#242019', vars: { '--sw-off-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-off-brd': 'var(--slate)' } },
red: { dot: 'var(--fail)', vars: { '--sw-off-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-off-brd': 'var(--fail)' } },
},
/* onText overrides the ink the `on` style sets. The two are not independent —
ink must contrast with its own background, which is why each `on` atom still
carries a sensible default; naming onText is how a caller says "keep the
pill, change the legend". That is what makes the `dark` preset possible:
both states unlit, the legend colour carrying the state on its own. */
onText: {
panel: { dot: 'var(--panel)', vars: { '--sw-on-ink': 'var(--panel)' } },
cream: { dot: 'var(--cream)', vars: { '--sw-on-ink': 'var(--cream)' } },
green: { dot: 'var(--sevgrn)', vars: { '--sw-on-ink': 'var(--sevgrn)' } },
},
offText: {
white: { dot: 'var(--cream)', vars: { '--sw-off-ink': 'var(--cream)' } },
/* Control red — the palette's "OFF marks" ink, so a red OFF legend is the
kit's existing grammar rather than a new claim. */
red: { dot: '#e0523a', vars: { '--sw-off-ink': '#e0523a' } },
/* steel is the kit's engraved-caption ink: lets an off toggle recede where a
panel carries many of them and eight cream OFFs would all shout equally. */
dim: { dot: 'var(--steel)', vars: { '--sw-off-ink': 'var(--steel)' } },
black: { dot: '#14110e', vars: { '--sw-off-ink': '#14110e' } },
},
thumb: {
light: { dot: '#d8d2c4', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#f6f2e8,#b0aa9a)' } },
dark: { dot: '#2c2824', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#4a453e,#16130f)' } },
chrome: { dot: '#9aa2b4', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#dfe6f2,#6d7484)' } },
brass: { dot: '#b8944a', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#e2c47a,#8a6a24)' } },
},
};
/* 02 console keys — mutually exclusive push buttons; {label, tone} per key.
tone picks the engaged look: undefined = gold (the default lit key), 'green' =
run green, 'red' = terracotta. Reading order runs safe -> live -> muted, and
LIVE takes green because that is what --pass means everywhere else in the kit
(the palette names it "run lamps, gear greens, monitor bars, LIVE lamps"). */
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 = '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('on', 'green', 'red');
if (j === idx) b.classList.add(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. */
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: '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', '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 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. */
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 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 = s.ownerDocument.querySelector('#dupre-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', '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', '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', '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', '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 */
DUPRE.faderH = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const f = document.createElement('div'); f.className = 'fader';
f.innerHTML = '
';
host.appendChild(f);
let val;
const set = p => {
val = Math.max(0, Math.min(100, p));
f.querySelector('.fill').style.width = val + '%';
f.querySelector('.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 — one channel-strip fader; compose per channel */
DUPRE.faderV = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const f = document.createElement('div'); f.className = 'vfader';
f.innerHTML = '
';
host.appendChild(f);
let val;
const set = p => {
val = Math.max(0, Math.min(100, p));
f.querySelector('.fill').style.height = val + '%';
f.querySelector('.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 */
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 = 'knob';
k.innerHTML = '';
host.appendChild(k);
let val;
const set = v => {
val = Math.max(min, Math.min(max, v));
k.querySelector('.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. */
DUPRE.segmented = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || ['TIMER', 'ALARM', 'POMO'];
const seg = document.createElement('div'); seg.className = '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 = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); };
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 */
DUPRE.chipToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const chip = document.createElement('span'); chip.className = '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('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 */
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 = 'arm'; a.textContent = label;
host.appendChild(a);
let armed = false;
a.addEventListener('click', () => {
if (!armed) { armed = true; a.classList.add('armed'); a.textContent = armLabel; onChange('armed', 'ARMED'); }
else { armed = false; a.classList.remove('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 */
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 = 'lrow';
row.innerHTML = ``;
row.querySelector('b').textContent = name;
host.appendChild(row);
let idx;
const set = i => {
idx = i;
row.querySelector('.dupre-lamp').className = 'dupre-lamp' + (states[i][0] ? ' dupre-' + states[i][0] : '');
row.querySelector('.what').textContent = states[i][1];
onChange(i, states[i][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 */
DUPRE.rotarySelector = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const values = opts.values || [4, 6, 8, 10, 12];
const fmt = opts.fmt || (v => 'size ' + v);
const POS = [[14, 53], [28, 18], [50, 7], [72, 18], [86, 53]], ANG = [-70, -35, 0, 35, 70];
const rs = document.createElement('span'); rs.className = 'rotsel';
values.forEach((v, i) => {
const p = document.createElement('span'); p.className = 'pos';
p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = v;
rs.appendChild(p);
});
const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = ''; rs.appendChild(k);
host.appendChild(rs);
let idx;
const set = i => {
idx = ((i % values.length) + values.length) % values.length;
rs.querySelector('.ind').style.transform = `rotate(${ANG[idx]}deg)`;
rs.querySelectorAll('.pos').forEach((p, j) => p.classList.toggle('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. set() takes a stop index; get() returns the value. opts.skin picks
a face from DUPRE.slideRule.STYLES (warm backlit / chrome / 80s black glass / marantz blue);
setStyle(axis, name) restyles a live instance. */
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 = 'tuner';
t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value');
const setStyle = (axis, name) => {
const o = (DUPRE.slideRule.STYLES[axis] || {})[name];
if (!o) return;
for (const [k, v] of Object.entries(o.vars)) t.style.setProperty(k, v);
};
setStyle('skin', opts.skin || 'warm');
stops.forEach(s => {
t.insertAdjacentHTML('beforeend', s.major
? `${s.v}`
: ``);
});
const ndl = document.createElement('span'); ndl.className = 'ndl'; t.appendChild(ndl);
host.appendChild(t);
let idx;
const set = i => {
idx = Math.max(0, Math.min(stops.length - 1, i));
ndl.style.left = stops[idx].x + 'px';
t.querySelectorAll('.mk').forEach(m => m.classList.toggle('on', +m.textContent === stops[idx].v));
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 */
DUPRE.rocker = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const r = document.createElement('span'); r.className = 'rocker';
r.innerHTML = `${opts.onLabel || 'ON'}${opts.offLabel || 'OFF'}`;
host.appendChild(r);
let on;
const set = v => { on = !!v; r.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
r.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: r, get: () => on, set };
};
/* N02 transport cluster — rew/play/stop/rec, one lit; reels turn while playing.
opts.animate: reels obey play state (default: not prefers-reduced-motion) */
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 = `
`;
host.appendChild(el);
const ndl = el.querySelector('.dupre-ndl'), num = el.querySelector('.dupre-gv span');
function set(nv) {
v = nv;
ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`;
num.textContent = Math.round(v);
onChange(v, 'value ' + Math.round(v) + '%');
}
dragDelta(el, () => v, set, { min: 0, max: 100 });
set(v);
return { el, get: () => v, set };
};
/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both.
Contract (everything a consumer needs; no page globals touched):
opts: bars (cells per channel, default 16); onChange([l, r], 'L n · R n')
fires on every set (values 0-1, readout in percent).
handle: el, get() ([l, r] last set), set(l, r). Peak-hold is display-side
state owned in here: each channel's peak cell decays 0.4 cells
per set() call, so the page's tick cadence is the decay clock
(the tick contract — the page owns the clock and the signal).
CSS lives in the "segmented VU / LED bar" block of DUPRE_CSS; no other
styles involved. */
DUPRE.vuPair = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const n = opts.bars || 16;
const el = document.createElement('div'); el.className = 'dupre-vu';
el.innerHTML = `
L
R
`;
host.appendChild(el);
const bars = el.querySelectorAll('.dupre-vubar');
bars.forEach(b => buildBars(b, n));
const pkL = { v: 0 }, pkR = { v: 0 };
function paint(bar, l, pk) {
const b = bar.children, lit = Math.round(l * n);
pk.v = Math.max(lit, (pk.v || 0) - 0.4); const p = Math.round(pk.v);
for (let k = 0; k < n; k++) {
let c = k < lit ? (k >= n - 2 ? 'dupre-clip' : k >= n - 4 ? 'dupre-hot' : 'dupre-on') : '';
if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'dupre-peak';
b[k].className = c;
}
}
let lv = 0, rv = 0;
function set(l, r) {
lv = l; rv = r;
paint(bars[0], l, pkL); paint(bars[1], r, pkR);
onChange([l, r], 'L ' + Math.round(l * 100) + ' · R ' + Math.round(r * 100));
}
return { el, get: () => [lv, rv], set };
};
/* 12 mini 4-bar signal — compact activity meter; set(level) */
DUPRE.miniSig = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const el = document.createElement('span'); el.className = 'sig';
el.innerHTML = '';
host.appendChild(el);
let v = 0;
function set(l) {
v = l;
const b = el.children, lit = Math.round(l * 4);
for (let k = 0; k < 4; k++) b[k].className = k < lit ? (k >= 3 ? 'clip' : k >= 2 ? 'hot' : 'on') : '';
onChange(l, Math.round(l * 100) + '%');
}
return { el, get: () => v, set };
};
/* 13 signal ladder — stepped 0-4 strength; click cycles.
Contract (everything a consumer needs; no page globals touched):
opts: value (0-4, default 3); onChange(v, 'v/4') fires on every set,
including the initial paint.
handle: el, get() (current step), set(v) — bars at or below v light
gold; click advances (v + 1) % 5.
CSS lives in the "signal ladder" block of DUPRE_CSS; the lit/unlit bar
colours are inline (--gold / --wash), no other styles involved. */
DUPRE.signalLadder = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 3;
const el = document.createElement('span'); el.className = 'dupre-ladder';
el.innerHTML = '';
host.appendChild(el);
function set(nv) {
v = nv;
const bars = el.children;
for (let i = 0; i < bars.length; i++) bars[i].style.background = i < v ? 'var(--gold)' : 'var(--wash)';
onChange(v, v + '/4');
}
el.onclick = () => set((v + 1) % 5);
set(v);
return { el, get: () => v, set };
};
/* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set */
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 = 'bar';
el.innerHTML = '';
host.appendChild(el);
const fill = el.querySelector('span');
function set(p) {
v = p;
fill.style.width = p + '%';
el.classList.toggle('warn', p < warnAt);
onChange(p, Math.round(p) + '%');
}
dragX(el, set);
set(v);
return { el, get: () => v, set };
};
/* 15 radial ring — percentage donut; drag up/down to set */
DUPRE.radialRing = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 68;
const el = document.createElement('span'); el.className = 'ring';
el.innerHTML = '';
host.appendChild(el);
const num = el.querySelector('b');
function set(nv) {
v = 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 */
DUPRE.sparkline = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const n = opts.samples || 40;
const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
const el = document.createElement('span'); el.className = 'spark';
el.innerHTML = '';
host.appendChild(el);
const line = el.querySelector('polyline');
const clamp = x => Math.max(0, Math.min(1, x));
function paint() {
line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 170},${44 - clamp(v) * 40 - 2}`).join(' '));
onChange(hist[n - 1], String(Math.round(clamp(hist[n - 1]) * 100)));
}
function push(v) { hist.push(v); hist.shift(); paint(); }
function fill(v) { hist.fill(v); paint(); }
return { el, get: () => hist[n - 1], push, fill };
};
/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1 */
DUPRE.waveStrip = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const el = document.createElement('span'); el.className = 'wave';
el.innerHTML = '';
host.appendChild(el);
const path = el.querySelector('path');
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 + 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 */
DUPRE.scope = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const el = document.createElement('span'); el.className = 'scope';
el.innerHTML = '';
host.appendChild(el);
const line = el.querySelector('polyline');
let vpp = 0;
function set(samples, v) {
vpp = v;
const n = samples.length;
line.setAttribute('points', samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + 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 */
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 = 'eq';
host.appendChild(el);
for (let b = 0; b < bands; b++) {
const band = document.createElement('span'); band.className = '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 = values[b], 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 ? 'clip' : k >= cells - 3 ? 'hot' : '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 */
DUPRE.crossNeedle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 55;
const el = document.createElement('div'); el.className = 'crossm';
el.innerHTML = `
FWDRFL
`;
host.appendChild(el);
const face = el.querySelector('.face'), nA = el.querySelector('.nA'), nB = el.querySelector('.nB');
function set(nv) {
v = 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 */
DUPRE.thermometer = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 58;
const el = document.createElement('div'); el.className = 'thermo';
el.innerHTML = `
906030
`;
host.appendChild(el);
const fill = el.querySelector('.fill');
function set(nv) {
v = 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 */
DUPRE.bourdon = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 45;
const el = document.createElement('div'); el.className = 'bourdon';
el.innerHTML = `
PSI
`;
host.appendChild(el);
const ndl = el.querySelector('.ndl');
function set(nv) {
v = 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;
push(v) appends, set(samples, current) replaces the trace (values 0..1) */
DUPRE.stripChart = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const n = opts.samples || 60;
const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
const el = document.createElement('span'); el.className = 'strip';
el.innerHTML = '';
host.appendChild(el);
const line = el.querySelector('polyline'), pen = el.querySelector('.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) {
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 */
DUPRE.corrMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let p = opts.value ?? 58;
const el = document.createElement('div'); el.className = 'corr';
el.innerHTML = `
−10+1
`;
host.appendChild(el);
const ndl = el.querySelector('.ndl');
function set(np) {
p = 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 */
DUPRE.battCells = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const cells = opts.cells || 8, warnAt = opts.warnAt ?? 25;
let p = opts.value ?? 62;
const el = document.createElement('div'); el.className = 'batt';
el.innerHTML = '';
host.appendChild(el);
const cc = el.querySelector('.cells');
for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'cell'; cc.appendChild(c); }
function set(np) {
p = np;
const lit = Math.round(p / 100 * cells);
for (let i = 0; i < cells; i++) cc.children[i].className = 'cell' + (i < lit ? ' on' : '') + ((p <= warnAt && i < lit) ? ' 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;
set(t) positions instantly (0..1.02); ballistics belong to the signal owner */
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';
const needle = svgEl(face, 'line', {
x1: cx, y1: cy, x2: cx, y2: cy - 62, stroke: '#1a1613', 'stroke-width': 1.6,
transform: `rotate(${sweep(.35)},${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})` });
let t = opts.value ?? .35;
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 */
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 */
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 */
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 */
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 */
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) */
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, 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 */
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 */
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 */
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 = opts.fuel ?? 2.4, vO = opts.oil ?? 3.1;
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);
draw();
return { el: s, get: () => [vF, vO], set };
};
/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts */
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 = opts.temp ?? 72, vH = opts.humidity ?? 45;
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);
draw();
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 */
DUPRE.badges = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const CLS = ['badge', 'badge red', 'badge ghost'];
const items = opts.items || [['TUNNEL', 0], ['LOW BATT', 1], ['2.4G', 2]];
const vars = items.map(it => it[1]);
const wrap = document.createElement('span');
const els = items.map(([txt, v], i) => {
const b = document.createElement('span'); b.className = CLS[v]; 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] = ((v % CLS.length) + CLS.length) % CLS.length;
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 */
DUPRE.tabularReadout = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const wrap = document.createElement('div'); wrap.style.textAlign = 'center';
wrap.innerHTML = '';
const out = wrap.querySelector('.readout');
wrap.querySelector('.u').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 */
DUPRE.engravedLabel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const e = document.createElement('span'); e.className = 'engrave';
e.append(opts.label || 'outputs');
const c = document.createElement('span'); c.className = '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 */
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 = 'owell'; host.appendChild(w);
const add = s => {
const d = document.createElement('div'); d.className = 'ostep';
d.innerHTML = `${s[1]}${s[2]}`;
w.appendChild(d); while (w.children.length > keep) w.removeChild(w.firstChild);
};
seed.forEach(add);
const push = s => { add(s); onChange(s, '+ ' + s[1]); };
let i = 0;
w.addEventListener('click', () => { i = (i + 1) % steps.length; push(steps[i]); });
onChange(null, 'click to stream');
return { el: w, push };
};
/* 23 toast — one-line transient confirmation; click fires the next demo
message; fire(msg) shows any message with the fade-in */
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 = 'toastw';
t.textContent = opts.text || msgs[msgs.length - 1];
host.appendChild(t);
const fire = msg => {
t.textContent = msg; t.style.opacity = '0';
requestAnimationFrame(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '1'; });
};
let n = 0;
t.addEventListener('click', () => { n++; fire(msgs[n % msgs.length]); onChange(n, 'fired ' + n); });
onChange(0, 'click to fire');
return { el: t, fire };
};
/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments.
Contract (everything a consumer needs; no page globals touched):
opts: digits (tube count, default 2); value (initial, default 8);
onChange(value, zero-padded string) fires on every set.
handle: el, get() (current value), set(v) — wraps modulo 10^digits;
leading-zero tubes go dark rather than showing 0, like the
hardware.
CSS lives in the "nixie tube" block of DUPRE_CSS; no other styles involved. */
DUPRE.nixie = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const digits = opts.digits || 2;
const max = Math.pow(10, digits);
const nx = document.createElement('span'); nx.className = 'dupre-nixie';
for (let i = 0; i < digits; i++) {
const tu = document.createElement('span'); tu.className = 'dupre-tube'; tu.innerHTML = ''; nx.appendChild(tu);
}
host.appendChild(nx);
let v;
const set = x => {
v = ((x % max) + max) % max;
const s = String(v).padStart(digits, '0');
[...nx.children].forEach((tu, i) => {
tu.classList.toggle('dupre-off', i < digits - 1 && v < Math.pow(10, digits - 1 - i));
tu.querySelector('b').textContent = s[i];
});
onChange(v, s);
};
nx.addEventListener('click', () => set(v + 1));
set(opts.value !== undefined ? opts.value : 8);
return { el: nx, get: () => v, set };
};
/* N20 split-flap — an honest Solari mechanism. Each cell owns a stack of
flaps in `chars` order (data, like R58's LAYOUT) and can only advance one
flap at a time, so a changed reading cascades through intermediates and
cells arrive staggered by travel distance. setText() mid-cascade re-aims
the running cells; animate:false (the page's reduced-motion gate)
collapses every move to an instant jump. One flip is two half-panel
animations: the current top falls (rotateX 0 -> -180) while the next
bottom lands (180 -> 0), backfaces hidden, crease preserved. First paint
is silent (no cascade on load), like the original. The page drives next()
on its own cadence.
Contract (everything a consumer needs; no page globals touched):
opts: rows (1) x cells (4) grid; chars (flap order, default space+A-Z+0-9);
flapMs (100, per-cell rate jittered 0.8x-1.35x so letters finish
at different times, base mutable via setFlapMs); animate (true);
skin ('paper'|'white'|'dark'|'light', card default paper); font ('helv'|'mono', card default helv);
words (the demo pool, at least rows+1 entries: next() sends every
row to a different random word, mutually distinct; set(i) pins the
top row for determinism); onChange(idx|-1, top word) fires at
command time. get() is the last set() index, -1 after a scramble.
handle: el, get(), set(i), next(), setText(multi-line string),
reading() (displayed grid, rows joined with newline),
setStyle(axis, name), chars, flapMs()/setFlapMs(ms) (base flap
rate, clamped 20-400), onSettle(cb) — cb(reading) fires once per
command when every cell has arrived (dwell hook).
CSS lives in the "split-flap" block of DUPRE_CSS; no other styles involved. */
DUPRE.splitFlap = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const words = opts.words || ['DNS ', 'LINK', 'SYNC', 'WIFI', 'SCAN'];
const animate = opts.animate !== undefined ? !!opts.animate : true;
const rows = opts.rows || 1;
const cells = opts.cells || 4;
const chars = opts.chars || ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const clampMs = ms => Math.min(400, Math.max(20, ms | 0));
let flapMs = clampMs(opts.flapMs || 100);
const ci = ch => Math.max(0, chars.indexOf(ch));
const fit = (str, w) => String(str).padEnd(w, ' ').slice(0, w);
const wordAt = k => words[((k % words.length) + words.length) % words.length];
// Four stacked half-panels per cell, painting order = stacking order:
// top-next (revealed as the current top falls), bottom-current (covered as
// the next bottom lands), top-current (the falling flap), bottom-next (the
// landing flap, resting folded away at rotateX(180) with its back hidden).
const st = []; // row-major cell states
const settleCbs = []; if (opts.onSettle) settleCbs.push(opts.onSettle);
let announced = true; // no announcement for the silent first paint
let idx = 0; // last set() index; -1 after a scramble
let lastGrid = ''; // the last commanded grid — the demo picks against
// commands, not in-flight glyphs mid-cascade
const allSettled = () => st.every(S => !S.running && S.cur === S.target);
const fitGrid = str => Array.from({ length: rows },
(_, r) => fit(String(str).split('\n')[r] || '', cells)).join('\n');
const reading = () => Array.from({ length: rows },
(_, r) => st.slice(r * cells, (r + 1) * cells).map(S => chars[S.cur]).join('')).join('\n');
const makeCell = () => {
const d = document.createElement('span'); d.className = 'flapd';
d.innerHTML = '';
st.push({
cur: 0, target: 0, running: false,
// Each cell's mechanism has its own character: a fixed per-cell jitter
// (0.8x-1.35x of the base rate) so letters finish at genuinely
// different times, beyond what travel distance alone staggers.
jitter: 0.8 + 0.55 * Math.random(),
ftn: d.querySelector('.ftn'), fbc: d.querySelector('.fbc'),
ftc: d.querySelector('.ftc'), fbn: d.querySelector('.fbn'),
});
return d;
};
const f = document.createElement('span'); f.className = 'flap' + (rows > 1 ? ' flap-rows' : '');
for (let r = 0; r < rows; r++) {
const line = rows > 1 ? document.createElement('span') : f;
if (rows > 1) { line.className = 'flapline'; f.appendChild(line); }
for (let c = 0; c < cells; c++) line.appendChild(makeCell());
}
host.appendChild(f);
// One cell's cascade: flip one place through the charset until the (live)
// target is reached. A retarget while running just moves the goalpost.
const run = S => {
if (S.running) return;
S.running = true;
(async () => {
while (S.cur !== S.target) {
const nk = (S.cur + 1) % chars.length;
S.ftn.textContent = chars[nk]; S.fbn.textContent = chars[nk];
if (animate) {
try {
const flip = { duration: Math.round(flapMs * S.jitter), easing: 'ease-in' };
await Promise.all([
S.ftc.animate([{ transform: 'rotateX(0deg)' }, { transform: 'rotateX(-180deg)' }], flip).finished,
S.fbn.animate([{ transform: 'rotateX(180deg)' }, { transform: 'rotateX(0deg)' }], flip).finished,
]);
} catch { break; /* cancelled (card torn down mid-flip) */ }
}
S.ftc.textContent = S.fbc.textContent = chars[nk];
S.cur = nk;
}
S.running = false;
maybeSettle();
})();
};
// Announce once per command, when the whole board has arrived. Consumers
// use it to dwell on the settled reading before commanding the next update.
const maybeSettle = () => {
if (announced || !allSettled()) return;
announced = true;
const r = reading();
settleCbs.forEach(cb => cb(r));
};
// Map a multi-line string onto the grid; missing lines/cells pad with space.
const gridTargets = str => {
const lines = String(str).split('\n');
return st.map((S, i) => ci(fit(lines[(i / cells) | 0] || '', cells)[i % cells]));
};
const setText = str => {
announced = false;
lastGrid = fitGrid(str);
// Assign every target before running any cell: a cell that finishes
// synchronously (animate:false) must not see its neighbours' stale,
// already-satisfied targets and announce a partial board as settled.
gridTargets(str).forEach((k, i) => { st[i].target = k; });
st.forEach(run);
maybeSettle(); // covers a command that changes nothing
};
const silent = str => {
lastGrid = fitGrid(str);
gridTargets(str).forEach((k, i) => {
const S = st[i]; S.cur = S.target = k;
S.ftn.textContent = S.fbc.textContent = S.ftc.textContent = S.fbn.textContent = chars[k];
});
};
// The words demo: every advance sends each row to a completely different
// word, drawn at random from the pool; rows stay mutually distinct. set(i)
// pins the top row (deterministic callers, tests); next() scrambles all.
const rowWords = () => lastGrid.split('\n');
const pickNew = taken => {
const open = words.filter(w => !taken.includes(fit(w, cells)));
return fit(open[(Math.random() * open.length) | 0] || wordAt(0), cells);
};
const gridFor = top => {
const out = [];
const prev = rowWords();
for (let r = 0; r < rows; r++)
out.push(r === 0 && top !== null ? fit(top, cells) : pickNew(out.concat(prev[r])));
return out.join('\n');
};
const set = i => { idx = ((i % words.length) + words.length) % words.length; setText(gridFor(wordAt(idx))); onChange(idx, wordAt(idx).trim()); };
const scramble = () => { idx = -1; const g = gridFor(null); setText(g); onChange(-1, g.split('\n')[0].trim()); };
f.addEventListener('click', scramble);
// construction axes (skin, font): swap this axis's classes per STYLES.
// Polarity and typeface are construction, not accent — no colour policy.
const setStyle = (axis, name) => {
const ax = DUPRE.splitFlap.STYLES[axis]; const o = ax && ax[name];
if (!o) return;
Object.values(ax).forEach(v => { if (v.cls) f.classList.remove(v.cls); });
if (o.cls) f.classList.add(o.cls);
};
if (opts.skin) setStyle('skin', opts.skin);
if (opts.font) setStyle('font', opts.font);
// first paint: consecutive pool words, silent (no cascade on load)
silent(Array.from({ length: rows }, (_, r) => fit(wordAt(r), cells)).join('\n'));
return {
el: f, get: () => idx, set, next: scramble, setStyle,
setText, chars, reading, onSettle: cb => settleCbs.push(cb),
flapMs: () => flapMs, setFlapMs: ms => { flapMs = clampMs(ms); },
};
};
DUPRE.splitFlap.STYLES = {
// board polarity + ink: dark board with the kit's cream ink (default),
// dark board with true-white ink, or the inverse ivory board
// dot: the board colour; the gallery card letters each chip in the skin's
// ink so the swatch is a miniature flap cell, not an ambiguous colour dot
skin: {
paper: { cls: 'flap-paper', dot: '#ffffff' },
white: { cls: 'flap-white', dot: '#141210' },
dark: { cls: '', dot: '#141210' },
light: { cls: 'flap-light', dot: '#f3e7c5' },
},
// typeface: the kit's Berkeley Mono, or the grotesque real boards wore
font: {
helv: { cls: 'flap-helv', dot: '#0d0c0b' },
mono: { cls: '', dot: '#0d0c0b' },
},
};
/* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick();
click adds a minute.
Contract (everything a consumer needs; no page globals touched):
opts: secs (initial seconds, default 24:10); onChange(secs, 'mm:ss')
fires on every set.
handle: el, get() (current seconds), set(v) — wraps modulo 3600;
tick() steps one second down (the tick contract — the page owns
the clock); click adds 60.
CSS lives in the "seven-segment" block of DUPRE_CSS; the digit glyphs come
from the shared seg7() engine helper (.seg7, shared with the DSKY). */
DUPRE.sevenSeg = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const sv = document.createElement('span'); sv.className = 'dupre-seven'; host.appendChild(sv);
let secs;
const set = v => {
secs = ((v % 3600) + 3600) % 3600;
const mm = String(Math.floor(secs / 60)).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0');
sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '' + seg7(ss[0]) + seg7(ss[1]);
onChange(secs, mm + ':' + ss);
};
sv.addEventListener('click', () => set(secs + 60));
set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10);
return { el: sv, get: () => secs, set, tick: () => set(secs - 1) };
};
/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message.
Contract (everything a consumer needs; no page globals touched):
opts: msgs (message pool, default demo set); width (window px, 176);
onChange(msg index, 'msg i/n') fires on click-cycle and once at
build.
handle: el, get() (current message index), tick() — advances the scroll
1.1px and wraps when the text clears the window (the tick
contract — the page owns the clock).
CSS lives in the "VFD marquee" block of DUPRE_CSS; no other styles involved. */
DUPRE.vfdMarquee = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const msgs = opts.msgs || ['ARCHSETUP · NET OK · BT 2 · SND 62%', 'WIFI @Hyatt · 300 Mbps · VPN UP', 'BATTERY 84% · DISK 61% · TEMP 47C'];
const W = opts.width || 176;
const m = document.createElement('span'); m.className = 'dupre-vfdm';
m.innerHTML = '';
const t = m.querySelector('.dupre-txt'); t.textContent = msgs[0];
host.appendChild(m);
let mi = 0, x = W;
m.addEventListener('click', () => {
mi = (mi + 1) % msgs.length; t.textContent = msgs[mi]; x = W;
onChange(mi, 'msg ' + (mi + 1) + '/' + msgs.length);
});
onChange(0, 'scrolling');
return {
el: m, get: () => mi,
tick: () => { x -= 1.1; if (x < -t.offsetWidth) x = W; t.style.left = x + 'px'; }
};
};
/* N23 annunciator — named alarm grid with the raise → MSTR CAUTION → ACK → RESET
lifecycle; TEST proves the bulbs then restores the board */
DUPRE.annunciator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const CLS = ['acell', 'acell warn', 'acell fault'];
const NAMES = ['ok', 'warn', 'fault'];
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 = 'annwrap';
const grid = document.createElement('div'); grid.className = 'annun'; wrap.appendChild(grid);
const bar = document.createElement('div'); bar.className = 'annbar';
const mc = document.createElement('span'); mc.className = 'mc'; mc.textContent = 'MSTR CAUTION'; bar.appendChild(mc);
['ACK', 'TEST', 'RESET'].forEach(a => {
const b = document.createElement('button'); b.className = '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[st]; c.textContent = label; grid.appendChild(c); return c;
});
let acked = false;
const active = () => grid.querySelectorAll('.warn,.fault').length;
const refresh = () => {
const n = active();
mc.classList.toggle('on', n > 0); mc.classList.toggle('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('.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 = 'acell'); acked = false; refresh(); }
else if (a === 'TEST') {
const prev = els.map(c => c.className);
els.forEach(c => c.className = 'acell fault');
mc.classList.add('on', '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[st]; if (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 */
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 = 'counter'; host.appendChild(ct);
for (let idx = 0; idx < wheels; idx++) {
const w = document.createElement('span'); w.className = 'cwheel' + (idx >= redFrom ? ' redw' : '');
const col = document.createElement('span'); col.className = '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('.col').style.top = (-(+s[i] + 1) * 34) + 'px'; });
onChange(v, s);
};
set(opts.value !== undefined ? opts.value : 471300);
return { el: ct, get: () => v, set };
};
/* N26 analog clock — engraved ticks + three hands; the page owns the time source
and drives set(h, m, s); silent until the first set, like the live original.
Contract (everything a consumer needs; no page globals touched):
opts: onChange([h, m, s], 'hh:mm:ss') fires on every set.
handle: el, set(h, m, s) — the minute hand carries a seconds fraction
and the hour hand a minutes fraction, so the face reads like a
real movement. Display-only: no state to get, no clock of its
own (the tick contract — the page owns the time source).
CSS lives in the "analog clock" block of DUPRE_CSS; no other styles involved. */
DUPRE.analogClock = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const cl = document.createElement('span'); cl.className = 'dupre-clock';
for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'dupre-tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); }
cl.insertAdjacentHTML('beforeend', '');
host.appendChild(cl);
const hh = cl.querySelector('.dupre-hh'), mh = cl.querySelector('.dupre-mh'), sh = cl.querySelector('.dupre-sh');
const set = (h, m, s) => {
sh.style.transform = `rotate(${s * 6}deg)`;
mh.style.transform = `rotate(${m * 6 + s * 0.1}deg)`;
hh.style.transform = `rotate(${(h % 12) * 30 + m * 0.5}deg)`;
onChange([h, m, s], String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'));
};
return { el: cl, set };
};
/* N27 frequency-dial scale — printed log axis, marks crowd low and spread high;
drag the pointer to tune */
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 = 'freqscale';
const band = document.createElement('span'); band.className = 'band'; band.textContent = unit; fs.appendChild(band);
marks.forEach(m => {
const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '11px'; fs.appendChild(tk);
const mk = document.createElement('span'); mk.className = '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 = '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 = '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 */
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 = 'patch';
const jacks = [];
for (let r = 0; r < rows; r++) {
const rowEl = document.createElement('div'); rowEl.className = 'row';
for (let c = 0; c < cols; c++) { const j = document.createElement('span'); j.className = '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('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('hot'); jacks[b].classList.add('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('sel'); return; }
if (pending === id) { pending = null; j.classList.remove('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('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) */
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 */
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 */
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 */
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