From f1c661f50d6af2a115fc1430ae0478f2acef2dec Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 22 Jul 2026 15:15:02 -0500 Subject: docs(settings-panel): seal the prototype arc and open the build Prototypes P12-P37 land, along with the night-watch and levels-compare pages plus the shared-engine updates and the kit-candidate file. The spec flips from DRAFT through READY to DOING with rewritten implementation phases and seven recorded build findings. The build tasks are filed in todo.org, and phase 1 is closed against the dotfiles commits. --- docs/prototypes/dupre-kit-additions.js | 1256 ++++++++++++++++++++++++++++++++ 1 file changed, 1256 insertions(+) create mode 100644 docs/prototypes/dupre-kit-additions.js (limited to 'docs/prototypes/dupre-kit-additions.js') diff --git a/docs/prototypes/dupre-kit-additions.js b/docs/prototypes/dupre-kit-additions.js new file mode 100644 index 0000000..005a577 --- /dev/null +++ b/docs/prototypes/dupre-kit-additions.js @@ -0,0 +1,1256 @@ +/* dupre-kit-additions.js — kit-candidate builders developed in the + * desktop-settings casting work, written to the widgets.js conventions + * (DUPRE.name(host, opts) → {el, get, set, ...}; onChange(value, text) on + * every state change including the initial paint; no page globals touched) + * so they can merge back into the kit with gallery cards. + * + * Load AFTER widgets.js. Contents: + * - DUPRE.detentFader (NEW) — multi-detent slide attenuator with + * speedbump drag physics. + * - DUPRE.drumRoller (UPGRADE) — backward-compatible redefinition: + * 1..N channels (stock hardcodes exactly two), configurable min/max + * range and stage height. Defaults reproduce the stock instrument. + * - DUPRE.guardedToggle (UPGRADE) — rotateX throw (no planar spin). + * - DUPRE.batToggle (UPGRADE) — same throw fix. + * - DUPRE.slideRule (UPGRADE) — width option; the printed scale + * spreads its stops proportionally. Default keeps stock 180px. + * - DUPRE.slideSelector (NEW) — stretchable n-position slide switch + * with a status lamp per position (pillSlide's full-size descendant). + * - DUPRE.programChart (NEW) — faceplate program matrix / punch card. + * - DUPRE.thumbSlideH (NEW) — horizontal thumb-slide attenuator; + * continuous 0-100 or detented positions (thumbSlide turned on its + * side, one channel, stretchable). + * - DUPRE.pinMatrix (UPGRADE) — configurable geometry, per-column + * pin colors, clickable button column-heads, group dividers, set(). + * Defaults reproduce the stock 5×6 synth patch exactly. + * - DUPRE.programMatrix (NEW) — the programmable matrix: programs as + * column heads, settings as rows, white pins program, the ACTIVE + * column's pins run green because the recalled program IS the live + * state. No separate live column needed. + * - DUPRE.tripRail (NEW) — idle tripper rail: a time-switch tripper + * scale for "when idle reaches here, throw this" — draggable stage + * tabs on a sqrt-spaced minutes rail, with a parking siding to + * disable a stage and a bypass state (caffeine dims the policy). + * - DUPRE.tripDial (NEW) — the round form Craig actually imagined: + * the Intermatic segment-timer dial with colored tripper tabs on the + * rim, an OFF notch at the bottom, and a side legend. Same policy + * semantics as the rail. + */ +(() => { + const noop = () => {}; + const SVGNS = 'http://www.w3.org/2000/svg'; + + /* local mini-helpers (widgets.js keeps its own private; additions carry + theirs so the file is self-contained until merged) */ + const svgEl = (parent, tag, attrs) => { + const e = document.createElementNS(SVGNS, tag); + for (const k in (attrs || {})) e.setAttribute(k, attrs[k]); + parent.appendChild(e); return e; + }; + const stageSvg = (host, cls, vw, vh) => { + const s = document.createElementNS(SVGNS, 'svg'); + s.setAttribute('class', cls); s.setAttribute('viewBox', `0 0 ${vw} ${vh}`); + s.setAttribute('width', vw); s.setAttribute('height', vh); + host.appendChild(s); return s; + }; + let uidN = 0; + const uid = p => p + '-a' + (++uidN); + + /* additions-scoped gradients (ids prefixed so they never collide with the + kit's shared defs plate; on merge these fold into gradDef) */ + let defsRoot = null; + const grad = (id, kind, geom, stops) => { + if (!defsRoot) { + const svg = document.createElementNS(SVGNS, 'svg'); + svg.setAttribute('width', 0); svg.setAttribute('height', 0); + svg.setAttribute('aria-hidden', 'true'); + svg.style.position = 'absolute'; + defsRoot = document.createElementNS(SVGNS, 'defs'); + svg.appendChild(defsRoot); + document.body.appendChild(svg); + } + if (defsRoot.querySelector('#' + id)) return; + const g = document.createElementNS(SVGNS, kind); + g.setAttribute('id', id); + for (const k in geom) g.setAttribute(k, geom[k]); + stops.forEach(([o, c]) => { + const st = document.createElementNS(SVGNS, 'stop'); + st.setAttribute('offset', o); st.setAttribute('stop-color', c); + g.appendChild(st); + }); + defsRoot.appendChild(g); + }; + + /* vertical drag with delta accumulation (the kit's dragDelta shape) */ + const dragDelta = (el, getV, setV, o) => { + el.style.touchAction = 'none'; + el.addEventListener('pointerdown', e => { + el.setPointerCapture(e.pointerId); + const y0 = e.clientY, v0 = getV(); + const move = m => setV(Math.max(o.min, Math.min(o.max, v0 + (y0 - m.clientY) * o.sens))); + const up = () => { el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); }; + el.addEventListener('pointermove', move); + el.addEventListener('pointerup', up); + e.preventDefault(); + }); + }; + + /* additions CSS, injected once (the kit pattern: DUPRE_CSS ships inside + widgets.js; these rules move into it on merge) */ + if (!document.getElementById('dupre-additions-css')) { + const st = document.createElement('style'); + st.id = 'dupre-additions-css'; + st.textContent = ` +.dupre-dfader{position:relative;display:inline-block;width:230px;height:36px;cursor:ew-resize;touch-action:none} +.dupre-dfader .df-slot{position:absolute;left:8px;right:8px;top:15px;height:6px;border-radius:4px; + background:var(--well,#0a0c0d);border:1px solid #0a0908;box-shadow:inset 0 1px 2px #000} +.dupre-dfader .df-fill{position:absolute;left:9px;top:16px;height:4px;border-radius:3px; + background:linear-gradient(180deg,var(--amber-grad-top,#f2c76a),var(--gold,#e2a038));pointer-events:none} +.dupre-dfader .df-tick{position:absolute;top:9px;width:2px;height:18px;border-radius:1px; + background:var(--steel,#969385);opacity:.55;transform:translateX(-50%);pointer-events:none} +.dupre-dfader .df-tick.df-park{background:var(--gold-hi,#ffbe54);opacity:1; + box-shadow:0 0 5px rgba(var(--glow-hi,255,190,84),.7)} +.dupre-dfader .df-cap{position:absolute;top:5px;width:12px;height:26px;border-radius:3px;transform:translateX(-50%); + background:linear-gradient(90deg,#7e7a70,#e8e5db 45%,#8a867c);border:1px solid #4e4a42; + box-shadow:0 2px 4px #000a;pointer-events:none} +.dupre-dfader .df-cap::after{content:"";position:absolute;left:50%;top:3px;bottom:3px;width:2px; + transform:translateX(-50%);background:rgba(0,0,0,.35);border-radius:1px} +.dupre-dfader.df-parked .df-cap{box-shadow:0 2px 4px #000a,0 0 7px rgba(var(--glow-hi,255,190,84),.55)} +.dupre-slsel{display:block;width:220px;cursor:pointer;touch-action:none;user-select:none} +.dupre-slsel .sl-cols{display:grid} +.dupre-slsel .sl-col{display:flex;flex-direction:column;align-items:center;gap:3px;padding-bottom:5px} +.dupre-slsel .sl-lamp{width:11px;height:11px;border-radius:50%; + background:radial-gradient(circle at 38% 32%,#2e2a24,#171310);border:1px solid #060505} +.dupre-slsel .sl-col.sl-on .sl-lamp{background:radial-gradient(circle at 38% 32%,var(--gold-hi,#ffbe54),var(--gold,#e2a038) 60%,#8f671f); + box-shadow:0 0 7px rgba(var(--glow-hi,255,190,84),.75)} +.dupre-slsel .sl-lbl{font-size:8px;letter-spacing:.1em;color:var(--dim,#7c838a);text-transform:uppercase;font-family:var(--mono)} +.dupre-slsel .sl-col.sl-on .sl-lbl{color:var(--gold-hi,#ffbe54)} +.dupre-slsel .sl-track{position:relative;height:20px;border-radius:10px; + background:var(--well,#0a0c0d);border:1px solid #0a0908;box-shadow:inset 0 2px 4px #000} +.dupre-slsel .sl-knob{position:absolute;top:2px;bottom:2px;border-radius:8px;transform:translateX(-50%); + background:linear-gradient(180deg,#f0efec,#c4c1b9 45%,#75726a);border:1px solid #4e4a42; + box-shadow:0 2px 4px #000a;transition:left .16s ease} +.dupre-slsel .sl-knob::after{content:"";position:absolute;left:50%;top:4px;bottom:4px;width:2px; + transform:translateX(-50%);background:rgba(0,0,0,.3);border-radius:1px} +.dupre-rocker.dupre-quiet:not(.dupre-on) .dupre-bot{background:linear-gradient(180deg,#141210,#100e0c); + color:var(--dim,#7c838a);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)} +.dupre-rocker.dupre-green.dupre-on .dupre-top{background:linear-gradient(180deg,#a9c95f,var(--pass,#74932f)); + color:var(--panel,#100f0f);box-shadow:0 0 10px rgba(116,147,47,.35)} +.dupre-slsel.sl-dark .sl-knob{background:linear-gradient(180deg,#33302a,#1c1a16);border-color:#3f3b33} +.dupre-slsel.sl-dark .sl-knob::after{background:rgba(255,255,255,.16)} +.dupre-pchart{display:block;font-family:var(--mono);pointer-events:none} +.dupre-pchart .pc-grid{display:grid;row-gap:2px} +.dupre-pchart .pc-cell{display:flex;align-items:center;justify-content:center;height:14px} +.dupre-pchart .pc-h{font-size:6.5px;letter-spacing:.08em;color:var(--steel,#969385);text-transform:uppercase} +.dupre-pchart .pc-name{font-size:7.5px;letter-spacing:.06em;color:var(--dim,#7c838a); + text-transform:uppercase;justify-content:flex-end;padding-right:6px} +.dupre-pchart .pc-dot{width:6px;height:6px;border-radius:50%} +.dupre-pchart .pc-dot.pc-set{background:#cfd3cf} +.dupre-pchart .pc-dot.pc-clear{border:1px solid var(--steel,#969385);opacity:.6} +.dupre-pchart .pc-dash{width:6px;height:1px;background:var(--wash,#2c2f32)} +.dupre-pchart .pc-pwr{font-size:7.5px;color:var(--steel,#969385)} +.dupre-pchart .pc-row-on .pc-name{color:#f2f4f2} +.dupre-pchart .pc-row-on .pc-dot.pc-set{background:#f2f4f2; + box-shadow:0 0 5px rgba(242,244,242,.6)} +.dupre-pchart .pc-row-on .pc-pwr{color:#f2f4f2} +.dupre-pchart.pc-edit{pointer-events:auto} +.dupre-pchart.pc-edit .pc-cell:not(.pc-h):not(.pc-name){cursor:pointer;border-radius:4px} +.dupre-pchart.pc-edit .pc-cell:not(.pc-h):not(.pc-name):hover{background:#ffffff10} +`; + document.head.appendChild(st); + } + + /* NEW — multi-detent slide attenuator: a horizontal fader whose thumb + parks at each detent; the pointer must push an escape margin past the + detent to break free (a speedbump, not a snap). Crossfader's center + detent generalized to a set. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 68); detents (values 0-100, default [50] — + the crossfader case); magnet (± units that capture the thumb, + default 3); escape (extra units past the magnet edge needed to + break out, default 3); onChange(v, label) on every change, + label 'N' or 'N · detent' while parked, including the initial + paint. + handle: el, get(), set(v) (clamped 0-100; set() obeys detent capture + so a programmatic sweep feels the same bumps), parked() + (the detent value or null). + CSS lives in the additions block (.dupre-dfader); no other styles + involved. */ + DUPRE.detentFader = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const detents = (opts.detents || [50]).slice().sort((a, b) => a - b); + const MAGNET = opts.magnet !== undefined ? opts.magnet : 3; + const ESCAPE = opts.escape !== undefined ? opts.escape : 3; + const el = document.createElement('span'); el.className = 'dupre-dfader'; + el.innerHTML = ''; + 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); + }, + }; + }; +})(); -- cgit v1.2.3