From 2ad81d10fc0daf7f56ae2b7e00063893f35203d4 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 18 Jul 2026 11:35:51 -0500 Subject: feat(gallery): bring the 12 waybar-bound builders to the extraction bar Each builder now carries a contract comment in the splitFlap shape (opts, handle, where its CSS lives). I prefixed every batch-owned class with dupre- so page chrome can't collide with instrument internals. The audit's C1 moves to 14/111, the .txt collision is gone, and .on clears as its owners' batches land. jewels also gets a real handle. Its state lived only in classList/--jc, so nothing could read or drive the lamps (a dead end for the waybar port). I moved state into the builder, made the DOM a paint of it, and added get/set. lampRow and outputWell consume the shared lamp class, so they map their state fragments to the prefixed names internally. Their opts APIs are unchanged. audit-extraction.mjs is the report-only grader for the bar (contract comments, page-styled internals, page reach-ins). It gates nothing until the sweep reaches 100%. --- docs/prototypes/widgets.js | 304 +++++++++++++++++++++++++++++++-------------- 1 file changed, 208 insertions(+), 96 deletions(-) (limited to 'docs/prototypes') diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 2766353..e6f02ce 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -801,13 +801,13 @@ DUPRE.lampRow = function (host, opts = {}) { const name = opts.name || 'WH-1000XM4'; const states = opts.states || [['gold', 'tap to connect'], ['busy', 'connecting…'], ['', 'connected']]; const row = document.createElement('div'); row.className = 'lrow'; - row.innerHTML = ``; + row.innerHTML = ``; row.querySelector('b').textContent = name; host.appendChild(row); let idx; const set = i => { idx = i; - row.querySelector('.lamp').className = 'lamp ' + states[i][0]; + row.querySelector('.dupre-lamp').className = 'dupre-lamp' + (states[i][0] ? ' dupre-' + states[i][0] : ''); row.querySelector('.what').textContent = states[i][1]; onChange(i, states[i][1]); }; @@ -1666,7 +1666,17 @@ DUPRE.waveRegion = function (host, opts = {}) { return { el: s, get: () => ({ s: S, e: E }), set }; }; -/* R20 drum roller selector — numbered paper drum in a window, center chip reads it */ +/* R20 drum roller selector — numbered paper drum in a window, center chip reads it. + + Contract (everything a consumer needs; no page globals touched): + opts: title (engraved header, default 'EQUALIZER'); channels (array of + { name, v } drums, v 1-10, default HIGH/LOW pair — the layout is + sized for two); onChange(values array, 'NAME v · NAME v') fires on + every set, including the initial paint. + handle: el, get() (values array), set(i, v) — v clamps 1-10; each drum + drags vertically on its own hit strip. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.drumRoller = function (host, opts = {}) { const onChange = opts.onChange || noop; const chans = (opts.channels || [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }]).map(c => ({ name: c.name, v: c.v })); @@ -2507,16 +2517,23 @@ DUPRE.voiceLoop = function (host, opts = {}) { onChange(value, text) like every other builder. Display-side state that belongs to the instrument (peak-hold, history buffers) lives in here. */ -/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc */ +/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 50); onChange(value, 'value N%') fires on + every set, including the initial paint. + handle: el, get() (current value), set(v) — no clamping beyond the + drag helper's 0-100; the needle maps value linearly to ±60°. + CSS lives in the "needle gauge" block of DUPRE_CSS; no other styles involved. */ DUPRE.needleGauge = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 50; - const el = document.createElement('div'); el.className = 'gauge'; - el.innerHTML = `
-
-
0%
`; + const el = document.createElement('div'); el.className = 'dupre-gauge'; + el.innerHTML = `
+
+
0%
`; host.appendChild(el); - const ndl = el.querySelector('.ndl'), num = el.querySelector('.gv span'); + const ndl = el.querySelector('.dupre-ndl'), num = el.querySelector('.dupre-gv span'); function set(nv) { v = nv; ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`; @@ -2528,23 +2545,33 @@ DUPRE.needleGauge = function (host, opts = {}) { return { el, get: () => v, set }; }; -/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both */ +/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both. + + Contract (everything a consumer needs; no page globals touched): + opts: bars (cells per channel, default 16); onChange([l, r], 'L n · R n') + fires on every set (values 0-1, readout in percent). + handle: el, get() ([l, r] last set), set(l, r). Peak-hold is display-side + state owned in here: each channel's peak cell decays 0.4 cells + per set() call, so the page's tick cadence is the decay clock + (the tick contract — the page owns the clock and the signal). + CSS lives in the "segmented VU / LED bar" block of DUPRE_CSS; no other + styles involved. */ DUPRE.vuPair = function (host, opts = {}) { const onChange = opts.onChange || noop; const n = opts.bars || 16; - const el = document.createElement('div'); el.className = 'vu'; - el.innerHTML = `
L
-
R
`; + const el = document.createElement('div'); el.className = 'dupre-vu'; + el.innerHTML = `
L
+
R
`; host.appendChild(el); - const bars = el.querySelectorAll('.vubar'); + const bars = el.querySelectorAll('.dupre-vubar'); bars.forEach(b => buildBars(b, n)); const pkL = { v: 0 }, pkR = { v: 0 }; function paint(bar, l, pk) { const b = bar.children, lit = Math.round(l * n); pk.v = Math.max(lit, (pk.v || 0) - 0.4); const p = Math.round(pk.v); for (let k = 0; k < n; k++) { - let c = k < lit ? (k >= n - 2 ? 'clip' : k >= n - 4 ? 'hot' : 'on') : ''; - if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'peak'; + let c = k < lit ? (k >= n - 2 ? 'dupre-clip' : k >= n - 4 ? 'dupre-hot' : 'dupre-on') : ''; + if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'dupre-peak'; b[k].className = c; } } @@ -2573,11 +2600,19 @@ DUPRE.miniSig = function (host, opts = {}) { return { el, get: () => v, set }; }; -/* 13 signal ladder — stepped 0-4 strength; click cycles */ +/* 13 signal ladder — stepped 0-4 strength; click cycles. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-4, default 3); onChange(v, 'v/4') fires on every set, + including the initial paint. + handle: el, get() (current step), set(v) — bars at or below v light + gold; click advances (v + 1) % 5. + CSS lives in the "signal ladder" block of DUPRE_CSS; the lit/unlit bar + colours are inline (--gold / --wash), no other styles involved. */ DUPRE.signalLadder = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 3; - const el = document.createElement('span'); el.className = 'ladder'; + const el = document.createElement('span'); el.className = 'dupre-ladder'; el.innerHTML = ''; host.appendChild(el); function set(nv) { @@ -2895,7 +2930,16 @@ DUPRE.mcVu = function (host, opts = {}) { return { el: s, get: () => t, set }; }; -/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down */ +/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 50); onChange(value, '+x.x dB') fires on + every set — the readout maps the linear value through the shared + VU law (VUDB/vuDb), like every VU-family meter. + handle: el, get() (current value), set(v) — the needle sweeps -40°..+40° + over the clamped 0-1 position. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the face clip is a per-instance defs entry. */ DUPRE.roundMeter = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 104); @@ -3501,10 +3545,19 @@ DUPRE.comfortMeter = function (host, opts = {}) { /* ================= indicators & readouts ================= */ -/* 18 status lamps — one lamp per health state; click any lamp to cycle it */ +/* 18 status lamps — one lamp per health state; click any lamp to cycle it. + + Contract (everything a consumer needs; no page globals touched): + opts: states (array of state indices, default [0,1,2,3,4] — one lamp + per entry; 0 ok · 1 engaged · 2 fault · 3 off · 4 busy); + onChange(states array copy, state name) fires per change and once + at build with a count summary. + handle: el, get() (states copy), set(i, st) — st wraps modulo 5. + CSS lives in the "shared primitives" .dupre-lamp block of DUPRE_CSS + (shared with the output well's step lamps); no other styles involved. */ DUPRE.statusLamps = function (host, opts = {}) { const onChange = opts.onChange || noop; - const CLS = ['lamp', 'lamp gold', 'lamp red', 'lamp off', 'lamp busy']; + const CLS = ['dupre-lamp', 'dupre-lamp dupre-gold', 'dupre-lamp dupre-red', 'dupre-lamp dupre-off', 'dupre-lamp dupre-busy']; const NAMES = ['ok', 'engaged', 'fault', 'off', 'busy']; const states = (opts.states || [0, 1, 2, 3, 4]).slice(); const wrap = document.createElement('span'); @@ -3592,7 +3645,7 @@ DUPRE.outputWell = function (host, opts = {}) { const w = document.createElement('div'); w.className = 'owell'; host.appendChild(w); const add = s => { const d = document.createElement('div'); d.className = 'ostep'; - d.innerHTML = `${s[1]}${s[2]}`; + d.innerHTML = `${s[1]}${s[2]}`; w.appendChild(d); while (w.children.length > keep) w.removeChild(w.firstChild); }; seed.forEach(add); @@ -3621,14 +3674,22 @@ DUPRE.toast = function (host, opts = {}) { return { el: t, fire }; }; -/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments */ +/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments. + + Contract (everything a consumer needs; no page globals touched): + opts: digits (tube count, default 2); value (initial, default 8); + onChange(value, zero-padded string) fires on every set. + handle: el, get() (current value), set(v) — wraps modulo 10^digits; + leading-zero tubes go dark rather than showing 0, like the + hardware. + CSS lives in the "nixie tube" block of DUPRE_CSS; no other styles involved. */ DUPRE.nixie = function (host, opts = {}) { const onChange = opts.onChange || noop; const digits = opts.digits || 2; const max = Math.pow(10, digits); - const nx = document.createElement('span'); nx.className = 'nixie'; + const nx = document.createElement('span'); nx.className = 'dupre-nixie'; for (let i = 0; i < digits; i++) { - const tu = document.createElement('span'); tu.className = 'tube'; tu.innerHTML = ''; nx.appendChild(tu); + const tu = document.createElement('span'); tu.className = 'dupre-tube'; tu.innerHTML = ''; nx.appendChild(tu); } host.appendChild(nx); let v; @@ -3636,7 +3697,7 @@ DUPRE.nixie = function (host, opts = {}) { v = ((x % max) + max) % max; const s = String(v).padStart(digits, '0'); [...nx.children].forEach((tu, i) => { - tu.classList.toggle('off', i < digits - 1 && v < Math.pow(10, digits - 1 - i)); + tu.classList.toggle('dupre-off', i < digits - 1 && v < Math.pow(10, digits - 1 - i)); tu.querySelector('b').textContent = s[i]; }); onChange(v, s); @@ -3837,15 +3898,24 @@ DUPRE.splitFlap.STYLES = { }; /* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick(); - click adds a minute */ + click adds a minute. + + Contract (everything a consumer needs; no page globals touched): + opts: secs (initial seconds, default 24:10); onChange(secs, 'mm:ss') + fires on every set. + handle: el, get() (current seconds), set(v) — wraps modulo 3600; + tick() steps one second down (the tick contract — the page owns + the clock); click adds 60. + CSS lives in the "seven-segment" block of DUPRE_CSS; the digit glyphs come + from the shared seg7() engine helper (.seg7, shared with the DSKY). */ DUPRE.sevenSeg = function (host, opts = {}) { const onChange = opts.onChange || noop; - const sv = document.createElement('span'); sv.className = 'seven'; host.appendChild(sv); + const sv = document.createElement('span'); sv.className = 'dupre-seven'; host.appendChild(sv); let secs; const set = v => { secs = ((v % 3600) + 3600) % 3600; const mm = String(Math.floor(secs / 60)).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0'); - sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '' + seg7(ss[0]) + seg7(ss[1]); + sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '' + seg7(ss[0]) + seg7(ss[1]); onChange(secs, mm + ':' + ss); }; sv.addEventListener('click', () => set(secs + 60)); @@ -3853,14 +3923,23 @@ DUPRE.sevenSeg = function (host, opts = {}) { return { el: sv, get: () => secs, set, tick: () => set(secs - 1) }; }; -/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message */ +/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message. + + Contract (everything a consumer needs; no page globals touched): + opts: msgs (message pool, default demo set); width (window px, 176); + onChange(msg index, 'msg i/n') fires on click-cycle and once at + build. + handle: el, get() (current message index), tick() — advances the scroll + 1.1px and wraps when the text clears the window (the tick + contract — the page owns the clock). + CSS lives in the "VFD marquee" block of DUPRE_CSS; no other styles involved. */ DUPRE.vfdMarquee = function (host, opts = {}) { const onChange = opts.onChange || noop; const msgs = opts.msgs || ['ARCHSETUP · NET OK · BT 2 · SND 62%', 'WIFI @Hyatt · 300 Mbps · VPN UP', 'BATTERY 84% · DISK 61% · TEMP 47C']; const W = opts.width || 176; - const m = document.createElement('span'); m.className = 'vfdm'; - m.innerHTML = ''; - const t = m.querySelector('.txt'); t.textContent = msgs[0]; + const m = document.createElement('span'); m.className = 'dupre-vfdm'; + m.innerHTML = ''; + const t = m.querySelector('.dupre-txt'); t.textContent = msgs[0]; host.appendChild(m); let mi = 0, x = W; m.addEventListener('click', () => { @@ -3922,28 +4001,43 @@ DUPRE.annunciator = function (host, opts = {}) { }; }; -/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark */ +/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark. + + Contract (everything a consumer needs; no page globals touched): + opts: states (array, one jewel per entry: 0 red · 1 amber · 2 green · + -1 dark; default [0,1,2,-1]); onChange(state, name) fires per + change and once at build with a hint. + handle: el, get() (states copy), set(i, st) — st clamps to the lens + range, negatives dark the jewel. State lives in here; the DOM + is a paint of it (was the reverse — the old builder kept state + only in classList/--jc, so nothing could read or drive it). + CSS lives in the "jewel" block of DUPRE_CSS; no other styles involved. */ DUPRE.jewels = function (host, opts = {}) { const onChange = opts.onChange || noop; const cols = ['var(--jewel-r)', 'var(--jewel-a)', 'var(--jewel-g)']; const NAMES = ['red', 'amber', 'green']; - const init = opts.states || [0, 1, 2, -1]; /* index into cols; -1 = dark */ + const states = (opts.states || [0, 1, 2, -1]).slice(); /* index into cols; -1 = dark */ const wrap = document.createElement('span'); wrap.style.cssText = 'display:inline-flex;gap:10px;align-items:center'; - init.forEach(st => { - const j = document.createElement('span'); j.className = 'jewel' + (st < 0 ? ' dim' : ''); - if (st >= 0) j.style.setProperty('--jc', cols[st]); - j.addEventListener('click', () => { - if (j.classList.contains('dim')) { j.classList.remove('dim'); j.style.setProperty('--jc', cols[0]); onChange(0, 'red'); return; } - const cur = j.style.getPropertyValue('--jc').trim(); const i = cols.indexOf(cur); - if (i >= cols.length - 1 || i < 0) { j.classList.add('dim'); onChange(-1, 'dark'); } - else { j.style.setProperty('--jc', cols[i + 1]); onChange(i + 1, NAMES[i + 1]); } - }); - wrap.appendChild(j); + const paint = i => { + const st = states[i], j = els[i]; + j.className = 'dupre-jewel' + (st < 0 ? ' dupre-dim' : ''); + if (st >= 0) j.style.setProperty('--jc', cols[st]); else j.style.removeProperty('--jc'); + }; + function set(i, st) { + states[i] = st < 0 ? -1 : Math.min(st, cols.length - 1); + paint(i); + onChange(states[i], states[i] < 0 ? 'dark' : NAMES[states[i]]); + } + const els = states.map((_, i) => { + const j = document.createElement('span'); + j.addEventListener('click', () => set(i, states[i] >= cols.length - 1 ? -1 : states[i] + 1)); + wrap.appendChild(j); return j; }); host.appendChild(wrap); + states.forEach((_, i) => paint(i)); onChange(null, 'click to cycle'); - return { el: wrap }; + return { el: wrap, get: () => states.slice(), set }; }; /* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit */ @@ -3970,14 +4064,22 @@ DUPRE.tapeCounter = function (host, opts = {}) { }; /* N26 analog clock — engraved ticks + three hands; the page owns the time source - and drives set(h, m, s); silent until the first set, like the live original */ + and drives set(h, m, s); silent until the first set, like the live original. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange([h, m, s], 'hh:mm:ss') fires on every set. + handle: el, set(h, m, s) — the minute hand carries a seconds fraction + and the hour hand a minutes fraction, so the face reads like a + real movement. Display-only: no state to get, no clock of its + own (the tick contract — the page owns the time source). + CSS lives in the "analog clock" block of DUPRE_CSS; no other styles involved. */ DUPRE.analogClock = function (host, opts = {}) { const onChange = opts.onChange || noop; - const cl = document.createElement('span'); cl.className = 'clock'; - for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); } - cl.insertAdjacentHTML('beforeend', ''); + const cl = document.createElement('span'); cl.className = 'dupre-clock'; + for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'dupre-tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); } + cl.insertAdjacentHTML('beforeend', ''); host.appendChild(cl); - const hh = cl.querySelector('.hh'), mh = cl.querySelector('.mh'), sh = cl.querySelector('.sh'); + const hh = cl.querySelector('.dupre-hh'), mh = cl.querySelector('.dupre-mh'), sh = cl.querySelector('.dupre-sh'); const set = (h, m, s) => { sh.style.transform = `rotate(${s * 6}deg)`; mh.style.transform = `rotate(${m * 6 + s * 0.1}deg)`; @@ -4378,7 +4480,17 @@ DUPRE.radarSweep = function (host, opts = {}) { }; /* R35 day-date disc calendar — coaxial printed discs rotate so today reads - under the fixed hand; starts on today; click rolls midnight forward */ + under the fixed hand; starts on today; click rolls midnight forward. + + Contract (everything a consumer needs; no page globals touched): + opts: date (1-31) and day (0-6, SUN=0), both defaulting to today; + onChange([date, day], 'DAY d') fires on every set, including the + initial paint. + handle: el, get() ([date, day]), set(d, w) — rotates both discs so the + commanded pair reads under the fixed hand; click advances date + and weekday together like a midnight rollover. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.dayDateCal = function (host, opts = {}) { const onChange = opts.onChange || noop; const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; @@ -4641,11 +4753,11 @@ DUPRE.blinkenlights = function (host, opts = {}) { /* instrument-internal CSS (moved from the gallery