/* 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 = ''; 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 = `${lbl}`; 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 ? `${sp.v}` : ``); }); 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 = `${opts.onLabel !== undefined ? opts.onLabel : 'ON'}${opts.offLabel !== undefined ? opts.offLabel : 'OFF'}`; 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 = ''; else if (v === false) c.innerHTML = ''; else if (typeof v === 'string') { c.classList.add('pc-pwr'); c.textContent = v; } else c.innerHTML = ''; }; 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); }, }; }; })();