aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/prototypes/widgets.js183
-rw-r--r--tests/gallery-probes/probe.mjs35
2 files changed, 177 insertions, 41 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index f009140..f09bedc 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -2266,7 +2266,16 @@ DUPRE.guardedToggle = function (host, opts = {}) {
};
/* R32 mechanical timer dial — dial rotates under a fixed index; wind by drag, stop by the red knob.
- Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set. */
+ Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: minutes (initial wind, clamps 0-60, default 0);
+ onChange(minutes, 'T-N MIN'|'OFF'|'STOP · OFF'|'DING · OFF') fires
+ on every set, including the initial paint.
+ handle: el, get() (minutes remaining), set(m) — m clamps 0-60; the dial
+ face drags, the red knob clicks to 0.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.timerDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 158, 132), cx = 62, cy = 58;
@@ -2327,7 +2336,15 @@ DUPRE.timerDial = function (host, opts = {}) {
return { el: s, get: () => min, set };
};
-/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press */
+/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange({x, y, dir}, 'DIR · x N y N') fires on every press and
+ once at the initial paint (dir null, origin).
+ handle: el, get() ({x, y} cursor position). No set — the cursor is a
+ relative accumulator; position only moves by presses.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.rockerPad = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55;
@@ -2366,7 +2383,16 @@ DUPRE.rockerPad = function (host, opts = {}) {
return { el: s, get: () => ({ x: rx, y: ry }) };
};
-/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state */
+/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: position ('A'|'B'|'C'|'D', default 'C'; anything else falls back
+ to 'C'); onChange(pos, 'POS Q') fires on every set, including the
+ initial paint.
+ handle: el, get() (current quadrant letter), set(q) — invalid quadrants
+ are ignored; quadrant click zones select directly.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.fourWayToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
@@ -2403,6 +2429,7 @@ DUPRE.fourWayToggle = function (host, opts = {}) {
svgEl(s, 'ellipse', { cx: cx - 2.6, cy: cy - 3, rx: 2.8, ry: 1.9, fill: 'rgba(255,255,255,.5)' });
let pos = null;
const set = q => {
+ if (!Object.hasOwn(QUAD, q)) return;
pos = q;
lever.setAttribute('transform', `rotate(${QUAD[q]},${cx},${cy})`);
for (const k of Object.keys(QUAD)) {
@@ -2420,16 +2447,29 @@ DUPRE.fourWayToggle = function (host, opts = {}) {
zone('B', `${cx},${cy} ${cx},5 125,5 125,${cy}`);
zone('C', `${cx},${cy} 125,${cy} 125,125 ${cx},125`);
zone('D', `${cx},${cy} ${cx},125 5,125 5,${cy}`);
- set(opts.position || 'C');
+ set(Object.hasOwn(QUAD, opts.position || '') ? opts.position : 'C');
return { el: s, get: () => pos, set };
};
-/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many */
+/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: rows (source labels, default 5-row synth set); cols (destination
+ labels, default 6-col set; the layout is sized for 5x6); pins
+ (initial 'ROW>COL' keys — keys naming no intersection are
+ dropped); onChange(pins array, 'N routes') fires on every seat or
+ pull, including the initial paint.
+ handle: el, get() ('ROW>COL' keys array). No set — pins seat and pull
+ by intersection click.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.pinMatrix = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV'];
const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD'];
- const pins = new Set(opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA']); /* a legible default patch */
+ /* a legible default patch; keys that name no intersection are dropped */
+ const pins = new Set((opts.pins || ['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', 160, 110);
const X0 = 42, Y0 = 30, DX = 19, DY = 15.5;
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
@@ -2452,7 +2492,17 @@ DUPRE.pinMatrix = function (host, opts = {}) {
return { el: s, get: () => [...pins] };
};
-/* R38 dead-man button — state exists only while the pointer holds it down */
+/* R38 dead-man button — state exists only while the pointer holds it down.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: caption (plate legend, default 'HOLD TO RUN'); label (button
+ face, default 'RUN'); onChange(held, 'RUNNING N.Ns'|'SAFE...')
+ fires on press, release, every 100 ms while held, and the
+ initial paint.
+ handle: el, get() (true while held). No set — by design the state
+ exists only under a live pointer.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.deadMan = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 120, 110), cx = 60, cy = 58;
@@ -2492,7 +2542,16 @@ DUPRE.deadMan = function (host, opts = {}) {
return { el: s, get: () => t0 !== null };
};
-/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns */
+/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns.
+ Spin animation is reduced-motion-gated; clicks during a spin are ignored.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(dialed, dialed) fires when each digit's return spin
+ lands, plus once at the initial paint ('dial a number').
+ handle: el, get() (dialed string, last 10 digits kept). No set — digits
+ only arrive through the dial.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.telephoneDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 63;
@@ -2544,7 +2603,18 @@ DUPRE.telephoneDial = function (host, opts = {}) {
return { el: s, get: () => dialed };
};
-/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step */
+/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step
+ (a tripped handle clicks to off, then to on).
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: names (breaker labels, default MAIN/PUMP/LAMP/AUX — the layout is
+ sized for four); onChange(states array, 'N/M closed[ · NAME
+ TRIPPED]') fires on every click or trip, including the initial
+ paint. The first three breakers start on, the rest off.
+ handle: el, get() (per-breaker 'on'|'off'|'tripped' array). No set —
+ breakers move by handle click and the TRIP button.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.breakerPanel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const NAMES = opts.names || ['MAIN', 'PUMP', 'LAMP', 'AUX'];
@@ -2584,32 +2654,42 @@ DUPRE.breakerPanel = function (host, opts = {}) {
return { el: s, get: () => brk.map(b => b.state) };
};
-/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb */
+/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: onChange(vals {prog, verb, noun}, status text) fires on ENTR,
+ RSET, OPR ERR, and the initial paint. Grammar: VERB/NOUN arm a
+ window, digits fill it (2 max), ENTR commits — V35 runs the lamp
+ test, V16 N36 sets PROG 16 (monitor clock), a bare-verb commit
+ with under 2 digits flashes OPR ERR.
+ handle: el, get() ({prog, verb, noun} copy).
+ CSS lives in the "DSKY" block of DUPRE_CSS; keys reuse the shared
+ .dupre-key face and digits render as shared .seg7 glyphs. */
DUPRE.dsky = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const LAMPS = ['UPLINK', 'TEMP', 'GIMBAL', 'PROG', 'RESTART', 'OPR ERR'];
- const el = document.createElement('div'); el.className = 'dsky'; host.appendChild(el);
+ const el = document.createElement('div'); el.className = 'dupre-dsky'; host.appendChild(el);
el.innerHTML =
- `<div class="lampcol">${LAMPS.map(l => `<div class="sl" data-l="${l}">${l}</div>`).join('')}</div>` +
- `<div class="right"><div class="wins">` +
- `<div class="win" data-w="prog"><span class="wl">PROG</span><span class="wd"></span></div>` +
- `<div class="win" data-w="verb"><span class="wl">VERB</span><span class="wd"></span></div>` +
- `<div class="win" data-w="noun"><span class="wl">NOUN</span><span class="wd"></span></div>` +
- `</div><div class="pad"></div></div>`;
+ `<div class="dupre-dsky-lamps">${LAMPS.map(l => `<div class="dupre-dsky-sl" data-l="${l}">${l}</div>`).join('')}</div>` +
+ `<div class="dupre-dsky-right"><div class="dupre-dsky-wins">` +
+ `<div class="dupre-dsky-win" data-w="prog"><span class="dupre-dsky-wl">PROG</span><span class="dupre-dsky-wd"></span></div>` +
+ `<div class="dupre-dsky-win" data-w="verb"><span class="dupre-dsky-wl">VERB</span><span class="dupre-dsky-wd"></span></div>` +
+ `<div class="dupre-dsky-win" data-w="noun"><span class="dupre-dsky-wl">NOUN</span><span class="dupre-dsky-wd"></span></div>` +
+ `</div><div class="dupre-dsky-pad"></div></div>`;
const win = w => el.querySelector(`[data-w="${w}"]`);
- const setWin = (w, txt) => { win(w).querySelector('.wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); };
+ const setWin = (w, txt) => { win(w).querySelector('.dupre-dsky-wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); };
const lampEl = l => el.querySelector(`[data-l="${l}"]`);
let mode = null, vals = { prog: ' ', verb: ' ', noun: ' ' }, entry = '';
const KEYS = ['VERB', 'NOUN', 'CLR', 'ENTR', 'RSET', '7', '8', '9', '4', '5', '6', '1', '2', '3', '0'];
- const pad = el.querySelector('.pad');
- const hot = () => { el.querySelectorAll('.win').forEach(w => w.classList.toggle('hot', w.dataset.w === mode)); };
- const oprErr = () => { lampEl('OPR ERR').classList.add('on'); setTimeout(() => lampEl('OPR ERR').classList.remove('on'), 1200); onChange(vals, 'OPR ERR'); };
+ const pad = el.querySelector('.dupre-dsky-pad');
+ const hot = () => { el.querySelectorAll('.dupre-dsky-win').forEach(w => w.classList.toggle('dupre-hot', w.dataset.w === mode)); };
+ const oprErr = () => { lampEl('OPR ERR').classList.add('dupre-on'); setTimeout(() => lampEl('OPR ERR').classList.remove('dupre-on'), 1200); onChange(vals, 'OPR ERR'); };
const commit = () => {
if (vals.verb.trim().length < 2) { oprErr(); return; }
const v = vals.verb, n = vals.noun.trim();
if (v === '35') {
- LAMPS.forEach(l => lampEl(l).classList.add('on'));
- setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('on')), 1400);
+ LAMPS.forEach(l => lampEl(l).classList.add('dupre-on'));
+ setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('dupre-on')), 1400);
onChange(vals, 'V35 · lamp test');
}
else if (v === '16' && n === '36') { vals.prog = '16'; setWin('prog', vals.prog); onChange(vals, 'V16 N36 · monitor clock'); }
@@ -2621,7 +2701,7 @@ DUPRE.dsky = function (host, opts = {}) {
b.addEventListener('click', () => {
if (k === 'VERB' || k === 'NOUN') { mode = k.toLowerCase(); entry = ''; vals[mode] = ' '; setWin(mode, ' '); hot(); }
else if (k === 'CLR') { if (mode) { vals[mode] = ' '; entry = ''; setWin(mode, ' '); } }
- else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('on')); onChange(vals, 'RSET'); }
+ else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('dupre-on')); onChange(vals, 'RSET'); }
else if (k === 'ENTR') commit();
else if (/\d/.test(k)) {
if (!mode) { oprErr(); return; }
@@ -2636,7 +2716,19 @@ DUPRE.dsky = function (host, opts = {}) {
};
/* R42 cam-timer program drum — the program is a ring; the pointer self-advances through it.
- Runs its own reduced-motion-gated 2 s step interval once started. */
+ Runs its own reduced-motion-gated 2 s step interval once started.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: steps (ring labels, index 0 is OFF, default 12-step wash
+ program — the layout is sized for 12); colors (label→wedge
+ fill map, default wash palette); position (initial step, clamps
+ to the ring, default 0); onChange(pos, 'OFF'|'STEP N ·
+ LABEL'|'CYCLE DONE · OFF') fires on every set, including the
+ initial paint.
+ handle: el, get() (step index), set(i) — i clamps to the ring; any
+ click advances one step.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.camTimer = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const STEPS = opts.steps || ['OFF', 'FILL', 'WASH', 'WASH', 'WASH', 'RINSE', 'RINSE', 'DRAIN', 'SPIN', 'SPIN', 'FLUFF', 'COOL'];
@@ -2660,8 +2752,8 @@ DUPRE.camTimer = function (host, opts = {}) {
svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
let pos;
const set = i => {
- pos = i;
- ptr.setAttribute('transform', `rotate(${i * 30 + 15},${cx},${cy})`);
+ pos = Math.max(0, Math.min(STEPS.length - 1, i | 0));
+ ptr.setAttribute('transform', `rotate(${pos * 30 + 15},${cx},${cy})`);
onChange(pos, pos === 0 ? 'OFF' : 'STEP ' + pos + ' · ' + STEPS[pos]);
};
s.style.cursor = 'pointer';
@@ -2672,7 +2764,16 @@ DUPRE.camTimer = function (host, opts = {}) {
return { el: s, get: () => pos, set };
};
-/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw */
+/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: closed (initial state, coerced to boolean, default true);
+ onChange(closed, 'CLOSED · live'|'OPEN · visibly dead') fires on
+ every set, including the initial paint.
+ handle: el, get() (true when closed), set(v) — coerced to boolean; any
+ click toggles.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.knifeSwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 110);
@@ -5497,21 +5598,21 @@ const DUPRE_CSS = `
.dupre-seven .dupre-colon i{width:4px;height:4px;border-radius:50%;background:var(--sevgrn);box-shadow:0 0 4px rgba(87,211,87,.7)}
/* DSKY verb/noun panel */
-.dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px}
-.dsky .lampcol{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px}
-.dsky .sl{font-size:6.5px;letter-spacing:.04em;display:flex;align-items:center;justify-content:center;text-align:center;
+.dupre-dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px}
+.dupre-dsky-lamps{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px}
+.dupre-dsky-sl{font-size:6.5px;letter-spacing:.04em;display:flex;align-items:center;justify-content:center;text-align:center;
background:#141210;color:#5c574c;border-radius:2px;border:1px solid #26221c}
-.dsky .sl.on{background:linear-gradient(180deg,var(--amber-warn),var(--gold));color:var(--panel);font-weight:700;
+.dupre-dsky-sl.dupre-on{background:linear-gradient(180deg,var(--amber-warn),var(--gold));color:var(--panel);font-weight:700;
box-shadow:0 0 6px rgba(var(--glow-lo),.5)}
-.dsky .right{display:flex;flex-direction:column;gap:6px}
-.dsky .wins{display:flex;gap:8px}
-.dsky .win{display:flex;flex-direction:column;align-items:center;gap:2px}
-.dsky .win .wl{font-size:6px;letter-spacing:.14em;color:var(--steel)}
-.dsky .win .wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px}
-.dsky .win .wd .seg7{width:13px;height:24px}
-.dsky .win.hot .wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)}
-.dsky .pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px}
-.dsky .pad .dupre-key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em}
+.dupre-dsky-right{display:flex;flex-direction:column;gap:6px}
+.dupre-dsky-wins{display:flex;gap:8px}
+.dupre-dsky-win{display:flex;flex-direction:column;align-items:center;gap:2px}
+.dupre-dsky-wl{font-size:6px;letter-spacing:.14em;color:var(--steel)}
+.dupre-dsky-wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px}
+.dupre-dsky-wd .seg7{width:13px;height:24px}
+.dupre-dsky-win.dupre-hot .dupre-dsky-wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)}
+.dupre-dsky-pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px}
+.dupre-dsky-pad .dupre-key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em}
/* VFD marquee */
.dupre-vfdm{width:176px;height:34px;border-radius:5px;overflow:hidden;position:relative;cursor:pointer;
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
index ed09d8d..16bff17 100644
--- a/tests/gallery-probes/probe.mjs
+++ b/tests/gallery-probes/probe.mjs
@@ -1240,6 +1240,41 @@ try {
ok('entryKeypad buffers digits only, caps at 6, clears and commits',
b5dom === JSON.stringify({ buf3: '123', stray: '123', cap: '123456', clr: '', commit: '' }), b5dom);
+ // batch-6 domain gates on detached hosts: fourWayToggle falls back to C on a
+ // bad initial position and ignores invalid quadrants in set(), camTimer
+ // clamps set() to the ring (12 steps → 0..11, fractions floor), pinMatrix
+ // drops initial pins that name no intersection.
+ const clamps6 = await evl(`(() => {
+ const fw = DUPRE.fourWayToggle(document.createElement('div'), { position: 'Z' });
+ const fwDef = fw.get(); fw.set('B'); const fwB = fw.get(); fw.set('Q'); const fwQ = fw.get();
+ fw.set('toString'); const fwProto = fw.get();
+ const ct = DUPRE.camTimer(document.createElement('div'), { position: 99 });
+ const ctHi = ct.get(); ct.set(-5); const ctLo = ct.get(); ct.set(2.7); const ctInt = ct.get();
+ const pm = DUPRE.pinMatrix(document.createElement('div'), { pins: ['OSC1>VCF', 'BAD>KEY', 'LFO>NOPE', 'OSC2>VCA>X'] });
+ return JSON.stringify({ fwDef, fwB, fwQ, fwProto, ctHi, ctLo, ctInt, pmN: pm.get().length });
+ })()`);
+ ok('batch-6 domain gates: fourWayToggle quadrant, camTimer step clamp, pinMatrix pin filter',
+ clamps6 === JSON.stringify({ fwDef: 'C', fwB: 'B', fwQ: 'B', fwProto: 'B', ctHi: 11, ctLo: 0, ctInt: 2, pmN: 1 }), clamps6);
+
+ // dsky verb/noun grammar through the prefixed DOM (catches rename stragglers):
+ // VERB arms exactly one hot window, V16 N36 commits PROG 16 and disarms,
+ // V35 lights all six status lamps.
+ const b6dom = await evl(`(() => {
+ const d = DUPRE.dsky(document.createElement('div'));
+ const K = {};
+ d.el.querySelectorAll('.dupre-dsky-pad .dupre-key').forEach(b => K[b.textContent] = b);
+ K.VERB.click();
+ const hot1 = d.el.querySelectorAll('.dupre-dsky-win.dupre-hot').length;
+ K['1'].click(); K['6'].click(); K.NOUN.click(); K['3'].click(); K['6'].click(); K.ENTR.click();
+ const prog = d.get().prog;
+ const hot0 = d.el.querySelectorAll('.dupre-dsky-win.dupre-hot').length;
+ K.VERB.click(); K['3'].click(); K['5'].click(); K.ENTR.click();
+ const lit = d.el.querySelectorAll('.dupre-dsky-sl.dupre-on').length;
+ return JSON.stringify({ hot1, prog, hot0, lit });
+ })()`);
+ 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);
+
// late exceptions from interactions
const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
ok('no exceptions after interaction', errs2.length === 0);