diff options
Diffstat (limited to 'docs/prototypes/widgets.js')
| -rw-r--r-- | docs/prototypes/widgets.js | 2908 |
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; })(); |
