aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/widgets.js
diff options
context:
space:
mode:
Diffstat (limited to 'docs/prototypes/widgets.js')
-rw-r--r--docs/prototypes/widgets.js2908
1 files changed, 2008 insertions, 900 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index 5ba1226..57ad33e 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -1,12 +1,12 @@
-/* widgets.js — retro-instrument widget library (GW namespace).
+/* 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 GW.* call. Works from file:// — no modules, no build step.
+ 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 GW = {};
+const DUPRE = {};
/* ================= shared engine ================= */
@@ -107,19 +107,19 @@ const SCREEN_FAMS = {
'--crt-face1': '#d0d2d0', '--crt-face2': '#a8aaa8', '--crt-glow': '#f7f7f7' },
};
-/* ================= widget builders =================
- Every builder: GW.name(host, opts) → handle. host is an empty element the
- widget renders into. opts.onChange(value, text) fires on every state change,
- including the initial paint; text is the widget's canonical readout string. */
+/* ================= 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 widgets whose colour IS their claim: a
+ 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 widgets whose colour is the object rather than a
+ 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
@@ -134,60 +134,60 @@ const ACCENTS = {
white: 'var(--cream)',
vfd: 'var(--vfd)',
};
-GW.accentStyles = varName => Object.fromEntries(
+DUPRE.accentStyles = varName => Object.fromEntries(
Object.entries(ACCENTS).map(([name, colour]) => [name, { dot: colour, vars: { [varName]: colour } }]));
/* ---- policy ----
- Every widget'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 (GW.<name>.POLICY)
+ Every instrument's colour is either the consumer's to pick or locked for a reason,
+ and the reason is one of these six kinds. Declared per builder (DUPRE.<name>.POLICY)
so it's a checked property rather than something the next person recalls — the
colour pass kept turning up cards where "is this one a standard?" was answered
from memory and answered wrong. The two FREE kinds get chips; the four LOCKED
ones must not, and the probe enforces the split.
- Policy is a property of the WIDGET, not the colour: the same vfd cyan is a free
+ 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 widget is bound that way, in its own terms.
+ 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. */
-GW.POLICIES = {
+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 widget; the set is a scale or legend (VU zones, crossed needles).' },
+ 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
- GW.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's
+ DUPRE.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's
setStyle(axis, name) restyles a live instance (the gallery chips use it). */
-GW.slideToggle = function (host, opts = {}) {
+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 = (GW.slideToggle.STYLES[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 = GW.slideToggle.PRESETS[opts.preset] || GW.slideToggle.PRESETS.panel;
+ 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 = GW.slideToggle.PRESETS[name];
+ const p = DUPRE.slideToggle.PRESETS[name];
if (!p) return null;
- for (const axis of GW.slideToggle.AXIS_ORDER) if (p[axis]) setStyle(axis, p[axis]);
+ 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 GW.slideToggle.AXIS_ORDER) setStyle(axis, pick[axis] || preset[axis]);
+ 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));
@@ -198,8 +198,8 @@ GW.slideToggle = function (host, opts = {}) {
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. */
-GW.slideToggle.AXIS_ORDER = ['on', 'onText', 'off', 'offText', 'thumb'];
-GW.slideToggle.PRESETS = {
+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' },
@@ -210,7 +210,7 @@ GW.slideToggle.PRESETS = {
dark: { on: 'dark', onText: 'green', off: 'dark', offText: 'red', thumb: 'chrome' },
};
/* named styles per axis: dot = swatch color for pickers, vars = --sw-* overrides */
-GW.slideToggle.STYLES = {
+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)' } },
@@ -258,21 +258,28 @@ GW.slideToggle.STYLES = {
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"). */
-GW.consoleKeys = function (host, opts = {}) {
+ (the palette names it "run lamps, gear greens, monitor bars, LIVE lamps").
+ Contract (everything a consumer needs; no page globals touched):
+ opts: keys ([{label, tone}], default DEFAULT_KEYS); active (index; the
+ default set rests on LIVE, a caller's own set on its first key);
+ onChange(idx, label) fires on every engage.
+ handle: el, get() (engaged index), set(i) (clamped).
+ CSS lives in the "console keys" block of DUPRE_CSS; dsky and annunciator
+ reuse the key class for their pads. */
+DUPRE.consoleKeys = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const keys = opts.keys || GW.consoleKeys.DEFAULT_KEYS;
+ 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;
+ const b = document.createElement('button'); b.className = 'dupre-key'; b.textContent = k.label;
wrap.appendChild(b); return b;
});
let idx;
const set = i => {
idx = Math.max(0, Math.min(keys.length - 1, i));
btns.forEach((b, j) => {
- b.classList.remove('on', 'green', 'red');
- if (j === idx) b.classList.add(keys[idx].tone || 'on');
+ b.classList.remove('dupre-on', 'dupre-green', 'dupre-red');
+ if (j === idx) b.classList.add('dupre-' + (keys[idx].tone || 'on'));
});
onChange(idx, keys[idx].label);
};
@@ -281,10 +288,10 @@ GW.consoleKeys = function (host, opts = {}) {
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 === GW.consoleKeys.DEFAULT_KEYS ? 1 : 0));
+ : (keys === DUPRE.consoleKeys.DEFAULT_KEYS ? 1 : 0));
return { el: wrap, get: () => idx, set };
};
-GW.consoleKeys.DEFAULT_KEYS = [
+DUPRE.consoleKeys.DEFAULT_KEYS = [
{ label: 'SCAN' }, { label: 'LIVE', tone: 'green' }, { label: 'MUTED', tone: 'red' },
];
@@ -302,8 +309,16 @@ GW.consoleKeys.DEFAULT_KEYS = [
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. */
-GW.abcKeypad = function (host, opts = {}) {
+ - DEL added, CANCEL dropped. See the layout block below for both.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: max (16, buffer cap); onChange(buf, label) on every press — ENT
+ reports 'ENTER · <buf>', DEL/CLR report the remaining buffer.
+ handle: el (focusable; keyboard per KEYS), get() (the buffer), press(k)
+ (any ACTIONS member: plate characters, SPC, DEL, ENT, CLR).
+ CSS lives in the "keypad / index-plate focus" block of DUPRE_CSS (the
+ faceplate itself is drawn SVG); the entry window recolours with the shared
+ --scr-* screen vars. */
+DUPRE.abcKeypad = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const MAX = opts.max || 16;
const s = stageSvg(host, 'rsvg', 232, 226);
@@ -318,7 +333,7 @@ GW.abcKeypad = function (host, opts = {}) {
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,
+ svgEl(s, 'rect', { class: 'dupre-kp-win', x: 12, y: 10, width: 208, height: 30, rx: 4,
fill: 'var(--scr-bg1, #0a0806)', stroke: 'var(--scr-brd, #2c261d)', 'stroke-width': 2 });
const disp = svgEl(s, 'text', { x: 20, y: 31, 'font-size': 14, 'letter-spacing': '.14em',
'font-family': 'var(--mono)', fill: 'var(--scr-hi, var(--gold-hi))' });
@@ -342,7 +357,7 @@ GW.abcKeypad = function (host, opts = {}) {
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 (!GW.abcKeypad.ACTIONS.has(k)) return;
+ 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; }
@@ -392,7 +407,7 @@ GW.abcKeypad = function (host, opts = {}) {
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;
+ const g = svgEl(s, 'g', {}); g.setAttribute('class', 'dupre-kp-key'); g.dataset.k = k;
g.style.cursor = 'pointer'; g.style.transition = 'transform .07s';
svgEl(g, 'rect', { x, y, width: wd, height: 20, rx: 3.5, fill: FILL[tone], stroke: '#4e4a42', 'stroke-width': 1, 'stroke-opacity': .8 });
const t = svgEl(g, 'text', { x: x + wd / 2, y: y + 14.5, 'text-anchor': 'middle',
@@ -408,14 +423,14 @@ GW.abcKeypad = function (host, opts = {}) {
/* 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. GW.slideRule is the precedent. */
- s.setAttribute('class', 'rsvg kp-pad');
+ own Escape handler. DUPRE.slideRule is the precedent. */
+ s.setAttribute('class', 'rsvg dupre-kp-pad');
s.setAttribute('tabindex', '0');
s.addEventListener('click', () => s.focus());
s.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) return; /* leave shortcuts alone */
const name = e.key === ' ' ? 'Space' : (e.key.length === 1 ? e.key.toUpperCase() : e.key);
- const k = GW.abcKeypad.KEYS[name];
+ 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
@@ -430,10 +445,10 @@ GW.abcKeypad = function (host, opts = {}) {
/* 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 widget accepts and the two bindings would drift apart.
+ 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. */
-GW.abcKeypad.KEYS = (() => {
+DUPRE.abcKeypad.KEYS = (() => {
const m = {};
for (const c of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') m[c] = c;
m.Space = 'SPC'; m.Backspace = 'DEL'; m.Enter = 'ENT';
@@ -442,7 +457,7 @@ GW.abcKeypad.KEYS = (() => {
/* 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). */
-GW.abcKeypad.ACTIONS = new Set([
+DUPRE.abcKeypad.ACTIONS = new Set([
...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 'SPC', 'DEL', 'ENT', 'CLR',
]);
@@ -462,11 +477,20 @@ GW.abcKeypad.ACTIONS = new Set([
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. */
-GW.indexPlate = function (host, opts = {}) {
+ keys will be revisited and a layout welded into the geometry never is.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: max (22, line cap); onChange(buf, label) on select, print and clear.
+ handle: el (focusable; typing selects a cell, Enter pulls the lever),
+ get() (the printed line), select(c), print(), press(k) (any
+ ACTIONS member: plate characters, PRINT, CLR), selected() (the
+ stylus position, null before the first select).
+ CSS lives in the "keypad / index-plate focus" block of DUPRE_CSS (the
+ machine itself is drawn SVG); the paper recolours with the shared --scr-*
+ screen vars. */
+DUPRE.indexPlate = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const MAX = opts.max || 22;
- const L = GW.indexPlate.LAYOUT;
+ 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
@@ -480,7 +504,7 @@ GW.indexPlate = function (host, opts = {}) {
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);
+ const s = stageSvg(host, 'rsvg dupre-ix-pad', VW, VH);
gradDef('ixPlate', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#efe6c4'], ['1', '#d8caa0']]);
gradDef('ixBody', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#26221c'], ['1', '#100e0b']]);
gradDef('ixSteel', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e6e9ef'], ['1', '#8d93a1']]);
@@ -495,7 +519,7 @@ GW.indexPlate = function (host, opts = {}) {
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('#gw-defs') || svgEl(s, 'defs', {});
+ const defs = svgEl(s, 'defs', {});
const pat = svgEl(defs, 'pattern', { id: 'ixHalf', width: 4, height: 4, patternUnits: 'userSpaceOnUse' });
svgEl(pat, 'rect', { width: 4, height: 4, fill: 'url(#ixPlate)' });
svgEl(pat, 'circle', { cx: 1, cy: 1, r: 1.05, fill: '#2b2318', 'fill-opacity': .55 });
@@ -507,11 +531,11 @@ GW.indexPlate = function (host, opts = {}) {
a dark one for the lowercase — the plate's photographic-negative trick */
const ZFILL = { caps: '#e9e0bb', lower: '#231d13' };
const zoneAt = (r, c) => {
- const z = GW.indexPlate.ZONES.find(z =>
+ 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';
};
- GW.indexPlate.ZONES.forEach(z => {
+ 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,
@@ -527,8 +551,8 @@ GW.indexPlate = function (host, opts = {}) {
/* 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 widget must not do. A tip is enough to say "the pointer is here". */
- const arm = svgEl(s, 'g', {}); arm.setAttribute('class', 'ix-stylus');
+ this instrument must not do. A tip is enough to say "the pointer is here". */
+ const arm = svgEl(s, 'g', {}); arm.setAttribute('class', 'dupre-ix-stylus');
svgEl(arm, 'line', { x1: 0, y1: -9, x2: 0, y2: -20, stroke: 'url(#ixSteel)', 'stroke-width': 2.6 });
svgEl(arm, 'circle', { cx: 0, cy: -21, r: 3.4, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': .6 });
svgEl(arm, 'path', { d: 'M -3.6 -9 L 3.6 -9 L 0 0 Z', fill: 'url(#ixSteel)', stroke: '#41464f', 'stroke-width': .7 });
@@ -553,7 +577,7 @@ GW.indexPlate = function (host, opts = {}) {
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');
+ const g = svgEl(s, 'g', {}); g.setAttribute('class', 'dupre-ix-cell');
g.dataset.c = c; g.dataset.zone = z;
g.style.cursor = 'pointer';
const ring = svgEl(g, 'circle', { cx: x, cy: y, r: 8.6, fill: DISC[z],
@@ -568,7 +592,7 @@ GW.indexPlate = function (host, opts = {}) {
/* 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');
+ const lever = svgEl(s, 'g', {}); lever.setAttribute('class', 'dupre-ix-lever');
lever.style.cursor = 'pointer'; lever.style.transition = 'transform .08s';
svgEl(lever, 'rect', { x: LX - 3.5, y: PY + 6, width: 7, height: 72, rx: 3.5, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': .8 });
svgEl(lever, 'circle', { cx: LX, cy: PY + 2, r: 8, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': 1 });
@@ -585,7 +609,7 @@ GW.indexPlate = function (host, opts = {}) {
s.appendChild(lever);
/* fresh paper */
- const clr = svgEl(s, 'g', {}); clr.setAttribute('class', 'ix-clear'); clr.style.cursor = 'pointer';
+ const clr = svgEl(s, 'g', {}); clr.setAttribute('class', 'dupre-ix-clear'); clr.style.cursor = 'pointer';
/* anchored to PY like the rest of the gutter stack, not to VH — mixing the two
is what let a shorter plate slide this up onto the PRINT legend */
svgEl(clr, 'rect', { x: LX - 16, y: PY + 104, width: 32, height: 18, rx: 3, fill: '#3a332a', stroke: '#5c5348', 'stroke-width': 1 });
@@ -601,7 +625,7 @@ GW.indexPlate = function (host, opts = {}) {
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 (!GW.indexPlate.ACTIONS.has(k)) return;
+ if (!DUPRE.indexPlate.ACTIONS.has(k)) return;
if (k === 'PRINT') return print();
if (k === 'CLR') return fresh();
select(k);
@@ -617,7 +641,7 @@ GW.indexPlate = function (host, opts = {}) {
s.addEventListener('click', () => s.focus());
s.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
- const k = GW.indexPlate.KEYS[e.key];
+ 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);
@@ -639,7 +663,7 @@ GW.indexPlate = function (host, opts = {}) {
- 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. */
-GW.indexPlate.LAYOUT = [
+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'],
@@ -654,7 +678,7 @@ GW.indexPlate.LAYOUT = [
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. */
-GW.indexPlate.ZONES = [
+DUPRE.indexPlate.ZONES = [
{ rows: [1, 5], cols: [1, 5], zone: 'caps' },
{ rows: [1, 5], cols: [6, 10], zone: 'lower' },
];
@@ -663,27 +687,32 @@ GW.indexPlate.ZONES = [
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. */
-GW.indexPlate.KEYS = (() => {
+DUPRE.indexPlate.KEYS = (() => {
const m = {};
- for (const c of GW.indexPlate.LAYOUT.flat()) if (c) m[c] = c;
+ 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. */
-GW.indexPlate.ACTIONS = new Set([...GW.indexPlate.LAYOUT.flat().filter(Boolean), 'PRINT', 'CLR']);
-
-/* 03 horizontal fader — continuous 0-100 */
-GW.faderH = function (host, opts = {}) {
+DUPRE.indexPlate.ACTIONS = new Set([...DUPRE.indexPlate.LAYOUT.flat().filter(Boolean), 'PRINT', 'CLR']);
+
+/* 03 horizontal fader — continuous 0-100; drag anywhere on the slot to seek.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 68); onChange(value, label) on every move.
+ handle: el, get(), set(v) (clamped 0-100).
+ CSS lives in the "fader" block of DUPRE_CSS; no other styles involved. */
+DUPRE.faderH = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const f = document.createElement('div'); f.className = 'fader';
- f.innerHTML = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>';
+ const f = document.createElement('div'); f.className = 'dupre-fader';
+ f.innerHTML = '<div class="dupre-slot"><div class="dupre-fill"></div></div><div class="dupre-cap"></div>';
host.appendChild(f);
+ const fill = f.querySelector('.dupre-fill'), cap = f.querySelector('.dupre-cap');
let val;
const set = p => {
val = Math.max(0, Math.min(100, p));
- f.querySelector('.fill').style.width = val + '%';
- f.querySelector('.cap').style.left = val + '%';
+ fill.style.width = val + '%';
+ cap.style.left = val + '%';
onChange(val, 'level ' + Math.round(val));
};
dragX(f, set);
@@ -691,17 +720,24 @@ GW.faderH = function (host, opts = {}) {
return { el: f, get: () => val, set };
};
-/* 04 vertical fader — one channel-strip fader; compose per channel */
-GW.faderV = function (host, opts = {}) {
+/* 04 vertical fader — continuous 0-100, one channel-strip fader; compose per
+ channel.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 60); onChange(value, label) on every move.
+ handle: el, get(), set(v) (clamped 0-100).
+ CSS lives in the "vertical fader" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.faderV = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const f = document.createElement('div'); f.className = 'vfader';
- f.innerHTML = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>';
+ const f = document.createElement('div'); f.className = 'dupre-vfader';
+ f.innerHTML = '<div class="dupre-slot"><div class="dupre-fill"></div></div><div class="dupre-cap"></div>';
host.appendChild(f);
+ const fill = f.querySelector('.dupre-fill'), cap = f.querySelector('.dupre-cap');
let val;
const set = p => {
val = Math.max(0, Math.min(100, p));
- f.querySelector('.fill').style.height = val + '%';
- f.querySelector('.cap').style.bottom = val + '%';
+ fill.style.height = val + '%';
+ cap.style.bottom = val + '%';
onChange(val, 'level ' + Math.round(val));
};
dragY(f, set);
@@ -709,17 +745,23 @@ GW.faderV = function (host, opts = {}) {
return { el: f, get: () => val, set };
};
-/* 05 rotary knob — drag up/down to turn, -150°..+150° sweep */
-GW.knob = function (host, opts = {}) {
+/* 05 rotary knob — drag up/down to turn, -150°..+150° sweep.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: min (0), max (100), value (53); onChange(value, label) per step.
+ handle: el, get(), set(v) (clamped min..max).
+ CSS lives in the "rotary knob" block of DUPRE_CSS; rotarySelector and
+ encoder reuse the knob classes for their spindles. */
+DUPRE.knob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const min = opts.min !== undefined ? opts.min : 0, max = opts.max !== undefined ? opts.max : 100;
- const k = document.createElement('span'); k.className = 'knob';
- k.innerHTML = '<span class="ind"></span>';
+ const k = document.createElement('span'); k.className = 'dupre-knob';
+ k.innerHTML = '<span class="dupre-ind"></span>';
host.appendChild(k);
+ const ind = k.querySelector('.dupre-ind');
let val;
const set = v => {
val = Math.max(min, Math.min(max, v));
- k.querySelector('.ind').style.transform = `rotate(${-150 + (val - min) / (max - min) * 300}deg)`;
+ ind.style.transform = `rotate(${-150 + (val - min) / (max - min) * 300}deg)`;
onChange(val, String(Math.round(val)));
};
dragDelta(k, () => val, set, { min, max });
@@ -728,27 +770,37 @@ GW.knob = function (host, opts = {}) {
};
/* 06 segmented selector — pick one of a few. opts.accent picks the lit-segment
- color from GW.segmented.STYLES (amber / green / red); defaults match the
- stylesheet fallbacks. setStyle(axis, name) restyles a live instance. */
-GW.segmented = function (host, opts = {}) {
+ color from DUPRE.segmented.STYLES (amber / green / red); defaults match the
+ stylesheet fallbacks. setStyle(axis, name) restyles a live instance.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: items (labels, default TIMER/ALARM/POMO); active (0); accent
+ ('amber', a STYLES name); onChange(idx, label) on every pick.
+ handle: el, get(), set(i) (clamped), setStyle(axis, name).
+ CSS lives in the "stepper / segmented selector" block of DUPRE_CSS; no
+ other styles involved. */
+DUPRE.segmented = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || ['TIMER', 'ALARM', 'POMO'];
- const seg = document.createElement('div'); seg.className = 'seg'; host.appendChild(seg);
+ const seg = document.createElement('div'); seg.className = 'dupre-seg'; host.appendChild(seg);
const setStyle = (axis, name) => {
- const o = (GW.segmented.STYLES[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]); };
+ const set = i => {
+ idx = Math.max(0, Math.min(items.length - 1, i));
+ btns.forEach((b, j) => b.classList.toggle('dupre-on', j === idx));
+ onChange(idx, items[idx]);
+ };
btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
set(opts.active || 0);
return { el: seg, get: () => idx, set, setStyle };
};
/* named styles per axis: dot = swatch color for pickers, vars = --seg-* overrides */
-GW.segmented.STYLES = {
+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)' } },
@@ -756,20 +808,26 @@ GW.segmented.STYLES = {
},
};
-/* 07 chip toggle — inline binary inside a line of text */
-GW.chipToggle = function (host, opts = {}) {
+/* 07 chip toggle — inline binary inside a line of text.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: label ('discoverable on'); on (true); accent ('amber', a STYLES
+ name); onChange(on, 'ON'|'OFF') on every flip.
+ handle: el, get(), set(bool), setStyle(axis, name).
+ CSS lives in the "chip toggle" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.chipToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const chip = document.createElement('span'); chip.className = 'chip';
+ const chip = document.createElement('span'); chip.className = 'dupre-chip';
chip.textContent = opts.label || 'discoverable on';
host.appendChild(chip);
const setStyle = (axis, name) => {
- const o = (GW.chipToggle.STYLES[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'); };
+ const set = v => { on = !!v; chip.classList.toggle('dupre-on', on); onChange(on, on ? 'ON' : 'OFF'); };
chip.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: chip, get: () => on, set, setStyle };
@@ -778,63 +836,88 @@ GW.chipToggle = function (host, opts = {}) {
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. */
-GW.chipToggle.STYLES = { accent: GW.accentStyles('--chip-on') };
-
-/* 08 arm-to-fire — two-stage confirm for destructive actions */
-GW.armButton = function (host, opts = {}) {
+DUPRE.chipToggle.STYLES = { accent: DUPRE.accentStyles('--chip-on') };
+
+/* 08 arm-to-fire — two-stage confirm for destructive actions: first click
+ arms, second fires and resets to safe.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: label ('forget'); armLabel (label + '? again'); onChange(state,
+ label) with state 'safe' | 'armed' | 'fired'.
+ handle: el, get() ('armed'|'safe'), fire() (clicks through one stage).
+ CSS lives in the "arm-to-fire" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.armButton = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const label = opts.label || 'forget', armLabel = opts.armLabel || label + '? again';
- const a = document.createElement('button'); a.className = 'arm'; a.textContent = label;
+ const a = document.createElement('button'); a.className = 'dupre-arm'; a.textContent = label;
host.appendChild(a);
let armed = false;
a.addEventListener('click', () => {
- if (!armed) { armed = true; a.classList.add('armed'); a.textContent = armLabel; onChange('armed', 'ARMED'); }
- else { armed = false; a.classList.remove('armed'); a.textContent = label; onChange('fired', 'FIRED · reset'); }
+ if (!armed) { armed = true; a.classList.add('dupre-armed'); a.textContent = armLabel; onChange('armed', 'ARMED'); }
+ else { armed = false; a.classList.remove('dupre-armed'); a.textContent = label; onChange('fired', 'FIRED · reset'); }
});
onChange('safe', 'SAFE');
return { el: a, get: () => (armed ? 'armed' : 'safe'), fire: () => a.click() };
};
-/* 09 lamp row — actionable list item: lamp + name + status, click to cycle */
-GW.lampRow = function (host, opts = {}) {
+/* 09 lamp row — actionable list item: lamp + name + status, click to cycle.
+ states are [lampTone, label] pairs; lampTone '' | 'gold' | 'busy' | 'red'
+ maps onto the shared dupre-lamp classes.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: name ('WH-1000XM4'); states ([[tone, label], ...]); state (0);
+ onChange(idx, label) on every step.
+ handle: el, get(), set(i) (clamped).
+ CSS lives in the "lamp row (list item)" block of DUPRE_CSS; the lamp
+ itself is the shared dupre-lamp. */
+DUPRE.lampRow = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const name = opts.name || 'WH-1000XM4';
const states = opts.states || [['gold', 'tap to connect'], ['busy', 'connecting…'], ['', 'connected']];
- const row = document.createElement('div'); row.className = 'lrow';
- row.innerHTML = `<span class="lamp"></span><span class="who"><b></b></span><span class="what"></span>`;
+ const row = document.createElement('div'); row.className = 'dupre-lrow';
+ row.innerHTML = `<span class="dupre-lamp"></span><span class="dupre-who"><b></b></span><span class="dupre-what"></span>`;
row.querySelector('b').textContent = name;
host.appendChild(row);
+ const lamp = row.querySelector('.dupre-lamp'), what = row.querySelector('.dupre-what');
let idx;
const set = i => {
- idx = i;
- row.querySelector('.lamp').className = 'lamp ' + states[i][0];
- row.querySelector('.what').textContent = states[i][1];
- onChange(i, states[i][1]);
+ idx = Math.max(0, Math.min(states.length - 1, i));
+ lamp.className = 'dupre-lamp' + (states[idx][0] ? ' dupre-' + states[idx][0] : '');
+ what.textContent = states[idx][1];
+ onChange(idx, states[idx][1]);
};
row.addEventListener('click', () => set((idx + 1) % states.length));
set(opts.state || 0);
return { el: row, get: () => idx, set };
};
-/* 24 rotary selector — pick one of five printed detents by position */
-GW.rotarySelector = function (host, opts = {}) {
+/* 24 rotary selector — pick one of five printed detents by position; click
+ the knob to step (wraps). Position/angle plates are fixed at five stops,
+ so values takes at most five entries.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: values (up to 5 labels, default [4,6,8,10,12]); index (2);
+ fmt(v) → readout label; onChange(value, label) per step.
+ handle: el, get() (the value), set(i) (wraps modulo values).
+ CSS lives in the "rotary selector" block of DUPRE_CSS; the spindle
+ reuses the shared dupre-knob classes from the "rotary knob" block. */
+DUPRE.rotarySelector = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const values = opts.values || [4, 6, 8, 10, 12];
+ const values = (opts.values || [4, 6, 8, 10, 12]).slice(0, 5);
const fmt = opts.fmt || (v => 'size ' + v);
const POS = [[14, 53], [28, 18], [50, 7], [72, 18], [86, 53]], ANG = [-70, -35, 0, 35, 70];
- const rs = document.createElement('span'); rs.className = 'rotsel';
- values.forEach((v, i) => {
- const p = document.createElement('span'); p.className = 'pos';
+ const rs = document.createElement('span'); rs.className = 'dupre-rotsel';
+ const marks = values.map((v, i) => {
+ const p = document.createElement('span'); p.className = 'dupre-pos';
p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = v;
- rs.appendChild(p);
+ rs.appendChild(p); return p;
});
- const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = '<span class="ind"></span>'; rs.appendChild(k);
+ const k = document.createElement('span'); k.className = 'dupre-knob'; k.innerHTML = '<span class="dupre-ind"></span>'; rs.appendChild(k);
host.appendChild(rs);
+ const ind = k.querySelector('.dupre-ind');
let idx;
const set = i => {
idx = ((i % values.length) + values.length) % values.length;
- rs.querySelector('.ind').style.transform = `rotate(${ANG[idx]}deg)`;
- rs.querySelectorAll('.pos').forEach((p, j) => p.classList.toggle('on', j === idx));
+ ind.style.transform = `rotate(${ANG[idx]}deg)`;
+ marks.forEach((p, j) => p.classList.toggle('dupre-on', j === idx));
onChange(values[idx], fmt(values[idx]));
};
k.addEventListener('click', () => set(idx + 1));
@@ -845,10 +928,16 @@ GW.rotarySelector = function (host, opts = {}) {
/* 25 slide-rule dial — lit pointer on a printed scale. The printed numerals
are the majors; the integer units between them get minor ticks and are
selectable too. Click a numeral, a mark, or between marks; ←/→ (↑/↓) step
- one unit. set() takes a stop index; get() returns the value. opts.skin picks
- a face from GW.slideRule.STYLES (warm backlit / chrome / 80s black glass / marantz blue);
- setStyle(axis, name) restyles a live instance. */
-GW.slideRule = function (host, opts = {}) {
+ one unit.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: values (majors, default [4,6,8,10,12]); value or index (2) for the
+ initial stop; fmt(v) → readout label; skin ('warm', a STYLES name:
+ warm backlit / chrome / 80s black glass / marantz blue);
+ onChange(value, label) per step.
+ handle: el, get() (the value), set(stopIndex) (clamped), setStyle(axis, name).
+ CSS lives in the "slide-rule tuner dial" block of DUPRE_CSS; skins override
+ its --tn-* vars. */
+DUPRE.slideRule = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const majors = opts.values || [4, 6, 8, 10, 12];
const fmt = opts.fmt || (v => 'pos ' + v);
@@ -862,26 +951,26 @@ GW.slideRule = function (host, opts = {}) {
for (let u = v + 1; u < b; u++)
stops.push({ v: u, x: X[i] + (X[i + 1] - X[i]) * (u - v) / (b - v), major: false });
});
- const t = document.createElement('span'); t.className = 'tuner';
+ const t = document.createElement('span'); t.className = 'dupre-tuner';
t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value');
const setStyle = (axis, name) => {
- const o = (GW.slideRule.STYLES[axis] || {})[name];
+ const o = (DUPRE.slideRule.STYLES[axis] || {})[name];
if (!o) return;
for (const [k, v] of Object.entries(o.vars)) t.style.setProperty(k, v);
};
setStyle('skin', opts.skin || 'warm');
stops.forEach(s => {
t.insertAdjacentHTML('beforeend', s.major
- ? `<span class="tick" style="left:${s.x}px"></span><span class="mk" style="left:${s.x}px">${s.v}</span>`
- : `<span class="tick mn" style="left:${s.x}px"></span>`);
+ ? `<span class="dupre-tick" style="left:${s.x}px"></span><span class="dupre-mk" style="left:${s.x}px">${s.v}</span>`
+ : `<span class="dupre-tick dupre-mn" style="left:${s.x}px"></span>`);
});
- const ndl = document.createElement('span'); ndl.className = 'ndl'; t.appendChild(ndl);
+ const ndl = document.createElement('span'); ndl.className = 'dupre-ndl'; t.appendChild(ndl);
host.appendChild(t);
let idx;
const set = i => {
idx = Math.max(0, Math.min(stops.length - 1, i));
ndl.style.left = stops[idx].x + 'px';
- t.querySelectorAll('.mk').forEach(m => m.classList.toggle('on', +m.textContent === stops[idx].v));
+ t.querySelectorAll('.dupre-mk').forEach(m => m.classList.toggle('dupre-on', +m.textContent === stops[idx].v));
onChange(stops[idx].v, fmt(stops[idx].v));
};
t.addEventListener('click', e => {
@@ -902,7 +991,7 @@ GW.slideRule = function (host, opts = {}) {
};
/* named faces: dot = swatch color for pickers, vars = --tn-* overrides.
warm restates the stylesheet fallbacks so switching back is exact. */
-GW.slideRule.STYLES = {
+DUPRE.slideRule.STYLES = {
skin: {
warm: { dot: 'var(--gold-hi)', vars: {
'--tn-bg': 'linear-gradient(180deg,#191510,#0b0908)', '--tn-brd': '#2a251c',
@@ -927,33 +1016,45 @@ GW.slideRule.STYLES = {
},
};
-/* N01 rocker power switch — hard on/off, lit legend */
-GW.rocker = function (host, opts = {}) {
+/* N01 rocker power switch — hard on/off, lit legend.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onLabel ('ON'); offLabel ('OFF'); on (true); onChange(on,
+ 'ON'|'OFF') on every flip.
+ handle: el, get(), set(bool).
+ CSS lives in the "rocker" block of DUPRE_CSS; no other styles involved. */
+DUPRE.rocker = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const r = document.createElement('span'); r.className = 'rocker';
- r.innerHTML = `<span class="half top">${opts.onLabel || 'ON'}</span><span class="half bot">${opts.offLabel || 'OFF'}</span>`;
+ const r = document.createElement('span'); r.className = 'dupre-rocker';
+ r.innerHTML = `<span class="dupre-half dupre-top">${opts.onLabel || 'ON'}</span><span class="dupre-half dupre-bot">${opts.offLabel || 'OFF'}</span>`;
host.appendChild(r);
let on;
- const set = v => { on = !!v; r.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
+ const set = v => { on = !!v; r.classList.toggle('dupre-on', on); onChange(on, on ? 'ON' : 'OFF'); };
r.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: r, get: () => on, set };
};
/* N02 transport cluster — rew/play/stop/rec, one lit; reels turn while playing.
- opts.animate: reels obey play state (default: not prefers-reduced-motion) */
-GW.transport = function (host, opts = {}) {
+ Contract (everything a consumer needs; no page globals touched):
+ opts: mode ('play', one of rew|play|stop|rec); animate (reels obey play
+ state; default: not prefers-reduced-motion); onChange(mode, NAME)
+ on every pick.
+ handle: el, get() (the mode key), set(mode).
+ CSS lives in the "transport" block of DUPRE_CSS plus the reelspin
+ keyframes in the motion section. */
+DUPRE.transport = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const animate = opts.animate !== undefined ? opts.animate : !matchMedia('(prefers-reduced-motion: reduce)').matches;
const MODES = { rew: '⏮', play: '▶', stop: '⏹', rec: '⏺' };
const NAMES = { rew: 'REW', play: 'PLAY', stop: 'STOP', rec: 'REC' };
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;flex-direction:column;gap:6px;align-items:center';
- wrap.innerHTML = `<div class="reels"><span class="reel spin"><i></i><i></i><i></i></span><span class="reel spin"><i></i><i></i><i></i></span></div><div class="transport"></div>`;
- const bar = wrap.querySelector('.transport');
+ wrap.innerHTML = `<div class="dupre-reels"><span class="dupre-reel dupre-spin"><i></i><i></i><i></i></span><span class="dupre-reel dupre-spin"><i></i><i></i><i></i></span></div><div class="dupre-transport"></div>`;
+ const bar = wrap.querySelector('.dupre-transport');
+ const reels = wrap.querySelectorAll('.dupre-reel');
const btns = {};
for (const m of Object.keys(MODES)) {
- const b = document.createElement('button'); b.className = 'tbtn' + (m === 'rec' ? ' rec' : '');
+ const b = document.createElement('button'); b.className = 'dupre-tbtn' + (m === 'rec' ? ' dupre-rec' : '');
b.textContent = MODES[m]; bar.appendChild(b); btns[m] = b;
b.addEventListener('click', () => set(m));
}
@@ -961,37 +1062,51 @@ GW.transport = function (host, opts = {}) {
let mode;
const set = m => {
mode = m;
- for (const k in btns) btns[k].classList.toggle('on', k === m);
- wrap.querySelectorAll('.reel.spin').forEach(r => r.style.animationPlayState = (m === 'play' && animate) ? 'running' : 'paused');
+ for (const k in btns) btns[k].classList.toggle('dupre-on', k === m);
+ reels.forEach(r => r.style.animationPlayState = (m === 'play' && animate) ? 'running' : 'paused');
onChange(m, NAMES[m]);
};
/* initial: light the mode but leave reel play-state to CSS, matching load behavior */
mode = opts.mode || 'play';
- btns[mode].classList.add('on');
+ btns[mode].classList.add('dupre-on');
onChange(mode, NAMES[mode]);
return { el: wrap, get: () => mode, set };
};
-/* N03 radio preset bank — mechanically exclusive presets */
-GW.presetBank = function (host, opts = {}) {
+/* N03 radio preset bank — mechanically exclusive presets.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: items (labels, default WIFI/ETH/CELL/OFF); active (0);
+ onChange(idx, label) on every pick.
+ handle: el, get(), set(i) (clamped).
+ CSS lives in the "radio bank" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.presetBank = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || ['WIFI', 'ETH', 'CELL', 'OFF'];
- const bank = document.createElement('div'); bank.className = 'radiobank'; host.appendChild(bank);
- const btns = items.map(t => { const b = document.createElement('button'); b.className = 'preset'; b.textContent = t; bank.appendChild(b); return b; });
+ const bank = document.createElement('div'); bank.className = 'dupre-radiobank'; host.appendChild(bank);
+ const btns = items.map(t => { const b = document.createElement('button'); b.className = 'dupre-preset'; b.textContent = t; bank.appendChild(b); return b; });
let idx;
- const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); };
+ const set = i => { idx = Math.max(0, Math.min(items.length - 1, i)); btns.forEach((b, j) => b.classList.toggle('dupre-on', j === idx)); onChange(idx, items[idx]); };
btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
set(opts.active || 0);
return { el: bank, get: () => idx, set };
};
-/* N04 concentric dual knob — two values on one spindle, outer ring + inner cap */
-GW.dualKnob = function (host, opts = {}) {
+/* N04 concentric dual knob — two values on one spindle, outer ring + inner cap.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: outer (50), inner (50), each 0..100; onChange(outer, inner) on
+ every move of either.
+ handle: el, get() ([outer, inner]), set(outer, inner) (either may be
+ undefined to hold; clamped 0..100).
+ CSS lives in the "concentric dual knob" block of DUPRE_CSS; its indicator
+ is self-styled, independent of the shared dupre-knob classes. */
+DUPRE.dualKnob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const dk = document.createElement('span'); dk.className = 'dualknob';
- dk.innerHTML = `<span class="outer"><span class="tick" style="transform:rotate(-120deg)"></span><span class="tick" style="transform:rotate(120deg)"></span></span><span class="inner"><span class="ind"></span></span>`;
+ const clamp = v => Math.max(0, Math.min(100, v));
+ const dk = document.createElement('span'); dk.className = 'dupre-dualknob';
+ dk.innerHTML = `<span class="dupre-outer"><span class="dupre-tick" style="transform:rotate(-120deg)"></span><span class="dupre-tick" style="transform:rotate(120deg)"></span></span><span class="dupre-inner"><span class="dupre-ind"></span></span>`;
host.appendChild(dk);
- const o = dk.querySelector('.outer'), n = dk.querySelector('.inner');
+ const o = dk.querySelector('.dupre-outer'), n = dk.querySelector('.dupre-inner');
let oV, iV;
const paint = () => {
o.style.transform = `rotate(${-150 + oV / 100 * 300}deg)`;
@@ -1000,29 +1115,37 @@ GW.dualKnob = function (host, opts = {}) {
};
dragDelta(o, () => oV, v => { oV = v; paint(); }, { min: 0, max: 100 });
dragDelta(n, () => iV, v => { iV = v; paint(); }, { min: 0, max: 100, stop: true });
- oV = opts.outer !== undefined ? opts.outer : 50;
- iV = opts.inner !== undefined ? opts.inner : 50;
+ oV = clamp(opts.outer !== undefined ? opts.outer : 50);
+ iV = clamp(opts.inner !== undefined ? opts.inner : 50);
paint();
- return { el: dk, get: () => [oV, iV], set: (a, b) => { if (a !== undefined) oV = a; if (b !== undefined) iV = b; paint(); } };
+ return { el: dk, get: () => [oV, iV], set: (a, b) => { if (a !== undefined) oV = clamp(a); if (b !== undefined) iV = clamp(b); paint(); } };
};
-/* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level */
-GW.encoder = function (host, opts = {}) {
+/* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (7, accumulates without bound); onChange(value, label) per
+ step.
+ handle: el, get(), set(v).
+ CSS lives in the "rotary encoder + LED ring" block of DUPRE_CSS; the
+ spindle reuses the shared dupre-knob classes from the "rotary knob"
+ block. */
+DUPRE.encoder = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const enc = document.createElement('span'); enc.className = 'encoder'; host.appendChild(enc);
+ const enc = document.createElement('span'); enc.className = 'dupre-encoder'; host.appendChild(enc);
const R = 27, cx = 33, cy = 33;
+ const leds = [];
for (let i = 0; i < 12; i++) {
const a = (i * 30 - 90) * Math.PI / 180;
- const d = document.createElement('span'); d.className = 'led';
+ const d = document.createElement('span'); d.className = 'dupre-led';
d.style.left = (cx + R * Math.cos(a)) + 'px'; d.style.top = (cy + R * Math.sin(a)) + 'px';
- enc.appendChild(d);
+ enc.appendChild(d); leds.push(d);
}
- const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = '<span class="ind"></span>'; enc.appendChild(k);
+ const k = document.createElement('span'); k.className = 'dupre-knob'; k.innerHTML = '<span class="dupre-ind"></span>'; enc.appendChild(k);
let val;
const set = v => {
val = v;
const lit = ((Math.floor(val) % 12) + 12) % 12;
- enc.querySelectorAll('.led').forEach((l, i) => l.classList.toggle('on', i <= lit));
+ leds.forEach((l, i) => l.classList.toggle('dupre-on', i <= lit));
k.style.transform = `rotate(${val * 30}deg)`;
onChange(val, 'pos ' + Math.round(val));
};
@@ -1031,25 +1154,34 @@ GW.encoder = function (host, opts = {}) {
return { el: enc, get: () => val, set };
};
-/* N06 keyed mode switch — guarded three-position mode, key-bit points at the live one */
-GW.keySwitch = function (host, opts = {}) {
+/* N06 keyed mode switch — guarded three-position mode, key-bit points at the
+ live one; click the barrel to step (wraps). Position/angle plates are fixed
+ at three stops, so items takes at most three entries.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: items (up to 3 labels, default OFF/ON/RUN); index (1);
+ onChange(idx, label) per step.
+ handle: el, get(), set(i) (wraps modulo items).
+ CSS lives in the "keyed mode switch" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.keySwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const items = opts.items || ['OFF', 'ON', 'RUN'];
+ const items = (opts.items || ['OFF', 'ON', 'RUN']).slice(0, 3);
const ANG = [-70, 0, 70], POS = [[16, 64], [50, 12], [84, 64]];
- const kl = document.createElement('span'); kl.className = 'keylock';
- items.forEach((t, i) => {
- const p = document.createElement('span'); p.className = 'kpos';
+ const kl = document.createElement('span'); kl.className = 'dupre-keylock';
+ const marks = items.map((t, i) => {
+ const p = document.createElement('span'); p.className = 'dupre-kpos';
p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = t;
- kl.appendChild(p);
+ kl.appendChild(p); return p;
});
- const body = document.createElement('span'); body.className = 'body';
- body.innerHTML = '<span class="bit"></span><span class="barrel"></span>';
+ const body = document.createElement('span'); body.className = 'dupre-body';
+ body.innerHTML = '<span class="dupre-bit"></span><span class="dupre-barrel"></span>';
kl.appendChild(body); host.appendChild(kl);
+ const bit = body.querySelector('.dupre-bit');
let idx;
const set = i => {
idx = ((i % items.length) + items.length) % items.length;
- body.querySelector('.bit').style.transform = `rotate(${ANG[idx]}deg)`;
- kl.querySelectorAll('.kpos').forEach((p, j) => p.classList.toggle('on', j === idx));
+ bit.style.transform = `rotate(${ANG[idx]}deg)`;
+ marks.forEach((p, j) => p.classList.toggle('dupre-on', j === idx));
onChange(idx, items[idx]);
};
body.addEventListener('click', () => set(idx + 1));
@@ -1057,16 +1189,23 @@ GW.keySwitch = function (host, opts = {}) {
return { el: kl, get: () => idx, set };
};
-/* N07 center-detented crossfader — throw to either side of zero */
-GW.crossfader = function (host, opts = {}) {
+/* N07 center-detented crossfader — throw to either side of zero.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: aLabel ('A'), bLabel ('B'); value (50, the cap position 0..100);
+ onChange(v, label) with v = position - 50 (so -50..+50) per move.
+ handle: el, get() (-50..+50), set(pct) (0..100, clamped).
+ CSS lives in the "crossfader" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.crossfader = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const xf = document.createElement('div'); xf.className = 'xfader';
- xf.innerHTML = `<div class="slot"></div><div class="detent"></div><span class="end" style="left:0">${opts.aLabel || 'A'}</span><span class="end" style="right:0">${opts.bLabel || 'B'}</span><div class="cap"></div>`;
+ const xf = document.createElement('div'); xf.className = 'dupre-xfader';
+ xf.innerHTML = `<div class="dupre-slot"></div><div class="dupre-detent"></div><span class="dupre-end" style="left:0">${opts.aLabel || 'A'}</span><span class="dupre-end" style="right:0">${opts.bLabel || 'B'}</span><div class="dupre-cap"></div>`;
host.appendChild(xf);
+ const cap = xf.querySelector('.dupre-cap');
let pct;
const set = p => {
pct = Math.max(0, Math.min(100, p));
- xf.querySelector('.cap').style.left = pct + '%';
+ cap.style.left = pct + '%';
const v = Math.round(pct - 50);
onChange(v, (v > 0 ? '+' : '') + v);
};
@@ -1075,50 +1214,71 @@ GW.crossfader = function (host, opts = {}) {
return { el: xf, get: () => Math.round(pct - 50), set };
};
-/* N08 thumbwheel — knurled edge-wheel with a windowed two-digit value */
-GW.thumbwheel = function (host, opts = {}) {
+/* N08 thumbwheel — knurled edge-wheel with a windowed two-digit value.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (42); onChange(value, label) per step, value wrapped to
+ 0..99.
+ handle: el, get() (0..99), set(v) (wraps modulo 100).
+ CSS lives in the "thumbwheel" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.thumbwheel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const w = document.createElement('div'); w.className = 'thumbw';
- w.innerHTML = '<span class="thumbwheel"></span><span class="win"></span>';
+ const w = document.createElement('div'); w.className = 'dupre-thumbw';
+ w.innerHTML = '<span class="dupre-thumbwheel"></span><span class="dupre-win"></span>';
host.appendChild(w);
+ const win = w.querySelector('.dupre-win');
let val;
const set = v => {
val = Math.round(v);
const s = ((val % 100) + 100) % 100;
- w.querySelector('.win').textContent = String(s).padStart(2, '0');
+ win.textContent = String(s).padStart(2, '0');
onChange(s, 'value ' + s);
};
- dragDelta(w.querySelector('.thumbwheel'), () => val, set, { min: 0, max: 99, sens: 0.2 });
+ dragDelta(w.querySelector('.dupre-thumbwheel'), () => val, set, { min: 0, max: 99, sens: 0.2 });
set(opts.value !== undefined ? opts.value : 42);
return { el: w, get: () => ((val % 100) + 100) % 100, set };
};
-/* N09 DIP-switch bank — hard flags, up is on; readout is the binary word */
-GW.dipBank = function (host, opts = {}) {
+/* N09 DIP-switch bank — hard flags, up is on; readout is the binary word.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: bits (array of booleans, one switch each, default 6 switches);
+ onChange(word, label) — both are the binary word string ('101100').
+ handle: el, get() (the word string), set(w) — a word string; position i
+ goes up on '1', down on any other character.
+ CSS lives in the "DIP bank" block of DUPRE_CSS; no other styles involved. */
+DUPRE.dipBank = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const bits = opts.bits || [true, false, true, true, false, false];
- const bank = document.createElement('span'); bank.className = 'dip'; host.appendChild(bank);
+ const bank = document.createElement('span'); bank.className = 'dupre-dip'; host.appendChild(bank);
const sws = bits.map(on => {
- const s = document.createElement('span'); s.className = 'dipsw' + (on ? ' on' : '');
+ const s = document.createElement('span'); s.className = 'dupre-dipsw' + (on ? ' dupre-on' : '');
s.innerHTML = '<i></i>'; bank.appendChild(s); return s;
});
- const word = () => sws.map(x => x.classList.contains('on') ? '1' : '0').join('');
+ const word = () => sws.map(x => x.classList.contains('dupre-on') ? '1' : '0').join('');
const upd = () => onChange(word(), word());
- sws.forEach(s => s.addEventListener('click', () => { s.classList.toggle('on'); upd(); }));
+ sws.forEach(s => s.addEventListener('click', () => { s.classList.toggle('dupre-on'); upd(); }));
upd();
- return { el: bank, get: word, set: w => { sws.forEach((s, i) => s.classList.toggle('on', w[i] === '1')); upd(); } };
+ return { el: bank, get: word, set: w => { sws.forEach((s, i) => s.classList.toggle('dupre-on', w[i] === '1')); upd(); } };
};
-/* N10 jog / shuttle wheel — scrub fine; position accumulates without limit */
-GW.jogWheel = function (host, opts = {}) {
+/* N10 jog / shuttle wheel — scrub fine; position accumulates without limit.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (initial position, default 0); onChange(v, label) on every
+ move.
+ handle: el, get() (the position), set(v) — unclamped by design; the jog
+ is a relative scrub, not an absolute dial (drag spans ±100000).
+ CSS lives in the "jog / shuttle" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.jogWheel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const j = document.createElement('span'); j.className = 'jog';
- j.innerHTML = `<span class="shuttle" style="--sh:40deg"></span><span class="inner"><span class="dimple"></span></span>`;
+ const j = document.createElement('span'); j.className = 'dupre-jog';
+ j.innerHTML = `<span class="dupre-shuttle" style="--sh:40deg"></span><span class="dupre-inner"><span class="dupre-dimple"></span></span>`;
host.appendChild(j);
+ const inner = j.querySelector('.dupre-inner');
let val;
const set = v => {
val = v;
- j.querySelector('.inner').style.transform = `rotate(${val * 4}deg)`;
+ inner.style.transform = `rotate(${val * 4}deg)`;
onChange(val, 'pos ' + Math.round(val));
};
dragDelta(j, () => val, set, { min: -100000, max: 100000, sens: 0.5 });
@@ -1127,7 +1287,7 @@ GW.jogWheel = function (host, opts = {}) {
};
/* ---- shared SVG defs: gradients/filters referenced by url(#id) are
- document-scoped, and several are used across widgets. Builders ensure the
+ document-scoped, and several are used across instruments. Builders ensure the
defs they use; the first caller creates it, later calls are no-ops. ---- */
let defsRoot = null;
const defsMade = new Set();
@@ -1160,8 +1320,13 @@ function stageSvg(host, cls, vw, vh) {
let uidN = 0;
function uid(prefix) { return prefix + '-' + (++uidN); }
-/* R02 calibrated vernier dial — the disc turns under a fixed hairline */
-GW.vernierDial = function (host, opts = {}) {
+/* R02 calibrated vernier dial — the disc turns under a fixed hairline.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 42.0); onChange(v, label) on every move.
+ handle: el, get(), set(v) (clamped 0-100).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.vernierDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 150, 150), cx = 75, cy = 75;
gradDef('vernFace', 'radialGradient', { cx: '50%', cy: '45%', r: '60%' }, [['0', '#f7f2da'], ['1', '#e4dfc2']]);
@@ -1184,14 +1349,20 @@ GW.vernierDial = function (host, opts = {}) {
svgEl(s, 'ellipse', { cx: 69, cy: 66, rx: 7, ry: 4, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-32,69,66)' });
svgEl(s, 'line', { x1: cx, y1: 6, x2: cx, y2: 22, stroke: 'var(--fail)', 'stroke-width': 2.2, 'stroke-linecap': 'round' });
let val;
- const set = v => { val = v; disc.setAttribute('transform', `rotate(${-v * 3},75,75)`); onChange(val, val.toFixed(1)); };
+ const set = v => { val = Math.max(0, Math.min(100, v)); disc.setAttribute('transform', `rotate(${-val * 3},75,75)`); onChange(val, val.toFixed(1)); };
dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.15 });
set(opts.value !== undefined ? opts.value : 42.0);
return { el: s, get: () => val, set };
};
-/* R03 bat-handle toggle — chrome lever throws between lit legends */
-GW.batToggle = function (host, opts = {}) {
+/* R03 bat-handle toggle — chrome lever throws between lit legends.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: on (initial state, default true); onLabel / offLabel (legends,
+ default 'ON' / 'OFF'); onChange(on, label) on every throw.
+ handle: el, get() (boolean), set(v) (coerced to boolean).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.batToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 70, 90), cx = 35, cy = 44;
gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]);
@@ -1221,8 +1392,14 @@ GW.batToggle = function (host, opts = {}) {
return { el: s, get: () => on, set };
};
-/* R04 bakelite fluted knob — scallop skirt over a printed 0-10 arc */
-GW.flutedKnob = function (host, opts = {}) {
+/* R04 bakelite fluted knob — scallop skirt over a printed 0-10 arc.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100 internally, default 63; the arc prints it as 0-10);
+ onChange(v, label) on every move, label as 'x.x / 10'.
+ handle: el, get(), set(v) (clamped 0-100).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.flutedKnob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 54;
gradDef('bakeSk', 'radialGradient', { cx: '42%', cy: '36%', r: '80%' }, [['0', '#312d27'], ['1', '#050404']]);
@@ -1242,28 +1419,35 @@ GW.flutedKnob = function (host, opts = {}) {
svgEl(s, 'circle', { cx, cy, r: 19, fill: 'url(#bakeDome)' });
svgEl(s, 'ellipse', { cx: 49, cy: 46, rx: 6, ry: 3.5, fill: 'rgba(255,255,255,.14)', transform: 'rotate(-28,49,46)' });
let val;
- const set = v => { val = v; idx.setAttribute('transform', `rotate(${v * 2.7 - 135},55,54)`); onChange(val, (val / 10).toFixed(1) + ' / 10'); };
+ const set = v => { val = Math.max(0, Math.min(100, v)); idx.setAttribute('transform', `rotate(${val * 2.7 - 135},55,54)`); onChange(val, (val / 10).toFixed(1) + ' / 10'); };
dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.25 });
set(opts.value !== undefined ? opts.value : 63);
return { el: s, get: () => val, set };
};
-/* R05 filter slider bank — a dense fader wall on a real faceplate. Period skins
- as constructor opts backed by GW.filterBank.STYLES, three independent axes:
- panel (silver hi-fi aluminum / studio black), caps — the cap SHAPE
- (chrome T / short ribbed / tall block fader), and capColor — the cap
- FINISH (black white-index / color stripes / chrome / cream). opts.style
- picks a native trio (silver+chrome+chrome, studio+ribbed+red);
- opts.panel / opts.caps / opts.capColor override an axis; setStyle(axis, name)
- restyles live (values kept). After the Pioneer SG-9500, the Technics
- SH-8065, and the Zaxcom Oasis block faders. */
-GW.filterBank = function (host, opts = {}) {
+/* R05 filter slider bank — a dense fader wall on a real faceplate. After the
+ Pioneer SG-9500, the Technics SH-8065, and the Zaxcom Oasis block faders.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: freqs (band centers in Hz, default 12 bands); values (initial dB
+ cuts 0-60, padded/truncated to freqs); onChange({band, hz, db},
+ label) per move. Period skins via DUPRE.filterBank.STYLES, three
+ independent axes: panel (silver hi-fi aluminum / studio black),
+ caps — the cap SHAPE (chrome T / short ribbed / tall block fader),
+ capColor — the cap FINISH (black white-index / color stripes /
+ chrome / cream). opts.style picks a native trio (silver+chrome+
+ chrome, studio+ribbed+red); opts.panel / opts.caps / opts.capColor
+ override an axis.
+ handle: el, get() (dB array copy), set(i, db) (band clamped, dB clamped
+ 0-60), setStyle(axis, name) — restyles live, values kept.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.filterBank = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const freqs = opts.freqs || [78, 113, 163, 235, 339, 487, 701, 1010, 1450, 2090, 3010, 4340];
- const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48, 38, 26, 44, 32]).slice(0, freqs.length);
+ const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48, 38, 26, 44, 32]).slice(0, freqs.length).map(v => Math.max(0, Math.min(60, v)));
while (vals.length < freqs.length) vals.push(30);
const fmtHz = f => f < 1000 ? f : (f / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
- const ST = GW.filterBank.STYLES;
+ const ST = DUPRE.filterBank.STYLES;
const native = {
silver: { caps: 'chrome', capColor: 'chrome' },
studio: { caps: 'ribbed', capColor: 'red' },
@@ -1306,6 +1490,7 @@ GW.filterBank = function (host, opts = {}) {
}
};
const set = (i, db) => {
+ i = Math.max(0, Math.min(freqs.length - 1, i));
db = Math.max(0, Math.min(60, db)); vals[i] = db; place(i);
onChange({ band: i, hz: freqs[i], db }, `${fmtHz(freqs[i])} Hz · −${Math.round(db)} dB`);
};
@@ -1352,7 +1537,7 @@ GW.filterBank = function (host, opts = {}) {
/* named styles per axis: dot = swatch color for pickers. caps is the cap
shape; capColor is the cap finish (body + rib + index line), applicable to
any shape. */
-GW.filterBank.STYLES = {
+DUPRE.filterBank.STYLES = {
panel: {
silver: { dot: '#c9c9c5', plate: 'url(#fbPlateSilver)', edge: '#8e8e88', bevel: 'rgba(255,255,255,.4)', ink: '#33332f', dim: '#5c5c56', screw: '#9c9c96', track: '#121210', trackW: 2.0, trackHi: null },
studio: { dot: '#1c1a18', plate: 'url(#fbPlateStudio)', edge: '#000', bevel: 'rgba(255,255,255,.06)', ink: '#b9b4a6', dim: '#6f6a5e', screw: '#3c3832', track: '#050505', trackW: 2.2, trackHi: 'rgba(255,255,255,.05)' },
@@ -1373,8 +1558,15 @@ GW.filterBank.STYLES = {
},
};
-/* R06 chicken-head selector — tapered lever aims at the position */
-GW.chickenHead = function (host, opts = {}) {
+/* R06 chicken-head selector — tapered lever aims at the position.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: items ([label, angleDeg] pairs, default OFF/LO/MID/HI); index
+ (initial position, default 2); onChange(index, label) per step.
+ Click advances one position, wrapping.
+ handle: el, get() (the index), set(i) (wraps modulo items.length).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.chickenHead = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || [['OFF', -60], ['LO', -20], ['MID', 20], ['HI', 60]];
const s = stageSvg(host, 'rsvg press', 110, 96), cx = 55, cy = 58;
@@ -1404,8 +1596,13 @@ GW.chickenHead = function (host, opts = {}) {
return { el: s, get: () => idx, set };
};
-/* R12 chrome slot fader — engraved dB scale, chrome T-handle in a screwed plate */
-GW.slotFader = function (host, opts = {}) {
+/* R12 chrome slot fader — engraved dB scale, chrome T-handle in a screwed plate.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (dB, default -4); onChange(db, label) on every move.
+ handle: el, get() (the dB), set(v) (clamped -24..+12).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.slotFader = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 90, 150), cx = 45, yTop = 30, yBot = 120;
const yOf = db => yTop + (12 - db) / 36 * (yBot - yTop);
@@ -1441,8 +1638,13 @@ GW.slotFader = function (host, opts = {}) {
return { el: s, get: () => db, set };
};
-/* R14 spade-pointer tuning knob — engraved relief arc, knurl ring turns with it */
-GW.spadeKnob = function (host, opts = {}) {
+/* R14 spade-pointer tuning knob — engraved relief arc, knurl ring turns with it.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-10, default 8.3); onChange(v, label) on every move.
+ handle: el, get(), set(v) (clamped 0-10).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.spadeKnob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 140, 124), cx = 70, cy = 82;
const sweep = v => -80 + v / 10 * 160;
@@ -1480,8 +1682,17 @@ GW.spadeKnob = function (host, opts = {}) {
return { el: s, get: () => val, set };
};
-/* R15 multi-band dial — nested arcs, one needle; the bandspread dial selects the ring */
-GW.multiBandDial = function (host, opts = {}) {
+/* R15 multi-band dial — nested arcs, one needle; the bandspread dial selects the ring.
+ Contract (everything a consumer needs; no page globals touched):
+ opts: ranges ([lo, hi] Mc pairs, one ring each, default 4 bands); value
+ (0-100 across the ring, default 45); band (initial ring index,
+ default 2); onChange({band, mc}, label) on every change. Dragging
+ the main dial tunes; clicking the bandspread dial cycles bands.
+ handle: el, get() ([value, band]), set(v, b) (value clamped 0-100, band
+ clamped to ranges; omit b to keep the current band).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.multiBandDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const ranges = opts.ranges || [[0.54, 1.6], [1.6, 5.1], [5.1, 15.5], [15.5, 30.5]];
const s = stageSvg(host, 'rsvg', 190, 110), cx = 62, cy = 62;
@@ -1513,13 +1724,14 @@ GW.multiBandDial = function (host, opts = {}) {
svgEl(s, 'circle', { cx: bx, cy: by, r: 6, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 });
svgEl(s, 'text', { x: bx, y: 96, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'BANDSPREAD';
let val, band;
- const set = (v, b) => {
- val = Math.max(0, Math.min(100, v)); band = b;
+ const set = (v, b = band) => {
+ val = Math.max(0, Math.min(100, v));
+ band = Math.max(0, Math.min(ranges.length - 1, b));
needle.setAttribute('transform', `rotate(${-70 + val / 100 * 140},62,62)`);
- spread.setAttribute('transform', `rotate(${-45 + b * 30},142,56)`);
- rings.forEach((g, i) => g.setAttribute('opacity', i === b ? '1' : '.4'));
- const [lo, hi] = ranges[b]; const mc = lo + val / 100 * (hi - lo);
- onChange({ band: b, mc }, `B${b + 1} · ${mc.toFixed(2)} Mc`);
+ spread.setAttribute('transform', `rotate(${-45 + band * 30},142,56)`);
+ rings.forEach((g, i) => g.setAttribute('opacity', i === band ? '1' : '.4'));
+ const [lo, hi] = ranges[band]; const mc = lo + val / 100 * (hi - lo);
+ onChange({ band, mc }, `B${band + 1} · ${mc.toFixed(2)} Mc`);
};
const dragHit = svgEl(s, 'rect', { x: 9, y: 9, width: 100, height: 92, fill: 'transparent' });
dragHit.style.cursor = 'ns-resize';
@@ -1531,8 +1743,18 @@ GW.multiBandDial = function (host, opts = {}) {
return { el: s, get: () => [val, band], set };
};
-/* R16 entry keypad — worn keys feed the amber display; lamps watch state */
-GW.entryKeypad = function (host, opts = {}) {
+/* R16 entry keypad — worn keys feed the amber display; lamps watch state.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(buffer, text) fires on every keypress and on the initial
+ paint ('', 'enter a code'); ✓ reports 'OK · <code>' (or 'empty')
+ then clears, ✗ reports 'cleared' and flashes the lower lamp.
+ handle: el, get() (the buffer string, up to 6 digits), press(key) — any
+ of '0'-'9', '✓', '✗'; digits past 6 are ignored. The upper lamp
+ lights while the buffer is non-empty.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.entryKeypad = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 150, 190);
gradDef('kpKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#dbd8cf'], ['1', '#a29d92']]);
@@ -1554,7 +1776,7 @@ GW.entryKeypad = function (host, opts = {}) {
lampBot.setAttribute('fill', 'var(--jewel-r)');
setTimeout(() => lampBot.setAttribute('fill', '#3a0f0a'), 350); return;
}
- if (buf.length < 6) { buf += k; render(); onChange(buf, buf); }
+ if (/^[0-9]$/.test(k) && buf.length < 6) { buf += k; render(); onChange(buf, buf); }
};
const KEYS = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['✓', '0', '✗']];
const jitter = [-2, 1, -1, 2, 0, -2, 1, -1, 2, 0, -1, 1];
@@ -1569,8 +1791,18 @@ GW.entryKeypad = function (host, opts = {}) {
return { el: s, get: () => buf, press };
};
-/* R18 thumb-slide attenuator pair — lit numeral strip, cream side tab */
-GW.thumbSlide = function (host, opts = {}) {
+/* R18 thumb-slide attenuator pair — lit numeral strip, cream side tab.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: channels (array of { name, v } strips, v 0-100, default BLEND/MIX
+ pair — the layout is sized for two); onChange(values array,
+ 'NAME v · NAME v') fires on every set, including the initial paint.
+ handle: el, get() (values array), set(i, v) — v clamps 0-100; each strip
+ drags vertically on its own hit rect, and the numerals within 5
+ of the value light up.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.thumbSlide = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const chans = (opts.channels || [{ name: 'BLEND', v: 74 }, { name: 'MIX', v: 92 }]).map(c => ({ name: c.name, v: c.v }));
const s = stageSvg(host, 'rsvg', 130, 150), y0 = 22, y1 = 122;
@@ -1615,8 +1847,19 @@ GW.thumbSlide = function (host, opts = {}) {
return { el: s, get: () => chans.map(c => c.v), set };
};
-/* R19 waveform region editor — monochrome LCD, draggable S/E flags */
-GW.waveRegion = function (host, opts = {}) {
+/* R19 waveform region editor — monochrome LCD, draggable S/E flags.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: start / end (region bounds in percent, defaults 22 / 76);
+ onChange({ s, e }, 'S n% · E n%') fires on every set, including
+ the initial paint.
+ handle: el, get() ({ s, e }), set(s, e) — s clamps 0-96, e clamps 4-100,
+ and e is kept at least 4 above s; dragging moves whichever flag
+ is nearer the pointer.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; the LCD inks read the --scr-* screen tokens with hex
+ fallbacks. */
+DUPRE.waveRegion = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 190, 100), x0 = 10, x1 = 180, yMid = 48, N = 85;
svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 98, rx: 6, fill: 'var(--scr-bg1,#0b0c0b)', stroke: 'var(--scr-brd,#2a2c2a)', 'stroke-width': 2 });
@@ -1648,6 +1891,7 @@ GW.waveRegion = function (host, opts = {}) {
let S, E;
const set = (sv, ev) => {
S = Math.max(0, Math.min(96, sv)); E = Math.max(4, Math.min(100, ev));
+ if (E < S + 4) E = S + 4;
const xOf = p => x0 + p / 100 * (x1 - x0);
flagS.setAttribute('transform', `translate(${xOf(S)},0)`);
flagE.setAttribute('transform', `translate(${xOf(E)},0)`);
@@ -1666,8 +1910,18 @@ GW.waveRegion = function (host, opts = {}) {
return { el: s, get: () => ({ s: S, e: E }), set };
};
-/* R20 drum roller selector — numbered paper drum in a window, center chip reads it */
-GW.drumRoller = function (host, opts = {}) {
+/* R20 drum roller selector — numbered paper drum in a window, center chip reads it.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: title (engraved header, default 'EQUALIZER'); channels (array of
+ { name, v } drums, v 1-10, default HIGH/LOW pair — the layout is
+ sized for two); onChange(values array, 'NAME v · NAME v') fires on
+ every set, including the initial paint.
+ handle: el, get() (values array), set(i, v) — v clamps 1-10; each drum
+ drags vertically on its own hit strip.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.drumRoller = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const chans = (opts.channels || [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }]).map(c => ({ name: c.name, v: c.v }));
const s = stageSvg(host, 'rsvg', 130, 140), cy = 76, step = 17;
@@ -1709,8 +1963,19 @@ GW.drumRoller = function (host, opts = {}) {
return { el: s, get: () => chans.map(c => c.v), set };
};
-/* R21 LED program row — exclusive select, the LED above the key carries the state */
-GW.ledRow = function (host, opts = {}) {
+/* R21 LED program row — exclusive select, the LED above the key carries the state.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: items (program names, default 8 reverb programs — the layout is
+ sized for about eight); label (engraved caption, default
+ 'PROGRAM'); index (initial selection — default 5 for the default
+ set, first item for a caller's own set); onChange(index,
+ 'n · name') fires on every set, including the initial paint.
+ handle: el, get() (selected index), set(i) — i clamps to the items
+ range; exactly one LED is lit at a time.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.ledRow = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || ['Sm Hall B', 'VocPlate', 'Lg Hall B', 'Chamber', 'ParcPlate', 'Sm Hall A', 'Room A', 'Const Plate'];
const s = stageSvg(host, 'rsvg', 190, 58);
@@ -1718,9 +1983,9 @@ GW.ledRow = function (host, opts = {}) {
const leds = [];
let idx;
const set = i => {
- idx = i;
+ idx = Math.max(0, Math.min(items.length - 1, i));
leds.forEach((l, k) => {
- const on = k === i;
+ const on = k === idx;
l.setAttribute('fill', on ? 'var(--jewel-r)' : '#3a0f0a');
l.setAttribute('style', on ? 'filter:drop-shadow(0 0 3px rgba(255,91,69,.8))' : '');
});
@@ -1736,12 +2001,24 @@ GW.ledRow = function (host, opts = {}) {
g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); set(i); });
});
svgEl(s, 'text', { x: 95, y: 56, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'PROGRAM';
- set(opts.index !== undefined ? opts.index : 5);
+ set(opts.index !== undefined ? opts.index : opts.items ? 0 : 5);
return { el: s, get: () => idx, set };
};
-/* R22 three-position slide — chrome pill between detents, honest LED pair */
-GW.pillSlide = function (host, opts = {}) {
+/* R22 three-position slide — chrome pill between detents, honest LED pair.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: positions (three detent labels, default A / AB / B — the detents
+ are fixed at three); title (engraved header, default
+ 'BASIC · VARIATION'); index (initial detent, default 1);
+ onChange(index, label) fires on every set, including the initial
+ paint.
+ handle: el, get() (detent index), set(i) — i clamps to the detents;
+ clicking the stage snaps the pill to the nearest detent, and the
+ LED pair reports A-side / B-side engagement (both lit at AB).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.pillSlide = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const positions = opts.positions || ['A', 'AB', 'B'];
const s = stageSvg(host, 'rsvg press', 110, 64), detX = [34, 55, 76];
@@ -1759,12 +2036,12 @@ GW.pillSlide = function (host, opts = {}) {
const leds = [44, 66].map(x => svgEl(s, 'circle', { cx: x, cy: 50.5, r: 2.4, fill: '#3a0f0a' }));
let idx;
const set = i => {
- idx = i;
- pill.setAttribute('transform', `translate(${detX[i]},0)`);
- lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)'));
- const lit = [i === 0 || i === 1, i === 2 || i === 1];
+ idx = Math.max(0, Math.min(positions.length - 1, i));
+ pill.setAttribute('transform', `translate(${detX[idx]},0)`);
+ lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)'));
+ const lit = [idx === 0 || idx === 1, idx === 2 || idx === 1];
leds.forEach((l, k) => l.setAttribute('fill', lit[k] ? 'var(--jewel-r)' : '#3a0f0a'));
- onChange(idx, positions[i]);
+ onChange(idx, positions[idx]);
};
s.addEventListener('click', e => {
const r = s.getBoundingClientRect();
@@ -1775,8 +2052,17 @@ GW.pillSlide = function (host, opts = {}) {
return { el: s, get: () => idx, set };
};
-/* R23 spun-aluminum knob — machined rings in a knurled grip, red index */
-GW.spunKnob = function (host, opts = {}) {
+/* R23 spun-aluminum knob — machined rings in a knurled grip, red index.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: label (engraved caption, default 'SPEED'); value (initial 0-100,
+ default 62); onChange(value, 'n%') fires on every set, including
+ the initial paint.
+ handle: el, get() (value), set(v) — v clamps 0-100; the whole stage
+ drags vertically.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.spunKnob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52;
gradDef('spunFace', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#e8e6e0'], ['.7', '#b3b0a8'], ['1', '#8a877e']]);
@@ -1800,8 +2086,18 @@ GW.spunKnob = function (host, opts = {}) {
return { el: s, get: () => val, set };
};
-/* R24 stomp switch + jewel — press to engage, the jewel reports */
-GW.stompSwitch = function (host, opts = {}) {
+/* R24 stomp switch + jewel — press to engage, the jewel reports.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: on (initial state, default false); onChange(on, 'ENGAGED' /
+ 'bypass') fires on every set, including the initial paint.
+ handle: el, get() (boolean), set(v) — coerced to boolean; clicking the
+ stage toggles, the dome dips, and the amber jewel glows while
+ engaged.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients and the avGlow filter register in the shared defs
+ plate. */
+DUPRE.stompSwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55;
gradDef('stompDome', 'radialGradient', { cx: '40%', cy: '30%', r: '80%' }, [['0', '#f4f2ec'], ['.55', '#b9b6ae'], ['1', '#6e6b63']]);
@@ -1834,8 +2130,18 @@ GW.stompSwitch = function (host, opts = {}) {
return { el: s, get: () => on, set };
};
-/* R27 winged gain selector — red T-bar over a dot ring, stepped detents */
-GW.wingSelector = function (host, opts = {}) {
+/* R27 winged gain selector — red T-bar over a dot ring, stepped detents.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: steps (dB values, one detent each, default 12 from -80 to +10);
+ index (initial detent, default 7); onChange(db, '+n dB') fires on
+ every set with the step's dB value, including the initial paint.
+ handle: el, get() (detent index, not the dB value), set(i) — i rounds
+ and clamps to the steps range; the whole stage drags vertically
+ between detents.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.wingSelector = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const steps = opts.steps || [-80, -70, -60, -50, -40, -30, -20, -10, -5, 0, 5, 10];
const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52;
@@ -1867,8 +2173,17 @@ GW.wingSelector = function (host, opts = {}) {
return { el: s, get: () => idx, set };
};
-/* R28 rotary disc switch — the whole disc turns between heavy positions */
-GW.discSwitch = function (host, opts = {}) {
+/* R28 rotary disc switch — the whole disc turns between heavy positions.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: positions (array of [label, angle°] stops, default OFF / ON /
+ COMBINE); index (initial stop, default 1); onChange(index, label)
+ fires on every set, including the initial paint.
+ handle: el, get() (stop index), set(i) — i clamps to the stops; clicking
+ the stage advances to the next stop, wrapping.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.discSwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const positions = opts.positions || [['OFF', -45], ['ON', 45], ['COMBINE', 135]];
const s = stageSvg(host, 'rsvg press', 110, 116), cx = 55, cy = 58;
@@ -1892,18 +2207,28 @@ GW.discSwitch = function (host, opts = {}) {
svgEl(s, 'ellipse', { cx: cx - 12, cy: cy - 14, rx: 12, ry: 6, fill: 'rgba(255,255,255,.10)', transform: `rotate(-28,${cx - 12},${cy - 14})` });
let idx;
const set = i => {
- idx = i;
- grp.style.transform = `rotate(${positions[i][1]}deg)`;
- lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)'));
- onChange(idx, positions[i][0]);
+ idx = Math.max(0, Math.min(positions.length - 1, i));
+ grp.style.transform = `rotate(${positions[idx][1]}deg)`;
+ lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)'));
+ onChange(idx, positions[idx][0]);
};
s.addEventListener('click', () => set((idx + 1) % positions.length));
set(opts.index !== undefined ? opts.index : 1);
return { el: s, get: () => idx, set };
};
-/* R29 guarded toggle — guard posts + red collar mark the critical throw */
-GW.guardedToggle = function (host, opts = {}) {
+/* R29 guarded toggle — guard posts + red collar mark the critical throw.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: on (initial state, default true); onLabel / offLabel (engraved
+ legends, defaults ON / OFF); onChange(on, label) fires on every
+ set, including the initial paint.
+ handle: el, get() (boolean), set(v) — coerced to boolean; clicking the
+ stage throws the lever, and the active legend carries the gold
+ ink.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.guardedToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52;
gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]);
@@ -1941,8 +2266,17 @@ GW.guardedToggle = function (host, opts = {}) {
};
/* R32 mechanical timer dial — dial rotates under a fixed index; wind by drag, stop by the red knob.
- Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set. */
-GW.timerDial = function (host, opts = {}) {
+ Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: minutes (initial wind, clamps 0-60, default 0);
+ onChange(minutes, 'T-N MIN'|'OFF'|'STOP · OFF'|'DING · OFF') fires
+ on every set, including the initial paint.
+ handle: el, get() (minutes remaining), set(m) — m clamps 0-60; the dial
+ face drags, the red knob clicks to 0.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.timerDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 158, 132), cx = 62, cy = 58;
gradDef('mtFace', 'radialGradient', { cx: '50%', cy: '42%', r: '75%' }, [['0', '#282320'], ['1', '#14110e']]);
@@ -2002,8 +2336,16 @@ GW.timerDial = function (host, opts = {}) {
return { el: s, get: () => min, set };
};
-/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press */
-GW.rockerPad = function (host, opts = {}) {
+/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange({x, y, dir}, 'DIR · x N y N') fires on every press and
+ once at the initial paint (dir null, origin).
+ handle: el, get() ({x, y} cursor position). No set — the cursor is a
+ relative accumulator; position only moves by presses.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.rockerPad = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55;
gradDef('rk4Pad', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#33302b'], ['1', '#141210']]);
@@ -2041,8 +2383,17 @@ GW.rockerPad = function (host, opts = {}) {
return { el: s, get: () => ({ x: rx, y: ry }) };
};
-/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state */
-GW.fourWayToggle = function (host, opts = {}) {
+/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: position ('A'|'B'|'C'|'D', default 'C'; anything else falls back
+ to 'C'); onChange(pos, 'POS Q') fires on every set, including the
+ initial paint.
+ handle: el, get() (current quadrant letter), set(q) — invalid quadrants
+ are ignored; quadrant click zones select directly.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.fourWayToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
gradDef('fw4Chrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e8e6e0'], ['.5', '#9a968c'], ['1', '#55524a']]);
@@ -2078,6 +2429,7 @@ GW.fourWayToggle = function (host, opts = {}) {
svgEl(s, 'ellipse', { cx: cx - 2.6, cy: cy - 3, rx: 2.8, ry: 1.9, fill: 'rgba(255,255,255,.5)' });
let pos = null;
const set = q => {
+ if (!Object.hasOwn(QUAD, q)) return;
pos = q;
lever.setAttribute('transform', `rotate(${QUAD[q]},${cx},${cy})`);
for (const k of Object.keys(QUAD)) {
@@ -2095,16 +2447,29 @@ GW.fourWayToggle = function (host, opts = {}) {
zone('B', `${cx},${cy} ${cx},5 125,5 125,${cy}`);
zone('C', `${cx},${cy} 125,${cy} 125,125 ${cx},125`);
zone('D', `${cx},${cy} ${cx},125 5,125 5,${cy}`);
- set(opts.position || 'C');
+ set(Object.hasOwn(QUAD, opts.position || '') ? opts.position : 'C');
return { el: s, get: () => pos, set };
};
-/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many */
-GW.pinMatrix = function (host, opts = {}) {
+/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: rows (source labels, default 5-row synth set); cols (destination
+ labels, default 6-col set; the layout is sized for 5x6); pins
+ (initial 'ROW>COL' keys — keys naming no intersection are
+ dropped); onChange(pins array, 'N routes') fires on every seat or
+ pull, including the initial paint.
+ handle: el, get() ('ROW>COL' keys array). No set — pins seat and pull
+ by intersection click.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.pinMatrix = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV'];
const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD'];
- const pins = new Set(opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA']); /* a legible default patch */
+ /* a legible default patch; keys that name no intersection are dropped */
+ const pins = new Set((opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA'])
+ .filter(k => { const [r, c] = k.split('>'); return ROWS.includes(r) && COLS.includes(c) && k === r + '>' + c; }));
const s = stageSvg(host, 'rsvg press', 160, 110);
const X0 = 42, Y0 = 30, DX = 19, DY = 15.5;
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
@@ -2127,8 +2492,18 @@ GW.pinMatrix = function (host, opts = {}) {
return { el: s, get: () => [...pins] };
};
-/* R38 dead-man button — state exists only while the pointer holds it down */
-GW.deadMan = function (host, opts = {}) {
+/* R38 dead-man button — state exists only while the pointer holds it down.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: caption (plate legend, default 'HOLD TO RUN'); label (button
+ face, default 'RUN'); onChange(held, 'RUNNING N.Ns'|'SAFE...')
+ fires on press, release, every 100 ms while held, and the
+ initial paint.
+ handle: el, get() (true while held). No set — by design the state
+ exists only under a live pointer.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.deadMan = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 120, 110), cx = 60, cy = 58;
gradDef('dmBtn', 'radialGradient', { cx: '40%', cy: '32%', r: '85%' }, [['0', '#3a3631'], ['1', '#16130f']]);
@@ -2167,8 +2542,17 @@ GW.deadMan = function (host, opts = {}) {
return { el: s, get: () => t0 !== null };
};
-/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns */
-GW.telephoneDial = function (host, opts = {}) {
+/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns.
+ Spin animation is reduced-motion-gated; clicks during a spin are ignored.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(dialed, dialed) fires when each digit's return spin
+ lands, plus once at the initial paint ('dial a number').
+ handle: el, get() (dialed string, last 10 digits kept). No set — digits
+ only arrive through the dial.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.telephoneDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 63;
const DIGITS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
@@ -2219,8 +2603,19 @@ GW.telephoneDial = function (host, opts = {}) {
return { el: s, get: () => dialed };
};
-/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step */
-GW.breakerPanel = function (host, opts = {}) {
+/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step
+ (a tripped handle clicks to off, then to on).
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: names (breaker labels, default MAIN/PUMP/LAMP/AUX — the layout is
+ sized for four); onChange(states array, 'N/M closed[ · NAME
+ TRIPPED]') fires on every click or trip, including the initial
+ paint. The first three breakers start on, the rest off.
+ handle: el, get() (per-breaker 'on'|'off'|'tripped' array). No set —
+ breakers move by handle click and the TRIP button.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.breakerPanel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const NAMES = opts.names || ['MAIN', 'PUMP', 'LAMP', 'AUX'];
const s = stageSvg(host, 'rsvg press', 160, 110);
@@ -2259,32 +2654,42 @@ GW.breakerPanel = function (host, opts = {}) {
return { el: s, get: () => brk.map(b => b.state) };
};
-/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb */
-GW.dsky = function (host, opts = {}) {
+/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(vals {prog, verb, noun}, status text) fires on ENTR,
+ RSET, OPR ERR, and the initial paint. Grammar: VERB/NOUN arm a
+ window, digits fill it (2 max), ENTR commits — V35 runs the lamp
+ test, V16 N36 sets PROG 16 (monitor clock), a bare-verb commit
+ with under 2 digits flashes OPR ERR.
+ handle: el, get() ({prog, verb, noun} copy).
+ CSS lives in the "DSKY" block of DUPRE_CSS; keys reuse the shared
+ .dupre-key face and digits render as shared .seg7 glyphs. */
+DUPRE.dsky = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const LAMPS = ['UPLINK', 'TEMP', 'GIMBAL', 'PROG', 'RESTART', 'OPR ERR'];
- const el = document.createElement('div'); el.className = 'dsky'; host.appendChild(el);
+ const el = document.createElement('div'); el.className = 'dupre-dsky'; host.appendChild(el);
el.innerHTML =
- `<div class="lampcol">${LAMPS.map(l => `<div class="sl" data-l="${l}">${l}</div>`).join('')}</div>` +
- `<div class="right"><div class="wins">` +
- `<div class="win" data-w="prog"><span class="wl">PROG</span><span class="wd"></span></div>` +
- `<div class="win" data-w="verb"><span class="wl">VERB</span><span class="wd"></span></div>` +
- `<div class="win" data-w="noun"><span class="wl">NOUN</span><span class="wd"></span></div>` +
- `</div><div class="pad"></div></div>`;
+ `<div class="dupre-dsky-lamps">${LAMPS.map(l => `<div class="dupre-dsky-sl" data-l="${l}">${l}</div>`).join('')}</div>` +
+ `<div class="dupre-dsky-right"><div class="dupre-dsky-wins">` +
+ `<div class="dupre-dsky-win" data-w="prog"><span class="dupre-dsky-wl">PROG</span><span class="dupre-dsky-wd"></span></div>` +
+ `<div class="dupre-dsky-win" data-w="verb"><span class="dupre-dsky-wl">VERB</span><span class="dupre-dsky-wd"></span></div>` +
+ `<div class="dupre-dsky-win" data-w="noun"><span class="dupre-dsky-wl">NOUN</span><span class="dupre-dsky-wd"></span></div>` +
+ `</div><div class="dupre-dsky-pad"></div></div>`;
const win = w => el.querySelector(`[data-w="${w}"]`);
- const setWin = (w, txt) => { win(w).querySelector('.wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); };
+ const setWin = (w, txt) => { win(w).querySelector('.dupre-dsky-wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); };
const lampEl = l => el.querySelector(`[data-l="${l}"]`);
let mode = null, vals = { prog: ' ', verb: ' ', noun: ' ' }, entry = '';
const KEYS = ['VERB', 'NOUN', 'CLR', 'ENTR', 'RSET', '7', '8', '9', '4', '5', '6', '1', '2', '3', '0'];
- const pad = el.querySelector('.pad');
- const hot = () => { el.querySelectorAll('.win').forEach(w => w.classList.toggle('hot', w.dataset.w === mode)); };
- const oprErr = () => { lampEl('OPR ERR').classList.add('on'); setTimeout(() => lampEl('OPR ERR').classList.remove('on'), 1200); onChange(vals, 'OPR ERR'); };
+ const pad = el.querySelector('.dupre-dsky-pad');
+ const hot = () => { el.querySelectorAll('.dupre-dsky-win').forEach(w => w.classList.toggle('dupre-hot', w.dataset.w === mode)); };
+ const oprErr = () => { lampEl('OPR ERR').classList.add('dupre-on'); setTimeout(() => lampEl('OPR ERR').classList.remove('dupre-on'), 1200); onChange(vals, 'OPR ERR'); };
const commit = () => {
if (vals.verb.trim().length < 2) { oprErr(); return; }
const v = vals.verb, n = vals.noun.trim();
if (v === '35') {
- LAMPS.forEach(l => lampEl(l).classList.add('on'));
- setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('on')), 1400);
+ LAMPS.forEach(l => lampEl(l).classList.add('dupre-on'));
+ setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('dupre-on')), 1400);
onChange(vals, 'V35 · lamp test');
}
else if (v === '16' && n === '36') { vals.prog = '16'; setWin('prog', vals.prog); onChange(vals, 'V16 N36 · monitor clock'); }
@@ -2292,11 +2697,11 @@ GW.dsky = function (host, opts = {}) {
vals.verb = ' '; vals.noun = ' '; entry = ''; mode = null; hot();
};
KEYS.forEach(k => {
- const b = document.createElement('button'); b.className = 'key'; b.textContent = k;
+ const b = document.createElement('button'); b.className = 'dupre-key'; b.textContent = k;
b.addEventListener('click', () => {
if (k === 'VERB' || k === 'NOUN') { mode = k.toLowerCase(); entry = ''; vals[mode] = ' '; setWin(mode, ' '); hot(); }
else if (k === 'CLR') { if (mode) { vals[mode] = ' '; entry = ''; setWin(mode, ' '); } }
- else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('on')); onChange(vals, 'RSET'); }
+ else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('dupre-on')); onChange(vals, 'RSET'); }
else if (k === 'ENTR') commit();
else if (/\d/.test(k)) {
if (!mode) { oprErr(); return; }
@@ -2311,8 +2716,20 @@ GW.dsky = function (host, opts = {}) {
};
/* R42 cam-timer program drum — the program is a ring; the pointer self-advances through it.
- Runs its own reduced-motion-gated 2 s step interval once started. */
-GW.camTimer = function (host, opts = {}) {
+ Runs its own reduced-motion-gated 2 s step interval once started.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: steps (ring labels, index 0 is OFF, default 12-step wash
+ program — the layout is sized for 12); colors (label→wedge
+ fill map, default wash palette); position (initial step, clamps
+ to the ring, default 0); onChange(pos, 'OFF'|'STEP N ·
+ LABEL'|'CYCLE DONE · OFF') fires on every set, including the
+ initial paint.
+ handle: el, get() (step index), set(i) — i clamps to the ring; any
+ click advances one step.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.camTimer = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const STEPS = opts.steps || ['OFF', 'FILL', 'WASH', 'WASH', 'WASH', 'RINSE', 'RINSE', 'DRAIN', 'SPIN', 'SPIN', 'FLUFF', 'COOL'];
const COLS = opts.colors || { OFF: '#3a3426', FILL: '#54677d', WASH: '#d29638', RINSE: '#46b89e', DRAIN: '#969385', SPIN: '#cb6b4d', FLUFF: '#c9b98a', COOL: '#7c99b0' };
@@ -2335,8 +2752,8 @@ GW.camTimer = function (host, opts = {}) {
svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
let pos;
const set = i => {
- pos = i;
- ptr.setAttribute('transform', `rotate(${i * 30 + 15},${cx},${cy})`);
+ pos = Math.max(0, Math.min(STEPS.length - 1, i | 0));
+ ptr.setAttribute('transform', `rotate(${pos * 30 + 15},${cx},${cy})`);
onChange(pos, pos === 0 ? 'OFF' : 'STEP ' + pos + ' · ' + STEPS[pos]);
};
s.style.cursor = 'pointer';
@@ -2347,8 +2764,17 @@ GW.camTimer = function (host, opts = {}) {
return { el: s, get: () => pos, set };
};
-/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw */
-GW.knifeSwitch = function (host, opts = {}) {
+/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: closed (initial state, coerced to boolean, default true);
+ onChange(closed, 'CLOSED · live'|'OPEN · visibly dead') fires on
+ every set, including the initial paint.
+ handle: el, get() (true when closed), set(v) — coerced to boolean; any
+ click toggles.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.knifeSwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 110);
gradDef('ksCu', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d09a5c'], ['1', '#8a5a2a']]);
@@ -2382,11 +2808,20 @@ GW.knifeSwitch = function (host, opts = {}) {
return { el: s, get: () => closed, set };
};
-/* R49 decade box — four skirted knobs, one digit each; the value is their sum */
-GW.decadeBox = function (host, opts = {}) {
+/* R49 decade box — four skirted knobs, one digit each; the value is their sum.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: digits (array of four 0-9 digits, most-significant first, default
+ [3, 5, 0, 0] — missing entries read 0, values clamp to 0-9);
+ onChange(total, 'N,NNN Ω') fires on every redraw, including the
+ initial paint.
+ handle: el, get() (the summed value; digits weight x1000/x100/x10/x1).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; no gradients registered. */
+DUPRE.decadeBox = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const MUL = [1000, 100, 10, 1], LBL = ['x1000', 'x100', 'x10', 'x1'];
- const digs = (opts.digits || [3, 5, 0, 0]).slice();
+ const digs = MUL.map((_, i) => Math.round(Math.max(0, Math.min(9, +(opts.digits || [3, 5, 0, 0])[i] || 0))));
const s = stageSvg(host, 'rsvg', 160, 100);
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
const wins = [], knobs = [];
@@ -2418,8 +2853,17 @@ GW.decadeBox = function (host, opts = {}) {
return { el: s, get: () => total() };
};
-/* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle */
-GW.twoHandSafety = function (host, opts = {}) {
+/* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(state, text) fires on every transition — 'ready',
+ 'armed' (one palm down, 0.5s window), 'running' (1.5s cycle),
+ 'fault' (window expired or same hand twice); initial fire 'ready'.
+ handle: el, press(side) — side is 'L' or 'R' only, anything else is
+ ignored; presses during a running cycle are ignored.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; no gradients registered. */
+DUPRE.twoHandSafety = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 160, 100);
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
@@ -2438,6 +2882,7 @@ GW.twoHandSafety = function (host, opts = {}) {
const flash = cap => { cap.setAttribute('fill', '#e0523a'); setTimeout(() => cap.setAttribute('fill', '#8f2416'), 200); };
const fault = () => { armed = null; clearTimeout(armT); onChange('fault', 'TIE-DOWN FAULT · release and retry'); };
const press = side => {
+ if (side !== 'L' && side !== 'R') return;
if (busy) return;
if (armed === null) {
armed = side; flash(side === 'L' ? L.cap : R.cap);
@@ -2461,19 +2906,33 @@ GW.twoHandSafety = function (host, opts = {}) {
return { el: s, press };
};
-/* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker */
-GW.voiceLoop = function (host, opts = {}) {
+/* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: loops (array of key labels; the default 8-loop set also gets a
+ legible demo state — FD + A/G monitored, GNC talking — while a
+ caller's own set starts all-idle); onChange(states, text) fires
+ on every click and the initial paint — states is an array of
+ '0' idle / '1' monitored / '2' talking, one per loop.
+ handle: el, get() (the states array). Click cycles idle → monitored →
+ talking → idle; talk is exclusive across keys. Activity
+ flicker is display-side and honors prefers-reduced-motion.
+ CSS lives in the "voice-loop keyset" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.voiceLoop = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const LOOPS = opts.loops || ['FD', 'GNC', 'ECOM', 'SURG', 'A/G', 'NET1', 'NET2', 'PAO'];
- const el = document.createElement('div'); el.className = 'vloop'; host.appendChild(el);
+ const el = document.createElement('div'); el.className = 'dupre-vloop'; host.appendChild(el);
const keys = LOOPS.map(l => {
- const k = document.createElement('div'); k.className = 'vk';
- k.innerHTML = l + '<span class="bar"></span>'; k.dataset.state = '0'; el.appendChild(k); return k;
+ const k = document.createElement('div'); k.className = 'dupre-vk';
+ k.innerHTML = l + '<span class="dupre-vk-bar"></span>'; k.dataset.state = '0'; el.appendChild(k); return k;
});
- /* a legible default: FD + A/G monitored, GNC talking */
- keys[0].dataset.state = '1'; keys[0].classList.add('mon');
- keys[4].dataset.state = '1'; keys[4].classList.add('mon');
- keys[1].dataset.state = '2'; keys[1].classList.add('mon', 'tlk');
+ if (!opts.loops) {
+ /* a legible default: FD + A/G monitored, GNC talking */
+ keys[0].dataset.state = '1'; keys[0].classList.add('dupre-mon');
+ keys[4].dataset.state = '1'; keys[4].classList.add('dupre-mon');
+ keys[1].dataset.state = '2'; keys[1].classList.add('dupre-mon', 'dupre-tlk');
+ }
const refresh = () => {
const mon = keys.filter(k => k.dataset.state !== '0').length;
const tlk = keys.findIndex(k => k.dataset.state === '2');
@@ -2481,20 +2940,20 @@ GW.voiceLoop = function (host, opts = {}) {
};
keys.forEach(k => k.addEventListener('click', () => {
const st = k.dataset.state;
- if (st === '0') { k.dataset.state = '1'; k.classList.add('mon'); }
+ if (st === '0') { k.dataset.state = '1'; k.classList.add('dupre-mon'); }
else if (st === '1') {
- keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('tlk'); } });
- k.dataset.state = '2'; k.classList.add('tlk');
+ keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('dupre-tlk'); } });
+ k.dataset.state = '2'; k.classList.add('dupre-tlk');
}
- else { k.dataset.state = '0'; k.classList.remove('mon', 'tlk', 'act'); }
+ else { k.dataset.state = '0'; k.classList.remove('dupre-mon', 'dupre-tlk', 'dupre-act'); }
refresh();
}));
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => {
const t = performance.now() / 1000;
keys.forEach((k, i) => {
- if (k.dataset.state === '0') { k.classList.remove('act'); return; }
- k.classList.toggle('act', Math.sin(t * (1.1 + i * 0.37) + i * 2.1) > 0.55);
+ if (k.dataset.state === '0') { k.classList.remove('dupre-act'); return; }
+ k.classList.toggle('dupre-act', Math.sin(t * (1.1 + i * 0.37) + i * 2.1) > 0.55);
});
}, 300);
refresh();
@@ -2507,16 +2966,23 @@ GW.voiceLoop = function (host, opts = {}) {
onChange(value, text) like every other builder. Display-side state that
belongs to the instrument (peak-hold, history buffers) lives in here. */
-/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc */
-GW.needleGauge = function (host, opts = {}) {
+/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 50); onChange(value, 'value N%') fires on
+ every set, including the initial paint.
+ handle: el, get() (current value), set(v) — no clamping beyond the
+ drag helper's 0-100; the needle maps value linearly to ±60°.
+ CSS lives in the "needle gauge" block of DUPRE_CSS; no other styles involved. */
+DUPRE.needleGauge = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 50;
- const el = document.createElement('div'); el.className = 'gauge';
- el.innerHTML = `<div class="dial"><div class="arc"></div>
- <div class="tk" style="transform:rotate(-60deg)"></div><div class="tk" style="transform:rotate(0)"></div><div class="tk" style="transform:rotate(60deg)"></div>
- <div class="ndl"></div><div class="hub"></div></div><div class="gv"><span>0</span>%</div>`;
+ const el = document.createElement('div'); el.className = 'dupre-gauge';
+ el.innerHTML = `<div class="dupre-dial"><div class="dupre-arc"></div>
+ <div class="dupre-tk" style="transform:rotate(-60deg)"></div><div class="dupre-tk" style="transform:rotate(0)"></div><div class="dupre-tk" style="transform:rotate(60deg)"></div>
+ <div class="dupre-ndl"></div><div class="dupre-hub"></div></div><div class="dupre-gv"><span>0</span>%</div>`;
host.appendChild(el);
- const ndl = el.querySelector('.ndl'), num = el.querySelector('.gv span');
+ 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)`;
@@ -2528,23 +2994,33 @@ GW.needleGauge = function (host, opts = {}) {
return { el, get: () => v, set };
};
-/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both */
-GW.vuPair = function (host, opts = {}) {
+/* 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 = 'vu';
- el.innerHTML = `<div class="vurow"><span class="ch">L</span><span class="vubar"></span></div>
- <div class="vurow"><span class="ch">R</span><span class="vubar"></span></div>`;
+ const el = document.createElement('div'); el.className = 'dupre-vu';
+ el.innerHTML = `<div class="dupre-vurow"><span class="dupre-ch">L</span><span class="dupre-vubar"></span></div>
+ <div class="dupre-vurow"><span class="dupre-ch">R</span><span class="dupre-vubar"></span></div>`;
host.appendChild(el);
- const bars = el.querySelectorAll('.vubar');
+ 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 ? 'clip' : k >= n - 4 ? 'hot' : 'on') : '';
- if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'peak';
+ 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;
}
}
@@ -2557,27 +3033,44 @@ GW.vuPair = function (host, opts = {}) {
return { el, get: () => [lv, rv], set };
};
-/* 12 mini 4-bar signal — compact activity meter; set(level) */
-GW.miniSig = function (host, opts = {}) {
+/* 12 mini 4-bar signal — compact activity meter; set(level).
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(level, 'N%') fires on every set (level 0-1, clamped;
+ readout in percent). No initial fire — the page's tick drives it
+ (the tick contract — the page owns the clock and the signal).
+ handle: el, get() (last level set), set(level). Bars light bottom-up:
+ dupre-on, third bar dupre-hot, top bar dupre-clip.
+ CSS lives in the "mini 4-bar signal" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.miniSig = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const el = document.createElement('span'); el.className = 'sig';
+ const el = document.createElement('span'); el.className = 'dupre-sig';
el.innerHTML = '<i></i><i></i><i></i><i></i>';
host.appendChild(el);
let v = 0;
function set(l) {
- v = 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) + '%');
+ v = Math.max(0, Math.min(1, l));
+ const b = el.children, lit = Math.round(v * 4);
+ for (let k = 0; k < 4; k++) b[k].className = k < lit ? (k >= 3 ? 'dupre-clip' : k >= 2 ? 'dupre-hot' : 'dupre-on') : '';
+ onChange(v, Math.round(v * 100) + '%');
}
return { el, get: () => v, set };
};
-/* 13 signal ladder — stepped 0-4 strength; click cycles */
-GW.signalLadder = function (host, opts = {}) {
+/* 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 = 'ladder';
+ const el = document.createElement('span'); el.className = 'dupre-ladder';
el.innerHTML = '<i></i><i></i><i></i><i></i>';
host.appendChild(el);
function set(nv) {
@@ -2591,36 +3084,53 @@ GW.signalLadder = function (host, opts = {}) {
return { el, get: () => v, set };
};
-/* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set */
-GW.fuelBar = function (host, opts = {}) {
+/* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 50); warnAt (threshold, default 20 — below
+ it the fill tints dupre-warn); onChange(value, 'N%') fires on
+ every set, including the initial paint.
+ handle: el, get() (current value), set(v) — clamped to 0-100; drag
+ along the bar sets by pointer position.
+ CSS lives in the "linear progress / fuel bar" block of DUPRE_CSS; no
+ other styles involved. */
+DUPRE.fuelBar = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const warnAt = opts.warnAt ?? 20;
let v = opts.value ?? 50;
- const el = document.createElement('div'); el.className = 'bar';
+ const el = document.createElement('div'); el.className = 'dupre-bar';
el.innerHTML = '<span></span>';
host.appendChild(el);
const fill = el.querySelector('span');
function set(p) {
- v = p;
- fill.style.width = p + '%';
- el.classList.toggle('warn', p < warnAt);
- onChange(p, Math.round(p) + '%');
+ v = Math.max(0, Math.min(100, p));
+ fill.style.width = v + '%';
+ el.classList.toggle('dupre-warn', v < warnAt);
+ onChange(v, Math.round(v) + '%');
}
dragX(el, set);
set(v);
return { el, get: () => v, set };
};
-/* 15 radial ring — percentage donut; drag up/down to set */
-GW.radialRing = function (host, opts = {}) {
+/* 15 radial ring — percentage donut; drag up/down to set.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 68); onChange(value, 'N%') fires on every
+ set, including the initial paint.
+ handle: el, get() (current value), set(v) — clamped to 0-100; the fill
+ is a conic gradient driven by the element-scoped --p property.
+ CSS lives in the "radial ring" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.radialRing = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 68;
- const el = document.createElement('span'); el.className = 'ring';
+ const el = document.createElement('span'); el.className = 'dupre-ring';
el.innerHTML = '<b></b>';
host.appendChild(el);
const num = el.querySelector('b');
function set(nv) {
- v = nv;
+ v = Math.max(0, Math.min(100, nv));
el.style.setProperty('--p', v);
num.textContent = Math.round(v);
onChange(v, Math.round(v) + '%');
@@ -2630,39 +3140,63 @@ GW.radialRing = function (host, opts = {}) {
return { el, get: () => v, set };
};
-/* 16 sparkline — rolling history trace; push(v) appends a sample, fill(v) levels it */
-GW.sparkline = function (host, opts = {}) {
+/* 16 sparkline — rolling history trace; push(v) appends a sample, fill(v) levels it.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: samples (history length, default 40, floor 2 — the trace needs
+ two points); value (initial level, default 0.5); onChange(last,
+ 'N') fires on every repaint (samples 0-1, clamped on entry;
+ readout 0-100). No initial fire — the page's tick drives it (the
+ tick contract — the page owns the clock and the signal). The
+ history buffer is display-side state owned in here.
+ handle: el, get() (newest sample), push(v) (append, oldest drops),
+ fill(v) (level the whole buffer).
+ CSS lives in the "sparkline" block of DUPRE_CSS; the trace colour is an
+ inline stroke, no other styles involved. */
+DUPRE.sparkline = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const n = opts.samples || 40;
- const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
- const el = document.createElement('span'); el.className = 'spark';
+ const n = Math.max(2, opts.samples || 40);
+ const clamp = x => Math.max(0, Math.min(1, x));
+ const hist = Array.from({ length: n }, () => clamp(opts.value ?? 0.5));
+ const el = document.createElement('span'); el.className = 'dupre-spark';
el.innerHTML = '<svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg>';
host.appendChild(el);
const line = el.querySelector('polyline');
- 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)));
+ line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 170},${44 - v * 40 - 2}`).join(' '));
+ onChange(hist[n - 1], String(Math.round(hist[n - 1] * 100)));
}
- function push(v) { hist.push(v); hist.shift(); paint(); }
- function fill(v) { hist.fill(v); paint(); }
+ function push(v) { hist.push(clamp(v)); hist.shift(); paint(); }
+ function fill(v) { hist.fill(clamp(v)); paint(); }
return { el, get: () => hist[n - 1], push, fill };
};
-/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1 */
-GW.waveStrip = function (host, opts = {}) {
+/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: amp (initial amplitude for the readout, default 0.6);
+ onChange(amp, 'amp N%') fires on every set, including the
+ initial paint (a flat centre line).
+ handle: el, get() (last amp), set(samples, amp) — samples in -1..1,
+ clamped per sample; fewer than two samples draws the flat
+ centre line. Amp is readout-only; the trace height comes from
+ the samples themselves.
+ CSS lives in the "waveform strip" block of DUPRE_CSS; the trace colour
+ is an inline stroke, no other styles involved. */
+DUPRE.waveStrip = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const el = document.createElement('span'); el.className = 'wave';
+ const el = document.createElement('span'); el.className = 'dupre-wave';
el.innerHTML = '<svg viewBox="0 0 170 38" preserveAspectRatio="none"><path fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg>';
host.appendChild(el);
const path = el.querySelector('path');
+ const clamp = x => Math.max(-1, Math.min(1, x));
let amp = 0;
function set(samples, a) {
amp = a;
let d = 'M0 19';
if (samples.length < 2) d += ' L170 19';
else for (let i = 0; i < samples.length; i++)
- d += ` L${(i / (samples.length - 1) * 170).toFixed(1)} ${(19 + samples[i] * 14).toFixed(1)}`;
+ d += ` L${(i / (samples.length - 1) * 170).toFixed(1)} ${(19 + clamp(samples[i]) * 14).toFixed(1)}`;
path.setAttribute('d', d);
onChange(amp, 'amp ' + Math.round(amp * 100) + '%');
}
@@ -2670,31 +3204,54 @@ GW.waveStrip = function (host, opts = {}) {
return { el, get: () => amp, set };
};
-/* N11 oscilloscope — sampled phosphor trace; set(samples, vpp) with samples in -1..1 */
-GW.scope = function (host, opts = {}) {
+/* N11 oscilloscope — sampled phosphor trace; set(samples, vpp) with samples in -1..1.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(vpp, 'Vpp N') fires on every set (readout is vpp x100).
+ No initial fire — the page's tick drives it (the tick contract —
+ the page owns the clock and the signal).
+ handle: el, get() (last vpp), set(samples, vpp) — samples in -1..1,
+ clamped per sample; fewer than two samples clears the trace.
+ Vpp is readout-only; the trace comes from the samples.
+ CSS lives in the "oscilloscope" block of DUPRE_CSS; the screen-family
+ --scr-* vars retint it, with the original green as fallback. */
+DUPRE.scope = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const el = document.createElement('span'); el.className = 'scope';
- el.innerHTML = '<span class="grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>';
+ const el = document.createElement('span'); el.className = 'dupre-scope';
+ el.innerHTML = '<span class="dupre-grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>';
host.appendChild(el);
const line = el.querySelector('polyline');
+ const clamp = x => Math.max(-1, Math.min(1, x));
let vpp = 0;
function set(samples, v) {
vpp = v;
const n = samples.length;
- line.setAttribute('points', samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + s * 22).toFixed(1)}`).join(' '));
+ line.setAttribute('points', n < 2 ? '' :
+ samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + clamp(s) * 22).toFixed(1)}`).join(' '));
onChange(vpp, 'Vpp ' + Math.round(vpp * 100));
}
return { el, get: () => vpp, set };
};
-/* N12 spectrum / EQ bars — set(values) paints one column per band */
-GW.eqBars = function (host, opts = {}) {
+/* N12 spectrum / EQ bars — set(values) paints one column per band.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: bands (columns, default 11); cells (segments per column, default
+ 9); onChange(values, 'peak N%') fires on every set (values 0-1
+ per band, clamped; a missing band reads 0). No initial fire —
+ the page's tick drives it (the tick contract — the page owns the
+ clock and the signal).
+ handle: el, get() (last values array), set(values). Cells light
+ bottom-up: dupre-on, top three dupre-hot, topmost dupre-clip.
+ CSS lives in the "spectrum / EQ" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.eqBars = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const bands = opts.bands || 11, cells = opts.cells || 9;
- const el = document.createElement('span'); el.className = 'eq';
+ const el = document.createElement('span'); el.className = 'dupre-eq';
host.appendChild(el);
for (let b = 0; b < bands; b++) {
- const band = document.createElement('span'); band.className = 'band';
+ const band = document.createElement('span'); band.className = 'dupre-band';
for (let s = 0; s < cells; s++) band.appendChild(document.createElement('i'));
el.appendChild(band);
}
@@ -2703,11 +3260,11 @@ GW.eqBars = function (host, opts = {}) {
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);
+ const col = el.children[b].children, val = Math.max(0, Math.min(1, values[b] || 0)), lit = Math.round(val * cells);
peak = Math.max(peak, val);
for (let k = 0; k < cells; k++) {
let c = '';
- if (k < lit) c = (k >= cells - 1 ? 'clip' : k >= cells - 3 ? 'hot' : 'on');
+ if (k < lit) c = (k >= cells - 1 ? 'dupre-clip' : k >= cells - 3 ? 'dupre-hot' : 'dupre-on');
col[k].className = c;
}
}
@@ -2716,17 +3273,26 @@ GW.eqBars = function (host, opts = {}) {
return { el, get: () => vals, set };
};
-/* N13 crossed-needle meter — one drive value, FWD and RFL needles cross */
-GW.crossNeedle = function (host, opts = {}) {
+/* N13 crossed-needle meter — one drive value, FWD and RFL needles cross.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 55); onChange(value, 'FWD n · RFL n') fires
+ on every set — RFL derives from the drive value (0.68×), the way a
+ crossed-needle SWR face couples the two.
+ handle: el, get() (current value), set(v) — clamped to 0-100; drag on
+ the face sets by vertical delta.
+ CSS lives in the "crossed-needle" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.crossNeedle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 55;
- const el = document.createElement('div'); el.className = 'crossm';
- el.innerHTML = `<div class="face"><div class="arc"></div><div class="nA"></div><div class="nB"></div></div>
- <div class="lbl"><span>FWD</span><span>RFL</span></div>`;
+ const el = document.createElement('div'); el.className = 'dupre-crossm';
+ el.innerHTML = `<div class="dupre-face"><div class="dupre-crossm-arc"></div><div class="dupre-fwd"></div><div class="dupre-rfl"></div></div>
+ <div class="dupre-lbl"><span>FWD</span><span>RFL</span></div>`;
host.appendChild(el);
- const face = el.querySelector('.face'), nA = el.querySelector('.nA'), nB = el.querySelector('.nB');
+ const face = el.querySelector('.dupre-face'), nA = el.querySelector('.dupre-fwd'), nB = el.querySelector('.dupre-rfl');
function set(nv) {
- v = nv;
+ v = Math.max(0, Math.min(100, nv));
const fwd = v, rfl = v * 0.68;
nA.style.transform = `rotate(${-42 + fwd / 100 * 84}deg)`;
nB.style.transform = `rotate(${42 - rfl / 100 * 84}deg)`;
@@ -2737,17 +3303,25 @@ GW.crossNeedle = function (host, opts = {}) {
return { el, get: () => v, set };
};
-/* N14 thermometer column — mercury rises with the value */
-GW.thermometer = function (host, opts = {}) {
+/* N14 thermometer column — mercury rises with the value.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 58); onChange(value, 'N°') fires on every
+ set — the readout maps 0-100 onto the printed 30-90° scale.
+ handle: el, get() (current value), set(v) — clamped to 0-100; drag
+ anywhere on the column sets by vertical delta.
+ CSS lives in the "thermometer" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.thermometer = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 58;
- const el = document.createElement('div'); el.className = 'thermo';
- el.innerHTML = `<div class="scale"><span>90</span><span>60</span><span>30</span></div>
- <div class="wrapcol"><div class="tube"><div class="fill"></div></div><div class="bulb"></div></div>`;
+ const el = document.createElement('div'); el.className = 'dupre-thermo';
+ el.innerHTML = `<div class="dupre-scale"><span>90</span><span>60</span><span>30</span></div>
+ <div class="dupre-wrapcol"><div class="dupre-thermo-tube"><div class="dupre-thermo-fill"></div></div><div class="dupre-bulb"></div></div>`;
host.appendChild(el);
- const fill = el.querySelector('.fill');
+ const fill = el.querySelector('.dupre-thermo-fill');
function set(nv) {
- v = nv;
+ v = Math.max(0, Math.min(100, nv));
fill.style.height = v + '%';
onChange(v, Math.round(30 + v / 100 * 60) + '°');
}
@@ -2756,18 +3330,27 @@ GW.thermometer = function (host, opts = {}) {
return { el, get: () => v, set };
};
-/* N15 bourdon pressure gauge — needle over a printed arc with a redline */
-GW.bourdon = function (host, opts = {}) {
+/* N15 bourdon pressure gauge — needle over a printed arc with a redline.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 45); onChange(value, 'N PSI') fires on every
+ set — the readout maps 0-100 onto a 0-160 PSI scale.
+ handle: el, get() (current value), set(v) — clamped to 0-100, the
+ needle sweeps -60°..+60°; drag anywhere on the dial sets by
+ vertical delta. The printed arc and redline are the builder's
+ own inline SVG.
+ CSS lives in the "bourdon" block of DUPRE_CSS; no other styles involved. */
+DUPRE.bourdon = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 45;
- const el = document.createElement('div'); el.className = 'bourdon';
+ const el = document.createElement('div'); el.className = 'dupre-bourdon';
el.innerHTML = `<svg viewBox="0 0 82 82"><path d="M14 62 A34 34 0 0 1 68 62" fill="none" stroke="#2c2f32" stroke-width="3"/>
<path d="M52 22 A34 34 0 0 1 68 62" fill="none" stroke="#cb6b4d" stroke-width="3"/></svg>
- <div class="ndl"></div><div class="hub"></div><div class="cap">PSI</div>`;
+ <div class="dupre-bourdon-ndl"></div><div class="dupre-bourdon-hub"></div><div class="dupre-bourdon-cap">PSI</div>`;
host.appendChild(el);
- const ndl = el.querySelector('.ndl');
+ const ndl = el.querySelector('.dupre-bourdon-ndl');
function set(nv) {
- v = nv;
+ v = Math.max(0, Math.min(100, nv));
ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`;
onChange(v, Math.round(v / 100 * 160) + ' PSI');
}
@@ -2776,16 +3359,25 @@ GW.bourdon = function (host, opts = {}) {
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) */
-GW.stripChart = function (host, opts = {}) {
+/* N16 strip-chart recorder — scrolling history, pen rides the newest value.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: samples (history length, default 60, floor 2); value (initial
+ level 0-1, default 0.5); onChange(current, 'N') fires on every
+ paint (readout in percent). Values clamp to 0-1 at paint.
+ handle: el, get() (newest value), push(v) appends one sample and
+ scrolls, set(samples, current) replaces the whole trace (a
+ short or missing array back-fills 0.5).
+ CSS lives in the "strip-chart" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.stripChart = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const n = opts.samples || 60;
+ const n = Math.max(2, opts.samples || 60);
const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
- const el = document.createElement('span'); el.className = 'strip';
- el.innerHTML = '<span class="rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline/></svg><span class="pen" style="top:31px"></span>';
+ const el = document.createElement('span'); el.className = 'dupre-strip';
+ el.innerHTML = '<span class="dupre-rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline/></svg><span class="dupre-pen" style="top:31px"></span>';
host.appendChild(el);
- const line = el.querySelector('polyline'), pen = el.querySelector('.pen');
+ const line = el.querySelector('polyline'), pen = el.querySelector('.dupre-pen');
const clamp = x => Math.max(0, Math.min(1, x));
const yPx = v => 59 - clamp(v) * 56;
function paint(current) {
@@ -2795,23 +3387,33 @@ GW.stripChart = function (host, opts = {}) {
}
function push(v) { hist.push(v); hist.shift(); paint(v); }
function set(samples, current) {
+ if (!Array.isArray(samples)) samples = [];
for (let i = 0; i < n; i++) hist[i] = samples[i] ?? 0.5;
paint(current ?? hist[n - 1]);
}
return { el, get: () => hist[n - 1], push, set };
};
-/* N17 correlation meter — needle rests at 0 and swings ±; drag left/right */
-GW.corrMeter = function (host, opts = {}) {
+/* N17 correlation meter — needle rests at 0 and swings ±; drag left/right.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 58 — 50 is the 0 rest point); onChange(corr,
+ '±x.xx') fires on every set with the CORRELATION (-1..+1 mapped
+ from the 0-100 position), not the raw position.
+ handle: el, get() (current 0-100 position), set(p) — clamped to 0-100;
+ drag left/right sets by pointer position.
+ CSS lives in the "correlation" block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.corrMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let p = opts.value ?? 58;
- const el = document.createElement('div'); el.className = 'corr';
- el.innerHTML = `<div class="face"><div class="arc"></div><div class="zero"></div><div class="ndl"></div></div>
- <div class="lbl"><span>−1</span><span>0</span><span>+1</span></div>`;
+ const el = document.createElement('div'); el.className = 'dupre-corr';
+ el.innerHTML = `<div class="dupre-corr-face"><div class="dupre-corr-arc"></div><div class="dupre-zero"></div><div class="dupre-corr-ndl"></div></div>
+ <div class="dupre-corr-lbl"><span>−1</span><span>0</span><span>+1</span></div>`;
host.appendChild(el);
- const ndl = el.querySelector('.ndl');
+ const ndl = el.querySelector('.dupre-corr-ndl');
function set(np) {
- p = np;
+ p = Math.max(0, Math.min(100, np));
const v = (p - 50) / 50;
ndl.style.transform = `rotate(${v * 38}deg)`;
onChange(v, (v >= 0 ? '+' : '') + v.toFixed(2));
@@ -2821,20 +3423,28 @@ GW.corrMeter = function (host, opts = {}) {
return { el, get: () => p, set };
};
-/* N18 battery-cell gauge — charge as discrete cells, the low end warns */
-GW.battCells = function (host, opts = {}) {
+/* N18 battery-cell gauge — charge as discrete cells, the low end warns.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: cells (count, default 8, floor 1); warnAt (threshold, default 25 —
+ at or below it lit cells tint dupre-warn); value (0-100, default
+ 62); onChange(value, 'N%') fires on every set.
+ handle: el, get() (current value), set(v) — clamped to 0-100; drag
+ left/right sets by pointer position. Lit cells carry dupre-on.
+ CSS lives in the "battery" block of DUPRE_CSS; no other styles involved. */
+DUPRE.battCells = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const cells = opts.cells || 8, warnAt = opts.warnAt ?? 25;
+ const cells = Math.max(1, opts.cells || 8), warnAt = opts.warnAt ?? 25;
let p = opts.value ?? 62;
- const el = document.createElement('div'); el.className = 'batt';
- el.innerHTML = '<div class="cells"></div><span class="nub"></span>';
+ const el = document.createElement('div'); el.className = 'dupre-batt';
+ el.innerHTML = '<div class="dupre-cells"></div><span class="dupre-nub"></span>';
host.appendChild(el);
- const cc = el.querySelector('.cells');
- for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'cell'; cc.appendChild(c); }
+ const cc = el.querySelector('.dupre-cells');
+ for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'dupre-cell'; cc.appendChild(c); }
function set(np) {
- p = np;
+ p = Math.max(0, Math.min(100, np));
const lit = Math.round(p / 100 * cells);
- for (let i = 0; i < cells; i++) cc.children[i].className = 'cell' + (i < lit ? ' on' : '') + ((p <= warnAt && i < lit) ? ' warn' : '');
+ for (let i = 0; i < cells; i++) cc.children[i].className = 'dupre-cell' + (i < lit ? ' dupre-on' : '') + ((p <= warnAt && i < lit) ? ' dupre-warn' : '');
onChange(p, Math.round(p) + '%');
}
dragX(el, set);
@@ -2843,8 +3453,19 @@ GW.battCells = function (host, opts = {}) {
};
/* 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 */
-GW.mcVu = function (host, opts = {}) {
+ ballistics belong to the signal owner.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-1.02, default .35 — clamped, the needle paints from it
+ at build); onChange(t, '+x.x VU') fires on every set, no initial
+ fire — the readout maps t through the shared VU law (VUDB/vuDb),
+ the needle caps at t=1.
+ handle: el, get() (current t), set(t) — clamped to 0-1.02; positions
+ instantly, no ballistics. No drag — display-only.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; the glass gradient and face clip are per-instance defs
+ entries. */
+DUPRE.mcVu = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 150, 96);
const cx = 75, cy = 112, sweep = t => -43 + t * 86;
@@ -2879,13 +3500,13 @@ GW.mcVu = function (host, opts = {}) {
x: 75, y: 78, 'text-anchor': 'middle', 'font-size': 11, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#3a3128'
}).textContent = 'VU';
+ let t = Math.max(0, Math.min(1.02, opts.value ?? .35));
const needle = svgEl(face, 'line', {
x1: cx, y1: cy, x2: cx, y2: cy - 62, stroke: '#1a1613', 'stroke-width': 1.6,
- transform: `rotate(${sweep(.35)},${cx},${cy})`
+ transform: `rotate(${sweep(Math.min(1, t))},${cx},${cy})`
});
svgEl(face, 'circle', { cx, cy: 88, r: 9, fill: '#16130f' });
svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: `url(#${glassId})` });
- 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})`);
@@ -2895,8 +3516,17 @@ GW.mcVu = function (host, opts = {}) {
return { el: s, get: () => t, set };
};
-/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down */
-GW.roundMeter = function (host, opts = {}) {
+/* 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;
@@ -2947,8 +3577,19 @@ GW.roundMeter = function (host, opts = {}) {
return { el: s, get: () => v, set };
};
-/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome */
-GW.chromeMinMax = function (host, opts = {}) {
+/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 50); onChange(value, readout) fires on
+ every set — the readout says 'MIN' at ≤2, 'MAX' at ≥98, 'N%'
+ between.
+ handle: el, get() (current value), set(v) — clamped to 0-100, the
+ pointer sweeps -55°..+55°; drag anywhere sets by vertical
+ delta.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; the ring and dome gradients register in the shared defs
+ plate. */
+DUPRE.chromeMinMax = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
const cx = 55, cy = 58;
@@ -2990,8 +3631,17 @@ GW.chromeMinMax = function (host, opts = {}) {
return { el: s, get: () => v, set };
};
-/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme */
-GW.aviationGauge = function (host, opts = {}) {
+/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 52); onChange(value, 'x.x ×100 rpm') fires
+ on every set — the readout maps 0-100 onto 0-8 ×100 RPM.
+ handle: el, get() (current value), set(v) — clamped to 0-100, the
+ needle sweeps -135°..+135°; drag anywhere sets by vertical
+ delta.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; the needle-glow filter registers in the shared defs plate. */
+DUPRE.aviationGauge = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
const cx = 55, cy = 52, sweep = t => -135 + t * 270;
@@ -3044,8 +3694,17 @@ GW.aviationGauge = function (host, opts = {}) {
return { el: s, get: () => v, set };
};
-/* R13 edgewise strip meter — compressed log scale, the bar rides the edge */
-GW.edgeMeter = function (host, opts = {}) {
+/* R13 edgewise strip meter — compressed log scale, the bar rides the edge.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (fraction of the window, .08-.94, default .62); onChange(
+ frac, '−x.x dB') fires on every set — the readout interpolates
+ the compressed 0..−40 dB scale printed on the face.
+ handle: el, get() (current fraction), set(f) — clamped to .08-.94 (the
+ printed scale's span); drag up/down sets by pointer position.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; the face gradient is a per-instance defs entry. */
+DUPRE.edgeMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const SCALE = [[0, .08], [3, .26], [6, .44], [12, .62], [20, .78], [40, .94]];
const s = stageSvg(host, 'rsvg drag', 70, 130);
@@ -3086,9 +3745,19 @@ GW.edgeMeter = function (host, opts = {}) {
return { el: s, get: () => frac, set };
};
-/* R17 round CRT scope — pale phosphor face; the trace is the widget's own
- animation, so it lives here with its reduced-motion gate */
-GW.roundCrt = function (host, opts = {}) {
+/* R17 round CRT scope — pale phosphor face; the trace is the instrument's own
+ animation, so it lives here with its reduced-motion gate.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(amp, 'Vpp N') fires on every animation tick, including
+ the initial paint — the demo waveform is instrument-owned.
+ handle: el, tick() — one animation step (the reduced-motion path paints
+ once and never ticks again on its own).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate, except the
+ face gradient, which reads screen-family vars from this instrument's
+ subtree and so lives in a local defs. */
+DUPRE.roundCrt = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 110, 104);
const cx = 55, cy = 52;
@@ -3098,7 +3767,7 @@ GW.roundCrt = function (host, opts = {}) {
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 widget's subtree,
+ /* 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%' });
@@ -3138,8 +3807,17 @@ GW.roundCrt = function (host, opts = {}) {
return { el: s, tick };
};
-/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */
-GW.attitudeIndicator = function (host, opts = {}) {
+/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: bank (initial degrees, -60..60, default 0); pitch (initial
+ degrees, -20..20, default 0); onChange({bank, pitch}, 'BANK …
+ · PITCH …') fires on every set, including the initial paint.
+ handle: el, get() ({bank, pitch}), set(bank, pitch) — both clamp to
+ their ranges; the stage itself is a 2D drag surface.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.attitudeIndicator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 130, 130);
const cx = 65, cy = 65;
@@ -3194,8 +3872,17 @@ GW.attitudeIndicator = function (host, opts = {}) {
};
/* R44 heading bug + servo needle — drag parks the command; the needle chases
- with honest servo lag (widget-owned animation, reduced-motion gated) */
-GW.headingBug = function (host, opts = {}) {
+ with honest servo lag (instrument-owned animation, reduced-motion gated).
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (initial commanded heading in degrees, default 90 —
+ normalized to 0..360); onChange({cmd, act}, 'CMD … · ACT …')
+ fires on every set and on every servo step.
+ handle: el, get() ({cmd, act}), set(v) — v normalizes to 0..360;
+ dragging the stage moves the command bug, the needle chases.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.headingBug = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 130, 130);
const cx = 65, cy = 65;
@@ -3225,7 +3912,7 @@ GW.headingBug = function (host, opts = {}) {
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;
+ let cmd = ((opts.value ?? 90) % 360 + 360) % 360, act = cmd;
const draw = () => {
bug.setAttribute('transform', `rotate(${cmd.toFixed(1)},${cx},${cy})`);
needle.setAttribute('transform', `rotate(${act.toFixed(1)},${cx},${cy})`);
@@ -3245,8 +3932,16 @@ GW.headingBug = function (host, opts = {}) {
};
/* R53 circular chart recorder — a day per revolution; the pen draws the cycle
- (widget-owned clock, reduced-motion paints the full day once); click for fresh paper */
-GW.chartRecorder = function (host, opts = {}) {
+ (instrument-owned clock, reduced-motion paints the full day once); click for fresh paper.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(hour, 'HH:MM · N%') fires on every pen step — the demo
+ day-cycle signal is instrument-owned.
+ handle: el, get() (current hour, 0..24), reset() — fresh paper, pen to
+ 00:00; clicking the stage does the same.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.chartRecorder = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 130);
const cx = 65, cy = 65;
@@ -3295,8 +3990,17 @@ GW.chartRecorder = function (host, opts = {}) {
return { el: s, get: () => hour, reset };
};
-/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive */
-GW.verticalTape = function (host, opts = {}) {
+/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (initial units, 5..35 on the printed RPM x100 scale,
+ default 24); onChange(value, 'RPM N') fires on every set,
+ including the initial paint.
+ handle: el, get() (units), set(v) — v clamps 5..35; the stage drags
+ vertically.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.verticalTape = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 70, 130);
const CX = 38, CY = 65;
@@ -3337,8 +4041,17 @@ GW.verticalTape = function (host, opts = {}) {
return { el: s, get: () => v, set };
};
-/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles */
-GW.twinNeedle = function (host, opts = {}) {
+/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: fuel (initial, 0..4, default 2.4); oil (initial, 0..4, default
+ 3.1); onChange([fuel, oil], 'FUEL n.n · OIL n.n') fires on every
+ set, including the initial paint.
+ handle: el, get() ([fuel, oil]), set(fuel, oil) — both clamp 0..4;
+ each half of the stage drags its own needle vertically.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.twinNeedle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 120, 110);
const cx = 60, cy = 62, R = 44;
@@ -3386,7 +4099,7 @@ GW.twinNeedle = function (host, opts = {}) {
};
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;
+ let vF, vO;
const draw = () => {
nF.setAttribute('transform', `rotate(${angL(vF).toFixed(1)},${cx},${cy})`);
nO.setAttribute('transform', `rotate(${angR(vO).toFixed(1)},${cx},${cy})`);
@@ -3401,12 +4114,22 @@ GW.twinNeedle = function (host, opts = {}) {
};
half(0, () => vF, v => vF = v);
half(60, () => vO, v => vO = v);
- draw();
+ set(opts.fuel ?? 2.4, opts.oil ?? 3.1);
return { el: s, get: () => [vF, vO], set };
};
-/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts */
-GW.comfortMeter = function (host, opts = {}) {
+/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: temp (initial °F, 40..100, default 72); humidity (initial %,
+ 0..100, default 45); onChange([temp, humidity], 'NF · N% RH ·
+ VERDICT') fires on every set, including the initial paint.
+ handle: el, get() ([temp, humidity]), set(temp, humidity) — both clamp
+ to their scales; the left half of the stage drags temp, the
+ right half humidity.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
+DUPRE.comfortMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 130, 122);
const cx = 65, cy = 60;
@@ -3478,7 +4201,7 @@ GW.comfortMeter = function (host, opts = {}) {
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;
+ let vT, vH;
const verdict = () => vT > 78 ? 'warm' : vT < 62 ? 'cold' : vH > 60 ? 'humid' : vH < 30 ? 'dry' : 'right';
const draw = () => {
nT.setAttribute('transform', `translate(${PT[0]},${PT[1]}) rotate(${angT(vT).toFixed(1)})`);
@@ -3495,16 +4218,25 @@ GW.comfortMeter = function (host, opts = {}) {
};
half(0, () => vT, v => vT = v, 40, 100);
half(65, () => vH, v => vH = v, 0, 100);
- draw();
+ set(opts.temp ?? 72, opts.humidity ?? 45);
return { el: s, get: () => [vT, vH], set };
};
/* ================= indicators & readouts ================= */
-/* 18 status lamps — one lamp per health state; click any lamp to cycle it */
-GW.statusLamps = function (host, opts = {}) {
+/* 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 = ['lamp', 'lamp gold', 'lamp red', 'lamp off', 'lamp busy'];
+ 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');
@@ -3524,15 +4256,25 @@ GW.statusLamps = function (host, opts = {}) {
return { el: wrap, get: () => states.slice(), set };
};
-/* 19 badges — labelled flags; click a badge to cycle its variant */
-GW.badges = function (host, opts = {}) {
+/* 19 badges — labelled flags; click a badge to cycle its variant.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: items (array of [label, variant] pairs, variant 0 gold · 1 red ·
+ 2 ghost, default a three-badge demo set); onChange(variants array
+ copy, label) fires per change and once at build with a count
+ summary.
+ handle: el, get() (variants copy), set(i, v) — v wraps modulo 3.
+ CSS lives in the .dupre-badge block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.badges = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const CLS = ['badge', 'badge red', 'badge ghost'];
+ const CLS = ['dupre-badge', 'dupre-badge dupre-red', 'dupre-badge dupre-ghost'];
+ const norm = v => (((v | 0) % CLS.length) + CLS.length) % CLS.length;
const items = opts.items || [['TUNNEL', 0], ['LOW BATT', 1], ['2.4G', 2]];
- const vars = items.map(it => it[1]);
+ const vars = items.map(it => norm(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';
+ const els = items.map(([txt], i) => {
+ const b = document.createElement('span'); b.className = CLS[vars[i]]; b.textContent = txt; b.style.cursor = 'pointer';
b.addEventListener('click', () => set(i, vars[i] + 1));
wrap.appendChild(b);
if (i < items.length - 1) wrap.appendChild(document.createTextNode(' '));
@@ -3540,7 +4282,7 @@ GW.badges = function (host, opts = {}) {
});
host.appendChild(wrap);
function set(i, v) {
- vars[i] = ((v % CLS.length) + CLS.length) % CLS.length;
+ vars[i] = norm(v);
els[i].className = CLS[vars[i]];
onChange(vars.slice(), items[i][0]);
}
@@ -3549,13 +4291,24 @@ GW.badges = function (host, opts = {}) {
};
/* 20 tabular readout — mm:ss countdown; builder owns the state, the page
- drives tick() on its own clock (tick contract); click pauses/resumes */
-GW.tabularReadout = function (host, opts = {}) {
+ drives tick() on its own clock (tick contract); click pauses/resumes.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: secs (initial seconds, wraps modulo 3600, default 24:10); run
+ (initial running state, default true); unit (caption under the
+ digits, default 'timer'); onChange(secs, 'running'/'paused')
+ fires on every set, including the initial paint.
+ handle: el, get() (secs), set(v) — v wraps modulo 3600; tick() — one
+ second down when running (the page's clock drives it); clicking
+ the digits pauses/resumes.
+ CSS lives in the .dupre-readout block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.tabularReadout = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const wrap = document.createElement('div'); wrap.style.textAlign = 'center';
- wrap.innerHTML = '<div class="readout"></div><span class="u"></span>';
- const out = wrap.querySelector('.readout');
- wrap.querySelector('.u').textContent = opts.unit || 'timer';
+ wrap.innerHTML = '<div class="dupre-readout"></div><span class="dupre-unit"></span>';
+ const out = wrap.querySelector('.dupre-readout');
+ wrap.querySelector('.dupre-unit').textContent = opts.unit || 'timer';
host.appendChild(wrap);
let secs, run = opts.run !== undefined ? !!opts.run : true;
const draw = () => {
@@ -3568,12 +4321,21 @@ GW.tabularReadout = function (host, opts = {}) {
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 */
-GW.engravedLabel = function (host, opts = {}) {
+/* 21 engraved label — hairline-flanked caps label with a count; click bumps 1-9.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: label (caption text, default 'outputs'); count (initial count,
+ default 3); onChange(count, 'count N') fires on every set,
+ including the initial paint.
+ handle: el, get() (count), set(v); clicking the label cycles the count
+ 1..9.
+ CSS lives in the .dupre-engrave block of DUPRE_CSS; no other styles
+ involved. */
+DUPRE.engravedLabel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const e = document.createElement('span'); e.className = 'engrave';
+ const e = document.createElement('span'); e.className = 'dupre-engrave';
e.append(opts.label || 'outputs');
- const c = document.createElement('span'); c.className = 'cnt'; e.appendChild(c);
+ const c = document.createElement('span'); c.className = 'dupre-cnt'; e.appendChild(c);
host.appendChild(e);
let n;
const set = v => { n = v; c.textContent = '· ' + n; onChange(n, 'count ' + n); };
@@ -3584,15 +4346,28 @@ GW.engravedLabel = function (host, opts = {}) {
/* 22 output well — streaming step log, lamp per step; click streams the next
demo step; push([lampCls, name, evidence]) appends programmatically */
-GW.outputWell = function (host, opts = {}) {
+/* 22 output well — a scrolling log of status steps; click streams the next demo
+ step, oldest rows scroll off once the well is full.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: seed (initial rows, default two); steps (the click-cycle demo pool);
+ keep (max visible rows before the oldest scrolls off, default 5).
+ Each row is [tone, label, detail]; tone is '' | 'gold' | 'red' and
+ lights the row's dupre-lamp accent. onChange(row|null, caption) fires
+ on every push and once at build with a hint.
+ handle: el, push(row) — appends a row and trims the well to keep. Click
+ advances through steps and pushes the next.
+ CSS lives in the "output well" block of DUPRE_CSS; the row lamp is the shared
+ .dupre-lamp primitive tinted by the tone accent. */
+DUPRE.outputWell = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const seed = opts.seed || [['', 'Link', 'wlp170s0 · @Hyatt'], ['gold', 'DNS', 'resolving…']];
const steps = opts.steps || [['gold', 'Probe', '8.8.8.8 …'], ['', 'Gateway', '10.0.0.1 ok'], ['', 'DNS', '1.1.1.1 ok'], ['red', 'Retry', 'timeout']];
const keep = opts.keep || 5;
- const w = document.createElement('div'); w.className = 'owell'; host.appendChild(w);
+ const w = document.createElement('div'); w.className = 'dupre-owell'; host.appendChild(w);
const add = s => {
- const d = document.createElement('div'); d.className = 'ostep';
- d.innerHTML = `<span class="lamp ${s[0]}"></span><span><b>${s[1]}</b><span class="ev">${s[2]}</span></span>`;
+ const d = document.createElement('div'); d.className = 'dupre-ostep';
+ d.innerHTML = `<span class="dupre-lamp${s[0] ? ' dupre-' + s[0] : ''}"></span><span><b>${s[1]}</b><span class="dupre-ev">${s[2]}</span></span>`;
w.appendChild(d); while (w.children.length > keep) w.removeChild(w.firstChild);
};
seed.forEach(add);
@@ -3604,11 +4379,19 @@ GW.outputWell = function (host, opts = {}) {
};
/* 23 toast — one-line transient confirmation; click fires the next demo
- message; fire(msg) shows any message with the fade-in */
-GW.toast = function (host, opts = {}) {
+ message; fire(msg) shows any message with the fade-in.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: msgs (the demo message pool, cycled by click); text (initial
+ message, default the last pool entry); onChange(n, 'fired n') fires
+ on each click and once at build with a hint.
+ handle: el, fire(msg) — shows any message with the opacity fade-in; the
+ page can drive it with its own text. Click cycles the demo pool.
+ CSS lives in the "toast" block of DUPRE_CSS; no other styles involved. */
+DUPRE.toast = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const msgs = opts.msgs || ['link up · 300 Mbps', 'bt paired — WH-1000XM4', 'profile switched', 'joined @Hyatt_WiFi — saved'];
- const t = document.createElement('span'); t.className = 'toastw';
+ const t = document.createElement('span'); t.className = 'dupre-toastw';
t.textContent = opts.text || msgs[msgs.length - 1];
host.appendChild(t);
const fire = msg => {
@@ -3621,14 +4404,22 @@ GW.toast = function (host, opts = {}) {
return { el: t, fire };
};
-/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments */
-GW.nixie = function (host, opts = {}) {
+/* 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 = 'nixie';
+ const nx = document.createElement('span'); nx.className = 'dupre-nixie';
for (let i = 0; i < digits; i++) {
- const tu = document.createElement('span'); tu.className = 'tube'; tu.innerHTML = '<b></b>'; nx.appendChild(tu);
+ const tu = document.createElement('span'); tu.className = 'dupre-tube'; tu.innerHTML = '<b></b>'; nx.appendChild(tu);
}
host.appendChild(nx);
let v;
@@ -3636,7 +4427,7 @@ GW.nixie = function (host, opts = {}) {
v = ((x % max) + max) % max;
const s = String(v).padStart(digits, '0');
[...nx.children].forEach((tu, i) => {
- tu.classList.toggle('off', i < digits - 1 && v < Math.pow(10, digits - 1 - 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);
@@ -3646,45 +4437,215 @@ GW.nixie = function (host, opts = {}) {
return { el: nx, get: () => v, set };
};
-/* N20 split-flap — windows flip to the next word with the mechanical clack;
- the page drives next() on its own cadence; first paint is silent like the original */
-GW.splitFlap = function (host, opts = {}) {
+/* 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 cells = opts.cells || 3;
- const f = document.createElement('span'); f.className = 'flap';
- for (let i = 0; i < cells; i++) {
- const d = document.createElement('span'); d.className = 'flapd'; d.innerHTML = '<b></b>'; f.appendChild(d);
+ const rows = opts.rows || 1;
+ const cells = opts.cells || 4;
+ const chars = opts.chars || ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+ const clampMs = ms => Math.min(400, Math.max(20, ms | 0));
+ let flapMs = clampMs(opts.flapMs || 100);
+
+ const ci = ch => Math.max(0, chars.indexOf(ch));
+ const fit = (str, w) => String(str).padEnd(w, ' ').slice(0, w);
+ const wordAt = k => words[((k % words.length) + words.length) % words.length];
+
+ // Four stacked half-panels per cell, painting order = stacking order:
+ // top-next (revealed as the current top falls), bottom-current (covered as
+ // the next bottom lands), top-current (the falling flap), bottom-next (the
+ // landing flap, resting folded away at rotateX(180) with its back hidden).
+ const st = []; // row-major cell states
+ const settleCbs = []; if (opts.onSettle) settleCbs.push(opts.onSettle);
+ let announced = true; // no announcement for the silent first paint
+ let idx = 0; // last set() index; -1 after a scramble
+ let lastGrid = ''; // the last commanded grid — the demo picks against
+ // commands, not in-flight glyphs mid-cascade
+ const allSettled = () => st.every(S => !S.running && S.cur === S.target);
+ const fitGrid = str => Array.from({ length: rows },
+ (_, r) => fit(String(str).split('\n')[r] || '', cells)).join('\n');
+ const reading = () => Array.from({ length: rows },
+ (_, r) => st.slice(r * cells, (r + 1) * cells).map(S => chars[S.cur]).join('')).join('\n');
+ const makeCell = () => {
+ const d = document.createElement('span'); d.className = 'flapd';
+ d.innerHTML = '<b class="fh ftn"></b><b class="fh fbc"></b><b class="fh ftc"></b><b class="fh fbn"></b>';
+ st.push({
+ cur: 0, target: 0, running: false,
+ // Each cell's mechanism has its own character: a fixed per-cell jitter
+ // (0.8x-1.35x of the base rate) so letters finish at genuinely
+ // different times, beyond what travel distance alone staggers.
+ jitter: 0.8 + 0.55 * Math.random(),
+ ftn: d.querySelector('.ftn'), fbc: d.querySelector('.fbc'),
+ ftc: d.querySelector('.ftc'), fbn: d.querySelector('.fbn'),
+ });
+ return d;
+ };
+ const f = document.createElement('span'); f.className = 'flap' + (rows > 1 ? ' flap-rows' : '');
+ for (let r = 0; r < rows; r++) {
+ const line = rows > 1 ? document.createElement('span') : f;
+ if (rows > 1) { line.className = 'flapline'; f.appendChild(line); }
+ for (let c = 0; c < cells; c++) line.appendChild(makeCell());
}
host.appendChild(f);
- let idx = 0;
- const paint = () => {
- const w = words[idx].padEnd(cells + 1, ' ');
- [...f.children].forEach((dd, i) => {
- const ch = w[i] || ' ', bel = dd.querySelector('b');
- if (bel.textContent !== ch) {
- bel.textContent = ch;
- if (animate) { dd.classList.remove('flip'); void dd.offsetWidth; dd.classList.add('flip'); }
+
+ // 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];
});
};
- const set = i => { idx = ((i % words.length) + words.length) % words.length; paint(); onChange(idx, words[idx].trim()); };
- f.addEventListener('click', () => set(idx + 1));
- paint();
- return { el: f, get: () => idx, set, next: () => set(idx + 1) };
+
+ // 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 */
-GW.sevenSeg = function (host, opts = {}) {
+ 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 = 'seven'; host.appendChild(sv);
+ const sv = document.createElement('span'); sv.className = 'dupre-seven'; host.appendChild(sv);
let secs;
const set = v => {
secs = ((v % 3600) + 3600) % 3600;
const mm = String(Math.floor(secs / 60)).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0');
- sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '<span class="colon"><i></i><i></i></span>' + seg7(ss[0]) + seg7(ss[1]);
+ sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '<span class="dupre-colon"><i></i><i></i></span>' + seg7(ss[0]) + seg7(ss[1]);
onChange(secs, mm + ':' + ss);
};
sv.addEventListener('click', () => set(secs + 60));
@@ -3692,14 +4653,23 @@ GW.sevenSeg = function (host, opts = {}) {
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 */
-GW.vfdMarquee = function (host, opts = {}) {
+/* 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 = 'vfdm';
- m.innerHTML = '<span class="txt"></span><span class="mesh"></span>';
- const t = m.querySelector('.txt'); t.textContent = msgs[0];
+ const m = document.createElement('span'); m.className = 'dupre-vfdm';
+ m.innerHTML = '<span class="dupre-txt"></span><span class="dupre-mesh"></span>';
+ const t = m.querySelector('.dupre-txt'); t.textContent = msgs[0];
host.appendChild(m);
let mi = 0, x = W;
m.addEventListener('click', () => {
@@ -3714,28 +4684,42 @@ GW.vfdMarquee = function (host, opts = {}) {
};
/* N23 annunciator — named alarm grid with the raise → MSTR CAUTION → ACK → RESET
- lifecycle; TEST proves the bulbs then restores the board */
-GW.annunciator = function (host, opts = {}) {
+ lifecycle; TEST proves the bulbs then restores the board.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: cells (array of [label, state] where state is 0 ok · 1 warn ·
+ 2 fault; defaults to a six-cell demo). onChange fires on every
+ transition: (count, summary) after a refresh, (index, 'label → name')
+ after a cell change, (null, 'lamp test') during TEST.
+ handle: el, get() (per-cell state array 0/1/2), set(i, st) — st clamps to
+ the 0..2 lens range at build AND at set, so an out-of-range caller
+ can't paint an undefined class. A new warn/fault re-arms the
+ MSTR CAUTION flasher; ACK stops it, RESET clears the board.
+ CSS lives in the "annunciator" block of DUPRE_CSS; the ACK/TEST/RESET buttons
+ are the shared .dupre-key primitive; the board uses the kit-wide dupre-warn
+ modifier and MSTR CAUTION the kit-wide dupre-on. */
+DUPRE.annunciator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const CLS = ['acell', 'acell warn', 'acell fault'];
+ const CLS = ['dupre-acell', 'dupre-acell dupre-warn', 'dupre-acell dupre-fault'];
const NAMES = ['ok', 'warn', 'fault'];
+ const clampSt = st => Math.max(0, Math.min(CLS.length - 1, st | 0));
const cells = opts.cells || [['SYNC', 0], ['LOW BATT', 1], ['LINK', 0], ['NO DNS', 2], ['VPN', 0], ['DISK', 0]];
- const wrap = document.createElement('div'); wrap.className = '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);
+ const wrap = document.createElement('div'); wrap.className = 'dupre-annwrap';
+ const grid = document.createElement('div'); grid.className = 'dupre-annun'; wrap.appendChild(grid);
+ const bar = document.createElement('div'); bar.className = 'dupre-annbar';
+ const mc = document.createElement('span'); mc.className = 'dupre-mc'; mc.textContent = 'MSTR CAUTION'; bar.appendChild(mc);
['ACK', 'TEST', 'RESET'].forEach(a => {
- const b = document.createElement('button'); b.className = 'key'; b.dataset.k = a; b.textContent = a; bar.appendChild(b);
+ const b = document.createElement('button'); b.className = 'dupre-key'; b.dataset.k = a; b.textContent = a; bar.appendChild(b);
});
wrap.appendChild(bar); host.appendChild(wrap);
const els = cells.map(([label, st]) => {
- const c = document.createElement('span'); c.className = CLS[st]; c.textContent = label; grid.appendChild(c); return c;
+ const c = document.createElement('span'); c.className = CLS[clampSt(st)]; c.textContent = label; grid.appendChild(c); return c;
});
let acked = false;
- const active = () => grid.querySelectorAll('.warn,.fault').length;
+ const active = () => grid.querySelectorAll('.dupre-warn,.dupre-fault').length;
const refresh = () => {
const n = active();
- mc.classList.toggle('on', n > 0); mc.classList.toggle('fl', n > 0 && !acked);
+ mc.classList.toggle('dupre-on', n > 0); mc.classList.toggle('dupre-fl', n > 0 && !acked);
onChange(n, n === 0 ? 'clear' : n + ' active · ' + (acked ? 'ACK' : 'UNACK'));
};
els.forEach((c, i) => c.addEventListener('click', () => {
@@ -3743,56 +4727,79 @@ GW.annunciator = function (host, opts = {}) {
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', () => {
+ bar.querySelectorAll('.dupre-key').forEach(k => k.addEventListener('click', () => {
const a = k.dataset.k;
if (a === 'ACK') { if (active() > 0) acked = true; refresh(); }
- else if (a === 'RESET') { els.forEach(c => c.className = 'acell'); acked = false; refresh(); }
+ else if (a === 'RESET') { els.forEach(c => c.className = 'dupre-acell'); acked = false; refresh(); }
else if (a === 'TEST') {
const prev = els.map(c => c.className);
- els.forEach(c => c.className = 'acell fault');
- mc.classList.add('on', 'fl'); onChange(null, 'lamp test');
+ els.forEach(c => c.className = 'dupre-acell dupre-fault');
+ mc.classList.add('dupre-on', 'dupre-fl'); onChange(null, 'lamp test');
setTimeout(() => { els.forEach((c, i) => c.className = prev[i]); refresh(); }, 1100);
}
}));
refresh();
return {
el: wrap, get: () => els.map(c => Math.max(0, CLS.indexOf(c.className.trim()))),
- set: (i, st) => { els[i].className = CLS[st]; if (st > 0) acked = false; refresh(); }
+ set: (i, st) => { els[i].className = CLS[clampSt(st)]; if (clampSt(st) > 0) acked = false; refresh(); }
};
};
-/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark */
-GW.jewels = function (host, opts = {}) {
+/* 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 init = opts.states || [0, 1, 2, -1]; /* index into cols; -1 = dark */
+ 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';
- init.forEach(st => {
- const j = document.createElement('span'); j.className = 'jewel' + (st < 0 ? ' dim' : '');
- if (st >= 0) j.style.setProperty('--jc', cols[st]);
- j.addEventListener('click', () => {
- if (j.classList.contains('dim')) { j.classList.remove('dim'); j.style.setProperty('--jc', cols[0]); onChange(0, 'red'); return; }
- const cur = j.style.getPropertyValue('--jc').trim(); const i = cols.indexOf(cur);
- if (i >= cols.length - 1 || i < 0) { j.classList.add('dim'); onChange(-1, 'dark'); }
- else { j.style.setProperty('--jc', cols[i + 1]); onChange(i + 1, NAMES[i + 1]); }
- });
- wrap.appendChild(j);
+ 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 };
+ return { el: wrap, get: () => states.slice(), set };
};
-/* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit */
-GW.tapeCounter = function (host, opts = {}) {
+/* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: wheels (digit count, default 6); redFrom (index from which the
+ high-order wheels wear the red band, default 4); value (initial,
+ default 471300); onChange(value, zero-padded string) fires on set.
+ handle: el, get() (current total), set(total) — rounds fractional input
+ and wraps modulo 10^wheels, then rolls each wheel to its digit.
+ CSS lives in the "tape counter" block of DUPRE_CSS; no other styles involved. */
+DUPRE.tapeCounter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const wheels = opts.wheels || 6, redFrom = opts.redFrom !== undefined ? opts.redFrom : 4;
- const ct = document.createElement('span'); ct.className = 'counter'; host.appendChild(ct);
+ const ct = document.createElement('span'); ct.className = 'dupre-counter'; host.appendChild(ct);
for (let idx = 0; idx < wheels; idx++) {
- const w = document.createElement('span'); w.className = 'cwheel' + (idx >= redFrom ? ' redw' : '');
- const col = document.createElement('span'); col.className = 'col';
+ const w = document.createElement('span'); w.className = 'dupre-cwheel' + (idx >= redFrom ? ' dupre-redw' : '');
+ const col = document.createElement('span'); col.className = 'dupre-col';
for (let n = -1; n <= 10; n++) { const sp = document.createElement('span'); sp.textContent = ((n + 10) % 10); col.appendChild(sp); }
w.appendChild(col); ct.appendChild(w);
}
@@ -3801,7 +4808,7 @@ GW.tapeCounter = function (host, opts = {}) {
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'; });
+ [...ct.children].forEach((w, i) => { w.querySelector('.dupre-col').style.top = (-(+s[i] + 1) * 34) + 'px'; });
onChange(v, s);
};
set(opts.value !== undefined ? opts.value : 471300);
@@ -3809,14 +4816,22 @@ GW.tapeCounter = function (host, opts = {}) {
};
/* 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 */
-GW.analogClock = function (host, opts = {}) {
+ 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 = 'clock';
- for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); }
- cl.insertAdjacentHTML('beforeend', '<span class="hh"></span><span class="mh"></span><span class="sh"></span><span class="pin"></span>');
+ const cl = document.createElement('span'); cl.className = 'dupre-clock';
+ for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'dupre-tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); }
+ cl.insertAdjacentHTML('beforeend', '<span class="dupre-hh"></span><span class="dupre-mh"></span><span class="dupre-sh"></span><span class="dupre-pin"></span>');
host.appendChild(cl);
- const hh = cl.querySelector('.hh'), mh = cl.querySelector('.mh'), sh = cl.querySelector('.sh');
+ 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)`;
@@ -3827,24 +4842,35 @@ GW.analogClock = function (host, opts = {}) {
};
/* N27 frequency-dial scale — printed log axis, marks crowd low and spread high;
- drag the pointer to tune */
-GW.freqScale = function (host, opts = {}) {
+ drag the pointer to tune.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: width (px, default 184); marks (labeled major marks, log-spaced,
+ default 0.5..20); unit (band label, default 'MHz'); value (initial
+ pointer percent 0..100, default 45); onChange(freq, 'nn unit') fires
+ on set and drag.
+ handle: el, get() (pointer percent 0..100), set(pct) — clamps to 0..100
+ and maps to a frequency along the log axis. dragX drives it.
+ CSS lives in the "frequency-dial scale" block of DUPRE_CSS; the ticks and
+ marks reuse the shared scoped dupre-tick/dupre-mk names (like the tuner and
+ dualknob), and the unit label is dupre-fs-band (dupre-band is the EQ's). */
+DUPRE.freqScale = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const W = opts.width || 184;
const marks = opts.marks || [0.5, 1, 2, 3, 5, 7, 10, 14, 20];
const unit = opts.unit || 'MHz';
const lo = Math.log10(marks[0]), hi = Math.log10(marks[marks.length - 1]);
const px = m => 6 + ((Math.log10(m) - lo) / (hi - lo)) * (W - 12);
- const fs = document.createElement('span'); fs.className = 'freqscale';
- const band = document.createElement('span'); band.className = 'band'; band.textContent = unit; fs.appendChild(band);
+ const fs = document.createElement('span'); fs.className = 'dupre-freqscale';
+ const band = document.createElement('span'); band.className = 'dupre-fs-band'; band.textContent = unit; fs.appendChild(band);
marks.forEach(m => {
- const tk = document.createElement('span'); tk.className = '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);
+ const tk = document.createElement('span'); tk.className = 'dupre-tick'; tk.style.left = px(m) + 'px'; tk.style.height = '11px'; fs.appendChild(tk);
+ const mk = document.createElement('span'); mk.className = 'dupre-mk'; mk.style.left = px(m) + 'px'; mk.textContent = m; fs.appendChild(mk);
});
for (let m = marks[0]; m <= marks[marks.length - 1]; m += (m < 2 ? 0.25 : m < 10 ? 1 : 2)) {
- const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '6px'; tk.style.opacity = '.5'; fs.appendChild(tk);
+ const tk = document.createElement('span'); tk.className = 'dupre-tick'; tk.style.left = px(m) + 'px'; tk.style.height = '6px'; tk.style.opacity = '.5'; fs.appendChild(tk);
}
- const ptr = document.createElement('span'); ptr.className = 'fptr'; fs.appendChild(ptr);
+ const ptr = document.createElement('span'); ptr.className = 'dupre-fptr'; fs.appendChild(ptr);
host.appendChild(fs);
let pct;
const set = p => {
@@ -3859,15 +4885,25 @@ GW.freqScale = function (host, opts = {}) {
};
/* N28 patch bay — jack grid with SVG cables; click one jack then another to
- patch or unpatch the pair; redraws itself on window resize */
-GW.patchBay = function (host, opts = {}) {
+ patch or unpatch the pair; redraws itself on window resize.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: rows (default 2), cols (default 4); conns (initial cables as
+ 'a-b' jack-index strings, default ['0-5', '2-7']); onChange(conns
+ array, summary) fires on every patch/unpatch and once at build.
+ handle: el, get() (conns copy), set(conns) — replaces the patch set and
+ redraws the cables. Click one jack then another to patch or
+ unpatch; the SVG cables re-solve on window resize.
+ CSS lives in the "patch-bay" block of DUPRE_CSS; a live jack wears the
+ kit-wide dupre-hot modifier. */
+DUPRE.patchBay = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const rows = opts.rows || 2, cols = opts.cols || 4;
- const patch = document.createElement('div'); patch.className = 'patch';
+ const patch = document.createElement('div'); patch.className = 'dupre-patch';
const jacks = [];
for (let r = 0; r < rows; r++) {
- const rowEl = document.createElement('div'); rowEl.className = '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); }
+ const rowEl = document.createElement('div'); rowEl.className = 'dupre-row';
+ for (let c = 0; c < cols; c++) { const j = document.createElement('span'); j.className = 'dupre-jack'; j.dataset.id = (r * cols + c); rowEl.appendChild(j); jacks.push(j); }
patch.appendChild(rowEl);
}
const svg = document.createElementNS(SVGNS, 'svg'); patch.appendChild(svg);
@@ -3877,7 +4913,7 @@ GW.patchBay = function (host, opts = {}) {
const draw = () => {
while (svg.firstChild) svg.removeChild(svg.firstChild);
const pr = patch.getBoundingClientRect();
- jacks.forEach(j => j.classList.remove('hot'));
+ jacks.forEach(j => j.classList.remove('dupre-hot'));
conns.forEach(pair => {
const [a, b] = pair.split('-').map(Number);
const ra = jacks[a].getBoundingClientRect(), rb = jacks[b].getBoundingClientRect();
@@ -3885,17 +4921,17 @@ GW.patchBay = function (host, opts = {}) {
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');
+ jacks[a].classList.add('dupre-hot'); jacks[b].classList.add('dupre-hot');
});
onChange(conns.slice(), conns.length ? conns.map(c => { const [a, b] = c.split('-'); return a + '↔' + b; }).join(' ') : 'no cables');
};
jacks.forEach(j => {
j.addEventListener('click', () => {
const id = +j.dataset.id;
- if (pending == null) { pending = id; j.classList.add('sel'); return; }
- if (pending === id) { pending = null; j.classList.remove('sel'); return; }
+ if (pending == null) { pending = id; j.classList.add('dupre-sel'); return; }
+ if (pending === id) { pending = null; j.classList.remove('dupre-sel'); return; }
const k = key(pending, id); const i = conns.indexOf(k); if (i >= 0) conns.splice(i, 1); else conns.push(k);
- jacks[pending].classList.remove('sel'); pending = null; draw();
+ jacks[pending].classList.remove('dupre-sel'); pending = null; draw();
});
});
draw(); window.addEventListener('resize', draw);
@@ -3903,8 +4939,21 @@ GW.patchBay = function (host, opts = {}) {
};
/* 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) */
-GW.dataMatrix = function (host, opts = {}) {
+ a page marked live re-renders on the builder's own 1s clock (reduced-motion gated).
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: pages (array of [name, rowsFn, live?] tuples, default SYS/NET/TIME;
+ rowsFn() returns the three text rows, and a truthy third element
+ marks a page that re-renders on the 1s clock); page (initial index,
+ default 0); onChange(index, 'page NAME') fires on set.
+ handle: el, get() (page index), set(i) — wraps modulo the page count.
+ Click cycles pages; a live page re-renders on the builder's own
+ 1s clock (reduced-motion gated).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block of
+ DUPRE_CSS; the glow filter registers in the shared defs plate, but the
+ screen-family background gradient lives in this instrument's local defs (it
+ reads --scr-* vars from its own subtree). */
+DUPRE.dataMatrix = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const pages = opts.pages || [
['SYS', () => ['CPU 42 MEM 61', 'DSK 58 TMP 47C', 'ARCH 6.18 LTS']],
@@ -3920,7 +4969,7 @@ GW.dataMatrix = function (host, opts = {}) {
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 widget's subtree,
+ /* 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', {});
@@ -3955,8 +5004,17 @@ GW.dataMatrix = function (host, opts = {}) {
};
/* R11 warning flag window — the striped mechanical flag slides into the window
- when the condition trips; click trips and clears it */
-GW.warningFlag = function (host, opts = {}) {
+ when the condition trips; click trips and clears it.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: label (window legend, default 'VIB'); on (initial tripped state,
+ default false); onChange(on, 'FLAG'|'clear') fires on set.
+ handle: el, get() (tripped bool), set(v) — slides the striped flag into
+ the window when truthy, retracts it when not. Click toggles.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block of
+ DUPRE_CSS; the barber-stripe pattern registers in the shared defs plate, the
+ window clip in this instrument's local defs. */
+DUPRE.warningFlag = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 120, 64);
def('barber', d => {
@@ -3984,8 +5042,17 @@ GW.warningFlag = function (host, opts = {}) {
};
/* R25 fourteen-segment display — the starburst alphanumeric that spells words;
- click cycles the word */
-GW.seg14 = function (host, opts = {}) {
+ click cycles the word.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: words (the four-letter word pool cycled by click, default
+ ZOOM/ECHO/TAPE/…; letters outside the segment MAP render blank);
+ index (initial, default 0); onChange(index, word) fires on set.
+ handle: el, get() (word index), set(i) — wraps modulo the pool and lights
+ the fourteen-segment cells to spell the word. Click cycles.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block of
+ DUPRE_CSS; the segments are filled inline (lit vs --sevoff), no shared defs. */
+DUPRE.seg14 = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const words = opts.words || ['ZOOM', 'ECHO', 'TAPE', 'MOOD', 'HALL', 'COMP'];
const MAP = {
@@ -4042,8 +5109,17 @@ GW.seg14 = function (host, opts = {}) {
};
/* R26 response graph — log-frequency axes with a draggable amber peak; 2D drag
- places the peak in both axes at once */
-GW.responseGraph = function (host, opts = {}) {
+ places the peak in both axes at once.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: fc (initial center frequency Hz, default 1200); gain (initial dB,
+ default 6.5); onChange([fc, gain], 'f · ±g dB') fires on set and drag.
+ handle: el, get() ([fc, gain]), set(f, g) — fc clamps 32..16000 Hz, gain
+ clamps ±12 dB; draws the bell curve and moves the peak handle. A
+ 2D drag on the plot places the peak in both axes at once.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block of
+ DUPRE_CSS; the amber glow filter registers in the shared defs plate. */
+DUPRE.responseGraph = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 190, 110);
const RG = { x0: 30, x1: 182, y0: 8, y1: 86, fLo: Math.log10(32), fHi: Math.log10(16000), db: 12 };
@@ -4111,7 +5187,7 @@ GW.responseGraph = function (host, opts = {}) {
/* R30 telegraph indicator — the pointer names the active state on labeled
sectors, engine-telegraph style; click steps the state */
-GW.telegraphIndicator = function (host, opts = {}) {
+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;
@@ -4144,7 +5220,7 @@ GW.telegraphIndicator = function (host, opts = {}) {
/* 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 */
-GW.radarSweep = function (host, opts = {}) {
+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;
@@ -4217,8 +5293,18 @@ GW.radarSweep = function (host, opts = {}) {
};
/* R35 day-date disc calendar — coaxial printed discs rotate so today reads
- under the fixed hand; starts on today; click rolls midnight forward */
-GW.dayDateCal = function (host, opts = {}) {
+ 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;
@@ -4274,7 +5360,7 @@ GW.dayDateCal = function (host, opts = {}) {
/* R36 LED dot matrix — 8x8 paintable bitmap behind a tinted window; click dots
to paint; starts on the reference K */
-GW.dotMatrix = function (host, opts = {}) {
+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'];
@@ -4303,7 +5389,7 @@ GW.dotMatrix = function (host, opts = {}) {
/* R45 flip-disc tile array — bistable mechanical pixels; scaleX flip with the
color swap at the midpoint; click a disc to flip it */
-GW.flipDisc = function (host, opts = {}) {
+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 */
@@ -4332,7 +5418,7 @@ GW.flipDisc = function (host, opts = {}) {
/* R46 dekatron counting ring — each click pulses the neon glow one cathode
around; the wrap flashes the carry dot */
-GW.dekatron = function (host, opts = {}) {
+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 });
@@ -4377,7 +5463,7 @@ GW.dekatron = function (host, opts = {}) {
/* R47 landing gear indicator — three greens or nothing; the lever cycles
down → transit (amber pulse) → up */
-GW.gearIndicator = function (host, opts = {}) {
+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 });
@@ -4428,7 +5514,7 @@ GW.gearIndicator = function (host, opts = {}) {
/* 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 */
-GW.blinkenlights = function (host, opts = {}) {
+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 });
@@ -4476,15 +5562,15 @@ GW.blinkenlights = function (host, opts = {}) {
return { el: s, get: () => sr, tick };
};
-/* ---- widget CSS: injected once, grows as builders move in ---- */
-/* widget-internal CSS (moved from the gallery <style> block; gallery keeps page furniture) */
-const GW_CSS = `
+/* ---- instrument CSS: injected once, grows as builders move in ---- */
+/* instrument-internal CSS (moved from the gallery <style> block; gallery keeps page furniture) */
+const DUPRE_CSS = `
/* ---- shared primitives ---- */
-.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
-.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)}
-.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
-.lamp.off{background:var(--wash);box-shadow:none}
-.lamp.busy{background:var(--gold);animation:pulse var(--pulse-rate) ease-in-out infinite}
+.dupre-lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.dupre-lamp.dupre-gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)}
+.dupre-lamp.dupre-red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.dupre-lamp.dupre-off{background:var(--wash);box-shadow:none}
+.dupre-lamp.dupre-busy{background:var(--gold);animation:pulse var(--pulse-rate) ease-in-out infinite}
/* standard pulse: 1s ease-in-out — the norm for every pulsing / flashing element */
@keyframes pulse{50%{opacity:.25}}
@@ -4509,526 +5595,548 @@ const GW_CSS = `
.switch.red::before{content:"OFF";order:1;color:var(--cream)}
.switch.red::after{order:2}
-/* The ABC keypad takes keys, so it must show when it is the one listening: an
- unlit focus state means typing vanishes into a card you thought was live.
+/* keypad / index-plate focus — both take keys, so each must show when it is
+ the one listening: an unlit focus state means typing vanishes into a card
+ you thought was live.
:focus, not :focus-visible — Chrome won't match :focus-visible on a
mouse-driven focus of a non-text element, and clicking the plate IS how it
gets focus here, so the ring would have appeared only when tabbed to. */
-.kp-pad,.ix-pad{outline:none}
-.kp-pad:focus,.ix-pad:focus{outline:2px solid var(--gold-hi);outline-offset:3px;border-radius:9px}
-.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer;
+.dupre-kp-pad,.dupre-ix-pad{outline:none}
+.dupre-kp-pad:focus,.dupre-ix-pad:focus{outline:2px solid var(--gold-hi);outline-offset:3px;border-radius:9px}
+
+/* console keys */
+.dupre-key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer;
background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
border-radius:8px;padding:8px 12px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
-.key:hover{color:var(--gold);border-color:var(--gold)}
-.key:active{transform:translateY(1px)}
-.key.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi);font-weight:700}
-.key.green{color:var(--cream);background:linear-gradient(180deg,#9cbf5e,var(--pass));border-color:var(--pass);font-weight:700}
-.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
-.key.off{opacity:.4}
+.dupre-key:hover{color:var(--gold);border-color:var(--gold)}
+.dupre-key:active{transform:translateY(1px)}
+.dupre-key.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi);font-weight:700}
+.dupre-key.dupre-green{color:var(--cream);background:linear-gradient(180deg,#9cbf5e,var(--pass));border-color:var(--pass);font-weight:700}
+.dupre-key.dupre-red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
-.chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px}
-.chip.on{color:var(--chip-on,var(--gold));border-color:var(--chip-on,var(--gold))}
+/* chip toggle */
+.dupre-chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px}
+.dupre-chip.dupre-on{color:var(--chip-on,var(--gold));border-color:var(--chip-on,var(--gold))}
-.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px}
-.badge.red{background:var(--fail);color:var(--cream)}
-.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+.dupre-badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px}
+.dupre-badge.dupre-red{background:var(--fail);color:var(--cream)}
+.dupre-badge.dupre-ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
/* fader */
-.fader{width:150px;height:16px;position:relative;cursor:pointer;touch-action:none}
-.fader .slot{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
-.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold))}
-.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px;
+.dupre-fader{width:150px;height:16px;position:relative;cursor:pointer;touch-action:none}
+.dupre-fader .dupre-slot{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.dupre-fader .dupre-fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold))}
+.dupre-fader .dupre-cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px;
background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)}
/* vertical fader */
-.vfader{width:16px;height:64px;position:relative;cursor:pointer;touch-action:none}
-.vfader .slot{position:absolute;left:6px;top:0;bottom:0;width:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
-.vfader .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,var(--amber-grad-bot),var(--gold))}
-.vfader .cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px;
+.dupre-vfader{width:16px;height:64px;position:relative;cursor:pointer;touch-action:none}
+.dupre-vfader .dupre-slot{position:absolute;left:6px;top:0;bottom:0;width:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.dupre-vfader .dupre-fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,var(--amber-grad-bot),var(--gold))}
+.dupre-vfader .dupre-cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px;
background:linear-gradient(90deg,var(--amber-grad-mid),var(--amber-grad-top));border:1px solid var(--amber-edge)}
/* rotary knob */
-.knob{width:52px;height:52px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none;
+.dupre-knob{width:52px;height:52px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none;
background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c;
box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
-.knob .ind{position:absolute;left:50%;top:5px;width:2px;height:16px;background:var(--gold-hi);
+.dupre-knob .dupre-ind{position:absolute;left:50%;top:5px;width:2px;height:16px;background:var(--gold-hi);
margin-left:-1px;transform-origin:50% 21px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)}
/* needle gauge */
-.gauge{width:96px;cursor:ns-resize;touch-action:none}
-.gauge .dial{position:relative;height:48px;overflow:hidden}
-.gauge .arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%}
-.gauge .tk{position:absolute;left:50%;top:1px;width:1.5px;height:8px;margin-left:-.75px;background:var(--steel);transform-origin:50% 47px}
-.gauge .ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi);
+.dupre-gauge{width:96px;cursor:ns-resize;touch-action:none}
+.dupre-gauge .dupre-dial{position:relative;height:48px;overflow:hidden}
+.dupre-gauge .dupre-arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%}
+.dupre-gauge .dupre-tk{position:absolute;left:50%;top:1px;width:1.5px;height:8px;margin-left:-.75px;background:var(--steel);transform-origin:50% 47px}
+.dupre-gauge .dupre-ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi);
transform-origin:50% 100%;transform:rotate(0deg);border-radius:2px;box-shadow:0 0 6px rgba(var(--glow-hi),.5);
transition:transform .12s cubic-bezier(.3,1.3,.5,1)}
-.gauge .hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)}
-.gauge .gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums}
+.dupre-gauge .dupre-hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)}
+.dupre-gauge .dupre-gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums}
/* segmented VU / LED bar */
-.vu{width:170px;display:flex;flex-direction:column;gap:5px}
-.vurow{display:flex;align-items:center;gap:7px}
-.vurow .ch{color:var(--steel);font-size:.6rem;width:8px}
-.vubar{flex:1;display:flex;gap:2px;height:9px}
-.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3}
-.vubar i.on{opacity:1;background:var(--pass)}.vubar i.hot{opacity:1;background:var(--gold)}
-.vubar i.clip{opacity:1;background:var(--fail)}.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px}
+.dupre-vu{width:170px;display:flex;flex-direction:column;gap:5px}
+.dupre-vurow{display:flex;align-items:center;gap:7px}
+.dupre-vurow .dupre-ch{color:var(--steel);font-size:.6rem;width:8px}
+.dupre-vubar{flex:1;display:flex;gap:2px;height:9px}
+.dupre-vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3}
+.dupre-vubar i.dupre-on{opacity:1;background:var(--pass)}.dupre-vubar i.dupre-hot{opacity:1;background:var(--gold)}
+.dupre-vubar i.dupre-clip{opacity:1;background:var(--fail)}.dupre-vubar i.dupre-peak{outline:1px solid var(--gold-hi);outline-offset:-1px}
/* mini 4-bar signal */
-.sig{display:flex;align-items:flex-end;gap:2px;height:18px}
-.sig i{width:4px;background:var(--wash);border-radius:1px}
-.sig i:nth-child(1){height:5px}.sig i:nth-child(2){height:9px}.sig i:nth-child(3){height:13px}.sig i:nth-child(4){height:17px}
-.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)}
+.dupre-sig{display:flex;align-items:flex-end;gap:2px;height:18px}
+.dupre-sig i{width:4px;background:var(--wash);border-radius:1px}
+.dupre-sig i:nth-child(1){height:5px}.dupre-sig i:nth-child(2){height:9px}.dupre-sig i:nth-child(3){height:13px}.dupre-sig i:nth-child(4){height:17px}
+.dupre-sig i.dupre-on{background:var(--pass)}.dupre-sig i.dupre-hot{background:var(--gold)}.dupre-sig i.dupre-clip{background:var(--fail)}
/* signal ladder (wifi bars) */
-.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px;cursor:pointer}
-.ladder i{width:5px;background:var(--wash);border-radius:1px}
-.ladder i:nth-child(1){height:6px}.ladder i:nth-child(2){height:10px}
-.ladder i:nth-child(3){height:14px}.ladder i:nth-child(4){height:18px}
+.dupre-ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px;cursor:pointer}
+.dupre-ladder i{width:5px;background:var(--wash);border-radius:1px}
+.dupre-ladder i:nth-child(1){height:6px}.dupre-ladder i:nth-child(2){height:10px}
+.dupre-ladder i:nth-child(3){height:14px}.dupre-ladder i:nth-child(4){height:18px}
/* linear progress / fuel bar */
-.bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none}
-.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold));border-radius:6px}
-.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+.dupre-bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none}
+.dupre-bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold));border-radius:6px}
+.dupre-bar.dupre-warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
/* radial ring */
-.ring{width:60px;height:60px;border-radius:50%;cursor:ns-resize;touch-action:none;
+.dupre-ring{width:60px;height:60px;border-radius:50%;cursor:ns-resize;touch-action:none;
background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);
display:grid;place-items:center;position:relative}
-.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)}
-.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums}
+.dupre-ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)}
+.dupre-ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums}
/* tabular readout */
-.readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em;cursor:pointer}
-.readout small{color:var(--dim);font-size:12px;font-weight:400}
-.readout .u{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px}
+.dupre-readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em;cursor:pointer}
+.dupre-readout small{color:var(--dim);font-size:12px;font-weight:400}
+.dupre-unit{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px}
/* sparkline */
-.spark{width:170px;height:44px}
-.spark svg{display:block;width:100%;height:100%}
+.dupre-spark{width:170px;height:44px}
+.dupre-spark svg{display:block;width:100%;height:100%}
/* lamp row (list item) */
-.lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px}
-.lrow:hover{background:var(--wash)}
-.lrow .who{color:var(--silver)}.lrow .who b{color:var(--cream)}
-.lrow .what{margin-left:auto;color:var(--dim);font-size:11px}
+.dupre-lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px}
+.dupre-lrow:hover{background:var(--wash)}
+.dupre-lrow .dupre-who{color:var(--silver)}.dupre-lrow .dupre-who b{color:var(--cream)}
+.dupre-lrow .dupre-what{margin-left:auto;color:var(--dim);font-size:11px}
/* arm-to-fire */
-.arm{font:inherit;font-size:11.5px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;
+.dupre-arm{font:inherit;font-size:11.5px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;
border-radius:8px;padding:7px 12px}
-.arm.armed{background:rgba(203,107,77,.12);border-color:var(--fail);color:var(--fail)}
+.dupre-arm.dupre-armed{background:rgba(203,107,77,.12);border-color:var(--fail);color:var(--fail)}
/* stepper / segmented selector */
-.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden}
-.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer}
-.seg button:last-child{border-right:0}
-.seg button.on{background:var(--seg-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold)));color:var(--seg-on-ink,var(--panel));font-weight:700}
+.dupre-seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden}
+.dupre-seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer}
+.dupre-seg button:last-child{border-right:0}
+.dupre-seg button.dupre-on{background:var(--seg-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold)));color:var(--seg-on-ink,var(--panel));font-weight:700}
/* engraved section label */
-.engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer;
+.dupre-engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer;
display:flex;align-items:center;gap:9px}
-.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
-.engrave::before{max-width:10px}
-.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none}
+.dupre-engrave::before,.dupre-engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.dupre-engrave::before{max-width:10px}
+.dupre-engrave .dupre-cnt{color:var(--dim);letter-spacing:.1em;text-transform:none}
/* waveform strip */
-.wave{width:170px;height:38px}
-.wave svg{width:100%;height:100%;display:block}
+.dupre-wave{width:170px;height:38px}
+.dupre-wave svg{width:100%;height:100%;display:block}
/* toast */
-.toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px;cursor:pointer}
+.dupre-toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px;cursor:pointer}
/* output well (log step) */
-.owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px;cursor:pointer}
-.ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0}
-.ostep .lamp{margin-top:3px;width:7px;height:7px}
-.ostep b{color:var(--cream);font-weight:700}.ostep .ev{color:var(--steel);display:block;font-size:10.5px}
+.dupre-owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px;cursor:pointer}
+.dupre-ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0}
+.dupre-ostep .dupre-lamp{margin-top:3px;width:7px;height:7px}
+.dupre-ostep b{color:var(--cream);font-weight:700}.dupre-ostep .dupre-ev{color:var(--steel);display:block;font-size:10.5px}
/* rotary selector */
-.rotsel{position:relative;width:118px;height:74px}
-.rotsel>.knob{position:absolute;left:50%;top:20px;margin-left:-26px;cursor:pointer}
-.rotsel .pos{position:absolute;font-size:9px;color:var(--dim);transform:translate(-50%,-50%);letter-spacing:.02em}
-.rotsel .pos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
+.dupre-rotsel{position:relative;width:118px;height:74px}
+.dupre-rotsel>.dupre-knob{position:absolute;left:50%;top:20px;margin-left:-26px;cursor:pointer}
+.dupre-rotsel .dupre-pos{position:absolute;font-size:9px;color:var(--dim);transform:translate(-50%,-50%);letter-spacing:.02em}
+.dupre-rotsel .dupre-pos.dupre-on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
/* slide-rule tuner dial */
-.tuner{width:180px;height:46px;position:relative;border-radius:6px;overflow:hidden;cursor:pointer;
+.dupre-tuner{width:180px;height:46px;position:relative;border-radius:6px;overflow:hidden;cursor:pointer;
background:var(--tn-bg,linear-gradient(180deg,#191510,#0b0908));border:1px solid var(--tn-brd,#2a251c);
box-shadow:inset 0 0 20px var(--tn-glow,rgba(var(--glow-lo),.12)),inset 0 1px 0 rgba(255,255,255,.03)}
-.tuner .tick{position:absolute;top:6px;width:1px;height:11px;background:var(--tn-tick,var(--steel));transform:translateX(-50%)}
-.tuner .tick.mn{height:6px;opacity:.5}
-.tuner .mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--tn-ink,var(--steel));font-size:10px}
-.tuner .mk.on{color:var(--tn-on,var(--gold-hi));text-shadow:0 0 6px var(--tn-onglow,rgba(var(--glow-hi),.6))}
-.tuner:focus{outline:1px solid rgba(var(--glow-lo),.5);outline-offset:2px}
-.tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
-.tuner .ndl{position:absolute;top:3px;bottom:3px;width:2px;margin-left:-1px;border-radius:1px;
+.dupre-tuner .dupre-tick{position:absolute;top:6px;width:1px;height:11px;background:var(--tn-tick,var(--steel));transform:translateX(-50%)}
+.dupre-tuner .dupre-tick.dupre-mn{height:6px;opacity:.5}
+.dupre-tuner .dupre-mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--tn-ink,var(--steel));font-size:10px}
+.dupre-tuner .dupre-mk.dupre-on{color:var(--tn-on,var(--gold-hi));text-shadow:0 0 6px var(--tn-onglow,rgba(var(--glow-hi),.6))}
+.dupre-tuner:focus{outline:1px solid rgba(var(--glow-lo),.5);outline-offset:2px}
+.dupre-tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
+.dupre-tuner .dupre-ndl{position:absolute;top:3px;bottom:3px;width:2px;margin-left:-1px;border-radius:1px;
background:var(--tn-ndl,var(--fail));box-shadow:0 0 7px var(--tn-ndlglow,rgba(203,107,77,.85));transition:left .25s}
/* nixie tube */
-.nixie{display:inline-flex;gap:5px;cursor:pointer}
-.nixie .tube{width:30px;height:44px;border-radius:5px;position:relative;overflow:hidden;
+.dupre-nixie{display:inline-flex;gap:5px;cursor:pointer}
+.dupre-nixie .dupre-tube{width:30px;height:44px;border-radius:5px;position:relative;overflow:hidden;
background:radial-gradient(circle at 50% 38%,#241a12,#0b0807);border:1px solid #2c261d;
display:grid;place-items:center;box-shadow:inset 0 0 12px rgba(0,0,0,.6)}
-.nixie .tube b{font-size:26px;font-weight:400;color:#ff9a3c;
+.dupre-nixie .dupre-tube b{font-size:26px;font-weight:400;color:#ff9a3c;
text-shadow:0 0 6px rgba(255,140,50,.85),0 0 15px rgba(255,120,40,.45)}
-.nixie .tube.off b{color:#3a2a1c;text-shadow:none}
-.nixie .tube::after{content:"";position:absolute;inset:0;pointer-events:none;
+.dupre-nixie .dupre-tube.dupre-off b{color:#3a2a1c;text-shadow:none}
+.dupre-nixie .dupre-tube::after{content:"";position:absolute;inset:0;pointer-events:none;
background:linear-gradient(180deg,rgba(255,255,255,.06),transparent 32%)}
/* ===== candidate-unique CSS ===== */
/* rocker */
-.rocker{width:66px;height:40px;border-radius:7px;cursor:pointer;position:relative;
+.dupre-rocker{width:66px;height:40px;border-radius:7px;cursor:pointer;position:relative;
background:linear-gradient(180deg,#25211c,#141210);border:1px solid #34302a;
box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5);overflow:hidden}
-.rocker .half{position:absolute;left:0;right:0;height:50%;display:flex;align-items:center;justify-content:center;
+.dupre-rocker .dupre-half{position:absolute;left:0;right:0;height:50%;display:flex;align-items:center;justify-content:center;
font-size:12px;letter-spacing:.06em;color:var(--dim)}
-.rocker .top{top:0;border-bottom:1px solid #0c0b0a;background:linear-gradient(180deg,#211d19,#181513)}
-.rocker .bot{bottom:0;background:linear-gradient(180deg,#141210,#100e0c);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)}
-.rocker.on .top{background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color:var(--panel);font-weight:700;box-shadow:0 0 10px rgba(var(--glow-hi),.35)}
-.rocker.on .bot{color:var(--steel)}
-.rocker:not(.on) .top{color:var(--steel)}
-.rocker:not(.on) .bot{background:linear-gradient(180deg,#2a1512,#1c0f0d);color:var(--fail);box-shadow:inset 0 3px 5px rgba(0,0,0,.55),0 0 8px rgba(203,107,77,.25)}
+.dupre-rocker .dupre-top{top:0;border-bottom:1px solid #0c0b0a;background:linear-gradient(180deg,#211d19,#181513)}
+.dupre-rocker .dupre-bot{bottom:0;background:linear-gradient(180deg,#141210,#100e0c);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)}
+.dupre-rocker.dupre-on .dupre-top{background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color:var(--panel);font-weight:700;box-shadow:0 0 10px rgba(var(--glow-hi),.35)}
+.dupre-rocker.dupre-on .dupre-bot{color:var(--steel)}
+.dupre-rocker:not(.dupre-on) .dupre-top{color:var(--steel)}
+.dupre-rocker:not(.dupre-on) .dupre-bot{background:linear-gradient(180deg,#2a1512,#1c0f0d);color:var(--fail);box-shadow:inset 0 3px 5px rgba(0,0,0,.55),0 0 8px rgba(203,107,77,.25)}
/* transport */
-.reels{display:flex;gap:16px;align-items:center;justify-content:center;margin-bottom:2px}
-.reel{width:26px;height:26px;border-radius:50%;border:2px solid #34302a;position:relative;
+.dupre-reels{display:flex;gap:16px;align-items:center;justify-content:center;margin-bottom:2px}
+.dupre-reel{width:26px;height:26px;border-radius:50%;border:2px solid #34302a;position:relative;
background:radial-gradient(circle at 45% 40%,#211d18,#0d0b09)}
-.reel::before{content:"";position:absolute;inset:9px;border-radius:50%;border:1px solid #4a443a;background:#161310}
-.reel i{position:absolute;left:50%;top:50%;width:2px;height:11px;background:var(--steel);margin:-5.5px 0 0 -1px;transform-origin:50% 5.5px}
-.reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(var(--glow-hi),.55)}
-.reel i:nth-child(2){transform:rotate(120deg)}.reel i:nth-child(3){transform:rotate(240deg)}
-.transport{display:flex;gap:5px}
-.tbtn{font:inherit;font-size:12px;color:var(--silver);cursor:pointer;width:30px;height:26px;display:grid;place-items:center;
+.dupre-reel::before{content:"";position:absolute;inset:9px;border-radius:50%;border:1px solid #4a443a;background:#161310}
+.dupre-reel i{position:absolute;left:50%;top:50%;width:2px;height:11px;background:var(--steel);margin:-5.5px 0 0 -1px;transform-origin:50% 5.5px}
+.dupre-reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(var(--glow-hi),.55)}
+.dupre-reel i:nth-child(2){transform:rotate(120deg)}.dupre-reel i:nth-child(3){transform:rotate(240deg)}
+.dupre-transport{display:flex;gap:5px}
+.dupre-tbtn{font:inherit;font-size:12px;color:var(--silver);cursor:pointer;width:30px;height:26px;display:grid;place-items:center;
background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:6px}
-.tbtn:hover{color:var(--gold);border-color:var(--gold)}
-.tbtn.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi)}
-.tbtn.rec.on{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.dupre-tbtn:hover{color:var(--gold);border-color:var(--gold)}
+.dupre-tbtn.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi)}
+.dupre-tbtn.dupre-rec.dupre-on{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
/* radio bank */
-.radiobank{display:flex;gap:0;border:1px solid #0c0b0a;border-radius:7px;overflow:hidden;
+.dupre-radiobank{display:flex;gap:0;border:1px solid #0c0b0a;border-radius:7px;overflow:hidden;
background:#0c0b0a;padding:3px;box-shadow:inset 0 2px 4px rgba(0,0,0,.5)}
-.preset{font:inherit;font-size:11px;color:var(--dim);cursor:pointer;padding:8px 9px;border:0;border-radius:4px;
+.dupre-preset{font:inherit;font-size:11px;color:var(--dim);cursor:pointer;padding:8px 9px;border:0;border-radius:4px;
background:linear-gradient(180deg,#211d19,#161310);box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}
-.preset+.preset{margin-left:3px}
-.preset:hover{color:var(--silver)}
-.preset.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));font-weight:700;
+.dupre-preset+.dupre-preset{margin-left:3px}
+.dupre-preset:hover{color:var(--silver)}
+.dupre-preset.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));font-weight:700;
box-shadow:inset 0 2px 4px rgba(0,0,0,.4)}
/* concentric dual knob */
-.dualknob{width:64px;height:64px;position:relative}
-.dualknob .outer{width:64px;height:64px;border-radius:50%;position:absolute;inset:0;cursor:ns-resize;touch-action:none;
+.dupre-dualknob{width:64px;height:64px;position:relative}
+.dupre-dualknob .dupre-outer{width:64px;height:64px;border-radius:50%;position:absolute;inset:0;cursor:ns-resize;touch-action:none;
background:radial-gradient(circle at 40% 35%,#2a2622,#100e0c);border:1px solid #3a352c;
box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
-.dualknob .outer .tick{position:absolute;left:50%;top:3px;width:2px;height:9px;background:var(--steel);margin-left:-1px;transform-origin:50% 29px}
-.dualknob .inner{width:34px;height:34px;border-radius:50%;position:absolute;left:15px;top:15px;cursor:ns-resize;touch-action:none;
+.dupre-dualknob .dupre-outer .dupre-tick{position:absolute;left:50%;top:3px;width:2px;height:9px;background:var(--steel);margin-left:-1px;transform-origin:50% 29px}
+.dupre-dualknob .dupre-inner{width:34px;height:34px;border-radius:50%;position:absolute;left:15px;top:15px;cursor:ns-resize;touch-action:none;
background:radial-gradient(circle at 40% 35%,#37322a,#1a1713);border:1px solid #4a443a;box-shadow:0 2px 4px rgba(0,0,0,.5)}
-.dualknob .inner .ind{position:absolute;left:50%;top:3px;width:2px;height:11px;background:var(--gold-hi);margin-left:-1px;
+.dupre-dualknob .dupre-inner .dupre-ind{position:absolute;left:50%;top:3px;width:2px;height:11px;background:var(--gold-hi);margin-left:-1px;
transform-origin:50% 14px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)}
/* rotary encoder + LED ring */
-.encoder{position:relative;width:66px;height:66px;display:grid;place-items:center;cursor:ns-resize;touch-action:none}
-.encoder .led{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--wash);
+.dupre-encoder{position:relative;width:66px;height:66px;display:grid;place-items:center;cursor:ns-resize;touch-action:none}
+.dupre-encoder .dupre-led{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--wash);
left:50%;top:50%;margin:-2.5px}
-.encoder .led.on{background:var(--gold);box-shadow:0 0 5px 1px rgba(var(--glow-lo),.7)}
-.encoder .knob{width:40px;height:40px;cursor:ns-resize}
-.encoder .knob .ind{transform-origin:50% 15px;top:4px;height:12px}
+.dupre-encoder .dupre-led.dupre-on{background:var(--gold);box-shadow:0 0 5px 1px rgba(var(--glow-lo),.7)}
+.dupre-encoder .dupre-knob{width:40px;height:40px;cursor:ns-resize}
+.dupre-encoder .dupre-knob .dupre-ind{transform-origin:50% 15px;top:4px;height:12px}
/* keyed mode switch */
-.keylock{position:relative;width:110px;height:70px}
-.keylock .body{position:absolute;left:50%;top:30px;margin-left:-21px;width:42px;height:42px;border-radius:50%;
+.dupre-keylock{position:relative;width:110px;height:70px}
+.dupre-keylock .dupre-body{position:absolute;left:50%;top:30px;margin-left:-21px;width:42px;height:42px;border-radius:50%;
background:radial-gradient(circle at 42% 36%,#2a2622,#0f0d0b);border:1px solid #3a352c;cursor:pointer;
box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
-.keylock .barrel{position:absolute;left:50%;top:50%;width:5px;height:5px;margin:-2.5px;border-radius:1px;background:#0a0908;
+.dupre-keylock .dupre-barrel{position:absolute;left:50%;top:50%;width:5px;height:5px;margin:-2.5px;border-radius:1px;background:#0a0908;
box-shadow:0 0 2px rgba(0,0,0,.8)}
-.keylock .bit{position:absolute;left:50%;top:50%;width:3px;height:15px;margin:-15px 0 0 -1.5px;background:var(--gold-hi);
+.dupre-keylock .dupre-bit{position:absolute;left:50%;top:50%;width:3px;height:15px;margin:-15px 0 0 -1.5px;background:var(--gold-hi);
border-radius:1px;transform-origin:50% 100%;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .2s}
-.keylock .kpos{position:absolute;font-size:9px;color:var(--dim);letter-spacing:.05em;transform:translate(-50%,-50%)}
-.keylock .kpos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
+.dupre-keylock .dupre-kpos{position:absolute;font-size:9px;color:var(--dim);letter-spacing:.05em;transform:translate(-50%,-50%)}
+.dupre-keylock .dupre-kpos.dupre-on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
/* crossfader */
-.xfader{width:160px;height:22px;position:relative;cursor:pointer;touch-action:none}
-.xfader .slot{position:absolute;top:9px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18}
-.xfader .detent{position:absolute;top:2px;left:50%;width:1px;height:18px;background:var(--steel);margin-left:-.5px;opacity:.7}
-.xfader .end{position:absolute;top:11px;font-size:9px;color:var(--steel);transform:translateY(-50%)}
-.xfader .cap{position:absolute;top:3px;width:9px;height:16px;border-radius:2px;margin-left:-4.5px;transition:left .05s;
+.dupre-xfader{width:160px;height:22px;position:relative;cursor:pointer;touch-action:none}
+.dupre-xfader .dupre-slot{position:absolute;top:9px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18}
+.dupre-xfader .dupre-detent{position:absolute;top:2px;left:50%;width:1px;height:18px;background:var(--steel);margin-left:-.5px;opacity:.7}
+.dupre-xfader .dupre-end{position:absolute;top:11px;font-size:9px;color:var(--steel);transform:translateY(-50%)}
+.dupre-xfader .dupre-cap{position:absolute;top:3px;width:9px;height:16px;border-radius:2px;margin-left:-4.5px;transition:left .05s;
background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)}
/* thumbwheel */
-.thumbw{display:flex;align-items:center;gap:10px}
-.thumbwheel{width:34px;height:52px;border-radius:6px;cursor:ns-resize;touch-action:none;position:relative;overflow:hidden;
+.dupre-thumbw{display:flex;align-items:center;gap:10px}
+.dupre-thumbwheel{width:34px;height:52px;border-radius:6px;cursor:ns-resize;touch-action:none;position:relative;overflow:hidden;
border:1px solid #34302a;box-shadow:inset 0 0 8px rgba(0,0,0,.6);
background:repeating-linear-gradient(0deg,#0e0c0a 0 2px,#221e19 2px 3px,#2f2a23 3px 5px,#221e19 5px 6px)}
-.thumbwheel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.55),transparent 30%,transparent 70%,rgba(0,0,0,.55))}
-.thumbw .win{color:var(--cream);font-size:15px;font-weight:700;font-variant-numeric:tabular-nums;
+.dupre-thumbwheel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.55),transparent 30%,transparent 70%,rgba(0,0,0,.55))}
+.dupre-thumbw .dupre-win{color:var(--cream);font-size:15px;font-weight:700;font-variant-numeric:tabular-nums;
background:var(--well);border:1px solid #231f18;border-radius:4px;padding:3px 8px}
/* DIP bank */
-.dip{display:flex;gap:3px;padding:6px 7px;background:#0f1a2a;border:1px solid #24344a;border-radius:5px;
+.dupre-dip{display:flex;gap:3px;padding:6px 7px;background:#0f1a2a;border:1px solid #24344a;border-radius:5px;
box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
-.dipsw{width:12px;height:26px;background:#0a1220;border-radius:2px;position:relative;cursor:pointer;border:1px solid #1c2c42}
-.dipsw i{position:absolute;left:1px;right:1px;height:11px;border-radius:1px;background:linear-gradient(180deg,#eae4d2,#b4ad98);
+.dupre-dipsw{width:12px;height:26px;background:#0a1220;border-radius:2px;position:relative;cursor:pointer;border:1px solid #1c2c42}
+.dupre-dipsw i{position:absolute;left:1px;right:1px;height:11px;border-radius:1px;background:linear-gradient(180deg,#eae4d2,#b4ad98);
bottom:1px;transition:bottom .12s,top .12s}
-.dipsw.on i{bottom:auto;top:1px;background:linear-gradient(180deg,var(--amber-grad-top),var(--gold))}
+.dupre-dipsw.dupre-on i{bottom:auto;top:1px;background:linear-gradient(180deg,var(--amber-grad-top),var(--gold))}
/* jog / shuttle */
-.jog{position:relative;width:74px;height:74px;cursor:ns-resize;touch-action:none}
-.jog .shuttle{position:absolute;inset:0;border-radius:50%;border:6px solid #1a1713;
+.dupre-jog{position:relative;width:74px;height:74px;cursor:ns-resize;touch-action:none}
+.dupre-jog .dupre-shuttle{position:absolute;inset:0;border-radius:50%;border:6px solid #1a1713;
background:conic-gradient(from -90deg,#3a352c 0 var(--sh,40deg),#141210 0 360deg);
box-shadow:inset 0 0 8px rgba(0,0,0,.6)}
-.jog .inner{position:absolute;inset:12px;border-radius:50%;transition:transform .05s linear;
+.dupre-jog .dupre-inner{position:absolute;inset:12px;border-radius:50%;transition:transform .05s linear;
background:radial-gradient(circle at 42% 36%,#2c2822,#100e0c);border:1px solid #3a352c;box-shadow:0 2px 5px rgba(0,0,0,.6)}
-.jog .dimple{position:absolute;left:50%;top:7px;width:8px;height:8px;margin-left:-4px;border-radius:50%;
+.dupre-jog .dupre-dimple{position:absolute;left:50%;top:7px;width:8px;height:8px;margin-left:-4px;border-radius:50%;
background:radial-gradient(circle at 40% 35%,#151210,#000);box-shadow:inset 0 1px 2px rgba(0,0,0,.9)}
/* oscilloscope — screen-family vars with the original green as fallback */
-.scope{width:176px;height:78px;border-radius:6px;position:relative;overflow:hidden;
+.dupre-scope{width:176px;height:78px;border-radius:6px;position:relative;overflow:hidden;
background:radial-gradient(circle at 50% 45%,var(--scr-bgc,#04140a),var(--scr-bge,#020a05));
border:1px solid var(--scr-brd,#123018);
box-shadow:inset 0 0 16px rgba(0,0,0,.7)}
-.scope .grat{position:absolute;inset:0;opacity:.5;
+.dupre-scope .dupre-grat{position:absolute;inset:0;opacity:.5;
background-image:linear-gradient(var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px),linear-gradient(90deg,var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px);
background-size:22px 19.5px}
-.scope .grat::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:var(--scr-gratc,rgba(111,206,51,.35))}
-.scope svg{position:absolute;inset:0;width:100%;height:100%}
-.scope polyline{fill:none;stroke:var(--scr-hi,var(--phos));stroke-width:1.6;filter:drop-shadow(0 0 3px var(--scr-glow,rgba(127,224,160,.8)))}
+.dupre-scope .dupre-grat::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:var(--scr-gratc,rgba(111,206,51,.35))}
+.dupre-scope svg{position:absolute;inset:0;width:100%;height:100%}
+.dupre-scope polyline{fill:none;stroke:var(--scr-hi,var(--phos));stroke-width:1.6;filter:drop-shadow(0 0 3px var(--scr-glow,rgba(127,224,160,.8)))}
/* voice-loop keyset */
-.vloop{display:grid;grid-template-columns:repeat(4,52px);gap:4px}
-.vk{display:flex;flex-direction:column;align-items:stretch;gap:4px;font-size:7px;letter-spacing:.05em;
+.dupre-vloop{display:grid;grid-template-columns:repeat(4,52px);gap:4px}
+.dupre-vk{display:flex;flex-direction:column;align-items:stretch;gap:4px;font-size:7px;letter-spacing:.05em;
text-align:center;padding:6px 4px 4px;cursor:pointer;
color:var(--dim);background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-radius:4px;
font-family:var(--mono)}
-.vk .bar{height:3px;width:40px;align-self:center;border-radius:2px;background:#16240f}
-.vk.mon{color:var(--silver)}
-.vk.mon .bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
-.vk.mon.act .bar{background:#a8d84a;box-shadow:0 0 7px rgba(150,200,60,.9)}
-.vk.tlk{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));
+.dupre-vk .dupre-vk-bar{height:3px;width:40px;align-self:center;border-radius:2px;background:#16240f}
+.dupre-vk.dupre-mon{color:var(--silver)}
+.dupre-vk.dupre-mon .dupre-vk-bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
+.dupre-vk.dupre-mon.dupre-act .dupre-vk-bar{background:#a8d84a;box-shadow:0 0 7px rgba(150,200,60,.9)}
+.dupre-vk.dupre-tlk{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));
border-color:var(--gold-hi);font-weight:700}
-.vk.tlk .bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
+.dupre-vk.dupre-tlk .dupre-vk-bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
/* spectrum / EQ */
-.eq{display:flex;align-items:flex-end;gap:3px;height:60px}
-.eq .band{width:9px;display:flex;flex-direction:column-reverse;gap:2px;height:100%}
-.eq .band i{height:5px;border-radius:1px;background:var(--wash);opacity:.3}
-.eq .band i.on{opacity:1;background:var(--pass)}
-.eq .band i.hot{opacity:1;background:var(--gold)}
-.eq .band i.clip{opacity:1;background:var(--fail)}
+.dupre-eq{display:flex;align-items:flex-end;gap:3px;height:60px}
+.dupre-eq .dupre-band{width:9px;display:flex;flex-direction:column-reverse;gap:2px;height:100%}
+.dupre-eq .dupre-band i{height:5px;border-radius:1px;background:var(--wash);opacity:.3}
+.dupre-eq .dupre-band i.dupre-on{opacity:1;background:var(--pass)}
+.dupre-eq .dupre-band i.dupre-hot{opacity:1;background:var(--gold)}
+.dupre-eq .dupre-band i.dupre-clip{opacity:1;background:var(--fail)}
/* crossed-needle */
-.crossm{width:120px}
-.crossm .face{position:relative;height:56px;overflow:hidden;cursor:ns-resize;touch-action:none}
-.crossm .arc{position:absolute;inset:2px 4px -56px;border:2px solid var(--wash);border-radius:50%}
-.crossm .nA{position:absolute;left:14px;bottom:2px;width:2px;height:52px;background:var(--gold-hi);transform-origin:50% 100%;
+.dupre-crossm{width:120px}
+.dupre-crossm .dupre-face{position:relative;height:56px;overflow:hidden;cursor:ns-resize;touch-action:none}
+.dupre-crossm .dupre-crossm-arc{position:absolute;inset:2px 4px -56px;border:2px solid var(--wash);border-radius:50%}
+.dupre-crossm .dupre-fwd{position:absolute;left:14px;bottom:2px;width:2px;height:52px;background:var(--gold-hi);transform-origin:50% 100%;
border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)}
-.crossm .nB{position:absolute;right:14px;bottom:2px;width:2px;height:52px;background:var(--fail);transform-origin:50% 100%;
+.dupre-crossm .dupre-rfl{position:absolute;right:14px;bottom:2px;width:2px;height:52px;background:var(--fail);transform-origin:50% 100%;
border-radius:2px;box-shadow:0 0 5px rgba(203,107,77,.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)}
-.crossm .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
+.dupre-crossm .dupre-lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
/* thermometer */
-.thermo{display:flex;align-items:flex-end;gap:6px;height:74px;cursor:ns-resize;touch-action:none}
-.thermo .tube{width:12px;height:64px;border-radius:6px 6px 0 0;position:relative;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
-.thermo .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#a35a3f,var(--fail))}
-.thermo .bulb{width:20px;height:20px;border-radius:50%;background:var(--fail);position:absolute;left:-4px;bottom:-9px;
+.dupre-thermo{display:flex;align-items:flex-end;gap:6px;height:74px;cursor:ns-resize;touch-action:none}
+.dupre-thermo .dupre-thermo-tube{width:12px;height:64px;border-radius:6px 6px 0 0;position:relative;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.dupre-thermo .dupre-thermo-fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#a35a3f,var(--fail))}
+.dupre-thermo .dupre-bulb{width:20px;height:20px;border-radius:50%;background:var(--fail);position:absolute;left:-4px;bottom:-9px;
box-shadow:0 0 6px rgba(203,107,77,.5)}
-.thermo .scale{display:flex;flex-direction:column;justify-content:space-between;height:64px;font-size:9px;color:var(--steel)}
-.thermo .wrapcol{position:relative;padding-bottom:9px}
+.dupre-thermo .dupre-scale{display:flex;flex-direction:column;justify-content:space-between;height:64px;font-size:9px;color:var(--steel)}
+.dupre-thermo .dupre-wrapcol{position:relative;padding-bottom:9px}
/* bourdon */
-.bourdon{width:82px;height:82px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none;
+.dupre-bourdon{width:82px;height:82px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none;
background:radial-gradient(circle at 48% 42%,#1d1a16,#0c0b09);border:2px solid #2c261d;
box-shadow:inset 0 2px 5px rgba(0,0,0,.6)}
-.bourdon svg{position:absolute;inset:0}
-.bourdon .ndl{position:absolute;left:50%;top:50%;width:2px;height:32px;margin:-32px 0 0 -1px;background:var(--gold-hi);
+.dupre-bourdon svg{position:absolute;inset:0}
+.dupre-bourdon-ndl{position:absolute;left:50%;top:50%;width:2px;height:32px;margin:-32px 0 0 -1px;background:var(--gold-hi);
transform-origin:50% 100%;border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .12s cubic-bezier(.3,1.2,.5,1)}
-.bourdon .hub{position:absolute;left:50%;top:50%;width:8px;height:8px;margin:-4px;border-radius:50%;background:var(--gold)}
-.bourdon .cap{position:absolute;left:0;right:0;bottom:12px;text-align:center;font-size:8px;letter-spacing:.16em;color:var(--steel)}
+.dupre-bourdon-hub{position:absolute;left:50%;top:50%;width:8px;height:8px;margin:-4px;border-radius:50%;background:var(--gold)}
+.dupre-bourdon-cap{position:absolute;left:0;right:0;bottom:12px;text-align:center;font-size:8px;letter-spacing:.16em;color:var(--steel)}
/* strip-chart */
-.strip{width:176px;height:62px;border-radius:5px;position:relative;overflow:hidden;
+.dupre-strip{width:176px;height:62px;border-radius:5px;position:relative;overflow:hidden;
background:#0c0e0f;border:1px solid #231f18}
-.strip .rule{position:absolute;inset:0;opacity:.4;
+.dupre-strip .dupre-rule{position:absolute;inset:0;opacity:.4;
background-image:linear-gradient(90deg,rgba(150,147,133,.14) 1px,transparent 1px);background-size:16px 100%}
-.strip svg{position:absolute;inset:0;width:100%;height:100%}
-.strip polyline{fill:none;stroke:var(--gold);stroke-width:1.4}
-.strip .pen{position:absolute;right:2px;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold-hi);
+.dupre-strip svg{position:absolute;inset:0;width:100%;height:100%}
+.dupre-strip polyline{fill:none;stroke:var(--gold);stroke-width:1.4}
+.dupre-strip .dupre-pen{position:absolute;right:2px;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold-hi);
box-shadow:0 0 6px rgba(var(--glow-hi),.8);transition:top .08s linear}
/* correlation */
-.corr{width:150px;cursor:pointer;touch-action:none}
-.corr .face{position:relative;height:44px;overflow:hidden}
-.corr .arc{position:absolute;inset:0 0 -44px;border:2px solid var(--wash);border-radius:50%}
-.corr .zero{position:absolute;left:50%;top:2px;width:1px;height:10px;background:var(--gold);margin-left:-.5px}
-.corr .ndl{position:absolute;left:50%;bottom:0;width:2px;height:38px;background:var(--gold-hi);transform-origin:50% 100%;
+.dupre-corr{width:150px;cursor:pointer;touch-action:none}
+.dupre-corr-face{position:relative;height:44px;overflow:hidden}
+.dupre-corr-arc{position:absolute;inset:0 0 -44px;border:2px solid var(--wash);border-radius:50%}
+.dupre-corr .dupre-zero{position:absolute;left:50%;top:2px;width:1px;height:10px;background:var(--gold);margin-left:-.5px}
+.dupre-corr-ndl{position:absolute;left:50%;bottom:0;width:2px;height:38px;background:var(--gold-hi);transform-origin:50% 100%;
border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .1s ease-out}
-.corr .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
+.dupre-corr-lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
/* battery */
-.batt{display:flex;align-items:center;cursor:pointer;touch-action:none}
-.batt .cells{display:flex;gap:2px;padding:3px;border:1px solid #34302a;border-radius:4px;background:#0d0f10}
-.batt .cell{width:12px;height:26px;border-radius:1px;background:var(--wash);opacity:.35}
-.batt .cell.on{opacity:1;background:linear-gradient(180deg,var(--pass),#5c7526)}
-.batt .cell.warn.on{background:linear-gradient(180deg,var(--fail),#a04a34)}
-.batt .nub{width:4px;height:12px;background:#34302a;border-radius:0 2px 2px 0}
-
-/* split-flap */
+.dupre-batt{display:flex;align-items:center;cursor:pointer;touch-action:none}
+.dupre-batt .dupre-cells{display:flex;gap:2px;padding:3px;border:1px solid #34302a;border-radius:4px;background:#0d0f10}
+.dupre-batt .dupre-cell{width:12px;height:26px;border-radius:1px;background:var(--wash);opacity:.35}
+.dupre-batt .dupre-cell.dupre-on{opacity:1;background:linear-gradient(180deg,var(--pass),#5c7526)}
+.dupre-batt .dupre-cell.dupre-warn.dupre-on{background:linear-gradient(180deg,var(--fail),#a04a34)}
+.dupre-batt .dupre-nub{width:4px;height:12px;background:#34302a;border-radius:0 2px 2px 0}
+
+/* split-flap — four clipped half-panels per cell; the fold happens on
+ .ftc (falls) and .fbn (lands), driven by the builder's WAAPI flips */
.flap{display:inline-flex;gap:4px;cursor:pointer}
-.flapd{width:30px;height:42px;border-radius:5px;position:relative;overflow:hidden;
- background:linear-gradient(180deg,#211d18,#141210);border:1px solid #34302a;box-shadow:0 2px 4px rgba(0,0,0,.5)}
-.flapd b{position:absolute;inset:0;display:grid;place-items:center;font-size:24px;color:var(--cream);
- text-shadow:0 1px 2px rgba(0,0,0,.7)}
-.flapd::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:rgba(0,0,0,.65);box-shadow:0 1px 0 rgba(255,255,255,.03)}
-.flapd.flip b{animation:flipdrop .3s ease-in}
-@keyframes flipdrop{0%{transform:translateY(-42%) scaleY(.4);opacity:.3}100%{transform:none;opacity:1}}
+.flapd{width:30px;height:42px;border-radius:5px;position:relative;
+ background:linear-gradient(180deg,#211d18,#141210);border:1px solid #34302a;box-shadow:0 2px 4px rgba(0,0,0,.5);
+ perspective:220px;transform-style:preserve-3d}
+.flapd .fh{position:absolute;inset:0;display:grid;place-items:center;font-size:24px;color:var(--cream);
+ text-shadow:0 1px 2px rgba(0,0,0,.7);backface-visibility:hidden;transform-origin:50% 50%}
+.flapd .ftn,.flapd .ftc{clip-path:inset(0 0 50% 0)}
+.flapd .fbc,.flapd .fbn{clip-path:inset(50% 0 0 0)}
+.flapd .fbn{transform:rotateX(180deg)}
+.flapd::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;z-index:5;
+ background:rgba(0,0,0,.65);box-shadow:0 1px 0 rgba(255,255,255,.03)}
+/* multi-row board: column of flap lines */
+.flap-rows{flex-direction:column;gap:5px}
+.flapline{display:inline-flex;gap:4px}
+/* white skin — the dark board with true-white ink (the default ink is cream) */
+.flap-white .flapd .fh{color:#fff}
+/* helvetica — the grotesque face real Solari boards wore */
+.flap-helv .flapd .fh{font-family:Helvetica,'Helvetica Neue',Arial,sans-serif;font-weight:600}
+/* paper skin — true-white cards, dark lettering */
+.flap-paper .flapd{background:linear-gradient(180deg,#ffffff,#e6e6e3);border-color:#b6b6b2}
+.flap-paper .flapd .fh{color:#17181a;text-shadow:0 1px 0 rgba(255,255,255,.5)}
+.flap-paper .flapd::after{background:rgba(0,0,0,.28);box-shadow:0 1px 0 rgba(255,255,255,.35)}
+/* light skin — the inverse board: ivory flaps, near-black ink, softer crease */
+.flap-light .flapd{background:linear-gradient(180deg,#f7eed3,#dccfa8);border-color:#a89c7e}
+.flap-light .flapd .fh{color:#1b1813;text-shadow:0 1px 0 rgba(255,255,255,.45)}
+.flap-light .flapd::after{background:rgba(0,0,0,.3);box-shadow:0 1px 0 rgba(255,255,255,.3)}
/* seven-segment */
-.seven{display:inline-flex;gap:6px;padding:6px 9px;border-radius:6px;background:#0a0806;border:1px solid #231f18;cursor:pointer;
+.dupre-seven{display:inline-flex;gap:6px;padding:6px 9px;border-radius:6px;background:#0a0806;border:1px solid #231f18;cursor:pointer;
box-shadow:inset 0 0 10px rgba(0,0,0,.6)}
.seg7{width:22px;height:40px;filter:drop-shadow(0 0 3px rgba(87,211,87,.55))}
.seg7.red{filter:drop-shadow(0 0 3px rgba(226,84,63,.55))}
-.seven .colon{align-self:center;display:flex;flex-direction:column;gap:9px}
-.seven .colon i{width:4px;height:4px;border-radius:50%;background:var(--sevgrn);box-shadow:0 0 4px rgba(87,211,87,.7)}
+.dupre-seven .dupre-colon{align-self:center;display:flex;flex-direction:column;gap:9px}
+.dupre-seven .dupre-colon i{width:4px;height:4px;border-radius:50%;background:var(--sevgrn);box-shadow:0 0 4px rgba(87,211,87,.7)}
/* DSKY verb/noun panel */
-.dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px}
-.dsky .lampcol{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px}
-.dsky .sl{font-size:6.5px;letter-spacing:.04em;display:flex;align-items:center;justify-content:center;text-align:center;
+.dupre-dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px}
+.dupre-dsky-lamps{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px}
+.dupre-dsky-sl{font-size:6.5px;letter-spacing:.04em;display:flex;align-items:center;justify-content:center;text-align:center;
background:#141210;color:#5c574c;border-radius:2px;border:1px solid #26221c}
-.dsky .sl.on{background:linear-gradient(180deg,var(--amber-warn),var(--gold));color:var(--panel);font-weight:700;
+.dupre-dsky-sl.dupre-on{background:linear-gradient(180deg,var(--amber-warn),var(--gold));color:var(--panel);font-weight:700;
box-shadow:0 0 6px rgba(var(--glow-lo),.5)}
-.dsky .right{display:flex;flex-direction:column;gap:6px}
-.dsky .wins{display:flex;gap:8px}
-.dsky .win{display:flex;flex-direction:column;align-items:center;gap:2px}
-.dsky .win .wl{font-size:6px;letter-spacing:.14em;color:var(--steel)}
-.dsky .win .wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px}
-.dsky .win .wd .seg7{width:13px;height:24px}
-.dsky .win.hot .wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)}
-.dsky .pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px}
-.dsky .pad .key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em}
+.dupre-dsky-right{display:flex;flex-direction:column;gap:6px}
+.dupre-dsky-wins{display:flex;gap:8px}
+.dupre-dsky-win{display:flex;flex-direction:column;align-items:center;gap:2px}
+.dupre-dsky-wl{font-size:6px;letter-spacing:.14em;color:var(--steel)}
+.dupre-dsky-wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px}
+.dupre-dsky-wd .seg7{width:13px;height:24px}
+.dupre-dsky-win.dupre-hot .dupre-dsky-wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)}
+.dupre-dsky-pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px}
+.dupre-dsky-pad .dupre-key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em}
/* VFD marquee */
-.vfdm{width:176px;height:34px;border-radius:5px;overflow:hidden;position:relative;cursor:pointer;
+.dupre-vfdm{width:176px;height:34px;border-radius:5px;overflow:hidden;position:relative;cursor:pointer;
background:linear-gradient(180deg,#04100e,#020807);border:1px solid #123028;box-shadow:inset 0 0 12px rgba(0,0,0,.6)}
-.vfdm .txt{position:absolute;top:50%;transform:translateY(-50%);white-space:nowrap;font-size:15px;letter-spacing:.22em;
+.dupre-vfdm .dupre-txt{position:absolute;top:50%;transform:translateY(-50%);white-space:nowrap;font-size:15px;letter-spacing:.22em;
color:var(--vfd);text-shadow:0 0 6px rgba(99,230,200,.65)}
-.vfdm .mesh{position:absolute;inset:0;pointer-events:none;opacity:.35;
+.dupre-vfdm .dupre-mesh{position:absolute;inset:0;pointer-events:none;opacity:.35;
background-image:radial-gradient(rgba(0,0,0,.6) 40%,transparent 41%);background-size:3px 3px}
/* annunciator */
-.annwrap{display:flex;flex-direction:column;gap:6px}
-.annbar{display:flex;gap:5px;align-items:center}
-.mc{font-size:7px;letter-spacing:.08em;padding:4px 8px;border-radius:3px;background:#141210;color:#5c574c;
+.dupre-annwrap{display:flex;flex-direction:column;gap:6px}
+.dupre-annbar{display:flex;gap:5px;align-items:center}
+.dupre-mc{font-size:7px;letter-spacing:.08em;padding:4px 8px;border-radius:3px;background:#141210;color:#5c574c;
border:1px solid #26221c;text-align:center}
-.mc.on{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream);font-weight:700;
+.dupre-mc.dupre-on{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream);font-weight:700;
box-shadow:0 0 8px rgba(203,107,77,.5)}
-.mc.on.fl{animation:pulse var(--pulse-rate) ease-in-out infinite}
-.annbar .key{padding:3px 7px;font-size:7.5px;border-radius:4px}
-.annun{display:grid;grid-template-columns:repeat(3,1fr);gap:3px}
-.acell{font-size:8.5px;letter-spacing:.08em;text-align:center;color:var(--dim);padding:6px 4px;border-radius:3px;cursor:pointer;
+.dupre-mc.dupre-on.dupre-fl{animation:pulse var(--pulse-rate) ease-in-out infinite}
+.dupre-annbar .dupre-key{padding:3px 7px;font-size:7.5px;border-radius:4px}
+.dupre-annun{display:grid;grid-template-columns:repeat(3,1fr);gap:3px}
+.dupre-acell{font-size:8.5px;letter-spacing:.08em;text-align:center;color:var(--dim);padding:6px 4px;border-radius:3px;cursor:pointer;
background:#141210;border:1px solid #262320;line-height:1.2}
-.acell.warn{color:var(--panel);background:linear-gradient(180deg,var(--amber-warn),var(--gold));font-weight:700;box-shadow:0 0 8px rgba(var(--glow-lo),.4)}
-.acell.fault{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));font-weight:700;box-shadow:0 0 8px rgba(203,107,77,.4);animation:pulse var(--pulse-rate) ease-in-out infinite}
+.dupre-acell.dupre-warn{color:var(--panel);background:linear-gradient(180deg,var(--amber-warn),var(--gold));font-weight:700;box-shadow:0 0 8px rgba(var(--glow-lo),.4)}
+.dupre-acell.dupre-fault{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));font-weight:700;box-shadow:0 0 8px rgba(203,107,77,.4);animation:pulse var(--pulse-rate) ease-in-out infinite}
/* jewel */
-.jewel{width:24px;height:24px;border-radius:50%;position:relative;cursor:pointer;
+.dupre-jewel{width:24px;height:24px;border-radius:50%;position:relative;cursor:pointer;
background:radial-gradient(circle at 36% 30%,rgba(255,255,255,.75),var(--jc) 42%,#3a0d08 100%);
box-shadow:0 0 10px 1px var(--jc),inset 0 -2px 3px rgba(0,0,0,.5)}
-.jewel::after{content:"";position:absolute;inset:0;border-radius:50%;
+.dupre-jewel::after{content:"";position:absolute;inset:0;border-radius:50%;
background:conic-gradient(from 0deg,rgba(255,255,255,.12) 0 22deg,transparent 22deg 45deg,rgba(0,0,0,.18) 45deg 67deg,transparent 67deg 90deg);
background-repeat:repeat}
-.jewel.dim{background:radial-gradient(circle at 36% 30%,rgba(120,120,120,.3),#241f1b 55%,#0e0c0a);box-shadow:inset 0 -2px 3px rgba(0,0,0,.5)}
+.dupre-jewel.dupre-dim{background:radial-gradient(circle at 36% 30%,rgba(120,120,120,.3),#241f1b 55%,#0e0c0a);box-shadow:inset 0 -2px 3px rgba(0,0,0,.5)}
/* tape counter */
-.counter{display:inline-flex;gap:2px;padding:4px;background:#0c0b0a;border:1px solid #2c261d;border-radius:4px;
+.dupre-counter{display:inline-flex;gap:2px;padding:4px;background:#0c0b0a;border:1px solid #2c261d;border-radius:4px;
box-shadow:inset 0 1px 3px rgba(0,0,0,.6)}
-.cwheel{width:18px;height:34px;border-radius:2px;position:relative;overflow:hidden;
+.dupre-cwheel{width:18px;height:34px;border-radius:2px;position:relative;overflow:hidden;
background:linear-gradient(180deg,#efe9d6,#c7c0ac);box-shadow:inset 0 0 3px rgba(0,0,0,.3)}
-.cwheel .col{position:absolute;left:0;right:0;text-align:center;color:#1a1613;font-size:18px;font-weight:700;line-height:34px;
+.dupre-cwheel .dupre-col{position:absolute;left:0;right:0;text-align:center;color:#1a1613;font-size:18px;font-weight:700;line-height:34px;
transition:top .35s cubic-bezier(.4,1.4,.5,1)}
-.cwheel .col span{display:block;height:34px}
-.cwheel::before,.cwheel::after{content:"";position:absolute;left:0;right:0;height:9px;z-index:2;pointer-events:none}
-.cwheel::before{top:0;background:linear-gradient(180deg,rgba(0,0,0,.4),transparent)}
-.cwheel::after{bottom:0;background:linear-gradient(0deg,rgba(0,0,0,.4),transparent)}
-.counter .redw{background:linear-gradient(180deg,#e7b46a,#cf9440)}
+.dupre-cwheel .dupre-col span{display:block;height:34px}
+.dupre-cwheel::before,.dupre-cwheel::after{content:"";position:absolute;left:0;right:0;height:9px;z-index:2;pointer-events:none}
+.dupre-cwheel::before{top:0;background:linear-gradient(180deg,rgba(0,0,0,.4),transparent)}
+.dupre-cwheel::after{bottom:0;background:linear-gradient(0deg,rgba(0,0,0,.4),transparent)}
+.dupre-counter .dupre-redw{background:linear-gradient(180deg,#e7b46a,#cf9440)}
/* analog clock */
-.clock{width:78px;height:78px;border-radius:50%;position:relative;
+.dupre-clock{width:78px;height:78px;border-radius:50%;position:relative;
background:radial-gradient(circle at 46% 40%,#1c1915,#0c0b09);border:2px solid #2c261d;box-shadow:inset 0 2px 5px rgba(0,0,0,.6)}
-.clock .tk{position:absolute;left:50%;top:4px;width:1.5px;height:6px;background:var(--steel);margin-left:-.75px;transform-origin:50% 35px}
-.clock .hh{position:absolute;left:50%;top:50%;width:3px;height:20px;margin:-20px 0 0 -1.5px;background:var(--cream);
+.dupre-clock .dupre-tk{position:absolute;left:50%;top:4px;width:1.5px;height:6px;background:var(--steel);margin-left:-.75px;transform-origin:50% 35px}
+.dupre-clock .dupre-hh{position:absolute;left:50%;top:50%;width:3px;height:20px;margin:-20px 0 0 -1.5px;background:var(--cream);
transform-origin:50% 100%;border-radius:2px}
-.clock .mh{position:absolute;left:50%;top:50%;width:2px;height:28px;margin:-28px 0 0 -1px;background:var(--silver);
+.dupre-clock .dupre-mh{position:absolute;left:50%;top:50%;width:2px;height:28px;margin:-28px 0 0 -1px;background:var(--silver);
transform-origin:50% 100%;border-radius:2px}
-.clock .sh{position:absolute;left:50%;top:50%;width:1px;height:30px;margin:-30px 0 0 -.5px;background:var(--gold-hi);
+.dupre-clock .dupre-sh{position:absolute;left:50%;top:50%;width:1px;height:30px;margin:-30px 0 0 -.5px;background:var(--gold-hi);
transform-origin:50% 100%;box-shadow:0 0 4px rgba(var(--glow-hi),.5)}
-.clock .pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold)}
+.dupre-clock .dupre-pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold)}
/* frequency-dial scale */
-.freqscale{width:184px;height:44px;position:relative;border-radius:5px;overflow:hidden;cursor:pointer;touch-action:none;
+.dupre-freqscale{width:184px;height:44px;position:relative;border-radius:5px;overflow:hidden;cursor:pointer;touch-action:none;
background:linear-gradient(180deg,#191510,#0b0908);border:1px solid #2a251c;
box-shadow:inset 0 0 16px rgba(var(--glow-lo),.1)}
-.freqscale .tick{position:absolute;top:5px;width:1px;background:var(--steel);transform:translateX(-50%)}
-.freqscale .mk{position:absolute;bottom:6px;transform:translateX(-50%);color:var(--steel);font-size:9px}
-.freqscale .band{position:absolute;bottom:2px;left:6px;color:var(--gold);font-size:8px;letter-spacing:.18em}
-.freqscale .fptr{position:absolute;top:3px;bottom:14px;width:2px;margin-left:-1px;border-radius:1px;
+.dupre-freqscale .dupre-tick{position:absolute;top:5px;width:1px;background:var(--steel);transform:translateX(-50%)}
+.dupre-freqscale .dupre-mk{position:absolute;bottom:6px;transform:translateX(-50%);color:var(--steel);font-size:9px}
+.dupre-freqscale .dupre-fs-band{position:absolute;bottom:2px;left:6px;color:var(--gold);font-size:8px;letter-spacing:.18em}
+.dupre-freqscale .dupre-fptr{position:absolute;top:3px;bottom:14px;width:2px;margin-left:-1px;border-radius:1px;
background:var(--fail);box-shadow:0 0 7px rgba(203,107,77,.85)}
/* patch-bay */
-.patch{padding:7px 9px;background:#141210;border:1px solid #2c261d;border-radius:5px;position:relative}
-.patch .row{display:flex;gap:9px}
-.patch .row+.row{margin-top:9px}
-.patch .jack{width:13px;height:13px;border-radius:50%;cursor:pointer;background:radial-gradient(circle at 40% 35%,#3a352c,#0a0908 70%);
+.dupre-patch{padding:7px 9px;background:#141210;border:1px solid #2c261d;border-radius:5px;position:relative}
+.dupre-patch .dupre-row{display:flex;gap:9px}
+.dupre-patch .dupre-row+.dupre-row{margin-top:9px}
+.dupre-patch .dupre-jack{width:13px;height:13px;border-radius:50%;cursor:pointer;background:radial-gradient(circle at 40% 35%,#3a352c,#0a0908 70%);
border:1px solid #4a443a;box-shadow:inset 0 1px 2px rgba(0,0,0,.8)}
-.patch .jack.hot{background:radial-gradient(circle at 40% 35%,var(--amber-edge),#1a1408 70%)}
-.patch .jack.sel{border-color:var(--gold-hi);box-shadow:0 0 6px 1px rgba(var(--glow-hi),.7)}
-.patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%}
-.patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round}
+.dupre-patch .dupre-jack.dupre-hot{background:radial-gradient(circle at 40% 35%,var(--amber-edge),#1a1408 70%)}
+.dupre-patch .dupre-jack.dupre-sel{border-color:var(--gold-hi);box-shadow:0 0 6px 1px rgba(var(--glow-hi),.7)}
+.dupre-patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%}
+.dupre-patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round}
-/* ===== reference-batch (R) widgets — SVG-first, after period hardware ===== */
+/* ===== reference-batch (R) instruments — SVG-first, after period hardware ===== */
.rsvg{display:block}
.rsvg.drag{cursor:ns-resize;touch-action:none}
.rsvg.press{cursor:pointer}
/* transport reels spin (was a page-side keyframe injector) */
@keyframes reelspin{to{transform:rotate(360deg)}}
-.reel.spin{animation:reelspin 2.6s linear infinite}
+.dupre-reel.dupre-spin{animation:reelspin 2.6s linear infinite}
`;
function ensureCss() {
- if (document.getElementById('gw-css') || !GW_CSS) return;
- const st = document.createElement('style'); st.id = 'gw-css'; st.textContent = GW_CSS;
+ if (document.getElementById('dupre-css') || !DUPRE_CSS) return;
+ const st = document.createElement('style'); st.id = 'dupre-css'; st.textContent = DUPRE_CSS;
document.head.appendChild(st);
}
if (document.head) ensureCss();
else document.addEventListener('DOMContentLoaded', ensureCss);
-/* Policy classification (see GW.POLICIES). This is the colour pass's worklist made
+/* Policy classification (see DUPRE.POLICIES). This is the colour pass's worklist made
explicit: every card gets a record here as we review it — the kind it's bound
by, why in its own terms, and the range it may vary and stay authentic. This
round covers the cards already touched; the rest are unclassified on purpose,
the review still to do card by card, not a memory dump to fill now. Assigned
after the builders exist, in one place, so it reads as a catalogue. */
-GW.slideToggle.POLICY = { kind: 'accent',
- why: 'On means good in one panel and a fault in the next, so the lit colour is the consumer’s claim, not the widget’s.',
+DUPRE.slideToggle.POLICY = { kind: 'accent',
+ why: 'On means good in one panel and a fault in the next, so the lit colour is the consumer’s claim, not the instrument’s.',
authentic: 'The on-colour from the accent family (red, amber, green, white, vfd), plus the pill and thumb finishes. The switch form stays.' };
-GW.segmented.POLICY = { kind: 'accent',
+DUPRE.segmented.POLICY = { kind: 'accent',
why: 'The active segment signals a mode whose meaning depends on the panel it sits in.',
authentic: 'The accent colour of the active segment. The segment count and labels are the deployment’s, not a colour choice.' };
-GW.chipToggle.POLICY = { kind: 'accent',
+DUPRE.chipToggle.POLICY = { kind: 'accent',
why: 'A filter chip’s on-state reads as good, warning or fault entirely by where it is used.',
authentic: 'The lit colour from the accent family. The chip keeps its inline weight and dotted underline.' };
-GW.dataMatrix.POLICY = { kind: 'screen',
+DUPRE.dataMatrix.POLICY = { kind: 'screen',
why: 'LED and LCD dot-matrix modules were sold in several emitter colours, so no one colour is definitive.',
authentic: 'The screen family (amber, green, red, blue, vfd, white) — each a panel that was actually built. Not an arbitrary hue.' };
-GW.roundCrt.POLICY = { kind: 'screen',
+DUPRE.roundCrt.POLICY = { kind: 'screen',
why: 'CRT phosphors were manufactured in more than one colour (P1 green, P3 amber, white), so the trace colour is a real choice.',
authentic: 'The screen family, limited to phosphors that existed. The face tint follows the phosphor.' };
-GW.waveRegion.POLICY = { kind: 'screen',
+DUPRE.waveRegion.POLICY = { kind: 'screen',
why: 'A backlit editor LCD was made in several tints; the ink colour is the panel’s, not fixed.',
authentic: 'The screen family. The waveform and region handles recolour with the screen, staying legible on it.' };
-GW.radarSweep.POLICY = { kind: 'screen',
+DUPRE.radarSweep.POLICY = { kind: 'screen',
why: 'PPI radar scopes ran amber and green phosphors both; neither is the one true colour.',
authentic: 'The screen family, phosphors that shipped. The sweep and afterglow track the chosen screen.' };
-GW.abcKeypad.POLICY = { kind: 'screen',
+DUPRE.abcKeypad.POLICY = { kind: 'screen',
why: 'The entry window is a screen like any other; its phosphor was made in several colours. (Mixed card: the keys are fixed-function and not a colour choice.)',
authentic: 'The window’s screen family. The keycap colours are functional and stay put.' };
-Object.assign(GW, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS });
-window.GW = GW;
+Object.assign(DUPRE, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS });
+window.DUPRE = DUPRE;
})();