aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/prototypes/widgets.js278
-rw-r--r--tests/gallery-probes/probe-fams.mjs6
-rw-r--r--tests/gallery-probes/probe.mjs65
3 files changed, 260 insertions, 89 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index f09bedc..6477046 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -2808,11 +2808,20 @@ DUPRE.knifeSwitch = function (host, opts = {}) {
return { el: s, get: () => closed, set };
};
-/* R49 decade box — four skirted knobs, one digit each; the value is their sum */
+/* R49 decade box — four skirted knobs, one digit each; the value is their sum.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: digits (array of four 0-9 digits, most-significant first, default
+ [3, 5, 0, 0] — missing entries read 0, values clamp to 0-9);
+ onChange(total, 'N,NNN Ω') fires on every redraw, including the
+ initial paint.
+ handle: el, get() (the summed value; digits weight x1000/x100/x10/x1).
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; no gradients registered. */
DUPRE.decadeBox = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const MUL = [1000, 100, 10, 1], LBL = ['x1000', 'x100', 'x10', 'x1'];
- const digs = (opts.digits || [3, 5, 0, 0]).slice();
+ const digs = MUL.map((_, i) => Math.round(Math.max(0, Math.min(9, +(opts.digits || [3, 5, 0, 0])[i] || 0))));
const s = stageSvg(host, 'rsvg', 160, 100);
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
const wins = [], knobs = [];
@@ -2844,7 +2853,16 @@ DUPRE.decadeBox = function (host, opts = {}) {
return { el: s, get: () => total() };
};
-/* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle */
+/* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(state, text) fires on every transition — 'ready',
+ 'armed' (one palm down, 0.5s window), 'running' (1.5s cycle),
+ 'fault' (window expired or same hand twice); initial fire 'ready'.
+ handle: el, press(side) — side is 'L' or 'R' only, anything else is
+ ignored; presses during a running cycle are ignored.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; no gradients registered. */
DUPRE.twoHandSafety = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 160, 100);
@@ -2864,6 +2882,7 @@ DUPRE.twoHandSafety = function (host, opts = {}) {
const flash = cap => { cap.setAttribute('fill', '#e0523a'); setTimeout(() => cap.setAttribute('fill', '#8f2416'), 200); };
const fault = () => { armed = null; clearTimeout(armT); onChange('fault', 'TIE-DOWN FAULT · release and retry'); };
const press = side => {
+ if (side !== 'L' && side !== 'R') return;
if (busy) return;
if (armed === null) {
armed = side; flash(side === 'L' ? L.cap : R.cap);
@@ -2887,19 +2906,33 @@ DUPRE.twoHandSafety = function (host, opts = {}) {
return { el: s, press };
};
-/* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker */
+/* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: loops (array of key labels; the default 8-loop set also gets a
+ legible demo state — FD + A/G monitored, GNC talking — while a
+ caller's own set starts all-idle); onChange(states, text) fires
+ on every click and the initial paint — states is an array of
+ '0' idle / '1' monitored / '2' talking, one per loop.
+ handle: el, get() (the states array). Click cycles idle → monitored →
+ talking → idle; talk is exclusive across keys. Activity
+ flicker is display-side and honors prefers-reduced-motion.
+ CSS lives in the "voice-loop keyset" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.voiceLoop = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const LOOPS = opts.loops || ['FD', 'GNC', 'ECOM', 'SURG', 'A/G', 'NET1', 'NET2', 'PAO'];
- const el = document.createElement('div'); el.className = 'vloop'; host.appendChild(el);
+ const el = document.createElement('div'); el.className = 'dupre-vloop'; host.appendChild(el);
const keys = LOOPS.map(l => {
- const k = document.createElement('div'); k.className = 'vk';
- k.innerHTML = l + '<span class="bar"></span>'; k.dataset.state = '0'; el.appendChild(k); return k;
+ const k = document.createElement('div'); k.className = 'dupre-vk';
+ k.innerHTML = l + '<span class="dupre-vk-bar"></span>'; k.dataset.state = '0'; el.appendChild(k); return k;
});
- /* a legible default: FD + A/G monitored, GNC talking */
- keys[0].dataset.state = '1'; keys[0].classList.add('mon');
- keys[4].dataset.state = '1'; keys[4].classList.add('mon');
- keys[1].dataset.state = '2'; keys[1].classList.add('mon', 'tlk');
+ if (!opts.loops) {
+ /* a legible default: FD + A/G monitored, GNC talking */
+ keys[0].dataset.state = '1'; keys[0].classList.add('dupre-mon');
+ keys[4].dataset.state = '1'; keys[4].classList.add('dupre-mon');
+ keys[1].dataset.state = '2'; keys[1].classList.add('dupre-mon', 'dupre-tlk');
+ }
const refresh = () => {
const mon = keys.filter(k => k.dataset.state !== '0').length;
const tlk = keys.findIndex(k => k.dataset.state === '2');
@@ -2907,20 +2940,20 @@ DUPRE.voiceLoop = function (host, opts = {}) {
};
keys.forEach(k => k.addEventListener('click', () => {
const st = k.dataset.state;
- if (st === '0') { k.dataset.state = '1'; k.classList.add('mon'); }
+ if (st === '0') { k.dataset.state = '1'; k.classList.add('dupre-mon'); }
else if (st === '1') {
- keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('tlk'); } });
- k.dataset.state = '2'; k.classList.add('tlk');
+ keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('dupre-tlk'); } });
+ k.dataset.state = '2'; k.classList.add('dupre-tlk');
}
- else { k.dataset.state = '0'; k.classList.remove('mon', 'tlk', 'act'); }
+ else { k.dataset.state = '0'; k.classList.remove('dupre-mon', 'dupre-tlk', 'dupre-act'); }
refresh();
}));
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => {
const t = performance.now() / 1000;
keys.forEach((k, i) => {
- if (k.dataset.state === '0') { k.classList.remove('act'); return; }
- k.classList.toggle('act', Math.sin(t * (1.1 + i * 0.37) + i * 2.1) > 0.55);
+ if (k.dataset.state === '0') { k.classList.remove('dupre-act'); return; }
+ k.classList.toggle('dupre-act', Math.sin(t * (1.1 + i * 0.37) + i * 2.1) > 0.55);
});
}, 300);
refresh();
@@ -3000,18 +3033,27 @@ DUPRE.vuPair = function (host, opts = {}) {
return { el, get: () => [lv, rv], set };
};
-/* 12 mini 4-bar signal — compact activity meter; set(level) */
+/* 12 mini 4-bar signal — compact activity meter; set(level).
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(level, 'N%') fires on every set (level 0-1, clamped;
+ readout in percent). No initial fire — the page's tick drives it
+ (the tick contract — the page owns the clock and the signal).
+ handle: el, get() (last level set), set(level). Bars light bottom-up:
+ dupre-on, third bar dupre-hot, top bar dupre-clip.
+ CSS lives in the "mini 4-bar signal" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.miniSig = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const el = document.createElement('span'); el.className = 'sig';
+ const el = document.createElement('span'); el.className = 'dupre-sig';
el.innerHTML = '<i></i><i></i><i></i><i></i>';
host.appendChild(el);
let v = 0;
function set(l) {
- v = l;
- const b = el.children, lit = Math.round(l * 4);
- for (let k = 0; k < 4; k++) b[k].className = k < lit ? (k >= 3 ? 'clip' : k >= 2 ? 'hot' : 'on') : '';
- onChange(l, Math.round(l * 100) + '%');
+ v = Math.max(0, Math.min(1, l));
+ const b = el.children, lit = Math.round(v * 4);
+ for (let k = 0; k < 4; k++) b[k].className = k < lit ? (k >= 3 ? 'dupre-clip' : k >= 2 ? 'dupre-hot' : 'dupre-on') : '';
+ onChange(v, Math.round(v * 100) + '%');
}
return { el, get: () => v, set };
};
@@ -3042,36 +3084,53 @@ DUPRE.signalLadder = function (host, opts = {}) {
return { el, get: () => v, set };
};
-/* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set */
+/* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 50); warnAt (threshold, default 20 — below
+ it the fill tints dupre-warn); onChange(value, 'N%') fires on
+ every set, including the initial paint.
+ handle: el, get() (current value), set(v) — clamped to 0-100; drag
+ along the bar sets by pointer position.
+ CSS lives in the "linear progress / fuel bar" block of DUPRE_CSS; no
+ other styles involved. */
DUPRE.fuelBar = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const warnAt = opts.warnAt ?? 20;
let v = opts.value ?? 50;
- const el = document.createElement('div'); el.className = 'bar';
+ const el = document.createElement('div'); el.className = 'dupre-bar';
el.innerHTML = '<span></span>';
host.appendChild(el);
const fill = el.querySelector('span');
function set(p) {
- v = p;
- fill.style.width = p + '%';
- el.classList.toggle('warn', p < warnAt);
- onChange(p, Math.round(p) + '%');
+ v = Math.max(0, Math.min(100, p));
+ fill.style.width = v + '%';
+ el.classList.toggle('dupre-warn', v < warnAt);
+ onChange(v, Math.round(v) + '%');
}
dragX(el, set);
set(v);
return { el, get: () => v, set };
};
-/* 15 radial ring — percentage donut; drag up/down to set */
+/* 15 radial ring — percentage donut; drag up/down to set.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 68); onChange(value, 'N%') fires on every
+ set, including the initial paint.
+ handle: el, get() (current value), set(v) — clamped to 0-100; the fill
+ is a conic gradient driven by the element-scoped --p property.
+ CSS lives in the "radial ring" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.radialRing = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 68;
- const el = document.createElement('span'); el.className = 'ring';
+ const el = document.createElement('span'); el.className = 'dupre-ring';
el.innerHTML = '<b></b>';
host.appendChild(el);
const num = el.querySelector('b');
function set(nv) {
- v = nv;
+ v = Math.max(0, Math.min(100, nv));
el.style.setProperty('--p', v);
num.textContent = Math.round(v);
onChange(v, Math.round(v) + '%');
@@ -3081,39 +3140,63 @@ DUPRE.radialRing = function (host, opts = {}) {
return { el, get: () => v, set };
};
-/* 16 sparkline — rolling history trace; push(v) appends a sample, fill(v) levels it */
+/* 16 sparkline — rolling history trace; push(v) appends a sample, fill(v) levels it.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: samples (history length, default 40, floor 2 — the trace needs
+ two points); value (initial level, default 0.5); onChange(last,
+ 'N') fires on every repaint (samples 0-1, clamped on entry;
+ readout 0-100). No initial fire — the page's tick drives it (the
+ tick contract — the page owns the clock and the signal). The
+ history buffer is display-side state owned in here.
+ handle: el, get() (newest sample), push(v) (append, oldest drops),
+ fill(v) (level the whole buffer).
+ CSS lives in the "sparkline" block of DUPRE_CSS; the trace colour is an
+ inline stroke, no other styles involved. */
DUPRE.sparkline = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const n = opts.samples || 40;
- const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
- const el = document.createElement('span'); el.className = 'spark';
+ const n = Math.max(2, opts.samples || 40);
+ const clamp = x => Math.max(0, Math.min(1, x));
+ const hist = Array.from({ length: n }, () => clamp(opts.value ?? 0.5));
+ const el = document.createElement('span'); el.className = 'dupre-spark';
el.innerHTML = '<svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg>';
host.appendChild(el);
const line = el.querySelector('polyline');
- const clamp = x => Math.max(0, Math.min(1, x));
function paint() {
- line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 170},${44 - clamp(v) * 40 - 2}`).join(' '));
- onChange(hist[n - 1], String(Math.round(clamp(hist[n - 1]) * 100)));
+ line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 170},${44 - v * 40 - 2}`).join(' '));
+ onChange(hist[n - 1], String(Math.round(hist[n - 1] * 100)));
}
- function push(v) { hist.push(v); hist.shift(); paint(); }
- function fill(v) { hist.fill(v); paint(); }
+ function push(v) { hist.push(clamp(v)); hist.shift(); paint(); }
+ function fill(v) { hist.fill(clamp(v)); paint(); }
return { el, get: () => hist[n - 1], push, fill };
};
-/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1 */
+/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: amp (initial amplitude for the readout, default 0.6);
+ onChange(amp, 'amp N%') fires on every set, including the
+ initial paint (a flat centre line).
+ handle: el, get() (last amp), set(samples, amp) — samples in -1..1,
+ clamped per sample; fewer than two samples draws the flat
+ centre line. Amp is readout-only; the trace height comes from
+ the samples themselves.
+ CSS lives in the "waveform strip" block of DUPRE_CSS; the trace colour
+ is an inline stroke, no other styles involved. */
DUPRE.waveStrip = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const el = document.createElement('span'); el.className = 'wave';
+ const el = document.createElement('span'); el.className = 'dupre-wave';
el.innerHTML = '<svg viewBox="0 0 170 38" preserveAspectRatio="none"><path fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg>';
host.appendChild(el);
const path = el.querySelector('path');
+ const clamp = x => Math.max(-1, Math.min(1, x));
let amp = 0;
function set(samples, a) {
amp = a;
let d = 'M0 19';
if (samples.length < 2) d += ' L170 19';
else for (let i = 0; i < samples.length; i++)
- d += ` L${(i / (samples.length - 1) * 170).toFixed(1)} ${(19 + samples[i] * 14).toFixed(1)}`;
+ d += ` L${(i / (samples.length - 1) * 170).toFixed(1)} ${(19 + clamp(samples[i]) * 14).toFixed(1)}`;
path.setAttribute('d', d);
onChange(amp, 'amp ' + Math.round(amp * 100) + '%');
}
@@ -3121,31 +3204,54 @@ DUPRE.waveStrip = function (host, opts = {}) {
return { el, get: () => amp, set };
};
-/* N11 oscilloscope — sampled phosphor trace; set(samples, vpp) with samples in -1..1 */
+/* N11 oscilloscope — sampled phosphor trace; set(samples, vpp) with samples in -1..1.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(vpp, 'Vpp N') fires on every set (readout is vpp x100).
+ No initial fire — the page's tick drives it (the tick contract —
+ the page owns the clock and the signal).
+ handle: el, get() (last vpp), set(samples, vpp) — samples in -1..1,
+ clamped per sample; fewer than two samples clears the trace.
+ Vpp is readout-only; the trace comes from the samples.
+ CSS lives in the "oscilloscope" block of DUPRE_CSS; the screen-family
+ --scr-* vars retint it, with the original green as fallback. */
DUPRE.scope = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const el = document.createElement('span'); el.className = 'scope';
- el.innerHTML = '<span class="grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>';
+ const el = document.createElement('span'); el.className = 'dupre-scope';
+ el.innerHTML = '<span class="dupre-grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>';
host.appendChild(el);
const line = el.querySelector('polyline');
+ const clamp = x => Math.max(-1, Math.min(1, x));
let vpp = 0;
function set(samples, v) {
vpp = v;
const n = samples.length;
- line.setAttribute('points', samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + s * 22).toFixed(1)}`).join(' '));
+ line.setAttribute('points', n < 2 ? '' :
+ samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + clamp(s) * 22).toFixed(1)}`).join(' '));
onChange(vpp, 'Vpp ' + Math.round(vpp * 100));
}
return { el, get: () => vpp, set };
};
-/* N12 spectrum / EQ bars — set(values) paints one column per band */
+/* N12 spectrum / EQ bars — set(values) paints one column per band.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: bands (columns, default 11); cells (segments per column, default
+ 9); onChange(values, 'peak N%') fires on every set (values 0-1
+ per band, clamped; a missing band reads 0). No initial fire —
+ the page's tick drives it (the tick contract — the page owns the
+ clock and the signal).
+ handle: el, get() (last values array), set(values). Cells light
+ bottom-up: dupre-on, top three dupre-hot, topmost dupre-clip.
+ CSS lives in the "spectrum / EQ" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.eqBars = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const bands = opts.bands || 11, cells = opts.cells || 9;
- const el = document.createElement('span'); el.className = 'eq';
+ const el = document.createElement('span'); el.className = 'dupre-eq';
host.appendChild(el);
for (let b = 0; b < bands; b++) {
- const band = document.createElement('span'); band.className = 'band';
+ const band = document.createElement('span'); band.className = 'dupre-band';
for (let s = 0; s < cells; s++) band.appendChild(document.createElement('i'));
el.appendChild(band);
}
@@ -3154,11 +3260,11 @@ DUPRE.eqBars = function (host, opts = {}) {
vals = values;
let peak = 0;
for (let b = 0; b < bands; b++) {
- const col = el.children[b].children, val = values[b], lit = Math.round(val * cells);
+ const col = el.children[b].children, val = Math.max(0, Math.min(1, values[b] || 0)), lit = Math.round(val * cells);
peak = Math.max(peak, val);
for (let k = 0; k < cells; k++) {
let c = '';
- if (k < lit) c = (k >= cells - 1 ? 'clip' : k >= cells - 3 ? 'hot' : 'on');
+ if (k < lit) c = (k >= cells - 1 ? 'dupre-clip' : k >= cells - 3 ? 'dupre-hot' : 'dupre-on');
col[k].className = c;
}
}
@@ -5266,10 +5372,10 @@ const DUPRE_CSS = `
.dupre-vubar i.dupre-clip{opacity:1;background:var(--fail)}.dupre-vubar i.dupre-peak{outline:1px solid var(--gold-hi);outline-offset:-1px}
/* mini 4-bar signal */
-.sig{display:flex;align-items:flex-end;gap:2px;height:18px}
-.sig i{width:4px;background:var(--wash);border-radius:1px}
-.sig i:nth-child(1){height:5px}.sig i:nth-child(2){height:9px}.sig i:nth-child(3){height:13px}.sig i:nth-child(4){height:17px}
-.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)}
+.dupre-sig{display:flex;align-items:flex-end;gap:2px;height:18px}
+.dupre-sig i{width:4px;background:var(--wash);border-radius:1px}
+.dupre-sig i:nth-child(1){height:5px}.dupre-sig i:nth-child(2){height:9px}.dupre-sig i:nth-child(3){height:13px}.dupre-sig i:nth-child(4){height:17px}
+.dupre-sig i.dupre-on{background:var(--pass)}.dupre-sig i.dupre-hot{background:var(--gold)}.dupre-sig i.dupre-clip{background:var(--fail)}
/* signal ladder (wifi bars) */
.dupre-ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px;cursor:pointer}
@@ -5278,16 +5384,16 @@ const DUPRE_CSS = `
.dupre-ladder i:nth-child(3){height:14px}.dupre-ladder i:nth-child(4){height:18px}
/* linear progress / fuel bar */
-.bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none}
-.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold));border-radius:6px}
-.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+.dupre-bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none}
+.dupre-bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold));border-radius:6px}
+.dupre-bar.dupre-warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
/* radial ring */
-.ring{width:60px;height:60px;border-radius:50%;cursor:ns-resize;touch-action:none;
+.dupre-ring{width:60px;height:60px;border-radius:50%;cursor:ns-resize;touch-action:none;
background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);
display:grid;place-items:center;position:relative}
-.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)}
-.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums}
+.dupre-ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)}
+.dupre-ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums}
/* tabular readout */
.readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em;cursor:pointer}
@@ -5295,8 +5401,8 @@ const DUPRE_CSS = `
.readout .u{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px}
/* sparkline */
-.spark{width:170px;height:44px}
-.spark svg{display:block;width:100%;height:100%}
+.dupre-spark{width:170px;height:44px}
+.dupre-spark svg{display:block;width:100%;height:100%}
/* lamp row (list item) */
.dupre-lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px}
@@ -5323,8 +5429,8 @@ const DUPRE_CSS = `
.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none}
/* waveform strip */
-.wave{width:170px;height:38px}
-.wave svg{width:100%;height:100%;display:block}
+.dupre-wave{width:170px;height:38px}
+.dupre-wave svg{width:100%;height:100%;display:block}
/* toast */
.toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px;cursor:pointer}
@@ -5471,38 +5577,38 @@ const DUPRE_CSS = `
background:radial-gradient(circle at 40% 35%,#151210,#000);box-shadow:inset 0 1px 2px rgba(0,0,0,.9)}
/* oscilloscope — screen-family vars with the original green as fallback */
-.scope{width:176px;height:78px;border-radius:6px;position:relative;overflow:hidden;
+.dupre-scope{width:176px;height:78px;border-radius:6px;position:relative;overflow:hidden;
background:radial-gradient(circle at 50% 45%,var(--scr-bgc,#04140a),var(--scr-bge,#020a05));
border:1px solid var(--scr-brd,#123018);
box-shadow:inset 0 0 16px rgba(0,0,0,.7)}
-.scope .grat{position:absolute;inset:0;opacity:.5;
+.dupre-scope .dupre-grat{position:absolute;inset:0;opacity:.5;
background-image:linear-gradient(var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px),linear-gradient(90deg,var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px);
background-size:22px 19.5px}
-.scope .grat::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:var(--scr-gratc,rgba(111,206,51,.35))}
-.scope svg{position:absolute;inset:0;width:100%;height:100%}
-.scope polyline{fill:none;stroke:var(--scr-hi,var(--phos));stroke-width:1.6;filter:drop-shadow(0 0 3px var(--scr-glow,rgba(127,224,160,.8)))}
+.dupre-scope .dupre-grat::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:var(--scr-gratc,rgba(111,206,51,.35))}
+.dupre-scope svg{position:absolute;inset:0;width:100%;height:100%}
+.dupre-scope polyline{fill:none;stroke:var(--scr-hi,var(--phos));stroke-width:1.6;filter:drop-shadow(0 0 3px var(--scr-glow,rgba(127,224,160,.8)))}
/* voice-loop keyset */
-.vloop{display:grid;grid-template-columns:repeat(4,52px);gap:4px}
-.vk{display:flex;flex-direction:column;align-items:stretch;gap:4px;font-size:7px;letter-spacing:.05em;
+.dupre-vloop{display:grid;grid-template-columns:repeat(4,52px);gap:4px}
+.dupre-vk{display:flex;flex-direction:column;align-items:stretch;gap:4px;font-size:7px;letter-spacing:.05em;
text-align:center;padding:6px 4px 4px;cursor:pointer;
color:var(--dim);background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-radius:4px;
font-family:var(--mono)}
-.vk .bar{height:3px;width:40px;align-self:center;border-radius:2px;background:#16240f}
-.vk.mon{color:var(--silver)}
-.vk.mon .bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
-.vk.mon.act .bar{background:#a8d84a;box-shadow:0 0 7px rgba(150,200,60,.9)}
-.vk.tlk{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));
+.dupre-vk .dupre-vk-bar{height:3px;width:40px;align-self:center;border-radius:2px;background:#16240f}
+.dupre-vk.dupre-mon{color:var(--silver)}
+.dupre-vk.dupre-mon .dupre-vk-bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
+.dupre-vk.dupre-mon.dupre-act .dupre-vk-bar{background:#a8d84a;box-shadow:0 0 7px rgba(150,200,60,.9)}
+.dupre-vk.dupre-tlk{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));
border-color:var(--gold-hi);font-weight:700}
-.vk.tlk .bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
+.dupre-vk.dupre-tlk .dupre-vk-bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
/* spectrum / EQ */
-.eq{display:flex;align-items:flex-end;gap:3px;height:60px}
-.eq .band{width:9px;display:flex;flex-direction:column-reverse;gap:2px;height:100%}
-.eq .band i{height:5px;border-radius:1px;background:var(--wash);opacity:.3}
-.eq .band i.on{opacity:1;background:var(--pass)}
-.eq .band i.hot{opacity:1;background:var(--gold)}
-.eq .band i.clip{opacity:1;background:var(--fail)}
+.dupre-eq{display:flex;align-items:flex-end;gap:3px;height:60px}
+.dupre-eq .dupre-band{width:9px;display:flex;flex-direction:column-reverse;gap:2px;height:100%}
+.dupre-eq .dupre-band i{height:5px;border-radius:1px;background:var(--wash);opacity:.3}
+.dupre-eq .dupre-band i.dupre-on{opacity:1;background:var(--pass)}
+.dupre-eq .dupre-band i.dupre-hot{opacity:1;background:var(--gold)}
+.dupre-eq .dupre-band i.dupre-clip{opacity:1;background:var(--fail)}
/* crossed-needle */
.crossm{width:120px}
diff --git a/tests/gallery-probes/probe-fams.mjs b/tests/gallery-probes/probe-fams.mjs
index c1b5f9d..a2333fb 100644
--- a/tests/gallery-probes/probe-fams.mjs
+++ b/tests/gallery-probes/probe-fams.mjs
@@ -43,9 +43,9 @@ try {
ok('R31 green chip recolors furniture', line0 !== line1 && line1 === 'rgb(88, 184, 126)', `${line0} -> ${line1}`);
// scope (CSS instrument): trace stroke changes on amber chip
- const tr0 = await evl(`getComputedStyle(document.querySelector('.scope polyline')).stroke`);
- await evl(`document.querySelector('.scope').closest('.card').querySelector('.fc[title="amber"]').click()`);
- const tr1 = await evl(`getComputedStyle(document.querySelector('.scope polyline')).stroke`);
+ const tr0 = await evl(`getComputedStyle(document.querySelector('.dupre-scope polyline')).stroke`);
+ await evl(`document.querySelector('.dupre-scope').closest('.card').querySelector('.fc[title="amber"]').click()`);
+ const tr1 = await evl(`getComputedStyle(document.querySelector('.dupre-scope polyline')).stroke`);
ok('N11 amber chip recolors trace', tr0 !== tr1 && tr1 === 'rgb(255, 190, 84)', `${tr0} -> ${tr1}`);
// R19: drag still works after family switch (click vfd, then check the handle's set() writes var-based fills)
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
index 16bff17..02b1062 100644
--- a/tests/gallery-probes/probe.mjs
+++ b/tests/gallery-probes/probe.mjs
@@ -1275,6 +1275,71 @@ try {
ok('dsky grammar drives prefixed DOM: hot window, V16 N36 sets PROG, V35 lights all lamps',
b6dom === JSON.stringify({ hot1: 1, prog: '16', hot0: 0, lit: 6 }), b6dom);
+ // batch-7 domain gates on detached hosts: decadeBox coerces and clamps its
+ // digits, twoHandSafety ignores sides outside L/R, the meters clamp their
+ // set()/push() domains, sparkline floors its history at two points, scope
+ // clears the trace below two samples, eqBars reads missing bands as 0.
+ const clamps7 = await evl(`(() => {
+ const db = DUPRE.decadeBox(document.createElement('div'), { digits: [12, -3, undefined, 7] });
+ const dbTot = db.get();
+ const thsLog = [];
+ const ths = DUPRE.twoHandSafety(document.createElement('div'), { onChange: st => thsLog.push(st) });
+ ths.press('X'); ths.press('L'); ths.press('R');
+ const ms = DUPRE.miniSig(document.createElement('div'));
+ ms.set(2); const msHi = ms.get(); ms.set(-1); const msLo = ms.get();
+ const fb = DUPRE.fuelBar(document.createElement('div'));
+ fb.set(150); const fbHi = fb.get();
+ const rr = DUPRE.radialRing(document.createElement('div'));
+ rr.set(150); const rrHi = rr.get();
+ const sp = DUPRE.sparkline(document.createElement('div'), { samples: 1 });
+ sp.push(5); const spHi = sp.get();
+ const spPts = sp.el.querySelector('polyline').getAttribute('points');
+ const ws = DUPRE.waveStrip(document.createElement('div'));
+ ws.set([0, 5, -5], 0.5);
+ const wsD = ws.el.querySelector('path').getAttribute('d');
+ const sc = DUPRE.scope(document.createElement('div'));
+ sc.set([0.5], 1);
+ const scEmpty = sc.el.querySelector('polyline').getAttribute('points');
+ sc.set([5, -5], 1);
+ const scPts = sc.el.querySelector('polyline').getAttribute('points');
+ let eqTxt; const eq = DUPRE.eqBars(document.createElement('div'), { bands: 3, cells: 4, onChange: (v, t) => eqTxt = t });
+ eq.set([2, -1]);
+ const eqB0 = eq.el.children[0].querySelectorAll('.dupre-on,.dupre-hot,.dupre-clip').length;
+ const eqRest = eq.el.children[1].querySelectorAll('.dupre-on,.dupre-hot,.dupre-clip').length
+ + eq.el.children[2].querySelectorAll('.dupre-on,.dupre-hot,.dupre-clip').length;
+ return JSON.stringify({ dbTot, ths: thsLog.join('|'), msHi, msLo, fbHi, rrHi, spHi,
+ spOk: !spPts.includes('NaN'), wsOk: !wsD.includes('NaN') && wsD.includes(' 33.0') && wsD.includes(' 5.0'),
+ scEmpty, scPts, eqTxt, eqB0, eqRest });
+ })()`);
+ ok('batch-7 domain gates: decadeBox digits, twoHandSafety side, meter clamps, trace guards',
+ clamps7 === JSON.stringify({ dbTot: 9007, ths: 'ready|armed|running', msHi: 1, msLo: 0, fbHi: 100,
+ rrHi: 100, spHi: 1, spOk: true, wsOk: true, scEmpty: '', scPts: '0.0,61.0 176.0,17.0',
+ eqTxt: 'peak 100%', eqB0: 4, eqRest: 0 }), clamps7);
+
+ // voiceLoop grammar through the prefixed DOM (catches rename stragglers):
+ // the default set opens FD/GNC/A-G engaged with GNC talking, click cycles
+ // idle → monitored → talking → idle with talk exclusive, and a caller's own
+ // loop set starts all-idle (the demo default is the stock set's alone).
+ const b7dom = await evl(`(() => {
+ const vl = DUPRE.voiceLoop(document.createElement('div'));
+ const ks = vl.el.querySelectorAll('.dupre-vk');
+ const nKeys = ks.length;
+ const mon0 = vl.el.querySelectorAll('.dupre-mon').length;
+ const tlk0 = vl.el.querySelectorAll('.dupre-tlk').length;
+ ks[2].click();
+ const mon1 = vl.el.querySelectorAll('.dupre-mon').length;
+ ks[0].click();
+ const tlk1 = vl.el.querySelectorAll('.dupre-tlk').length;
+ const fdTalks = ks[0].classList.contains('dupre-tlk') && !ks[1].classList.contains('dupre-tlk');
+ ks[0].click();
+ const fdIdle = vl.get()[0] === '0' && !ks[0].classList.contains('dupre-mon');
+ const own = DUPRE.voiceLoop(document.createElement('div'), { loops: ['A', 'B', 'C'] });
+ const ownIdle = own.get().join('') === '000' && own.el.querySelectorAll('.dupre-mon,.dupre-tlk').length === 0;
+ return JSON.stringify({ nKeys, mon0, tlk0, mon1, tlk1, fdTalks, fdIdle, ownIdle });
+ })()`);
+ ok('voiceLoop cycles through prefixed DOM: demo default, exclusive talk, own set idle',
+ b7dom === JSON.stringify({ nKeys: 8, mon0: 3, tlk0: 1, mon1: 4, tlk1: 1, fdTalks: true, fdIdle: true, ownIdle: true }), b7dom);
+
// late exceptions from interactions
const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
ok('no exceptions after interaction', errs2.length === 0);