aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/dupre-kit-additions.js
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-22 15:15:02 -0500
committerCraig Jennings <c@cjennings.net>2026-07-22 15:15:02 -0500
commitf1c661f50d6af2a115fc1430ae0478f2acef2dec (patch)
treeb894ea2e14327794bb75671b962f37185cff22ea /docs/prototypes/dupre-kit-additions.js
parentf594068bed75302aba50793ccf5e6ce9cf53c7f8 (diff)
downloadarchsetup-f1c661f50d6af2a115fc1430ae0478f2acef2dec.tar.gz
archsetup-f1c661f50d6af2a115fc1430ae0478f2acef2dec.zip
docs(settings-panel): seal the prototype arc and open the build
Prototypes P12-P37 land, along with the night-watch and levels-compare pages plus the shared-engine updates and the kit-candidate file. The spec flips from DRAFT through READY to DOING with rewritten implementation phases and seven recorded build findings. The build tasks are filed in todo.org, and phase 1 is closed against the dotfiles commits.
Diffstat (limited to 'docs/prototypes/dupre-kit-additions.js')
-rw-r--r--docs/prototypes/dupre-kit-additions.js1256
1 files changed, 1256 insertions, 0 deletions
diff --git a/docs/prototypes/dupre-kit-additions.js b/docs/prototypes/dupre-kit-additions.js
new file mode 100644
index 0000000..005a577
--- /dev/null
+++ b/docs/prototypes/dupre-kit-additions.js
@@ -0,0 +1,1256 @@
+/* dupre-kit-additions.js — kit-candidate builders developed in the
+ * desktop-settings casting work, written to the widgets.js conventions
+ * (DUPRE.name(host, opts) → {el, get, set, ...}; onChange(value, text) on
+ * every state change including the initial paint; no page globals touched)
+ * so they can merge back into the kit with gallery cards.
+ *
+ * Load AFTER widgets.js. Contents:
+ * - DUPRE.detentFader (NEW) — multi-detent slide attenuator with
+ * speedbump drag physics.
+ * - DUPRE.drumRoller (UPGRADE) — backward-compatible redefinition:
+ * 1..N channels (stock hardcodes exactly two), configurable min/max
+ * range and stage height. Defaults reproduce the stock instrument.
+ * - DUPRE.guardedToggle (UPGRADE) — rotateX throw (no planar spin).
+ * - DUPRE.batToggle (UPGRADE) — same throw fix.
+ * - DUPRE.slideRule (UPGRADE) — width option; the printed scale
+ * spreads its stops proportionally. Default keeps stock 180px.
+ * - DUPRE.slideSelector (NEW) — stretchable n-position slide switch
+ * with a status lamp per position (pillSlide's full-size descendant).
+ * - DUPRE.programChart (NEW) — faceplate program matrix / punch card.
+ * - DUPRE.thumbSlideH (NEW) — horizontal thumb-slide attenuator;
+ * continuous 0-100 or detented positions (thumbSlide turned on its
+ * side, one channel, stretchable).
+ * - DUPRE.pinMatrix (UPGRADE) — configurable geometry, per-column
+ * pin colors, clickable button column-heads, group dividers, set().
+ * Defaults reproduce the stock 5×6 synth patch exactly.
+ * - DUPRE.programMatrix (NEW) — the programmable matrix: programs as
+ * column heads, settings as rows, white pins program, the ACTIVE
+ * column's pins run green because the recalled program IS the live
+ * state. No separate live column needed.
+ * - DUPRE.tripRail (NEW) — idle tripper rail: a time-switch tripper
+ * scale for "when idle reaches here, throw this" — draggable stage
+ * tabs on a sqrt-spaced minutes rail, with a parking siding to
+ * disable a stage and a bypass state (caffeine dims the policy).
+ * - DUPRE.tripDial (NEW) — the round form Craig actually imagined:
+ * the Intermatic segment-timer dial with colored tripper tabs on the
+ * rim, an OFF notch at the bottom, and a side legend. Same policy
+ * semantics as the rail.
+ */
+(() => {
+ const noop = () => {};
+ const SVGNS = 'http://www.w3.org/2000/svg';
+
+ /* local mini-helpers (widgets.js keeps its own private; additions carry
+ theirs so the file is self-contained until merged) */
+ const svgEl = (parent, tag, attrs) => {
+ const e = document.createElementNS(SVGNS, tag);
+ for (const k in (attrs || {})) e.setAttribute(k, attrs[k]);
+ parent.appendChild(e); return e;
+ };
+ const stageSvg = (host, cls, vw, vh) => {
+ const s = document.createElementNS(SVGNS, 'svg');
+ s.setAttribute('class', cls); s.setAttribute('viewBox', `0 0 ${vw} ${vh}`);
+ s.setAttribute('width', vw); s.setAttribute('height', vh);
+ host.appendChild(s); return s;
+ };
+ let uidN = 0;
+ const uid = p => p + '-a' + (++uidN);
+
+ /* additions-scoped gradients (ids prefixed so they never collide with the
+ kit's shared defs plate; on merge these fold into gradDef) */
+ let defsRoot = null;
+ const grad = (id, kind, geom, stops) => {
+ if (!defsRoot) {
+ const svg = document.createElementNS(SVGNS, 'svg');
+ svg.setAttribute('width', 0); svg.setAttribute('height', 0);
+ svg.setAttribute('aria-hidden', 'true');
+ svg.style.position = 'absolute';
+ defsRoot = document.createElementNS(SVGNS, 'defs');
+ svg.appendChild(defsRoot);
+ document.body.appendChild(svg);
+ }
+ if (defsRoot.querySelector('#' + id)) return;
+ const g = document.createElementNS(SVGNS, kind);
+ g.setAttribute('id', id);
+ for (const k in geom) g.setAttribute(k, geom[k]);
+ stops.forEach(([o, c]) => {
+ const st = document.createElementNS(SVGNS, 'stop');
+ st.setAttribute('offset', o); st.setAttribute('stop-color', c);
+ g.appendChild(st);
+ });
+ defsRoot.appendChild(g);
+ };
+
+ /* vertical drag with delta accumulation (the kit's dragDelta shape) */
+ const dragDelta = (el, getV, setV, o) => {
+ el.style.touchAction = 'none';
+ el.addEventListener('pointerdown', e => {
+ el.setPointerCapture(e.pointerId);
+ const y0 = e.clientY, v0 = getV();
+ const move = m => setV(Math.max(o.min, Math.min(o.max, v0 + (y0 - m.clientY) * o.sens)));
+ const up = () => { el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); };
+ el.addEventListener('pointermove', move);
+ el.addEventListener('pointerup', up);
+ e.preventDefault();
+ });
+ };
+
+ /* additions CSS, injected once (the kit pattern: DUPRE_CSS ships inside
+ widgets.js; these rules move into it on merge) */
+ if (!document.getElementById('dupre-additions-css')) {
+ const st = document.createElement('style');
+ st.id = 'dupre-additions-css';
+ st.textContent = `
+.dupre-dfader{position:relative;display:inline-block;width:230px;height:36px;cursor:ew-resize;touch-action:none}
+.dupre-dfader .df-slot{position:absolute;left:8px;right:8px;top:15px;height:6px;border-radius:4px;
+ background:var(--well,#0a0c0d);border:1px solid #0a0908;box-shadow:inset 0 1px 2px #000}
+.dupre-dfader .df-fill{position:absolute;left:9px;top:16px;height:4px;border-radius:3px;
+ background:linear-gradient(180deg,var(--amber-grad-top,#f2c76a),var(--gold,#e2a038));pointer-events:none}
+.dupre-dfader .df-tick{position:absolute;top:9px;width:2px;height:18px;border-radius:1px;
+ background:var(--steel,#969385);opacity:.55;transform:translateX(-50%);pointer-events:none}
+.dupre-dfader .df-tick.df-park{background:var(--gold-hi,#ffbe54);opacity:1;
+ box-shadow:0 0 5px rgba(var(--glow-hi,255,190,84),.7)}
+.dupre-dfader .df-cap{position:absolute;top:5px;width:12px;height:26px;border-radius:3px;transform:translateX(-50%);
+ background:linear-gradient(90deg,#7e7a70,#e8e5db 45%,#8a867c);border:1px solid #4e4a42;
+ box-shadow:0 2px 4px #000a;pointer-events:none}
+.dupre-dfader .df-cap::after{content:"";position:absolute;left:50%;top:3px;bottom:3px;width:2px;
+ transform:translateX(-50%);background:rgba(0,0,0,.35);border-radius:1px}
+.dupre-dfader.df-parked .df-cap{box-shadow:0 2px 4px #000a,0 0 7px rgba(var(--glow-hi,255,190,84),.55)}
+.dupre-slsel{display:block;width:220px;cursor:pointer;touch-action:none;user-select:none}
+.dupre-slsel .sl-cols{display:grid}
+.dupre-slsel .sl-col{display:flex;flex-direction:column;align-items:center;gap:3px;padding-bottom:5px}
+.dupre-slsel .sl-lamp{width:11px;height:11px;border-radius:50%;
+ background:radial-gradient(circle at 38% 32%,#2e2a24,#171310);border:1px solid #060505}
+.dupre-slsel .sl-col.sl-on .sl-lamp{background:radial-gradient(circle at 38% 32%,var(--gold-hi,#ffbe54),var(--gold,#e2a038) 60%,#8f671f);
+ box-shadow:0 0 7px rgba(var(--glow-hi,255,190,84),.75)}
+.dupre-slsel .sl-lbl{font-size:8px;letter-spacing:.1em;color:var(--dim,#7c838a);text-transform:uppercase;font-family:var(--mono)}
+.dupre-slsel .sl-col.sl-on .sl-lbl{color:var(--gold-hi,#ffbe54)}
+.dupre-slsel .sl-track{position:relative;height:20px;border-radius:10px;
+ background:var(--well,#0a0c0d);border:1px solid #0a0908;box-shadow:inset 0 2px 4px #000}
+.dupre-slsel .sl-knob{position:absolute;top:2px;bottom:2px;border-radius:8px;transform:translateX(-50%);
+ background:linear-gradient(180deg,#f0efec,#c4c1b9 45%,#75726a);border:1px solid #4e4a42;
+ box-shadow:0 2px 4px #000a;transition:left .16s ease}
+.dupre-slsel .sl-knob::after{content:"";position:absolute;left:50%;top:4px;bottom:4px;width:2px;
+ transform:translateX(-50%);background:rgba(0,0,0,.3);border-radius:1px}
+.dupre-rocker.dupre-quiet:not(.dupre-on) .dupre-bot{background:linear-gradient(180deg,#141210,#100e0c);
+ color:var(--dim,#7c838a);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)}
+.dupre-rocker.dupre-green.dupre-on .dupre-top{background:linear-gradient(180deg,#a9c95f,var(--pass,#74932f));
+ color:var(--panel,#100f0f);box-shadow:0 0 10px rgba(116,147,47,.35)}
+.dupre-slsel.sl-dark .sl-knob{background:linear-gradient(180deg,#33302a,#1c1a16);border-color:#3f3b33}
+.dupre-slsel.sl-dark .sl-knob::after{background:rgba(255,255,255,.16)}
+.dupre-pchart{display:block;font-family:var(--mono);pointer-events:none}
+.dupre-pchart .pc-grid{display:grid;row-gap:2px}
+.dupre-pchart .pc-cell{display:flex;align-items:center;justify-content:center;height:14px}
+.dupre-pchart .pc-h{font-size:6.5px;letter-spacing:.08em;color:var(--steel,#969385);text-transform:uppercase}
+.dupre-pchart .pc-name{font-size:7.5px;letter-spacing:.06em;color:var(--dim,#7c838a);
+ text-transform:uppercase;justify-content:flex-end;padding-right:6px}
+.dupre-pchart .pc-dot{width:6px;height:6px;border-radius:50%}
+.dupre-pchart .pc-dot.pc-set{background:#cfd3cf}
+.dupre-pchart .pc-dot.pc-clear{border:1px solid var(--steel,#969385);opacity:.6}
+.dupre-pchart .pc-dash{width:6px;height:1px;background:var(--wash,#2c2f32)}
+.dupre-pchart .pc-pwr{font-size:7.5px;color:var(--steel,#969385)}
+.dupre-pchart .pc-row-on .pc-name{color:#f2f4f2}
+.dupre-pchart .pc-row-on .pc-dot.pc-set{background:#f2f4f2;
+ box-shadow:0 0 5px rgba(242,244,242,.6)}
+.dupre-pchart .pc-row-on .pc-pwr{color:#f2f4f2}
+.dupre-pchart.pc-edit{pointer-events:auto}
+.dupre-pchart.pc-edit .pc-cell:not(.pc-h):not(.pc-name){cursor:pointer;border-radius:4px}
+.dupre-pchart.pc-edit .pc-cell:not(.pc-h):not(.pc-name):hover{background:#ffffff10}
+`;
+ document.head.appendChild(st);
+ }
+
+ /* NEW — multi-detent slide attenuator: a horizontal fader whose thumb
+ parks at each detent; the pointer must push an escape margin past the
+ detent to break free (a speedbump, not a snap). Crossfader's center
+ detent generalized to a set.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 68); detents (values 0-100, default [50] —
+ the crossfader case); magnet (± units that capture the thumb,
+ default 3); escape (extra units past the magnet edge needed to
+ break out, default 3); onChange(v, label) on every change,
+ label 'N' or 'N · detent' while parked, including the initial
+ paint.
+ handle: el, get(), set(v) (clamped 0-100; set() obeys detent capture
+ so a programmatic sweep feels the same bumps), parked()
+ (the detent value or null).
+ CSS lives in the additions block (.dupre-dfader); no other styles
+ involved. */
+ DUPRE.detentFader = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const detents = (opts.detents || [50]).slice().sort((a, b) => a - b);
+ const MAGNET = opts.magnet !== undefined ? opts.magnet : 3;
+ const ESCAPE = opts.escape !== undefined ? opts.escape : 3;
+ const el = document.createElement('span'); el.className = 'dupre-dfader';
+ el.innerHTML = '<span class="df-slot"></span><span class="df-fill"></span>';
+ const ticks = detents.map(d => {
+ const t = document.createElement('span'); t.className = 'df-tick';
+ t.style.left = `calc(8px + ${d / 100} * (100% - 16px))`;
+ el.appendChild(t); return t;
+ });
+ const cap = document.createElement('span'); cap.className = 'df-cap'; el.appendChild(cap);
+ host.appendChild(el);
+
+ let v, park = null;
+ const paint = () => {
+ const left = `calc(8px + ${v / 100} * (100% - 16px))`;
+ cap.style.left = left;
+ el.querySelector('.df-fill').style.width = `calc(${v / 100} * (100% - 18px))`;
+ el.classList.toggle('df-parked', park !== null);
+ ticks.forEach((t, i) => t.classList.toggle('df-park', detents[i] === park));
+ };
+ /* speedbump core: raw is where the pointer says the thumb should be;
+ the thumb only goes there if it isn't captured, and capture only
+ breaks when raw clears the magnet+escape band. */
+ const apply = raw => {
+ raw = Math.max(0, Math.min(100, raw));
+ if (park !== null) {
+ if (Math.abs(raw - park) <= MAGNET + ESCAPE) raw = park; /* still parked */
+ else park = null; /* broke through */
+ }
+ if (park === null) {
+ const near = detents.find(d => Math.abs(raw - d) <= MAGNET);
+ if (near !== undefined) { park = near; raw = near; }
+ }
+ const nv = Math.round(raw);
+ if (nv === v && el.dataset.painted) return;
+ v = nv; el.dataset.painted = 1;
+ paint();
+ onChange(v, park !== null ? v + ' · detent' : String(v));
+ };
+
+ el.addEventListener('pointerdown', e => {
+ el.setPointerCapture(e.pointerId);
+ const rect = () => el.getBoundingClientRect();
+ const pct = m => ((m.clientX - rect().left - 8) / (rect().width - 16)) * 100;
+ apply(pct(e));
+ const move = m => apply(pct(m));
+ const up = () => { el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); };
+ el.addEventListener('pointermove', move);
+ el.addEventListener('pointerup', up);
+ e.preventDefault();
+ });
+
+ v = -1; park = null;
+ apply(opts.value !== undefined ? opts.value : 68);
+ return { el, get: () => v, set: apply, parked: () => park };
+ };
+
+ /* UPGRADE — drum roller selector, generalized. Stock hardcodes exactly two
+ drums (the onChange label indexes chans[1] unconditionally, so one
+ channel throws) and a fixed 1-10 range. This redefinition accepts 1..N
+ channels and a min/max range; every default reproduces the stock
+ instrument, so existing callers are unaffected.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: title (engraved header, default 'EQUALIZER'); channels (array of
+ { name, v } drums, any count ≥ 1, default HIGH/LOW pair);
+ min / max (drum range, default 1 / 10); height (stage height,
+ default 140 — a taller stage opens a longer paper window and
+ shows more of the scale); 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 to min..max;
+ 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 are additions-scoped until merge. */
+ DUPRE.drumRoller = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const MIN = opts.min !== undefined ? opts.min : 1;
+ const MAX = opts.max !== undefined ? opts.max : 10;
+ const chans = (opts.channels || [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }])
+ .map(c => ({ name: c.name, v: c.v }));
+ const N = chans.length;
+ const vw = Math.max(130, N * 50 + 40);
+ const vh = Math.max(100, opts.height !== undefined ? opts.height : 140);
+ const railY = 24, railH = vh - 36, winY = railY + 4, winH = railH - 8;
+ const s = stageSvg(host, 'rsvg', vw, vh), cy = winY + winH / 2, step = 17;
+ grad('dkaRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]);
+ grad('dkaPaper', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#cfc6a8'], ['.5', '#ece4c8'], ['1', '#c6bd9e']]);
+ grad('dkaCurve', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'rgba(0,0,0,.4)'], ['.28', 'rgba(0,0,0,0)'], ['.72', 'rgba(0,0,0,0)'], ['1', 'rgba(0,0,0,.4)']]);
+ const localDefs = svgEl(s, 'defs', {});
+ if (opts.title !== '') svgEl(s, 'text', { x: vw / 2, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'EQUALIZER';
+ const grps = [];
+ const label = () => chans.map(c => `${c.name} ${Math.round(c.v)}`).join(' · ');
+ const set = (i, v) => {
+ v = Math.max(MIN, Math.min(MAX, v)); chans[i].v = v;
+ const p = grps[i];
+ p.nums.style.transform = `translateY(${(v - p.base) * step}px)`;
+ p.chip.textContent = Math.round(v);
+ onChange(chans.map(c => c.v), label());
+ };
+ const x0 = (vw - (N - 1) * 50) / 2; /* drums centered whatever the count */
+ chans.forEach((ch, i) => {
+ const x = x0 + i * 50, cid = uid('dkaClip');
+ svgEl(s, 'text', { x: x + 18, y: winY + 8, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '+';
+ svgEl(s, 'text', { x: x + 18, y: winY + winH - 2, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '−';
+ svgEl(s, 'rect', { x: x - 12, y: railY, width: 24, height: railH, rx: 4, fill: 'url(#dkaRail)', stroke: '#0a0908', 'stroke-width': 1.2 });
+ const clip = svgEl(localDefs, 'clipPath', { id: cid });
+ svgEl(clip, 'rect', { x: x - 9, y: winY, width: 18, height: winH });
+ svgEl(s, 'rect', { x: x - 9, y: winY, width: 18, height: winH, fill: 'url(#dkaPaper)' });
+ const g = svgEl(s, 'g', { 'clip-path': `url(#${cid})` });
+ const nums = svgEl(g, 'g', {}); nums.style.transition = 'transform .12s';
+ for (let n = MIN; n <= MAX; n++)
+ svgEl(nums, 'text', { x, y: cy + 3 + (ch.v - n) * step, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#2b2418' }).textContent = n;
+ svgEl(g, 'rect', { x: x - 9, y: winY, width: 18, height: winH, fill: 'url(#dkaCurve)', 'pointer-events': 'none' });
+ svgEl(s, 'rect', { x: x - 8, y: cy - 6.5, width: 16, height: 13, rx: 1.5, fill: '#1a1613', opacity: .92 });
+ const chip = svgEl(s, 'text', { x, y: cy + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' });
+ svgEl(s, 'text', { x, y: vh - 4, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name;
+ const hit = svgEl(s, 'rect', { x: x - 14, y: railY, width: 28, height: railH, fill: 'transparent' });
+ hit.style.cursor = 'ns-resize';
+ grps.push({ nums, chip, base: ch.v });
+ dragDelta(hit, () => chans[i].v, v => set(i, v), { min: MIN, max: MAX, sens: (MAX - MIN) / (winH * 1.1) });
+ });
+ chans.forEach((c, i) => set(i, c.v));
+ return { el: s, get: () => chans.map(c => c.v), set };
+ };
+
+ /* UPGRADE — guarded toggle, realistic throw. Stock animates the lever with
+ a 180° planar rotate, so mid-transition the handle points sideways (the
+ Linda Blair problem — Craig, 2026-07-21). A real switch bat flips toward
+ the viewer: this redefinition throws with rotateX, so the lever
+ foreshortens through the pivot and re-extends downward, never sweeping
+ sideways. Contract unchanged from stock: opts onLabel/offLabel/on/
+ onChange(on, 'ON'|'OFF'); handle {el, get, set}. */
+ DUPRE.guardedToggle = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52;
+ grad('dkaDiscRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]);
+ grad('dkaNut', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]);
+ grad('dkaChrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]);
+ grad('dkaTip', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]);
+ const lblOn = svgEl(s, 'text', { x: cx, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' });
+ lblOn.textContent = opts.onLabel || 'ON';
+ const lblOff = svgEl(s, 'text', { x: cx, y: 96, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
+ lblOff.textContent = opts.offLabel || 'OFF';
+ svgEl(s, 'circle', { cx, cy, r: 13, fill: 'url(#dkaDiscRed)', stroke: '#4e150a', 'stroke-width': 1 });
+ const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 11 * Math.cos(a)) + ',' + (cy + 11 * Math.sin(a))); }
+ svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#dkaNut)', stroke: '#2b2822' });
+ svgEl(s, 'circle', { cx, cy, r: 6, fill: '#14110e', stroke: '#000' });
+ for (const gx of [16, 74]) {
+ svgEl(s, 'rect', { x: gx - 6, y: cy - 24, width: 12, height: 48, rx: 6, fill: 'url(#dkaNut)', stroke: '#2b2822', 'stroke-width': 1 });
+ svgEl(s, 'rect', { x: gx - 4, y: cy - 21, width: 3.5, height: 42, rx: 2, fill: 'rgba(255,255,255,.14)' });
+ }
+ const lever = svgEl(s, 'g', {});
+ svgEl(lever, 'path', { d: `M ${cx - 3} ${cy} L ${cx - 2} 26 L ${cx + 2} 26 L ${cx + 3} ${cy} Z`, fill: 'url(#dkaChrome)', stroke: '#4e4a42', 'stroke-width': .5 });
+ svgEl(lever, 'rect', { x: cx - 4.5, y: 16, width: 9, height: 12, rx: 2, fill: 'url(#dkaTip)', stroke: '#5e5a52', 'stroke-width': .6 });
+ for (let r = 0; r < 4; r++) svgEl(lever, 'line', { x1: cx - 3.5, y1: 18.5 + r * 2.4, x2: cx + 3.5, y2: 18.5 + r * 2.4, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 });
+ lever.style.transformBox = 'view-box';
+ lever.style.transformOrigin = `${cx}px ${cy}px`;
+ lever.style.transition = 'transform .16s ease-in-out';
+ let on;
+ const set = v => {
+ on = !!v;
+ /* rotateX: the bat flips through the pivot plane (foreshortens, then
+ re-extends downward) — the head-on view of a real throw. */
+ lever.style.transform = on ? 'rotateX(0deg)' : 'rotateX(180deg)';
+ lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)');
+ lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)');
+ onChange(on, on ? 'ON' : 'OFF');
+ };
+ s.addEventListener('click', () => set(!on));
+ set(opts.on !== undefined ? opts.on : true);
+ return { el: s, get: () => on, set };
+ };
+
+ /* UPGRADE — bat toggle, realistic throw. Same disease and same cure as
+ guardedToggle: stock spins the bat 180° in the plane (sideways at the
+ midpoint); this flips it with rotateX like a real switch bat. Contract
+ unchanged: opts onLabel/offLabel/on/onChange(on, 'ON'|'OFF'); handle
+ {el, get, set}. */
+ DUPRE.batToggle = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 70, 90), cx = 35, cy = 44;
+ grad('dkaNut', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]);
+ grad('dkaChrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]);
+ grad('dkaTip', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]);
+ const lblOn = svgEl(s, 'text', { x: cx, y: 9, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' });
+ lblOn.textContent = opts.onLabel || 'ON';
+ const lblOff = svgEl(s, 'text', { x: cx, y: 87, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
+ lblOff.textContent = opts.offLabel || 'OFF';
+ const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 17 * Math.cos(a)) + ',' + (cy + 17 * Math.sin(a))); }
+ svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#dkaNut)', stroke: '#2b2822' });
+ svgEl(s, 'circle', { cx, cy, r: 9, fill: '#14110e', stroke: '#000' });
+ const lever = svgEl(s, 'g', {});
+ svgEl(lever, 'path', { d: `M ${cx - 3.2} ${cy} L ${cx - 1.8} 21 L ${cx + 1.8} 21 L ${cx + 3.2} ${cy} Z`, fill: 'url(#dkaChrome)', stroke: '#4e4a42', 'stroke-width': .5 });
+ svgEl(lever, 'ellipse', { cx, cy: 17, rx: 6.5, ry: 8, fill: 'url(#dkaTip)', stroke: '#5e5a52', 'stroke-width': .6 });
+ lever.style.transformBox = 'view-box';
+ lever.style.transformOrigin = `${cx}px ${cy}px`;
+ lever.style.transition = 'transform .16s ease-in-out';
+ let on;
+ const set = v => {
+ on = !!v;
+ lever.style.transform = on ? 'rotateX(0deg)' : 'rotateX(180deg)';
+ lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)');
+ lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)');
+ onChange(on, on ? 'ON' : 'OFF');
+ };
+ s.addEventListener('click', () => set(!on));
+ set(opts.on !== undefined ? opts.on : true);
+ return { el: s, get: () => on, set };
+ };
+
+ /* NEW — n-position slide switch with a status lamp per position.
+ pillSlide's full-size descendant: the pill slide is a fixed 110px SVG
+ with mix LEDs; this one is DOM-built so it stretches to any width, and
+ each position carries its own lamp + label (only the active one lit).
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: items (position labels, default ['A','B','C']); index (initial
+ position, default 0); width (px, default 220 — set it to match
+ a neighboring instrument); knob ('chrome' default, or 'dark'
+ for a machined-black cap); onChange(index, label) fires on
+ every set, including the initial paint.
+ handle: el, get() (the index), set(i) (clamped); click or drag lands
+ on the nearest position.
+ CSS lives in the additions block (.dupre-slsel); the lamp is
+ self-styled (the shared dupre-lamp is display-managed by consumers). */
+ DUPRE.slideSelector = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const items = opts.items || ['A', 'B', 'C'];
+ const N = items.length;
+ const el = document.createElement('div'); el.className = 'dupre-slsel' + (opts.knob === 'dark' ? ' sl-dark' : '');
+ if (opts.width) el.style.width = opts.width + 'px';
+ const cols = document.createElement('div'); cols.className = 'sl-cols';
+ cols.style.gridTemplateColumns = `repeat(${N},1fr)`;
+ const colEls = items.map(lbl => {
+ const c = document.createElement('div'); c.className = 'sl-col';
+ c.innerHTML = `<span class="sl-lamp"></span><span class="sl-lbl">${lbl}</span>`;
+ cols.appendChild(c); return c;
+ });
+ el.appendChild(cols);
+ const track = document.createElement('div'); track.className = 'sl-track';
+ const knob = document.createElement('div'); knob.className = 'sl-knob';
+ track.appendChild(knob); el.appendChild(track);
+ host.appendChild(el);
+ knob.style.width = `calc(${100 / N}% - 8px)`;
+ let idx;
+ const set = i => {
+ idx = Math.max(0, Math.min(N - 1, i | 0));
+ knob.style.left = ((idx + 0.5) / N * 100) + '%';
+ colEls.forEach((c, k) => c.classList.toggle('sl-on', k === idx));
+ onChange(idx, items[idx]);
+ };
+ const pick = e => {
+ const r = track.getBoundingClientRect();
+ set(Math.floor(((e.clientX - r.left) / r.width) * N));
+ };
+ el.addEventListener('pointerdown', e => {
+ el.setPointerCapture(e.pointerId);
+ pick(e);
+ const move = m => pick(m);
+ const up = () => { el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); };
+ el.addEventListener('pointermove', move);
+ el.addEventListener('pointerup', up);
+ e.preventDefault();
+ });
+ set(opts.index !== undefined ? opts.index : 0);
+ return { el, get: () => idx, set };
+ };
+
+ /* UPGRADE — slide-rule dial with a width option. Stock prints its stops
+ at hardcoded x positions inside a fixed 180px window; this redefinition
+ spreads majors evenly across opts.width (default keeps stock geometry).
+ Contract otherwise unchanged: values/value/index/fmt/skin,
+ onChange(v, label); handle {el, get, set, setStyle}; unit stops still
+ interpolate between adjacent numeric majors; ←/→ step. */
+ (() => {
+ const OLD_STYLES = DUPRE.slideRule.STYLES;
+ DUPRE.slideRule = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const majors = opts.values || [4, 6, 8, 10, 12];
+ const fmt = opts.fmt || (v => 'pos ' + v);
+ const W = opts.width || 180;
+ /* custom widths get a wider edge margin so end-stop labels stay inside */
+ const M = opts.width ? 20 : 12;
+ const X = majors.map((_, i) => M + i * (W - 2 * M) / (majors.length - 1));
+ const stops = [];
+ majors.forEach((v, i) => {
+ stops.push({ v, x: X[i], major: true });
+ const b = majors[i + 1];
+ if (typeof v === 'number' && typeof b === 'number')
+ for (let u = v + 1; u < b; u++)
+ stops.push({ v: u, x: X[i] + (X[i + 1] - X[i]) * (u - v) / (b - v), major: false });
+ });
+ const t = document.createElement('span'); t.className = 'dupre-tuner';
+ if (opts.width) t.style.width = W + 'px';
+ t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value');
+ const setStyle = (axis, name) => {
+ const o = (DUPRE.slideRule.STYLES[axis] || {})[name];
+ if (!o) return;
+ for (const [k, v] of Object.entries(o.vars)) t.style.setProperty(k, v);
+ };
+ setStyle('skin', opts.skin || 'warm');
+ stops.forEach(sp => {
+ t.insertAdjacentHTML('beforeend', sp.major
+ ? `<span class="dupre-tick" style="left:${sp.x}px"></span><span class="dupre-mk" style="left:${sp.x}px">${sp.v}</span>`
+ : `<span class="dupre-tick dupre-mn" style="left:${sp.x}px"></span>`);
+ });
+ const ndl = document.createElement('span'); ndl.className = 'dupre-ndl'; t.appendChild(ndl);
+ host.appendChild(t);
+ let idx;
+ const set = i => {
+ idx = Math.max(0, Math.min(stops.length - 1, i));
+ ndl.style.left = stops[idx].x + 'px';
+ t.querySelectorAll('.dupre-mk').forEach(m =>
+ m.classList.toggle('dupre-on', m.textContent === String(stops[idx].v)));
+ onChange(stops[idx].v, fmt(stops[idx].v));
+ };
+ t.addEventListener('click', e => {
+ const r = t.getBoundingClientRect();
+ const x = (e.clientX - r.left) * (t.offsetWidth / r.width);
+ let best = 0, bd = 1e9; stops.forEach((sp, j) => { const d = Math.abs(sp.x - x); if (d < bd) { bd = d; best = j; } });
+ set(best); t.focus();
+ });
+ t.addEventListener('keydown', e => {
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); set(idx - 1); }
+ else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); set(idx + 1); }
+ });
+ const initV = opts.value !== undefined ? opts.value : majors[opts.index !== undefined ? opts.index : 2];
+ const init = stops.findIndex(sp => sp.v === initV);
+ set(init < 0 ? 0 : init);
+ return { el: t, get: () => stops[idx].v, set, setStyle };
+ };
+ DUPRE.slideRule.STYLES = OLD_STYLES;
+ })();
+
+ /* UPGRADE — rocker with a `quiet` option. Stock paints the off state's
+ bottom half warning-red, which reads as a fault for switches whose off
+ state is unremarkable (auto-dim off is not an alarm). quiet: true keeps
+ the off half plain dark. Red-off stays the default for switches where
+ off IS the notable state. Contract otherwise unchanged.
+ Intended pairing: onLabel carries the switch's FUNCTION name (lit when
+ engaged), offLabel is empty — state reads from light and position, not
+ from ON/OFF prose. opts.accent: 'amber' (default) or 'green' — green
+ for run/engaged semantics per the kit accent family. */
+ DUPRE.rocker = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const r = document.createElement('span'); r.className = 'dupre-rocker'
+ + (opts.quiet ? ' dupre-quiet' : '') + (opts.accent === 'green' ? ' dupre-green' : '');
+ r.innerHTML = `<span class="dupre-half dupre-top">${opts.onLabel !== undefined ? opts.onLabel : 'ON'}</span><span class="dupre-half dupre-bot">${opts.offLabel !== undefined ? opts.offLabel : 'OFF'}</span>`;
+ host.appendChild(r);
+ let on;
+ const set = v => { on = !!v; r.classList.toggle('dupre-on', on); onChange(on, on ? 'ON' : 'OFF'); };
+ r.addEventListener('click', () => set(!on));
+ set(opts.on !== undefined ? opts.on : true);
+ return { el: r, get: () => on, set };
+ };
+
+ /* NEW — program chart: the washing-machine faceplate matrix, now a punch
+ card. One row per program, one column per contact; a filled WHITE dot
+ means the program will close it on recall, a ring means it will open
+ it, a dash means it leaves it alone; letter cells carry enums. The dots
+ are future state, so they light white — deliberately outside the amber
+ live-state family. With editable: true, clicking a cell reprograms the
+ preset: dot cells cycle set → clear → untouched, letter cells cycle
+ the `letters` list; onEdit reports the change and the consumer owns
+ the stored program.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: cols (column labels); rows ({ name, cells } aligned with cols:
+ true dot / false ring / null dash / string letter); active
+ (lit row, -1 none); width (px, default 260); editable (false);
+ letters (cycle for letter columns, default ['P','B','S',null]);
+ onEdit(rowIdx, colIdx, newValue) on every cell edit;
+ onChange(index, row name | 'custom') on every set, including
+ the initial paint.
+ handle: el, get() (active index), set(i) (-1..rows-1).
+ CSS lives in the additions block (.dupre-pchart); no other styles
+ involved. */
+ DUPRE.programChart = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const onEdit = opts.onEdit || noop;
+ const cols = opts.cols || ['A', 'B', 'C'];
+ const rows = (opts.rows || []).map(r => ({ name: r.name, cells: r.cells.slice() }));
+ const LETTERS = opts.letters || ['P', 'B', 'S', null];
+ /* a column is a letter column if any row carries a string there */
+ const letterCol = ci => rows.some(r => typeof r.cells[ci] === 'string');
+ const el = document.createElement('div');
+ el.className = 'dupre-pchart' + (opts.editable ? ' pc-edit' : '');
+ el.style.width = (opts.width || 260) + 'px';
+ const grid = document.createElement('div'); grid.className = 'pc-grid';
+ grid.style.gridTemplateColumns = `minmax(52px,auto) repeat(${cols.length},1fr)`;
+ const cell = cls => { const c = document.createElement('div'); c.className = 'pc-cell' + (cls ? ' ' + cls : ''); grid.appendChild(c); return c; };
+ cell('pc-h');
+ cols.forEach(c => { cell('pc-h').textContent = c; });
+ const paintCell = (c, v) => {
+ c.classList.remove('pc-pwr');
+ if (v === true) c.innerHTML = '<span class="pc-dot pc-set"></span>';
+ else if (v === false) c.innerHTML = '<span class="pc-dot pc-clear"></span>';
+ else if (typeof v === 'string') { c.classList.add('pc-pwr'); c.textContent = v; }
+ else c.innerHTML = '<span class="pc-dash"></span>';
+ };
+ const rowEls = rows.map((r, ri) => {
+ const els = [cell('pc-name')];
+ els[0].textContent = r.name;
+ cols.forEach((_, ci) => {
+ const c = cell();
+ paintCell(c, r.cells[ci]);
+ if (opts.editable) c.addEventListener('click', () => {
+ const v = rows[ri].cells[ci];
+ let nv;
+ if (letterCol(ci)) {
+ const cur = LETTERS.indexOf(v === undefined ? null : v);
+ nv = LETTERS[(cur + 1) % LETTERS.length];
+ } else if (v === true) nv = false;
+ else if (v === false) nv = null;
+ else nv = true;
+ rows[ri].cells[ci] = nv;
+ paintCell(c, nv);
+ onEdit(ri, ci, nv);
+ });
+ els.push(c);
+ });
+ return els;
+ });
+ el.appendChild(grid); host.appendChild(el);
+ let idx;
+ const set = i => {
+ idx = Math.max(-1, Math.min(rows.length - 1, i));
+ rowEls.forEach((els, ri) => els.forEach(c => c.classList.toggle('pc-row-on', ri === idx)));
+ onChange(idx, idx < 0 ? 'custom' : rows[idx].name);
+ };
+ set(opts.active !== undefined ? opts.active : -1);
+ return { el, get: () => idx, set };
+ };
+
+ /* NEW — horizontal thumb-slide attenuator. The vertical thumbSlide turned
+ on its side: one channel, a dark rail with a chrome thumb tab, printed
+ scale beneath. Continuous by default; pass positions for a detented
+ selector (the three-position switch case) — the tab snaps and the
+ active position label lights.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: width (px, default 220); value (0-100, default 50 — continuous
+ mode); positions (label array — switches to detent mode);
+ index (initial detent, default 0); onChange — continuous:
+ (value, 'N'); detented: (index, label). Fires on every set,
+ including the initial paint.
+ handle: el, get() (value or index), set(v|i). Drag or click anywhere
+ on the rail.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage
+ block of DUPRE_CSS; gradients are additions-scoped until merge. */
+ DUPRE.thumbSlideH = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const W = opts.width || 220;
+ const POS = opts.positions || null;
+ const H = 36, x0 = 14, x1 = W - 14, railY = 14;
+ grad('dkaThRail', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]);
+ grad('dkaThTab', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2efe6'], ['1', '#c6c1b3']]);
+ const s = stageSvg(host, 'rsvg', W, H);
+ svgEl(s, 'rect', { x: x0 - 8, y: railY - 7, width: x1 - x0 + 16, height: 14, rx: 3, fill: 'url(#dkaThRail)', stroke: '#0a0908', 'stroke-width': 1 });
+ svgEl(s, 'rect', { x: x0 - 3, y: railY - 3, width: x1 - x0 + 6, height: 6, fill: '#0d0a08', stroke: '#050404' });
+ const labels = [];
+ const xAt = f => x0 + f * (x1 - x0);
+ if (POS) {
+ POS.forEach((lbl, i) => {
+ const x = xAt(POS.length === 1 ? 0.5 : i / (POS.length - 1));
+ svgEl(s, 'line', { x1: x, y1: railY + 5, x2: x, y2: railY + 8, stroke: 'var(--steel)', 'stroke-width': 1 });
+ const t = svgEl(s, 'text', { x, y: H - 3, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
+ t.textContent = lbl; labels.push(t);
+ });
+ } else {
+ for (let n = 0; n <= 10; n++) {
+ const x = xAt(n / 10);
+ svgEl(s, 'line', { x1: x, y1: railY + 5, x2: x, y2: railY + (n % 2 ? 6.5 : 8), stroke: 'var(--steel)', 'stroke-width': n % 2 ? .5 : .9, opacity: n % 2 ? .5 : .8 });
+ if (n % 2 === 0) {
+ const t = svgEl(s, 'text', { x, y: H - 3, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .55 });
+ t.textContent = n * 10; labels.push(t);
+ }
+ }
+ }
+ const tab = svgEl(s, 'g', {});
+ svgEl(tab, 'rect', { x: -6, y: railY - 9, width: 12, height: 18, rx: 2.5, fill: 'url(#dkaThTab)', stroke: '#7a766a', 'stroke-width': .6 });
+ svgEl(tab, 'rect', { x: -1, y: railY - 6.5, width: 2, height: 13, fill: 'rgba(0,0,0,.25)' });
+ let val;
+ const paint = () => {
+ const f = POS ? (POS.length === 1 ? 0.5 : val / (POS.length - 1)) : val / 100;
+ tab.setAttribute('transform', `translate(${xAt(f)},0)`);
+ if (POS) labels.forEach((t, i) => {
+ t.setAttribute('fill', i === val ? 'var(--gold-hi)' : 'var(--dim)');
+ });
+ else labels.forEach((t, i) => {
+ const on = Math.abs(i * 20 - val) <= 10;
+ t.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--gold)'); t.setAttribute('opacity', on ? '1' : '.55');
+ });
+ };
+ const set = v => {
+ val = POS ? Math.max(0, Math.min(POS.length - 1, Math.round(v)))
+ : Math.max(0, Math.min(100, Math.round(v)));
+ paint();
+ if (POS) onChange(val, POS[val]);
+ else onChange(val, String(val));
+ };
+ const fromPointer = e => {
+ const r = s.getBoundingClientRect();
+ const f = Math.max(0, Math.min(1, ((e.clientX - r.left) * (W / r.width) - x0) / (x1 - x0)));
+ set(POS ? f * (POS.length - 1) : f * 100);
+ };
+ s.style.cursor = 'ew-resize'; s.style.touchAction = 'none';
+ s.addEventListener('pointerdown', e => {
+ s.setPointerCapture(e.pointerId);
+ fromPointer(e);
+ const move = m => fromPointer(m);
+ const up = () => { s.removeEventListener('pointermove', move); s.removeEventListener('pointerup', up); };
+ s.addEventListener('pointermove', move);
+ s.addEventListener('pointerup', up);
+ e.preventDefault();
+ });
+ set(POS ? (opts.index !== undefined ? opts.index : 0) : (opts.value !== undefined ? opts.value : 50));
+ return { el: s, get: () => val, set };
+ };
+
+ /* UPGRADE — pin routing matrix, generalized. Stock is a fixed 160×110
+ five-by-six with gold pins, text heads, and no set(). This
+ redefinition keeps those defaults exactly and adds: width/dy geometry,
+ colColors (pin fill per column), colHeads: 'button' (column labels
+ become clickable key plates; setActiveCol lights one), groups (row
+ counts drawing hairline dividers between row groups), set(key, on),
+ and an onPin convenience callback.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: rows / cols (labels); pins ('ROW>COL' keys); width (px —
+ custom geometry mode; omit for stock); dy (row pitch, default
+ 16 in custom mode); x0 (label gutter, default 56 in custom
+ mode); colColors (css fill per column index, default gold);
+ colHeads ('text' default | 'button'); activeCol (initial lit
+ head, -1); groups (e.g. [4,3] → divider after row 4);
+ onChange(pins array, label) on every seat/pull including the
+ initial paint; onPin(rowLabel, colLabel, seated) per click;
+ onColHead(colIndex) when a button head is clicked.
+ handle: el, get() (pin keys), set(key, on), setActiveCol(i).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage
+ block of DUPRE_CSS. */
+ DUPRE.pinMatrix = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const onPin = opts.onPin || noop;
+ const onColHead = opts.onColHead || noop;
+ const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV'];
+ const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD'];
+ const custom = !!opts.width;
+ const W = opts.width || 160;
+ const X0 = custom ? (opts.x0 || 56) : 42;
+ const DY = custom ? (opts.dy || 16) : 15.5;
+ const Y0 = custom ? (opts.colHeads === 'button' ? 40 : 28) : 30;
+ /* button heads are wider than a pin column; leave them room at the edge */
+ const DX = custom ? (W - X0 - (opts.colHeads === 'button' ? 36 : 16)) / (COLS.length - 1) : 19;
+ const H = custom ? Y0 + (ROWS.length - 1) * DY + 14 : 110;
+ const COLC = opts.colColors || [];
+ const groups = opts.groups || null;
+ const pins = new Set((opts.pins || (custom ? [] : ['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', W, H);
+ svgEl(s, 'rect', { x: 2, y: 2, width: W - 4, height: H - 4, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
+ let activeCol = opts.activeCol !== undefined ? opts.activeCol : -1;
+ const headEls = [];
+ COLS.forEach((c, j) => {
+ const cx = X0 + j * DX;
+ if (opts.colHeads === 'button') {
+ const bw = Math.min(DX - 4, 58);
+ const g = svgEl(s, 'g', {});
+ const rect = svgEl(g, 'rect', { x: cx - bw / 2, y: 8, width: bw, height: 20, rx: 5,
+ fill: '#131110', stroke: '#33302b', 'stroke-width': 1 });
+ const txt = svgEl(g, 'text', { x: cx, y: 21, 'text-anchor': 'middle', 'font-size': 6,
+ 'letter-spacing': '.04em', 'font-family': 'var(--mono)', fill: 'var(--silver)' });
+ txt.textContent = c;
+ g.style.cursor = 'pointer';
+ g.addEventListener('click', () => onColHead(j));
+ headEls.push({ rect, txt });
+ } else {
+ svgEl(s, 'text', { x: cx, y: custom ? 18 : 16, 'text-anchor': 'middle', 'font-size': custom ? 6 : 5.4,
+ 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = c;
+ headEls.push(null);
+ }
+ });
+ const paintHeads = () => headEls.forEach((he, j) => {
+ if (!he) return;
+ he.rect.setAttribute('stroke', j === activeCol ? 'var(--gold)' : '#33302b');
+ he.rect.setAttribute('fill', j === activeCol ? '#2a2618' : '#131110');
+ he.txt.setAttribute('fill', j === activeCol ? 'var(--cream)' : 'var(--silver)');
+ });
+ paintHeads();
+ ROWS.forEach((r, i) => svgEl(s, 'text', { x: X0 - 12, y: Y0 + i * DY + 2, 'text-anchor': 'end',
+ 'font-size': custom ? 6 : 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = r);
+ if (groups) {
+ let acc = 0;
+ groups.slice(0, -1).forEach(n => {
+ acc += n;
+ const y = Y0 + (acc - 0.5) * DY;
+ svgEl(s, 'line', { x1: 10, y1: y, x2: W - 10, y2: y, stroke: '#2c2820', 'stroke-width': 1 });
+ });
+ }
+ const caps = {};
+ ROWS.forEach((r, i) => COLS.forEach((c, j) => {
+ const cx = X0 + j * DX, cy = Y0 + i * DY, key = r + '>' + c;
+ svgEl(s, 'circle', { cx, cy, r: 3.4, fill: '#0a0908', stroke: '#2c2820', 'stroke-width': 1 });
+ const fill = COLC[j] || 'var(--gold)';
+ const cap = svgEl(s, 'circle', { cx, cy, r: 4.4, fill, stroke: 'rgba(0,0,0,.45)', 'stroke-width': 1,
+ opacity: pins.has(key) ? 1 : 0, filter: 'drop-shadow(0 1px 2px rgba(0,0,0,.6))' });
+ caps[key] = cap;
+ const hit = svgEl(s, 'circle', { cx, cy, r: Math.min(8, DX / 2 - 1), fill: 'transparent' });
+ hit.style.cursor = 'pointer';
+ hit.addEventListener('click', () => {
+ const on = !pins.has(key);
+ if (on) pins.add(key); else pins.delete(key);
+ cap.setAttribute('opacity', on ? 1 : 0);
+ onPin(r, c, on);
+ onChange([...pins], (on ? r + ' → ' + c : 'pulled ' + r + ' → ' + c) + ' · ' + pins.size + ' routes');
+ });
+ }));
+ onChange([...pins], pins.size + ' routes');
+ return {
+ el: s,
+ get: () => [...pins],
+ set: (key, on) => {
+ if (!caps[key]) return;
+ if (on) pins.add(key); else pins.delete(key);
+ caps[key].setAttribute('opacity', on ? 1 : 0);
+ },
+ setActiveCol: i => { activeCol = i; paintHeads(); },
+ };
+ };
+
+ /* NEW — programmable matrix. The pin routing matrix specialized into a
+ program store: each column is a stored program, each row a setting; a
+ WHITE jewel pin means the program includes that setting. Pressing a
+ head key activates the program and the active column renders its
+ verdicts — active-is-live: a seated pin becomes a green jewel, an
+ empty socket lights a smaller red one (the program holds it off).
+ Editing the active column edits the present; a white column, a
+ future.
+
+ Head keys are the kit console-key primitive (.dupre-key), lighting
+ GREEN when their program is active. Programs may be strings or
+ { id, label, glyph, slim } — a glyph renders large on the key in
+ place of the label (label still names the program in callbacks);
+ blank labels make unassigned slots; ids keep pin keys unique. Rows are strings (pin rows) or { label, values }
+ — a LETTER ROW: every program cell is a tape-counter wheel (cream
+ paper, bezeled window, printed glyph from `values`); clicking a wheel
+ cycles that program's value; the active column's wheel IS the live
+ value and carries no highlight (paper doesn't glow).
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: rows (strings | {label, values}); programs (strings |
+ {id, label}); pins ('ROW>PROGID' keys); letters
+ ({'ROW>PROGID': glyph}); radios (row-label arrays: one pin
+ per program in the group; a radio pin moves, never pulls);
+ groups (row counts → hairline dividers); active (program
+ index, -1); width / x0 / dy (508/128/23-ish); engraved (row
+ labels as infilled relief); onActivate(index, programId) on
+ head press; onPin(rowLabel, programId, seated, isActive);
+ onLetter(rowLabel, programId, glyph, isActive) on wheel click.
+ handle: el (the wrap), get() (pins), set(row, programId, on),
+ setLetter(row, programId, glyph), setActive(i), getActive().
+ Composite-built: DOM console keys over an SVG pin field. */
+ DUPRE.programMatrix = function (host, opts = {}) {
+ const onActivate = opts.onActivate || noop;
+ const onPin = opts.onPin || noop;
+ const onLetter = opts.onLetter || noop;
+ const ROWS = (opts.rows || []).map(r => typeof r === 'string' ? { label: r } : r);
+ const PROGS = (opts.programs || ['P1', 'P2']).map(p => typeof p === 'string' ? { id: p, label: p } : p);
+ /* slim columns (e.g. unassigned slots) take a narrower band */
+ const weights = PROGS.map(p => p.slim ? 0.55 : 1);
+ const wSum = weights.reduce((a, b) => a + b, 0);
+ const radios = (opts.radios || []).map(g => g.slice());
+ const W = opts.width || 306;
+ const X0 = opts.x0 || 104;
+ const DY = opts.dy || 21;
+ const Y0 = 52;
+ const span = W - X0 - 30;
+ const colX = []; /* column centers by cumulative weight bands */
+ { let acc = 0; weights.forEach(w => { colX.push(X0 - 14 + (acc + w / 2) / wSum * span); acc += w; }); }
+ const colW = weights.map(w => w / wSum * span);
+ const DX = colW.length > 1 ? Math.min(...colW) : span; /* hit-radius basis */
+ const H = Y0 + (ROWS.length - 1) * DY + 18;
+ const WHITE = '#cfd3cf', GREEN = '#a9c95f';
+ grad('dkaJewelG', 'radialGradient', { cx: '38%', cy: '30%', r: '80%' }, [['0', '#dcebae'], ['.5', '#a9c95f'], ['1', '#55702a']]);
+ grad('dkaJewelR', 'radialGradient', { cx: '38%', cy: '30%', r: '80%' }, [['0', '#e0937e'], ['.5', '#c0503a'], ['1', '#6e2517']]);
+ grad('dkaJewelW', 'radialGradient', { cx: '38%', cy: '30%', r: '80%' }, [['0', '#f2f4f2'], ['.55', '#cfd3cf'], ['1', '#8e948e']]);
+ grad('dkaPaper2', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#cfc6a8'], ['.5', '#ece4c8'], ['1', '#c6bd9e']]);
+ grad('dkaCurve2', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'rgba(0,0,0,.45)'], ['.3', 'rgba(0,0,0,0)'], ['.7', 'rgba(0,0,0,0)'], ['1', 'rgba(0,0,0,.45)']]);
+ const pins = new Set(opts.pins || []);
+ const letters = Object.assign({}, opts.letters || {});
+ let active = opts.active !== undefined ? opts.active : -1;
+ /* container: a DOM head row of kit console keys over the SVG pin field */
+ const wrap = document.createElement('div');
+ wrap.style.cssText = 'position:relative;width:' + W + 'px';
+ host.appendChild(wrap);
+ const s = stageSvg(wrap, 'rsvg press', W, H);
+ svgEl(s, 'rect', { x: 2, y: 2, width: W - 4, height: H - 4, rx: 8, fill: '#0e0c0a', stroke: '#060505', 'stroke-width': 1.5 });
+ const heads = PROGS.map((pgm, j) => {
+ const cx = colX[j];
+ const headBw = Math.min(colW[j] - 8, 82);
+ const b = document.createElement('button');
+ b.className = 'dupre-key';
+ b.textContent = pgm.glyph || pgm.label;
+ if (pgm.glyph) b.style.fontSize = '15px';
+ b.style.cssText = 'position:absolute;top:8px;left:' + (cx - headBw / 2) + 'px;width:' + headBw +
+ 'px;height:24px;padding:0;text-align:center;font-size:7.5px;letter-spacing:.08em;box-sizing:border-box';
+ b.addEventListener('click', () => { b.blur(); onActivate(j, pgm.id); });
+ wrap.appendChild(b);
+ return b;
+ });
+ /* row labels right-align to the first head's LEFT edge; engraved-and-
+ infilled at faceplate size */
+ const labelX = colX[0] - Math.min(colW[0] - 8, 82) / 2 - 4;
+ ROWS.forEach((r, i) => {
+ const y = Y0 + i * DY + 2;
+ if (opts.engraved) {
+ svgEl(s, 'text', { x: labelX, y: y - 1, 'text-anchor': 'end', 'font-size': 7.6,
+ 'letter-spacing': '.1em', 'font-weight': 600, 'font-family': 'var(--mono)', fill: 'rgba(0,0,0,.8)' }).textContent = r.label;
+ svgEl(s, 'text', { x: labelX, y, 'text-anchor': 'end', 'font-size': 7.6,
+ 'letter-spacing': '.1em', 'font-weight': 600, 'font-family': 'var(--mono)', fill: '#a59d8c' }).textContent = r.label;
+ } else {
+ svgEl(s, 'text', { x: labelX, y, 'text-anchor': 'end', 'font-size': 6,
+ 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = r.label;
+ }
+ });
+ if (opts.groups) {
+ let acc = 0;
+ opts.groups.slice(0, -1).forEach(n => {
+ acc += n;
+ const y = Y0 + (acc - 0.5) * DY;
+ svgEl(s, 'line', { x1: 10, y1: y, x2: W - 10, y2: y, stroke: '#2c2820', 'stroke-width': 1 });
+ });
+ }
+ const caps = {}, letterEls = {};
+ const paint = () => {
+ heads.forEach((b, j) => {
+ b.classList.remove('dupre-on', 'dupre-green', 'dupre-red');
+ if (j === active) b.classList.add('dupre-green');
+ });
+ ROWS.forEach(r => PROGS.forEach((pgm, j) => {
+ const key = r.label + '>' + pgm.id;
+ if (r.values) { /* wheels carry no live highlight — paper doesn't glow */ }
+ else if (caps[key]) {
+ const seated = pins.has(key);
+ if (j === active) {
+ caps[key].setAttribute('fill', seated ? 'url(#dkaJewelG)' : 'url(#dkaJewelR)');
+ caps[key].setAttribute('opacity', 1);
+ caps[key].setAttribute('r', seated ? 5.6 : 4.8);
+ } else {
+ caps[key].setAttribute('fill', 'url(#dkaJewelW)');
+ caps[key].setAttribute('opacity', seated ? 1 : 0);
+ caps[key].setAttribute('r', 5.6);
+ }
+ }
+ }));
+ };
+ ROWS.forEach((r, i) => {
+ const cy = Y0 + i * DY;
+ PROGS.forEach((pgm, j) => {
+ const cx = colX[j], key = r.label + '>' + pgm.id;
+ if (r.values) {
+ svgEl(s, 'rect', { x: cx - 13, y: cy - 10.5, width: 26, height: 21, rx: 3,
+ fill: '#0a0908', stroke: '#3a352c', 'stroke-width': 1.2 });
+ svgEl(s, 'rect', { x: cx - 10, y: cy - 7.5, width: 20, height: 15, fill: 'url(#dkaPaper2)' });
+ const t = svgEl(s, 'text', { x: cx, y: cy + 3.8, 'text-anchor': 'middle', 'font-size': 11,
+ 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#2b2418' });
+ t.textContent = letters[key] !== undefined ? letters[key] : r.values[0];
+ svgEl(s, 'rect', { x: cx - 10, y: cy - 7.5, width: 20, height: 15, fill: 'url(#dkaCurve2)', 'pointer-events': 'none' });
+ letterEls[key] = { t };
+ const hit = svgEl(s, 'rect', { x: cx - 12, y: cy - 10, width: 24, height: 20, fill: 'transparent' });
+ hit.style.cursor = 'pointer';
+ hit.addEventListener('click', () => {
+ const cur = r.values.indexOf(t.textContent);
+ const nv = r.values[(cur + 1) % r.values.length];
+ t.textContent = nv; letters[key] = nv;
+ onLetter(r.label, pgm.id, nv, j === active);
+ });
+ } else {
+ svgEl(s, 'circle', { cx, cy, r: 3.4, fill: '#0a0908', stroke: '#2c2820', 'stroke-width': 1 });
+ const cap = svgEl(s, 'circle', { cx, cy, r: 5.6, fill: 'url(#dkaJewelW)', stroke: 'rgba(0,0,0,.45)', 'stroke-width': 1,
+ opacity: pins.has(key) ? 1 : 0, filter: 'drop-shadow(0 1px 2px rgba(0,0,0,.6))' });
+ caps[key] = cap;
+ const hit = svgEl(s, 'circle', { cx, cy, r: Math.min(9, DX / 2 - 1), fill: 'transparent' });
+ hit.style.cursor = 'pointer';
+ hit.addEventListener('click', () => {
+ const group = radios.find(g => g.includes(r.label));
+ const seated = !pins.has(key);
+ if (group) {
+ if (!seated) return; /* a radio pin moves, never pulls */
+ group.forEach(gr => pins.delete(gr + '>' + pgm.id));
+ pins.add(key);
+ } else {
+ if (seated) pins.add(key); else pins.delete(key);
+ }
+ paint();
+ onPin(r.label, pgm.id, seated, j === active);
+ });
+ }
+ });
+ });
+ paint();
+ return {
+ el: wrap,
+ get: () => [...pins],
+ set: (r, c, on) => { const k = r + '>' + c; if (!caps[k]) return; if (on) pins.add(k); else pins.delete(k); paint(); },
+ setLetter: (r, c, v) => { const k = r + '>' + c; if (letterEls[k]) { letterEls[k].t.textContent = v; letters[k] = v; } },
+ setActive: i => { active = Math.max(-1, Math.min(PROGS.length - 1, i)); paint(); },
+ getActive: () => active,
+ };
+ };
+
+ /* NEW — idle tripper rail. The mechanical time-switch tripper translated
+ from clock time to idle time: a horizontal engraved scale of idle
+ minutes with clamp tabs along it, each engraved with the action it
+ throws when idleness reaches its mark. Nothing moves by itself — the
+ rail is a standing policy, readable left to right. Tabs keep their
+ stage order (they clamp between neighbors), and the siding past the
+ scale end parks a tab OFF (stage disabled). setBypassed(true) dims the
+ whole policy — the caffeine-engaged look.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: stages ([{ id, label, minutes }] in pipeline order; minutes
+ null = parked/disabled); max (scale end, default 60);
+ width (px, default 460); onChange(id, minutes|null, stages
+ snapshot) on every drag commit, including the initial paint
+ (id null on the initial paint).
+ handle: el, get() (stages snapshot), set(id, minutes|null),
+ setBypassed(bool).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage
+ block of DUPRE_CSS; sqrt scale spreads the crowded early minutes. */
+ DUPRE.tripRail = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const MAX = opts.max || 60;
+ const W = opts.width || 460;
+ const H = 86;
+ const stages = (opts.stages || []).map(st => ({ id: st.id, label: st.label, minutes: st.minutes }));
+ const x0 = 16, x1 = W - 64, railY = 44;
+ const sideX = x1 + 14;
+ const xOf = m => x0 + Math.sqrt(Math.max(0, m) / MAX) * (x1 - x0);
+ const mOf = x => Math.pow(Math.max(0, x - x0) / (x1 - x0), 2) * MAX;
+ grad('dkaTabM', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]);
+ const s = stageSvg(host, 'rsvg', W, H);
+ const all = svgEl(s, 'g', {});
+ /* rail groove + sqrt tick scale */
+ svgEl(all, 'rect', { x: x0 - 8, y: railY - 4, width: x1 - x0 + 16, height: 8, rx: 3, fill: '#0d0a08', stroke: '#050404' });
+ [0, 1, 2, 5, 10, 15, 20, 30, 45, 60].filter(m => m <= MAX).forEach(m => {
+ const x = xOf(m);
+ svgEl(all, 'line', { x1: x, y1: railY + 6, x2: x, y2: railY + 10, stroke: 'var(--steel)', 'stroke-width': .9, opacity: .8 });
+ svgEl(all, 'text', { x, y: railY + 18, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--steel)', opacity: .8 }).textContent = m;
+ });
+ svgEl(all, 'text', { x: (x0 + x1) / 2, y: railY + 28, 'text-anchor': 'middle', 'font-size': 5.5,
+ 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }).textContent = 'MINUTES IDLE';
+ /* parking siding */
+ svgEl(all, 'rect', { x: sideX - 4, y: railY - 7, width: 46, height: 14, rx: 4, fill: 'none', stroke: '#2c2820', 'stroke-width': 1, 'stroke-dasharray': '3 2' });
+ svgEl(all, 'text', { x: sideX + 19, y: railY + 20, 'text-anchor': 'middle', 'font-size': 5.5,
+ 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }).textContent = 'OFF';
+ const tabs = stages.map((st) => {
+ const g = svgEl(s, 'g', {});
+ const line = svgEl(g, 'line', { x1: 0, y1: 0, x2: 0, y2: 0, stroke: '#3a352c', 'stroke-width': 1 });
+ const lbl = svgEl(g, 'text', { x: 0, y: 0, 'text-anchor': 'middle', 'font-size': 6,
+ 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: '#a59d8c' });
+ lbl.textContent = st.label;
+ const min = svgEl(g, 'text', { x: 0, y: 0, 'text-anchor': 'middle', 'font-size': 5.5,
+ 'font-family': 'var(--mono)', fill: 'var(--gold)' });
+ svgEl(g, 'rect', { x: -5, y: railY - 8, width: 10, height: 16, rx: 2, fill: 'url(#dkaTabM)', stroke: '#5e5a52', 'stroke-width': .7 });
+ svgEl(g, 'rect', { x: -1, y: railY - 5, width: 2, height: 10, fill: 'rgba(0,0,0,.3)' });
+ g.style.cursor = 'ew-resize';
+ return { g, line, lbl, min, st };
+ });
+ const paint = () => {
+ /* flag sides alternate among ACTIVE tabs so neighbors never share a
+ line even when parking breaks the built order */
+ let rank = 0;
+ tabs.forEach(t => {
+ const parked = t.st.minutes === null || t.st.minutes === undefined;
+ const up = parked ? true : (rank++ % 2 === 0);
+ const flagY = up ? 14 : railY + 34;
+ t.line.setAttribute('y1', up ? flagY + 3 : railY + 6);
+ t.line.setAttribute('y2', up ? railY - 6 : flagY - 8);
+ t.lbl.setAttribute('y', flagY);
+ t.min.setAttribute('y', up ? flagY + 8 : flagY - 12);
+ const x = parked ? sideX + 6 + tabs.filter(o => (o.st.minutes == null) && stages.indexOf(o.st) < stages.indexOf(t.st)).length * 12 : xOf(t.st.minutes);
+ t.g.setAttribute('transform', 'translate(' + x + ',0)');
+ t.min.textContent = parked ? 'off' : Math.round(t.st.minutes) + 'm';
+ t.g.setAttribute('opacity', parked ? .55 : 1);
+ });
+ };
+ /* drag: clamp between neighboring active stages; past the scale end parks */
+ tabs.forEach((t, i) => {
+ t.g.addEventListener('pointerdown', e => {
+ t.g.setPointerCapture(e.pointerId);
+ const move = m => {
+ const r = s.getBoundingClientRect();
+ const px = (m.clientX - r.left) * (W / r.width);
+ if (px > x1 + 8) { t.st.minutes = null; paint(); return; }
+ let mm = Math.round(mOf(Math.min(px, x1)) * 2) / 2;
+ const prev = tabs.slice(0, i).reverse().find(o => o.st.minutes != null);
+ const next = tabs.slice(i + 1).find(o => o.st.minutes != null);
+ if (prev) mm = Math.max(mm, prev.st.minutes + 0.5);
+ if (next) mm = Math.min(mm, next.st.minutes - 0.5);
+ t.st.minutes = Math.max(0.5, Math.min(MAX, mm));
+ /* exact-number counter: the dial's center reads the dragged value */
+ centerTxt.textContent = (t.st.minutes % 1 ? t.st.minutes.toFixed(1) : Math.round(t.st.minutes)) + 'm';
+ paint();
+ };
+ const up = () => {
+ t.g.removeEventListener('pointermove', move);
+ t.g.removeEventListener('pointerup', up);
+ centerTxt.textContent = 'IDLE';
+ onChange(t.st.id, t.st.minutes, stages.map(o => ({ ...o })));
+ };
+ t.g.addEventListener('pointermove', move);
+ t.g.addEventListener('pointerup', up);
+ e.preventDefault();
+ });
+ });
+ const stamp = svgEl(s, 'text', { x: (x0 + x1) / 2, y: 12, 'text-anchor': 'middle', 'font-size': 6.5,
+ 'letter-spacing': '.22em', 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: 0 });
+ stamp.textContent = 'BYPASSED · CAFFEINE';
+ let bypassed = false;
+ paint();
+ onChange(null, null, stages.map(o => ({ ...o })));
+ return {
+ el: s,
+ get: () => stages.map(o => ({ ...o })),
+ set: (id, m) => { const t = stages.find(o => o.id === id); if (t) { t.minutes = m; paint(); } },
+ setBypassed: b => {
+ bypassed = !!b;
+ all.setAttribute('opacity', bypassed ? .3 : 1);
+ tabs.forEach(t => t.g.setAttribute('opacity', bypassed ? .3 : (t.st.minutes == null ? .55 : 1)));
+ stamp.setAttribute('opacity', bypassed ? 1 : 0);
+ },
+ };
+ };
+
+ /* NEW — idle tripper dial. The mechanical segment-timer dial (Intermatic
+ idiom) translated from clock time to idle time: a circular sqrt-spaced
+ minutes scale sweeping 300° with COLORED tripper tabs clamped on the
+ rim — drag a tab around the rim to retune when its stage throws; drag
+ it into the bottom OFF notch to disable the stage. A side legend maps
+ color → stage → minutes. Nothing self-moves: the dial is a standing
+ policy. setBypassed(true) dims the face under a BYPASSED · CAFFEINE
+ stamp.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: stages ([{ id, label, color, minutes }] in pipeline order;
+ minutes null = parked in the OFF notch); max (scale end,
+ default 60); size (dial diameter px, default 132); legend
+ (true draws the color/label/minutes legend — legendPos
+ 'right' default, or 'bottom' beneath the dial);
+ onChange(id, minutes|null, stages snapshot) on every drag
+ commit, plus once at build (id null).
+ handle: el, get() (snapshot), set(id, minutes|null),
+ setBypassed(bool).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage
+ block of DUPRE_CSS. */
+ DUPRE.tripDial = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const MAX = opts.max || 60;
+ const SIZE = opts.size || 132;
+ const stages = (opts.stages || []).map(st => ({ ...st }));
+ const LEG_BOTTOM = opts.legend && opts.legendPos === 'bottom';
+ const LEG_W = opts.legend && !LEG_BOTTOM ? 96 : 0;
+ const W = SIZE + LEG_W + 8;
+ const H = SIZE + 8 + (LEG_BOTTOM ? stages.length * 13 + 8 : 0);
+ const cx = SIZE / 2 + 4, cy = SIZE / 2 + 4;
+ const R = SIZE / 2 - 4; /* bezel radius */
+ const TR = R - 7; /* tab track radius */
+ const START = -150, SWEEP = 300; /* bottom 60° = the OFF notch */
+ const aOf = m => START + Math.sqrt(Math.max(0, m) / MAX) * SWEEP;
+ const mOf = a => Math.pow((a - START) / SWEEP, 2) * MAX;
+ const rad = a => (a - 90) * Math.PI / 180;
+ const at = (r, a) => [cx + r * Math.cos(rad(a)), cy + r * Math.sin(rad(a))];
+ const s = stageSvg(host, 'rsvg', W, H);
+ const all = svgEl(s, 'g', {});
+ svgEl(all, 'circle', { cx, cy, r: R, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
+ svgEl(all, 'circle', { cx, cy, r: R - 14, fill: '#100e0a', stroke: '#2a231c', 'stroke-width': 1 });
+ /* sqrt scale ticks + numerals */
+ [0, 1, 2, 5, 10, 15, 20, 30, 45, 60].filter(m => m <= MAX).forEach(m => {
+ const a = aOf(m);
+ const [x1, y1] = at(R - 2, a), [x2, y2] = at(R - 8, a);
+ svgEl(all, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': .9, opacity: .75 });
+ const [tx, ty] = at(R - 20, a);
+ svgEl(all, 'text', { x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 6.2,
+ 'font-family': 'var(--mono)', fill: 'var(--cream)', opacity: .85 }).textContent = m;
+ });
+ /* OFF notch at the bottom gap */
+ const [nx, ny] = at(R - 20, 180);
+ svgEl(all, 'text', { x: nx, y: ny + 2, 'text-anchor': 'middle', 'font-size': 5.5,
+ 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }).textContent = 'OFF';
+ const centerTxt = svgEl(all, 'text', { x: cx, y: cy + 3, 'text-anchor': 'middle', 'font-size': 9,
+ 'letter-spacing': '.2em', 'font-family': 'var(--mono)', fill: 'var(--cream)', opacity: .85 });
+ centerTxt.textContent = 'IDLE';
+ /* legend */
+ const legendEls = {};
+ if (opts.legend) {
+ stages.forEach((st, i) => {
+ const lx = LEG_BOTTOM ? 14 : SIZE + 12;
+ const ly = LEG_BOTTOM ? SIZE + 12 + i * 13 : 18 + i * 18;
+ const lw = LEG_BOTTOM ? W - 14 : W;
+ svgEl(s, 'rect', { x: lx, y: ly - 6, width: 8, height: 8, rx: 2, fill: st.color, stroke: 'rgba(0,0,0,.5)' });
+ svgEl(s, 'text', { x: lx + 13, y: ly + 1, 'font-size': 6, 'letter-spacing': '.06em',
+ 'font-family': 'var(--mono)', fill: '#a59d8c' }).textContent = st.label;
+ legendEls[st.id] = svgEl(s, 'text', { x: lw - 4, y: ly + 1, 'text-anchor': 'end', 'font-size': 6,
+ 'font-family': 'var(--mono)', fill: 'var(--gold)' });
+ });
+ }
+ /* tabs: colored clamps riding the rim */
+ const tabs = stages.map(st => {
+ const g = svgEl(s, 'g', {});
+ svgEl(g, 'rect', { x: -4.5, y: -TR - 8, width: 9, height: 15, rx: 2, fill: st.color, stroke: 'rgba(0,0,0,.55)', 'stroke-width': 1 });
+ svgEl(g, 'rect', { x: -1, y: -TR - 5, width: 2, height: 9, fill: 'rgba(0,0,0,.3)' });
+ g.style.cursor = 'grab';
+ return { g, st };
+ });
+ const paint = () => {
+ const parkedIds = stages.filter(o => o.minutes == null).map(o => o.id);
+ tabs.forEach(t => {
+ const parked = t.st.minutes == null;
+ const a = parked ? 172 + parkedIds.indexOf(t.st.id) * 9 : aOf(t.st.minutes);
+ t.g.setAttribute('transform', 'translate(' + cx + ',' + cy + ') rotate(' + a + ')');
+ t.g.setAttribute('opacity', parked ? .55 : 1);
+ if (legendEls[t.st.id]) legendEls[t.st.id].textContent = parked ? 'off'
+ : (t.st.minutes % 1 ? t.st.minutes.toFixed(1) : Math.round(t.st.minutes)) + 'm';
+ });
+ };
+ tabs.forEach((t, i) => {
+ t.g.addEventListener('pointerdown', e => {
+ t.g.setPointerCapture(e.pointerId);
+ const move = m => {
+ const r = s.getBoundingClientRect();
+ const px = (m.clientX - r.left) * (W / r.width) - cx;
+ const py = (m.clientY - r.top) * (H / r.height) - cy;
+ let a = Math.atan2(px, -py) * 180 / Math.PI; /* 0 up, cw */
+ if (a > 150 || a < -150) { t.st.minutes = null; paint(); return; }
+ let mm = Math.round(mOf(Math.max(START, Math.min(START + SWEEP, a))) * 2) / 2;
+ const prev = tabs.slice(0, i).reverse().find(o => o.st.minutes != null);
+ const next = tabs.slice(i + 1).find(o => o.st.minutes != null);
+ if (prev) mm = Math.max(mm, prev.st.minutes + 0.5);
+ if (next) mm = Math.min(mm, next.st.minutes - 0.5);
+ t.st.minutes = Math.max(0.5, Math.min(MAX, mm));
+ /* exact-number counter: the dial's center reads the dragged value */
+ centerTxt.textContent = (t.st.minutes % 1 ? t.st.minutes.toFixed(1) : Math.round(t.st.minutes)) + 'm';
+ paint();
+ };
+ const up = () => {
+ t.g.removeEventListener('pointermove', move);
+ t.g.removeEventListener('pointerup', up);
+ centerTxt.textContent = 'IDLE';
+ onChange(t.st.id, t.st.minutes, stages.map(o => ({ ...o })));
+ };
+ t.g.addEventListener('pointermove', move);
+ t.g.addEventListener('pointerup', up);
+ e.preventDefault();
+ });
+ });
+ const stamp = svgEl(s, 'text', { x: cx, y: cy - 14, 'text-anchor': 'middle', 'font-size': 5.5,
+ 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: 0 });
+ stamp.textContent = 'BYPASSED';
+ paint();
+ onChange(null, null, stages.map(o => ({ ...o })));
+ return {
+ el: s,
+ get: () => stages.map(o => ({ ...o })),
+ set: (id, m) => { const t = stages.find(o => o.id === id); if (t) { t.minutes = m; paint(); } },
+ setBypassed: b => {
+ all.setAttribute('opacity', b ? .3 : 1);
+ tabs.forEach(t => t.g.setAttribute('opacity', b ? .3 : (t.st.minutes == null ? .55 : 1)));
+ stamp.setAttribute('opacity', b ? 1 : 0);
+ },
+ };
+ };
+})();