/* 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);
},
};
};
})();