aboutsummaryrefslogtreecommitdiff
path: root/tests/gallery-probes
diff options
context:
space:
mode:
Diffstat (limited to 'tests/gallery-probes')
-rw-r--r--tests/gallery-probes/README.md26
-rw-r--r--tests/gallery-probes/audit-extraction.mjs77
-rw-r--r--tests/gallery-probes/probe-fams.mjs101
-rw-r--r--tests/gallery-probes/probe-vstatus.mjs209
-rw-r--r--tests/gallery-probes/probe.mjs1563
5 files changed, 1976 insertions, 0 deletions
diff --git a/tests/gallery-probes/README.md b/tests/gallery-probes/README.md
new file mode 100644
index 0000000..9b5ce7d
--- /dev/null
+++ b/tests/gallery-probes/README.md
@@ -0,0 +1,26 @@
+# Gallery CDP probes
+
+Behavioral verification for docs/prototypes/panel-widget-gallery.html — headless Chrome
+driven over the DevTools protocol (Node global WebSocket/fetch, no playwright).
+
+- probe.mjs — full regression: card count, size toggle, drag + click behavior under zoom,
+ zero-exception gate. Run: `node probe.mjs [--shot out.png]`. Update the card-count
+ assertion when cards are added.
+- probe-fams.mjs — screen-color families: default fallbacks pixel-identical, chip clicks
+ recolor ink/face/trace, dynamic fills stay var-based.
+
+Both exit nonzero on failure. The gallery's componentization work (widgets.js extraction)
+uses a green run of these as its no-regression gate per batch.
+
+## Traps when writing new checks
+
+- Dispatch clicks on the element that owns the listener, not an ancestor; drive drags
+ with in-page synthetic PointerEvents (setPointerCapture stubbed).
+- A `find()` by textContent that matches nothing returns undefined, and the dispatch
+ no-ops silently — the check then fails looking like a widget bug. Match glyphs
+ exactly against the builder source (e.g. the transport stop button is '⏹' U+23F9,
+ not '■' U+25A0), and prefer asserting the find() hit before dispatching.
+- Reduced-motion emulation must be set before Page.navigate (launch on about:blank,
+ Emulation.setEmulatedMedia, then navigate) or the page's matchMedia snapshot misses it.
+- Kill stale headless Chromes after a crashed run; bracket the pkill pattern
+ (`pkill -f 'remote-debugging-port=934[5]'`) so it can't match its own command line.
diff --git a/tests/gallery-probes/audit-extraction.mjs b/tests/gallery-probes/audit-extraction.mjs
new file mode 100644
index 0000000..3a69f2b
--- /dev/null
+++ b/tests/gallery-probes/audit-extraction.mjs
@@ -0,0 +1,77 @@
+// audit-extraction.mjs — report-only grader for the extraction-readiness bar.
+//
+// Every DUPRE builder should be liftable out of the gallery without archaeology:
+// a contract comment documenting opts + handle, its CSS in DUPRE_CSS rather than
+// the page stylesheet, and no page code reaching past a handle into instrument
+// DOM. This audit is static (no browser): it reads the sources and reports a
+// worklist. It gates nothing — the gate flips on only when the sweep reaches
+// 100% (the VSTATUS/policy ratchet pattern). Exit 0 always, unless the audit
+// itself cannot parse the sources.
+//
+// Checks:
+// C1 contract comment — the block comment directly above DUPRE.<name> =
+// function mentions both opts and the handle (the split-flap shape).
+// C2 page-styled internals — class selectors in the page <style> that also
+// appear in DUPRE_CSS (the page overriding component internals).
+// C3 page reach-ins — page script querySelectors descending into a card's
+// stage (past the handle) instead of using the .dupre surface.
+//
+// Usage: node audit-extraction.mjs [--verbose]
+
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
+const kitSrc = readFileSync(join(root, 'docs/prototypes/widgets.js'), 'utf8');
+const page = readFileSync(join(root, 'docs/prototypes/panel-widget-gallery.html'), 'utf8');
+const verbose = process.argv.includes('--verbose');
+
+// ---- C1: contract comments -------------------------------------------------
+// Builders are `DUPRE.<name> = function`; the comment block that ends directly
+// above (allowing blank lines) is its documentation.
+const builders = [];
+const re = /DUPRE\.(\w+)\s*=\s*function/g;
+for (let m; (m = re.exec(kitSrc)); ) {
+ const name = m[1];
+ const head = kitSrc.slice(0, m.index);
+ // Only the LAST comment block directly above counts — a lazy match from the
+ // file start would hand every builder the whole file's text and grade
+ // everything compliant (it did, on the audit's first run).
+ const at = head.lastIndexOf('/*');
+ const tail = at >= 0 ? head.slice(at) : '';
+ const comment = /^\/\*[\s\S]*\*\/\s*$/.test(tail) ? tail : '';
+ const contract = /\bopts\b/i.test(comment) && /\bhandle\b/i.test(comment);
+ builders.push({ name, contract, commentLen: comment.length });
+}
+
+// ---- C2: page-styled instrument internals -------------------------------------
+const classSels = src => new Set([...src.matchAll(/\.([a-zA-Z][\w-]*)/g)].map(x => x[1]));
+const cssBlock = kitSrc.match(/DUPRE_CSS\s*=\s*\[([\s\S]*?)\]\.join|DUPRE_CSS\s*=\s*`([\s\S]*?)`/);
+const dupreCss = cssBlock ? (cssBlock[1] || cssBlock[2]) : '';
+if (!dupreCss) { console.error('AUDIT ERROR: DUPRE_CSS not found'); process.exit(1); }
+const pageStyle = [...page.matchAll(/<style>([\s\S]*?)<\/style>/g)].map(x => x[1]).join('\n');
+const dupreClasses = classSels(dupreCss);
+// Rig and page chrome legitimately live in the page; only instrument-internal
+// classes (defined in DUPRE_CSS) styled by the page are violations.
+const pageOverrides = [...classSels(pageStyle)].filter(c => dupreClasses.has(c));
+
+// ---- C3: page reach-ins ----------------------------------------------------
+// Page script (inline <script> bodies only; widgets.js is external).
+const pageScript = [...page.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(x => x[1]).join('\n');
+const RIG = /\.(famchips|fc|fgroup|lab|opts|wrd|stagew|winfo|wnote|wname|no)\b/;
+const reachIns = [...pageScript.matchAll(/querySelector(?:All)?\(\s*['"](#card-[\w-]+\s+[^'"]+)['"]/g)]
+ .map(x => x[1]).filter(sel => !RIG.test(sel));
+
+// ---- report ----------------------------------------------------------------
+const good = builders.filter(b => b.contract);
+console.log(`extraction audit — ${builders.length} builders`);
+console.log(`C1 contract comments: ${good.length}/${builders.length} compliant`);
+if (verbose || true) {
+ const bad = builders.filter(b => !b.contract).map(b => b.name);
+ if (bad.length) console.log(` missing (${bad.length}): ${bad.join(' ')}`);
+}
+console.log(`C2 page-styled instrument internals: ${pageOverrides.length}${pageOverrides.length ? ' — ' + [...new Set(pageOverrides)].join(' ') : ' (clean)'}`);
+console.log(`C3 page reach-ins past handles: ${reachIns.length}${reachIns.length ? '' : ' (clean)'}`);
+reachIns.forEach(r => console.log(' ' + r));
+process.exit(0);
diff --git a/tests/gallery-probes/probe-fams.mjs b/tests/gallery-probes/probe-fams.mjs
new file mode 100644
index 0000000..a2333fb
--- /dev/null
+++ b/tests/gallery-probes/probe-fams.mjs
@@ -0,0 +1,101 @@
+// Verify screen-family chips: presence, default fallbacks intact, live recolor on click.
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+const PORT = 9335;
+const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html';
+const chrome = spawn('google-chrome-stable', ['--headless=new', `--remote-debugging-port=${PORT}`, '--no-first-run', '--window-size=1600,1200', URL], { stdio: 'ignore' });
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+let ws, id = 0; const pending = new Map(); const events = [];
+for (let i = 0; i < 40; i++) { try { const l = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); const p = l.find(t => t.type === 'page' && t.url.startsWith('file')); if (p) { ws = new WebSocket(p.webSocketDebuggerUrl); break; } } catch {} await sleep(250); }
+await new Promise(r => ws.onopen = r);
+ws.onmessage = m => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } else if (d.method) events.push(d); };
+const send = (method, params = {}) => new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
+const evl = async e => { const r = await send('Runtime.evaluate', { expression: e, returnByValue: true }); if (r.result.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails)); return r.result.result.value; };
+const fails = [];
+const ok = (n, c, d = '') => { console.log(`${c ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`); if (!c) fails.push(n); };
+await send('Runtime.enable'); await sleep(1500);
+
+/* Everything below runs inside try/finally so a failing check cannot orphan the
+ browser. It could before, and the consequence was worse than a leak: the next
+ run connected to the survivor on this same port and reported results from a
+ STALE page — old widgets.js, chips already clicked. A probe that answers from
+ the previous run is worse than one that crashes. */
+try {
+ ok('no exceptions on load', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0);
+ // count screen-family rows only — card option groups (e.g. card 01) reuse .famchips
+ const famRows = await evl(`[...document.querySelectorAll('.famchips .lab')].filter(l=>l.textContent==='screen').length`);
+ ok('6 screen chip rows', famRows === 6, `got ${famRows}`);
+
+ // default fallback: dmx ink computes to gold-hi rgb(255,190,84)
+ const dmxFill0 = await evl(`getComputedStyle(document.querySelector('#card-R10 svg text')).fill`);
+ ok('R10 default ink = gold-hi', dmxFill0 === 'rgb(255, 190, 84)', dmxFill0);
+
+ // click green chip on R10 -> ink becomes phos rgb(127,224,160)
+ await evl(`document.querySelector('#card-R10').querySelector('.fc[title="green"]').click()`);
+ const dmxFill1 = await evl(`getComputedStyle(document.querySelector('#card-R10 svg text')).fill`);
+ ok('R10 green chip recolors ink', dmxFill1 === 'rgb(127, 224, 160)', dmxFill1);
+
+ // radar: default sweep line = gold-hi; click green -> phos
+ const swSel = `document.querySelector('#card-R31')`;
+ const line0 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`);
+ await evl(`${swSel}.querySelector('.fc[title="green"]').click()`);
+ const line1 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`);
+ 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('.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)
+ await evl(`document.querySelector('#card-R19 .fc[title="vfd"]').click()`);
+ await evl(`document.getElementById('card-R19').dupre.set(30,70)`);
+ const barFill = await evl(`(()=>{const b=document.querySelectorAll('#card-R19 svg rect')[10];return b.getAttribute('fill');})()`);
+ ok('R19 dynamic fills use vars', barFill.startsWith('var(--scr-'), barFill);
+
+ // R17: face gradient stop resolves to amber face after chip
+ await evl(`document.querySelector('#card-R17 .fc[title="amber"]').click()`);
+ const face = await evl(`getComputedStyle(document.querySelector('#card-R17 svg radialGradient stop')).stopColor`);
+ ok('R17 amber chip retints face', face === 'rgb(216, 203, 166)', face);
+
+ // R57's window is a screen like any other, and it offers ALL six families
+ // rather than the five its siblings carry — including vfd, the marquee cyan.
+ const kpFams = await evl(`(()=>{
+ const chips = [...document.querySelectorAll('#card-R57 .fc')].map(c => c.title);
+ const want = ['amber','green','red','blue','vfd','white'];
+ return want.every(f => chips.includes(f)) ? 'ok' : 'got ' + JSON.stringify(chips);
+ })()`);
+ ok('R57 offers all six screen families', kpFams === 'ok', kpFams);
+
+ // Default is the shipped gold, unchanged until a chip is clicked. It reads the
+ // same as the amber family's --scr-hi because --gold-hi IS #ffbe54 — which is
+ // what makes the amber default pixel-identical rather than a near-miss.
+ const kpInk0 = await evl(`getComputedStyle(document.querySelector('#card-R57 .dupre-kp-pad text')).fill`);
+ ok('R57 default ink = gold-hi', kpInk0 === 'rgb(255, 190, 84)', kpInk0);
+
+ // vfd recolors both the ink and the window behind it — a screen is not just its text
+ await evl(`document.querySelector('#card-R57 .fc[title="vfd"]').click()`);
+ const kpInk1 = await evl(`getComputedStyle(document.querySelector('#card-R57 .dupre-kp-pad text')).fill`);
+ ok('R57 vfd chip recolors the ink to marquee cyan', kpInk1 === 'rgb(99, 230, 200)', kpInk1);
+ /* .dupre-kp-win, not the first rect on the pad — that one is the faceplate, and
+ reading it made this check report the plate's colour and fail for the wrong
+ reason. */
+ const kpBg = await evl(`getComputedStyle(document.querySelector('#card-R57 .dupre-kp-win')).fill`);
+ ok('R57 vfd chip recolors the window too', kpBg === 'rgb(6, 16, 13)', kpBg);
+
+ ok('no exceptions after chip clicks', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0);
+
+ // screenshot the recolored screens region (scroll R31 into view)
+ await evl(`document.querySelector('#card-R31 svg').scrollIntoView({block:'center'})`);
+ await sleep(400);
+ const shot = await send('Page.captureScreenshot', { format: 'png' });
+ writeFileSync('fams.png', Buffer.from(shot.result.data, 'base64'));
+ console.log('shot: fams.png');
+} catch (e) {
+ console.error('PROBE ERROR: ' + e.message);
+ fails.push('probe-error');
+} finally {
+ chrome.kill();
+}
+process.exit(fails.length ? 1 : 0);
diff --git a/tests/gallery-probes/probe-vstatus.mjs b/tests/gallery-probes/probe-vstatus.mjs
new file mode 100644
index 0000000..aefe43b
--- /dev/null
+++ b/tests/gallery-probes/probe-vstatus.mjs
@@ -0,0 +1,209 @@
+// CDP probe for the gallery's baked validation statuses (VSTATUS) — no playwright.
+// Usage: node probe-vstatus.mjs
+//
+// Runs against a throwaway --user-data-dir so localStorage starts empty every run:
+// the whole point of VSTATUS is what renders when there is no local state, so a
+// probe that inherited a profile's leftover gv-* keys would test nothing.
+import { spawn } from 'node:child_process';
+import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const PORT = 9334;
+const URL_FILE = '/home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html';
+const URL = 'file://' + URL_FILE;
+const PROFILE = mkdtempSync(join(tmpdir(), 'gallery-vstatus-'));
+
+const chrome = spawn('google-chrome-stable', [
+ '--headless=new', `--remote-debugging-port=${PORT}`,
+ `--user-data-dir=${PROFILE}`,
+ '--no-first-run', '--no-default-browser-check',
+ '--window-size=1600,1200', URL,
+], { stdio: 'ignore' });
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+let ws, id = 0;
+const pending = new Map();
+const events = [];
+
+async function connect() {
+ for (let i = 0; i < 40; i++) {
+ try {
+ const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
+ const page = list.find(t => t.type === 'page' && t.url.startsWith('file'));
+ if (page) { ws = new WebSocket(page.webSocketDebuggerUrl); break; }
+ } catch { /* retry */ }
+ await sleep(250);
+ }
+ if (!ws) throw new Error('no page target');
+ await new Promise(r => ws.onopen = r);
+ ws.onmessage = m => {
+ const d = JSON.parse(m.data);
+ if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); }
+ else if (d.method) events.push(d);
+ };
+}
+function send(method, params = {}) {
+ return new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
+}
+async function evl(expr) {
+ const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true });
+ if (r.result.exceptionDetails) throw new Error('eval failed: ' + JSON.stringify(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text));
+ return r.result.result.value;
+}
+async function reload() {
+ await send('Page.reload');
+ await sleep(1200);
+}
+const lampOf = no => `document.querySelector('#card-${no} .vlamp').dataset.v`;
+
+const fails = [];
+const ok = (name, cond, detail = '') => { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) fails.push(name); };
+
+try {
+ await connect();
+ await send('Runtime.enable');
+ await send('Page.enable');
+ await sleep(1500);
+
+ const errs = events.filter(e => e.method === 'Runtime.exceptionThrown');
+ ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200));
+
+ // 1. VSTATUS exists and is a plain record of no -> state
+ const shape = await evl(`(()=>{
+ if (typeof VSTATUS === 'undefined') return 'undefined';
+ if (typeof VSTATUS !== 'object' || VSTATUS === null || Array.isArray(VSTATUS)) return 'not-an-object';
+ const bad = Object.entries(VSTATUS).filter(([,v]) => !['amber','green'].includes(v));
+ return bad.length ? 'bad-values:' + JSON.stringify(bad.slice(0,3)) : 'ok';
+ })()`);
+ ok('VSTATUS is a no->state record of amber/green only', shape === 'ok', shape);
+
+ // 2. every baked entry names a real card
+ const orphans = await evl(`Object.keys(VSTATUS).filter(no => !document.getElementById('card-'+no))`);
+ ok('no baked entry names a missing card', orphans.length === 0, orphans.join(', '));
+
+ // 2b. the baked block's SOURCE TEXT is in DOM order — it is hand-pasted from
+ // GVexport, and a block written in some other order reshuffles wholesale on
+ // the next bake. This reads the file rather than the live object on purpose:
+ // JS hoists canonical integer keys ('12' before '01') on any object, so
+ // Object.keys can never report DOM order and asserting it would be
+ // unsatisfiable. Only the text on disk carries the property that matters.
+ const src = readFileSync(URL_FILE, 'utf8');
+ const block = src.match(/const VSTATUS=\{([\s\S]*?)\n\};/);
+ if (!block) {
+ ok('VSTATUS source block is findable', false, 'regex did not match');
+ } else {
+ const textKeys = [...block[1].matchAll(/"([^"]+)"\s*:/g)].map(m => m[1]);
+ const domOrder = await evl(`[...document.querySelectorAll('.vlamp')].map(l => l.closest('.card').id.slice(5))`);
+ const want = domOrder.filter(no => textKeys.includes(no));
+ ok('VSTATUS source text is in DOM order',
+ JSON.stringify(textKeys) === JSON.stringify(want),
+ `baked=${JSON.stringify(textKeys)} want=${JSON.stringify(want)}`);
+ }
+
+ // 3. with empty localStorage, baked entries render as their baked state
+ const mismatched = await evl(`Object.entries(VSTATUS)
+ .filter(([no,v]) => document.querySelector('#card-'+no+' .vlamp').dataset.v !== v)
+ .map(([no,v]) => no+':want='+v+',got='+document.querySelector('#card-'+no+' .vlamp').dataset.v)`);
+ ok('baked entries render on a fresh profile', mismatched.length === 0, mismatched.slice(0, 5).join(' '));
+
+ // 4. a card with no baked entry renders off
+ const unbaked = await evl(`(()=>{
+ const l = [...document.querySelectorAll('.vlamp')].find(l => !(l.closest('.card').id.slice(5) in VSTATUS));
+ return l ? l.closest('.card').id.slice(5) + '=' + l.dataset.v : 'none';
+ })()`);
+ ok('an unbaked card defaults to off', unbaked === 'none' || unbaked.endsWith('=off'), unbaked);
+
+ // 5. the tally's done count equals the green lamps on screen
+ const tally = await evl(`(()=>{
+ const green = document.querySelectorAll('.vlamp[data-v="green"]').length;
+ const row = document.querySelector('.vtally .vrow[data-v="green"] .vn');
+ return [green, row ? +row.textContent : -1];
+ })()`);
+ ok('tally done count matches green lamps', tally[0] === tally[1], `lamps=${tally[0]} tally=${tally[1]}`);
+
+ // 6. localStorage wins over the default. Holds with an empty VSTATUS, so this
+ // check is live from the first commit rather than waiting on a baked block.
+ const any = await evl(`(()=>{
+ const l = [...document.querySelectorAll('.vlamp')].find(l => !(l.closest('.card').id.slice(5) in VSTATUS));
+ return l ? l.closest('.card').id.slice(5) : null;
+ })()`);
+ if (any) {
+ await evl(`localStorage.setItem('gv-${any}','green'); ''`);
+ await reload();
+ const set = await evl(lampOf(any));
+ ok('localStorage sets an unbaked lamp', set === 'green', `card ${any} -> ${set}`);
+ await evl(`localStorage.removeItem('gv-${any}'); ''`);
+ await reload();
+ const cleared = await evl(lampOf(any));
+ ok('clearing an unbaked lamp returns it to off', cleared === 'off', `card ${any} -> ${cleared}`);
+ }
+
+ // 6b. localStorage beats baked, both ways — a deliberate un-check must survive a
+ // baked green, and clearing must fall back rather than stick at off.
+ const target = await evl(`Object.keys(VSTATUS)[0] || null`);
+ if (target) {
+ await evl(`localStorage.setItem('gv-${target}','off'); ''`);
+ await reload();
+ const got = await evl(lampOf(target));
+ ok('localStorage off overrides a baked entry', got === 'off', `card ${target} -> ${got}`);
+ await evl(`localStorage.removeItem('gv-${target}'); ''`);
+ await reload();
+ const back = await evl(lampOf(target));
+ const want = await evl(`VSTATUS['${target}']`);
+ ok('clearing localStorage falls back to baked', back === want, `card ${target} -> ${back}, want ${want}`);
+ } else {
+ console.log('SKIP baked-override checks — VSTATUS is empty (nothing baked yet)');
+ }
+
+ // 7. the export affordance reproduces what is on screen, in VSTATUS shape
+ const round = await evl(`(()=>{
+ if (typeof GVexport !== 'function') return 'no-GVexport';
+ let parsed;
+ try { parsed = JSON.parse(GVexport()); } catch (e) { return 'not-json: ' + e.message; }
+ const live = {};
+ document.querySelectorAll('.vlamp').forEach(l => {
+ const v = l.dataset.v; if (v !== 'off') live[l.closest('.card').id.slice(5)] = v;
+ });
+ return JSON.stringify(parsed) === JSON.stringify(live) ? 'ok'
+ : 'export=' + JSON.stringify(parsed).slice(0,120) + ' live=' + JSON.stringify(live).slice(0,120);
+ })()`);
+ ok('GVexport reproduces the live lamp state', round === 'ok', round);
+
+ // 7b. exported keys follow card order. JSON.stringify would hoist canonical
+ // integer keys ('12') above the rest ('01', 'R05'), reshuffling the baked
+ // block on every bake; the export builds its text by hand to avoid it.
+ const order = await evl(`(()=>{
+ const keys = [...GVexport().matchAll(/"([^"]+)":/g)].map(m => m[1]);
+ const dom = [...document.querySelectorAll('.vlamp')]
+ .filter(l => l.dataset.v !== 'off').map(l => l.closest('.card').id.slice(5));
+ return JSON.stringify(keys) === JSON.stringify(dom) ? 'ok'
+ : 'export=' + JSON.stringify(keys) + ' dom=' + JSON.stringify(dom);
+ })()`);
+ ok('exported keys follow card order', order === 'ok', order);
+
+ // 8. export tracks a click, so a mid-walk copy is current
+ const clicked = await evl(`(()=>{
+ const l = document.querySelector('.vlamp');
+ const no = l.closest('.card').id.slice(5);
+ l.click();
+ const v = l.dataset.v;
+ const parsed = JSON.parse(GVexport());
+ return (v === 'off' ? !(no in parsed) : parsed[no] === v) ? 'ok' : 'stale: lamp=' + v + ' export=' + JSON.stringify(parsed[no]);
+ })()`);
+ ok('GVexport tracks a lamp click', clicked === 'ok', clicked);
+
+ const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
+ ok('no exceptions after interaction', errs2.length === 0);
+} catch (e) {
+ console.error('PROBE ERROR: ' + e.message);
+ fails.push('probe-error');
+} finally {
+ // Wait for Chrome to actually exit before removing its profile — kill() only
+ // signals, and a browser still flushing its profile dir makes rmSync throw
+ // ENOTEMPTY, which would fail an otherwise-green run.
+ chrome.kill();
+ await Promise.race([new Promise(r => chrome.once('exit', r)), sleep(3000)]);
+ rmSync(PROFILE, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
+}
+process.exit(fails.length ? 1 : 0);
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
new file mode 100644
index 0000000..3db09a5
--- /dev/null
+++ b/tests/gallery-probes/probe.mjs
@@ -0,0 +1,1563 @@
+// CDP probe for the instrument gallery — no playwright, Node global WebSocket/fetch.
+// Usage: node probe.mjs [--shot out.png] [--size N]
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+
+const PORT = 9333;
+const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html';
+const shotIdx = process.argv.indexOf('--shot');
+const shotPath = shotIdx > -1 ? process.argv[shotIdx + 1] : null;
+
+const chrome = spawn('google-chrome-stable', [
+ '--headless=new', `--remote-debugging-port=${PORT}`,
+ '--no-first-run', '--no-default-browser-check',
+ '--window-size=1600,1200', URL,
+], { stdio: 'ignore' });
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+let ws, id = 0;
+const pending = new Map();
+const events = [];
+
+async function connect() {
+ for (let i = 0; i < 40; i++) {
+ try {
+ const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
+ const page = list.find(t => t.type === 'page' && t.url.startsWith('file'));
+ if (page) { ws = new WebSocket(page.webSocketDebuggerUrl); break; }
+ } catch { /* retry */ }
+ await sleep(250);
+ }
+ if (!ws) throw new Error('no page target');
+ await new Promise(r => ws.onopen = r);
+ ws.onmessage = m => {
+ const d = JSON.parse(m.data);
+ if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); }
+ else if (d.method) events.push(d);
+ };
+}
+function send(method, params = {}) {
+ return new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
+}
+async function evl(expr) {
+ const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true });
+ if (r.result.exceptionDetails) throw new Error('eval failed: ' + JSON.stringify(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text));
+ return r.result.result.value;
+}
+async function drag(x1, y1, x2, y2) {
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: x1, y: y1, button: 'left', clickCount: 1 });
+ const steps = 8;
+ for (let i = 1; i <= steps; i++)
+ await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: x1 + (x2 - x1) * i / steps, y: y1 + (y2 - y1) * i / steps, button: 'left' });
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: x2, y: y2, button: 'left', clickCount: 1 });
+}
+
+const fails = [];
+const ok = (name, cond, detail = '') => { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) fails.push(name); };
+
+try {
+ await connect();
+ await send('Runtime.enable');
+ await send('Page.enable');
+ await sleep(1500);
+
+ // 0. console errors / exceptions
+ const errs = events.filter(e => e.method === 'Runtime.exceptionThrown');
+ ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200));
+
+ // 1. card count (the instruments/row default is checked in 1h)
+ const cards = await evl(`document.querySelectorAll('.card').length`);
+ ok('111 cards', cards === 111, `got ${cards}`);
+
+ // 1b. Every card carries a spec sheet. R58 shipped without one and nothing
+ // noticed — the sheet is the card's actual specification, so a card without
+ // it is a drawing with no contract. Cheap to forget, cheap to check.
+ const sheets = await evl(`(()=>{
+ const missing = [...document.querySelectorAll('.card')]
+ .filter(c => !c.querySelector('.winfo'))
+ .map(c => c.id.slice(5));
+ return missing.length ? 'no spec sheet: ' + missing.join(' ') : 'ok';
+ })()`);
+ ok('every card has a spec sheet', sheets === 'ok', sheets);
+
+ // 1c. Card 07's lit colour comes from the shared accent family, not a hardcode.
+ // The chip said exactly one thing (gold) when "on" is good in one panel, a
+ // warning in the next and a fault in the one after. Checks the family is
+ // shared rather than copied: a second consumer must get the same five.
+ const accents = await evl(`(()=>{
+ const want = ['amber','green','red','white','vfd'];
+ const chips = [...document.querySelectorAll('#card-07 .fc')].map(c => c.title);
+ const missing = want.filter(w => !chips.includes(w));
+ if (missing.length) return 'card 07 missing accents: ' + missing.join(' ');
+ const built = Object.keys(DUPRE.accentStyles('--x'));
+ return JSON.stringify(built) === JSON.stringify(want) ? 'ok' : 'family is ' + JSON.stringify(built);
+ })()`);
+ ok('card 07 offers the shared accent family', accents === 'ok', accents);
+
+ // 1d. The chip actually recolours, and its default is untouched until asked —
+ // the same fallback discipline the screen families use.
+ const chipInk = await evl(`(()=>{
+ const chip = document.querySelector('#card-07 .dupre-chip');
+ const lit = () => getComputedStyle(chip).color;
+ const before = lit();
+ document.querySelector('#card-07 .fc[title="vfd"]').click();
+ const after = lit();
+ return before === 'rgb(226, 160, 56)' && after === 'rgb(99, 230, 200)'
+ ? 'ok' : before + ' -> ' + after;
+ })()`);
+ ok('card 07 defaults to gold and recolours on click', chipInk === 'ok', chipInk);
+
+ // 1e. The colour-policy backbone. Every instrument's colour is either free (the
+ // consumer picks) or locked for a stated reason, and the colour pass kept
+ // finding cards where "is this one a standard?" was answered from memory and
+ // wrong. So policy is declared on the builder and checked here. The invariant
+ // that matters: the accent recolour mechanism and the 'accent' declaration
+ // imply each other, so a locked card can't silently sprout accent chips and
+ // an accent card can't forget to say so. Coverage grows card by card; this
+ // round classifies the touched set, and the count is reported not gated.
+ const policy = await evl(`(()=>{
+ const KINDS = DUPRE.POLICIES;
+ if (!KINDS) return 'no POLICIES vocabulary';
+ const valid = new Set(Object.keys(KINDS));
+ const fns = Object.entries(DUPRE).filter(([,v]) => typeof v === 'function');
+ const declared = fns.filter(([,v]) => v.POLICY);
+ // each POLICY record is complete: a valid kind, a why, an authentic range
+ const shape = declared.filter(([,v]) => !valid.has(v.POLICY.kind) || !v.POLICY.why || !v.POLICY.authentic)
+ .map(([k,v]) => k + '(' + v.POLICY.kind + ')');
+ if (shape.length) return 'malformed policy record: ' + shape.join(' ');
+ // A recolour mechanism and an 'accent' kind imply each other, BOTH directions,
+ // and both directions must recognise the SAME mechanisms — or a locked card
+ // carrying a multi-colour on-family slips the reverse check. A recolour
+ // mechanism is a STYLES.accent axis or a STYLES.on family (slideToggle's shape).
+ const hasMech = v => !!(v.STYLES && (v.STYLES.accent || v.STYLES.on));
+ const mechNotAccent = fns.filter(([,v]) => hasMech(v) && (!v.POLICY || v.POLICY.kind !== 'accent')).map(([k]) => k);
+ if (mechNotAccent.length) return 'recolour mechanism but not declared accent: ' + mechNotAccent.join(' ');
+ const accentNoMech = declared.filter(([,v]) => v.POLICY.kind === 'accent' && !hasMech(v)).map(([k]) => k);
+ if (accentNoMech.length) return 'declared accent but no recolour mechanism: ' + accentNoMech.join(' ');
+ // a screen kind must actually read the screen vars
+ const screenNoVars = declared.filter(([,v]) => v.POLICY.kind === 'screen' && !v.toString().includes('--scr-')).map(([k]) => k);
+ if (screenNoVars.length) return 'declared screen but reads no --scr- vars: ' + screenNoVars.join(' ');
+ return 'ok (' + declared.length + ' classified)';
+ })()`);
+ ok('policy records valid, complete and mechanism-matched', policy.startsWith('ok'), policy);
+
+ // 1f. The policy is on the PAGE, not just in code and this probe. Each card
+ // shows a badge derived from its builder's COLOR (single source, so the
+ // page can't drift from the declaration), and the badge marks free vs
+ // locked vs unclassified. Checks a known classified card renders its policy
+ // and a locked/unclassified one renders honestly.
+ const shown = await evl(`(()=>{
+ const badge = no => document.querySelector('#card-' + no + ' .cpol');
+ const b07 = badge('07'), b10 = badge('R10');
+ if (!b07 || !b10) return 'no policy badge on card';
+ if (b07.textContent !== 'accent' || b07.dataset.free !== 'true') return 'card 07 badge: ' + b07.textContent + '/' + b07.dataset.free;
+ if (b10.textContent !== 'screen') return 'card R10 badge: ' + b10.textContent;
+ // an unclassified card shows the dash, not a stale or invented policy
+ const un = [...document.querySelectorAll('.cpol')].find(b => b.dataset.pol === 'none');
+ if (un && un.textContent !== '—') return 'unclassified badge should read dash: ' + un.textContent;
+ // the badge text always matches the builder's actual COLOR, never a copy
+ const drift = [...document.querySelectorAll('.card')].map(c => {
+ const b = c.querySelector('.cpol'); const want = c.dataset.cpol;
+ return (want === 'none' ? '—' : want) === b.textContent ? null : c.id;
+ }).filter(Boolean);
+ return drift.length ? 'badge drifts from data-cpol: ' + drift.slice(0,3).join(' ') : 'ok';
+ })()`);
+ ok('colour policy is shown on each card, derived not copied', shown === 'ok', shown);
+
+ // 1g. The index tally counts coverage the way the validation tally counts the
+ // walk, so "how far has the colour pass reached" is a glance, not a grep.
+ // free + locked + unclassified must sum to the card total.
+ const ptally = await evl(`(()=>{
+ const rows = [...document.querySelectorAll('#ptally .vrow[data-g] .vn')].map(v => +v.textContent);
+ const total = +document.querySelector('#ptally .vtot .vn').textContent;
+ const cards = document.querySelectorAll('.card').length;
+ if (total !== cards) return 'tally total ' + total + ' != ' + cards + ' cards';
+ if (rows.reduce((a,b)=>a+b,0) !== total) return 'free+locked+unclassified ' + rows + ' != ' + total;
+ return 'ok free=' + rows[0] + ' locked=' + rows[1] + ' unclassified=' + rows[2];
+ })()`);
+ ok('colour policy tally sums to the card total', ptally.startsWith('ok'), ptally);
+
+ // 1h. instruments/row: the control sets an explicit column count and the cards
+ // resize to match. One-per-row cards must be much wider than four-per-row,
+ // and the count actually reaches the grid.
+ ok('default is 3 instruments/row', await evl(`document.body.dataset.cols`) === '3');
+ await evl(`document.querySelector('.szbar .dupre-key[data-cols="1"]').click()`);
+ const w1col = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
+ const cols1 = await evl(`getComputedStyle(document.querySelector('.grid')).gridTemplateColumns.split(' ').length`);
+ await evl(`document.querySelector('.szbar .dupre-key[data-cols="4"]').click()`);
+ const w4col = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
+ const cols4 = await evl(`getComputedStyle(document.querySelector('.grid')).gridTemplateColumns.split(' ').length`);
+ ok('1/row is much wider than 4/row', w1col > w4col * 2.5, `w1=${Math.round(w1col)} w4=${Math.round(w4col)}`);
+ ok('the grid renders the chosen column count', cols1 === 1 && cols4 === 4, `1->${cols1} 4->${cols4}`);
+ ok('instruments/row chip flips state', await evl(`document.body.dataset.cols`) === '4');
+
+ // 3. behavioral, zoomed: fader drag on card 03 changes readout. Run at 2/row,
+ // not 1/row: at one-per-row the card is wide enough that its controls fall
+ // outside the 1600px probe window and the dispatched mouse events miss.
+ await evl(`document.querySelector('.szbar .dupre-key[data-cols="2"]').click()`);
+ await evl(`document.querySelectorAll('.card')[2].scrollIntoView({block:'center'}); ''`);
+ await sleep(200);
+ const fr = await evl(`(()=>{const c=document.querySelectorAll('.card')[2];const f=c.querySelector('.dupre-fader');const r=f.getBoundingClientRect();return [r.left,r.top,r.width,r.height];})()`);
+ const before = await evl(`document.getElementById('rd-03').textContent`);
+ await drag(fr[0] + fr[2] * 0.2, fr[1] + fr[3] / 2, fr[0] + fr[2] * 0.9, fr[1] + fr[3] / 2);
+ await sleep(150);
+ const after = await evl(`document.getElementById('rd-03').textContent`);
+ ok('fader drag tracks at 2/row', before !== after && after !== '—', `'${before}' -> '${after}'`);
+
+ // 4. behavioral, zoomed: toggle click on card 01
+ await evl(`document.querySelectorAll('.card')[0].scrollIntoView({block:'center'}); ''`);
+ await sleep(200);
+ const sw = await evl(`(()=>{const c=document.querySelectorAll('.card')[0];const s=c.querySelector('.switch')||c.querySelector('.stagew > *');const r=s.getBoundingClientRect();return [r.left+r.width/2,r.top+r.height/2];})()`);
+ const t0 = await evl(`document.getElementById('rd-01').textContent`);
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: sw[0], y: sw[1], button: 'left', clickCount: 1 });
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sw[0], y: sw[1], button: 'left', clickCount: 1 });
+ await sleep(150);
+ const t1 = await evl(`document.getElementById('rd-01').textContent`);
+ ok('toggle click responds at 2/row', t0 !== t1, `'${t0}' -> '${t1}'`);
+
+ // 5. card 02 console keys: reading order and per-key tone. LIVE is green
+ // because --pass is what the kit means by live everywhere else; gold stays
+ // the generic engaged look. Craig's call, 2026-07-16.
+ const order = await evl(`[...document.querySelectorAll('#card-02 .dupre-key')].map(b => b.textContent)`);
+ ok('card 02 keys read SCAN, LIVE, MUTED',
+ JSON.stringify(order) === JSON.stringify(['SCAN', 'LIVE', 'MUTED']), JSON.stringify(order));
+
+ const engaged = await evl(`(()=>{
+ const lit = [...document.querySelectorAll('#card-02 .dupre-key')].filter(b => /\\bdupre-(on|green|red)\\b/.test(b.className));
+ return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('dupre-key dupre-','') : 'lit=' + lit.length;
+ })()`);
+ ok('card 02 engages LIVE in green by default', engaged === 'LIVE:green', engaged);
+
+ const muted = await evl(`(()=>{
+ const keys = [...document.querySelectorAll('#card-02 .dupre-key')];
+ keys.find(b => b.textContent === 'MUTED').click();
+ const lit = keys.filter(b => /\\bdupre-(on|green|red)\\b/.test(b.className));
+ return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('dupre-key dupre-','') : 'lit=' + lit.length;
+ })()`);
+ ok('card 02 MUTED engages red and releases LIVE', muted === 'MUTED:red', muted);
+
+ const rd02 = await evl(`document.getElementById('rd-02').textContent`);
+ ok('card 02 readout tracks the engaged key', rd02 === 'MUTED', rd02);
+
+ // 6. card 01 slide toggle: the new tone atoms exist. red/warn on the `on` axis
+ // let the ENGAGED state be the notable one (mute, record, airplane) — before
+ // these, red was only reachable via the `off` axis, which colours the
+ // disengaged state instead. dim offText lets an off toggle recede in a dense
+ // panel. Craig's call, 2026-07-16.
+ const atoms = await evl(`(()=>{
+ const S = DUPRE.slideToggle.STYLES;
+ const want = { on: ['amber','green','dark','red','warn'], onText: ['panel','cream','green'],
+ off: ['dark','red'], offText: ['white','red','black','dim'],
+ thumb: ['light','dark','chrome','brass'] };
+ const missing = [];
+ for (const [axis, names] of Object.entries(want))
+ for (const n of names) if (!S[axis] || !S[axis][n]) missing.push(axis + '.' + n);
+ return missing.length ? 'missing: ' + missing.join(', ') : 'ok';
+ })()`);
+ ok('slide toggle style atoms present', atoms === 'ok', atoms);
+
+ // 7. presets name intent (a combination), not paint. Every axis a preset names
+ // must resolve in STYLES — a typo here would silently fall through to the
+ // stylesheet default and look "nearly right", which is the worst outcome.
+ const presets = await evl(`(()=>{
+ const P = DUPRE.slideToggle.PRESETS, S = DUPRE.slideToggle.STYLES;
+ if (!P) return 'no PRESETS';
+ const AX = DUPRE.slideToggle.AXIS_ORDER;
+ const bad = [];
+ for (const [name, p] of Object.entries(P)) {
+ for (const ax of AX) {
+ if (!p[ax]) bad.push(name + ' missing ' + ax);
+ else if (!S[ax][p[ax]]) bad.push(name + '.' + ax + '="' + p[ax] + '" not in STYLES');
+ }
+ }
+ return bad.length ? bad.join('; ') : Object.keys(P).join(',');
+ })()`);
+ ok('every preset names a resolvable style on every axis',
+ presets === 'panel,run,armed,caution,dark', presets);
+
+ // 7a. the dark preset: neither state lights the pill, so the legend colour is
+ // the only thing carrying state. Both backgrounds must stay dark while the
+ // inks diverge — if either pill lights, the preset has lost its point.
+ const dark = await evl(`(()=>{
+ const P = DUPRE.slideToggle.PRESETS.dark, S = DUPRE.slideToggle.STYLES;
+ const onBg = S.on[P.on].vars['--sw-on-bg'], offBg = S.off[P.off].vars['--sw-off-bg'];
+ const onInk = S.onText[P.onText].vars['--sw-on-ink'], offInk = S.offText[P.offText].vars['--sw-off-ink'];
+ if (onBg !== offBg) return 'pills differ: on=' + onBg + ' off=' + offBg;
+ if (onInk === offInk) return 'inks identical, state unreadable: ' + onInk;
+ return 'ok';
+ })()`);
+ ok('dark preset keeps both pills dark and the inks distinct', dark === 'ok', dark);
+
+ // 7b. the card claims the preset it is actually in. The instrument defaults to
+ // `panel`, so a preset group with nothing lit would assert "no preset
+ // active" — false, and exactly the kind of quiet mislabel this walk exists
+ // to catch. Nothing above this point touches card 01's chips (checks 2-4
+ // only toggle its switch, which does not restyle), so the default still
+ // stands here — but this must stay ABOVE checks 8/8b/9, which do mutate
+ // the chips. Insert preset-touching checks after them, not before.
+ const defaultPreset = await evl(`(()=>{
+ const pg = [...document.querySelectorAll('#card-01 .fgroup')]
+ .find(g => g.querySelector('.lab')?.textContent === 'preset');
+ if (!pg) return 'no preset group';
+ const lit = [...pg.querySelectorAll('.fc')].filter(c => c.classList.contains('on'));
+ return lit.length === 1 ? lit[0].title : 'lit=' + lit.length;
+ })()`);
+ ok('card 01 defaults to the panel preset', defaultPreset === 'panel', defaultPreset);
+
+ // 8. a preset chip drives the instrument AND re-syncs the axis chips, so the card
+ // never shows a combination the instrument isn't in.
+ const applied = await evl(`(()=>{
+ const card = document.getElementById('card-01');
+ const groups = [...card.querySelectorAll('.fgroup')];
+ const pg = groups.find(g => g.querySelector('.lab')?.textContent === 'preset');
+ if (!pg) return 'no preset group';
+ const armed = [...pg.querySelectorAll('.fc')].find(c => c.title === 'armed');
+ if (!armed) return 'no armed chip';
+ armed.click();
+ const onGroup = groups.find(g => g.querySelector('.lab')?.textContent === 'on');
+ const lit = [...onGroup.querySelectorAll('.fc')].filter(c => c.classList.contains('on'));
+ const brd = card.querySelector('.switch').style.getPropertyValue('--sw-on-brd');
+ return (lit.length === 1 ? lit[0].title : 'lit=' + lit.length) + '|' + brd;
+ })()`);
+ ok('armed preset drives instrument and syncs the on chip', applied === 'red|var(--fail)', applied);
+
+ // 8b. axes are not independent: onText overrides the ink `on` sets, so changing
+ // `on` after picking an onText must not silently revert the legend while its
+ // chip still shows lit. Drive it in the hazardous order and check the ink.
+ const orderSafe = await evl(`(()=>{
+ const card = document.getElementById('card-01');
+ const groups = [...card.querySelectorAll('.fgroup')];
+ const grp = l => groups.find(g => g.querySelector('.lab')?.textContent === l);
+ const chip = (l, t) => [...grp(l).querySelectorAll('.fc')].find(c => c.title === t);
+ chip('on text', 'green').click(); // legend green
+ chip('on', 'dark').click(); // then change the pill — must keep it
+ const ink = card.querySelector('.switch').style.getPropertyValue('--sw-on-ink');
+ const lit = [...grp('on text').querySelectorAll('.fc')].filter(c => c.classList.contains('on'));
+ return ink + '|' + (lit.length === 1 ? lit[0].title : 'lit=' + lit.length);
+ })()`);
+ ok('changing on keeps the chosen onText (chips cannot lie)',
+ orderSafe === 'var(--sevgrn)|green', orderSafe);
+
+ // 9. diverging on one axis clears the preset — the card stops claiming a preset
+ // it is no longer in. Re-establishes its own precondition (preset lit) rather
+ // than inheriting it: the checks above already clicked axis chips, each of
+ // which clears the preset, so asserting lit===0 without re-lighting first
+ // would pass against an already-cleared group and prove nothing — including
+ // against a regression where clearing fired for onText but not for `on`.
+ const diverged = await evl(`(()=>{
+ const groups = [...document.querySelectorAll('#card-01 .fgroup')];
+ const grp = l => groups.find(g => g.querySelector('.lab')?.textContent === l);
+ const litPresets = () => [...grp('preset').querySelectorAll('.fc')].filter(c => c.classList.contains('on')).length;
+ [...grp('preset').querySelectorAll('.fc')].find(c => c.title === 'run').click();
+ const before = litPresets();
+ [...grp('on').querySelectorAll('.fc')].find(c => c.title === 'green').click();
+ return before + '->' + litPresets();
+ })()`);
+ ok('changing an axis clears the preset selection', diverged === '1->0', diverged);
+
+ // 10. R57 ABC keypad — fills the taxonomy's text x alphanumeric empty cell.
+ // ABC order is the whole point: it is what industrial keypads do wherever
+ // the operator can't be assumed to touch-type, and it is what Craig's
+ // reference photos show. A QWERTY drift here would silently lose the idiom.
+ const abcOrder = await evl(`(()=>{
+ const letters = [...document.querySelectorAll('#card-R57 .dupre-kp-key')]
+ .map(k => k.dataset.k).filter(k => /^[A-Z]$/.test(k));
+ const want = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
+ return JSON.stringify(letters) === JSON.stringify(want)
+ ? 'ok' : 'got ' + letters.length + ': ' + letters.join('');
+ })()`);
+ ok('R57 carries A-Z in alphabetical order', abcOrder === 'ok', abcOrder);
+
+ const abcDigits = await evl(`(()=>{
+ const d = [...document.querySelectorAll('#card-R57 .dupre-kp-key')]
+ .map(k => k.dataset.k).filter(k => /^[0-9]$/.test(k));
+ return d.length === 10 ? 'ok' : 'got ' + d.length + ': ' + d.join('');
+ })()`);
+ ok('R57 carries a full 0-9 block', abcDigits === 'ok', abcDigits);
+
+ // 10b. LAYOUT, geometrically. The A-Z check above reads DOM order, which the
+ // builder controls by push order — it would pass with every key rendered
+ // in the wrong place. This reads actual x positions instead: letters own
+ // the left columns, digits the right, and the alphabet column-aligns with
+ // itself all the way down (the discontinuity Craig caught: A-L used to
+ // start at column 3 while M-X started at column 0).
+ const layout = await evl(`(()=>{
+ const x = k => {
+ const g = [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k);
+ return g ? Math.round(g.querySelector('rect').getBBox().x) : null;
+ };
+ const colStarts = ['A','D','G','J','M','S','Y'].map(x);
+ if (new Set(colStarts).size !== 1) return 'alphabet not column-aligned: ' + JSON.stringify(colStarts);
+ if (!(x('A') < x('1'))) return 'letters not left of digits: A=' + x('A') + ' 1=' + x('1');
+ if (!(x('DEL') > x('J'))) return 'DEL not in the block beside the digits: DEL=' + x('DEL') + ' J=' + x('J');
+ return 'ok';
+ })()`);
+ ok('R57 letters left, digits right, alphabet column-aligned', layout === 'ok', layout);
+
+ // 10c. DEL sits where the hand already is and CLR is exiled to the corner.
+ // Frequency and blast radius pull the same way: DEL is constant and costs
+ // one character, CLR is rare and costs the entry. Pinned because it is a
+ // deliberate inversion of where they started, easy to "tidy" back.
+ const reach = await evl(`(()=>{
+ const box = k => {
+ const g = [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k);
+ return g ? g.querySelector('rect').getBBox() : null;
+ };
+ const del = box('DEL'), clr = box('CLR'), ent = box('ENT');
+ if (!del || !clr || !ent) return 'missing key';
+ if (!(del.y < clr.y)) return 'DEL should sit above CLR: DEL.y=' + del.y + ' CLR.y=' + clr.y;
+ if (!(Math.abs(del.y - ent.y) < 1)) return 'DEL should share the ENT row';
+ return 'ok';
+ })()`);
+ ok('R57 DEL is in reach, CLR is in the corner', reach === 'ok', reach);
+
+ // 10d. The three function keys are a cost ladder — DEL takes one character
+ // back, CLR throws the entry away, ENT commits — so each must read as a
+ // different key before the legend is read. Checks they are mutually
+ // distinct and all differ from a plain cap, rather than naming a gradient:
+ // the palette may be retuned, the distinction may not collapse.
+ const ladder = await evl(`(()=>{
+ const fill = k => {
+ const g = [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k);
+ return g ? g.querySelector('rect').getAttribute('fill') : null;
+ };
+ const f = { DEL: fill('DEL'), CLR: fill('CLR'), ENT: fill('ENT'), plain: fill('A'), digit: fill('1') };
+ const fn = [f.DEL, f.CLR, f.ENT];
+ if (new Set(fn).size !== 3) return 'function keys not mutually distinct: ' + JSON.stringify(f);
+ if (fn.includes(f.plain) || fn.includes(f.digit)) return 'a function key wears a plain cap: ' + JSON.stringify(f);
+ return 'ok';
+ })()`);
+ ok('R57 DEL/CLR/ENT each read as their own key', ladder === 'ok', ladder);
+
+ // 11. typing accumulates, in order. A keypad that registers presses but drops
+ // or reorders them is the failure that matters for a password field.
+ const typed = await evl(`(()=>{
+ const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k);
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ for (const c of ['W','I','F','I','7']) key(c).dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ return document.getElementById('rd-R57').textContent;
+ })()`);
+ ok('R57 accumulates typed characters in order', typed.includes('WIFI7'), typed);
+
+ // 11b. DEL takes back ONE character. Without it the only way out of a typo is
+ // wiping the whole entry, which on a 20-character passphrase means
+ // starting over — so the check that matters is that DEL is not CLR.
+ const del = await evl(`(()=>{
+ const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k);
+ const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ click('CLR');
+ for (const c of ['C','A','B','S']) click(c);
+ click('DEL');
+ const back = document.getElementById('rd-R57').textContent;
+ for (let i = 0; i < 6; i++) click('DEL'); // past empty: must not throw or wrap
+ const floor = document.getElementById('rd-R57').textContent;
+ return back + ' | ' + floor;
+ })()`);
+ // Both halves are asserted: the earlier version computed the past-empty
+ // state and then never looked at it, so "stops at empty" was a promise in
+ // the name only — a DEL that wrapped the buffer would have passed.
+ ok('R57 DEL takes back one character, and stops at empty',
+ del.split(' | ')[0] === 'CAB' && del.split(' | ')[1] === 'empty', del);
+
+ // 11c. A space must be VISIBLE in the window. The buffer is honest either way,
+ // but SVG collapses trailing whitespace, so a space rendered as a space is
+ // a keypress with no feedback: the operator presses SPACE, sees nothing,
+ // presses again, and now carries two spaces they cannot see in a
+ // passphrase they can't read back. Checked past the 13-char truncation
+ // boundary, where there are no pad dots left for a space to displace.
+ const space = await evl(`(()=>{
+ const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k);
+ const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ const win = () => document.querySelector('#card-R57 text[font-size="14"]').textContent;
+ click('CLR');
+ for (let i = 0; i < 13; i++) click('A');
+ const before = win();
+ click('SPC');
+ const after = win();
+ if (before === after) return 'space produced no visible change: ' + JSON.stringify(after);
+ if (/ $/.test(after)) return 'space rendered as a raw trailing space (invisible): ' + JSON.stringify(after);
+ click('CLR');
+ return 'ok';
+ })()`);
+ ok('R57 a typed space is visible in the window', space === 'ok', space);
+
+ // 12. the two committing keys do different things: ENTER commits the buffer,
+ // CLEAR empties it. Types its own buffer rather than inheriting one from
+ // the checks above — they mutate it, so a check that assumed their leftovers
+ // would pass or fail on their behaviour instead of its own.
+ const committed = await evl(`(()=>{
+ const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k);
+ const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ click('CLR');
+ for (const c of ['N','E','T','5']) click(c);
+ click('ENT');
+ const after = document.getElementById('rd-R57').textContent;
+ click('CLR');
+ return after + ' | ' + document.getElementById('rd-R57').textContent;
+ })()`);
+ // Asserts the COMMIT SIGNAL ('ENTER · ' + buf), not merely that the buffer
+ // is still readable: typing the last character already put NET5 in the
+ // readout, so /NET5/ was true before ENT was ever pressed. That check
+ // passed with the ENT branch deleted (the key falls through to buf += 'ENT'
+ // and NET5 still matches) — it could not fail.
+ ok('R57 ENTER commits and CLEAR empties',
+ /^ENTER · NET5$/.test(committed.split(' | ')[0]) && !/NET5/.test(committed.split(' | ')[1]), committed);
+
+ // 13. KEYS is a declarative TABLE, not a function over a DOM event. The Emacs
+ // port installs this same table into a keymap — it never sees a keydown —
+ // so a function here would force it to re-derive the instrument's intent and
+ // the two bindings would drift. (README, keyboard contract.)
+ const keysTable = await evl(`(()=>{
+ const K = DUPRE.abcKeypad.KEYS;
+ if (!K) return 'no KEYS';
+ if (typeof K !== 'object' || Array.isArray(K)) return 'KEYS is not a plain table: ' + typeof K;
+ const missing = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'].filter(c => K[c] !== c);
+ if (missing.length) return 'unmapped or mis-mapped: ' + missing.join('');
+ const want = { Space: 'SPC', Backspace: 'DEL', Enter: 'ENT' };
+ for (const [k, v] of Object.entries(want)) if (K[k] !== v) return k + ' -> ' + K[k] + ', want ' + v;
+ return 'ok';
+ })()`);
+ ok('R57 KEYS is a declarative table covering the plate', keysTable === 'ok', keysTable);
+
+ // 13b. THE contract's first rule: no document-level listener. An instrument that
+ // binds globally types into itself from anywhere on a 110-card page.
+ // Typing at the body with the card unfocused must do nothing at all.
+ const unfocused = await evl(`(()=>{
+ const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k);
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ document.activeElement.blur();
+ for (const c of ['A','B','C']) document.body.dispatchEvent(
+ new KeyboardEvent('keydown', { key: c, bubbles: true, cancelable: true }));
+ return document.getElementById('rd-R57').textContent;
+ })()`);
+ ok('R57 ignores keys when it does not have focus', unfocused === 'cleared', unfocused);
+
+ // 13c. Focused, the same keys land — and land through press(), so click and key
+ // cannot drift apart.
+ const typedByKey = await evl(`(()=>{
+ const pad = document.querySelector('#card-R57 .dupre-kp-pad');
+ const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k);
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ pad.focus();
+ const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
+ ['n','e','t',' ','5'].forEach(send); // lowercase must normalise; space must map to SPC
+ const typed = document.getElementById('rd-R57').textContent;
+ send('Backspace');
+ const bs = document.getElementById('rd-R57').textContent;
+ send('Enter');
+ return typed + ' | ' + bs + ' | ' + document.getElementById('rd-R57').textContent;
+ })()`);
+ ok('R57 types from the keyboard when focused', typedByKey === 'NET 5 | NET | ENTER · NET ', typedByKey);
+
+ // 13c-2. Click and key must not drift. Both routes are supposed to land in the
+ // same press(), so the same sequence entered each way must produce an
+ // identical buffer and readout. Duplicated logic in the handler would
+ // pass every check above this one and fail here.
+ const drift = await evl(`(()=>{
+ const pad = document.querySelector('#card-R57 .dupre-kp-pad');
+ const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k);
+ const rd = () => document.getElementById('rd-R57').textContent;
+ const seq = ['A','B','SPC','7'];
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ seq.forEach(k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true})));
+ const byClick = rd() + '/' + document.getElementById('card-R57').dupre.get();
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ pad.focus();
+ ['A','B',' ','7'].forEach(k =>
+ pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })));
+ const byKey = rd() + '/' + document.getElementById('card-R57').dupre.get();
+ return byClick === byKey ? 'ok' : 'drift: click=' + byClick + ' key=' + byKey;
+ })()`);
+ ok('R57 click and key land in the same place', drift === 'ok', drift);
+
+ // 13c-3. press() is the allowlist, not the keydown handler. The handler guards
+ // the web; the Emacs port installs KEYS into a keymap and calls press
+ // directly, with no handler in the stack — so a press that trusts its
+ // caller is a hole in exactly the target the table exists for.
+ const pressGuard = await evl(`(()=>{
+ const card = document.getElementById('card-R57');
+ const h = card.dupre;
+ if (!h || !h.press) return 'no handle';
+ const key = k => [...card.querySelectorAll('.dupre-kp-key')].find(e => e.dataset.k === k);
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ h.press('F1'); h.press('ArrowLeft'); h.press('');
+ const junk = h.get();
+ h.press('A');
+ return junk === '' && h.get() === 'A' ? 'ok' : 'junk=' + JSON.stringify(junk) + ' then=' + JSON.stringify(h.get());
+ })()`);
+ ok('R57 press() filters junk from any caller, not just the keyboard', pressGuard === 'ok', pressGuard);
+
+ // 13c-4. Every key the table maps must be a real plate action, or the Emacs
+ // port installs a binding that silently does nothing.
+ const keysSubset = await evl(`(()=>{
+ const bad = Object.entries(DUPRE.abcKeypad.KEYS).filter(([,v]) => !DUPRE.abcKeypad.ACTIONS.has(v));
+ return bad.length ? 'KEYS maps to non-actions: ' + JSON.stringify(bad) : 'ok';
+ })()`);
+ ok('R57 every KEYS value is a real plate action', keysSubset === 'ok', keysSubset);
+
+ // 13d. preventDefault is spent only where there is a default worth killing.
+ // Space scrolls and Backspace navigates back, so those are claimed; Tab is
+ // how the page is navigable and Escape belongs to the audit stepper, so a
+ // instrument that swallows either breaks something it cannot see.
+ const defaults = await evl(`(()=>{
+ const pad = document.querySelector('#card-R57 .dupre-kp-pad');
+ pad.focus();
+ const fired = k => {
+ const e = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true });
+ pad.dispatchEvent(e);
+ return e.defaultPrevented;
+ };
+ const claimed = { Space: fired(' '), Backspace: fired('Backspace') };
+ /* 'A' and 'Enter' are MAPPED keys that must still not be claimed — they reach
+ the same code path as Space, so they are what catches a preventDefault
+ moved after the lookup. Tab/Escape/F1 return before it and would stay green
+ through that regression on their own. */
+ const free = { A: fired('A'), Enter: fired('Enter'), Tab: fired('Tab'), Escape: fired('Escape'), F1: fired('F1') };
+ if (!claimed.Space || !claimed.Backspace) return 'not claimed: ' + JSON.stringify(claimed);
+ if (Object.values(free).some(Boolean)) return 'swallowed: ' + JSON.stringify(free);
+ return 'ok';
+ })()`);
+ ok('R57 claims Space and Backspace, lets Tab/Escape through', defaults === 'ok', defaults);
+
+ // 13e. press() is an allowlist, not a mailbox: an unmapped key must not append.
+ const unmapped = await evl(`(()=>{
+ const pad = document.querySelector('#card-R57 .dupre-kp-pad');
+ const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k);
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ pad.focus();
+ ['F1','ArrowLeft','Home','é','!'].forEach(k =>
+ pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })));
+ return document.getElementById('rd-R57').textContent;
+ })()`);
+ ok('R57 drops keys that are not on the plate', unmapped === 'cleared', unmapped);
+
+ // 14. R58 index typewriter. THE check: selecting is not committing. Walking the
+ // stylus over the plate must print nothing at all — that separation IS the
+ // grammar, and it's the whole reason this card exists next to R57. If a cell
+ // click ever prints, the card has silently become a keypad with extra steps.
+ // Reads the BUFFER, not the card readout: the readout is supposed to change
+ // while hunting ("stylus over g"), and asserting on it would fail a correct
+ // instrument for showing the operator where the pointer is.
+ const grammar = await evl(`(()=>{
+ const cell = c => document.querySelector('#card-R58 .dupre-ix-cell[data-c="' + c + '"]');
+ const lever = document.querySelector('#card-R58 .dupre-ix-lever');
+ const buf = () => document.getElementById('card-R58').dupre.get();
+ document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ const empty = buf();
+ ['M','i','g'].forEach(c => cell(c).dispatchEvent(new MouseEvent('click', {bubbles:true})));
+ const afterSelecting = buf();
+ lever.dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ return JSON.stringify([empty, afterSelecting, buf()]);
+ })()`);
+ ok('R58 selecting prints nothing; only the lever commits', grammar === '["","","g"]', grammar);
+
+ // 14b. The lever prints whatever the stylus is resting on, once per pull — the
+ // operator's two hands are two separate acts.
+ const spelled = await evl(`(()=>{
+ const cell = c => document.querySelector('#card-R58 .dupre-ix-cell[data-c="' + c + '"]');
+ const lever = document.querySelector('#card-R58 .dupre-ix-lever');
+ document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ for (const c of ['M','i','g','n','o','n']) {
+ cell(c).dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ lever.dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ }
+ return document.getElementById('rd-R58').textContent;
+ })()`);
+ ok('R58 stylus + lever spells a word', spelled.includes('Mignon'), spelled);
+
+ // 14c. Pulling the lever twice prints the character twice: the selection stays
+ // put, which is what lets you type 'ss' without re-aiming.
+ const repeat = await evl(`(()=>{
+ const cell = c => document.querySelector('#card-R58 .dupre-ix-cell[data-c="' + c + '"]');
+ const lever = document.querySelector('#card-R58 .dupre-ix-lever');
+ document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ cell('s').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ lever.dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ lever.dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ return document.getElementById('card-R58').dupre.get();
+ })()`);
+ /* Exact, on the buffer. /ss$/ on the readout also matched 'sss', so it passed
+ even if selecting printed — unable to fail on the one bug this card is about. */
+ ok('R58 the lever repeats without re-aiming', repeat === 'ss', JSON.stringify(repeat));
+
+ // 14d. The plate's whole point, per Craig: it considered the characters a
+ // keyboard skips. Both cases with no shift key, plus accents, fractions and
+ // the section mark — that coverage is the idea being preserved from the
+ // Mignon, where the key ORDER deliberately is not.
+ // The plate is the Mignon's own, so the set is its set: both full cases,
+ // the accents and fractions a keyboard skips — and deliberately NO 1 and NO
+ // 0, because the machine has you type them with lowercase l and capital O.
+ // That economy is an old value worth keeping, so it's asserted rather than
+ // tolerated: adding a 1 key would be a silent departure from the reference.
+ const charset = await evl(`(()=>{
+ const have = new Set([...document.querySelectorAll('#card-R58 .dupre-ix-cell')].map(c => c.dataset.c));
+ const need = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ', ...'abcdefghijklmnopqrstuvwxyz',
+ ...'23456789', 'ä','ö','ü','§','½','¼','¾'];
+ const missing = need.filter(c => !have.has(c));
+ if (missing.length) return 'missing: ' + missing.join(' ');
+ const shouldNotExist = ['1','0'].filter(c => have.has(c));
+ if (shouldNotExist.length) return 'plate grew a key the Mignon economises away: ' + shouldNotExist.join(' ');
+ return 'ok';
+ })()`);
+ ok('R58 carries the Mignon set: both cases, accents, no 1 or 0', charset === 'ok', charset);
+
+ // 14j. The inversion is ZONAL, not per-case. The capitals block is dark discs on
+ // a light panel; the lowercase block is its photographic negative. But J is
+ // a capital sitting out on the ring, un-inverted — so a rule of "capitals
+ // are dark" is wrong, and only position decides.
+ const zones = await evl(`(()=>{
+ const disc = c => {
+ const g = [...document.querySelectorAll('#card-R58 .dupre-ix-cell')].find(e => e.dataset.c === c);
+ return g ? g.querySelector('circle').getAttribute('fill') : null;
+ };
+ const caps = disc('P'), lower = disc('p'), ringCap = disc('J'), ringPunct = disc('&');
+ if (caps === lower) return 'capitals and lowercase discs are the same: ' + caps;
+ if (ringCap !== ringPunct) return 'J should wear the ring look like punctuation: J=' + ringCap + ' &=' + ringPunct;
+ if (ringCap === caps) return 'J is inverted, but it sits on the ring, not in the block';
+ return 'ok';
+ })()`);
+ ok('R58 inversion is zonal: J is a capital but not inverted', zones === 'ok', zones);
+
+ // 14e. The layout is DATA, so revising the order is a table edit rather than a
+ // redraw. Craig has already said the keys will change; a layout welded into
+ // the drawing is one that never does.
+ const layoutData = await evl(`(()=>{
+ const L = DUPRE.indexPlate && DUPRE.indexPlate.LAYOUT;
+ if (!L) return 'no LAYOUT';
+ if (!Array.isArray(L) || !L.every(Array.isArray)) return 'LAYOUT is not a grid';
+ const cells = document.querySelectorAll('#card-R58 .dupre-ix-cell').length;
+ const declared = L.flat().filter(Boolean).length;
+ return cells === declared ? 'ok' : 'drawn ' + cells + ' but declared ' + declared;
+ })()`);
+ ok('R58 layout is a declared table the plate renders', layoutData === 'ok', layoutData);
+
+ // 14f. Nothing sits on top of the plate. The lever and CLR started life over the
+ // last column, burying characters and the PRINT legend, and every check
+ // above stayed green through it — geometry is invisible to behaviour. The
+ // gutter is sized off the layout table, so a wider plate must not slide the
+ // controls back onto the characters.
+ const clear = await evl(`(()=>{
+ const box = sel => { const e = document.querySelector('#card-R58 ' + sel); return e ? e.getBBox() : null; };
+ const cells = [...document.querySelectorAll('#card-R58 .dupre-ix-cell')].map(c => c.getBBox());
+ const right = Math.max(...cells.map(b => b.x + b.width));
+ const hits = [];
+ for (const sel of ['.dupre-ix-lever', '.dupre-ix-clear']) {
+ const b = box(sel);
+ if (!b) { hits.push(sel + ' missing'); continue; }
+ if (b.x < right) hits.push(sel + ' starts at ' + Math.round(b.x) + ', left of the plate edge ' + Math.round(right));
+ }
+ return hits.length ? hits.join('; ') : 'ok';
+ })()`);
+ ok('R58 lever and CLR clear the plate', clear === 'ok', clear);
+
+ // 14g. The gutter stack doesn't collide with itself. The x-only check above
+ // can't see this: the lever and legend are anchored to the plate's top and
+ // CLR to the viewBox, so a SHORTER layout table (five rows is a plausible
+ // edit) used to ride CLR up over the PRINT legend. Growth was always safe;
+ // shrink was the trap, which is why VH now takes a floor.
+ const stack = await evl(`(()=>{
+ const bb = sel => { const e = document.querySelector('#card-R58 ' + sel); return e ? e.getBBox() : null; };
+ const lever = bb('.dupre-ix-lever'), clr = bb('.dupre-ix-clear');
+ const legend = [...document.querySelectorAll('#card-R58 text')].find(t => t.textContent === 'PRINT');
+ if (!lever || !clr || !legend) return 'missing gutter part';
+ const lg = legend.getBBox();
+ const overlaps = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width &&
+ a.y < b.y + b.height && b.y < a.y + a.height;
+ if (overlaps(lever, clr)) return 'lever overlaps CLR';
+ if (overlaps(lg, clr)) return 'PRINT legend overlaps CLR';
+ if (overlaps(lg, lever)) return 'PRINT legend overlaps the lever';
+ const vb = document.querySelector('#card-R58 .dupre-ix-pad').viewBox.baseVal;
+ if (clr.y + clr.height > vb.height) return 'CLR falls outside the viewBox';
+ return 'ok';
+ })()`);
+ ok('R58 gutter stack does not collide or overflow', stack === 'ok', stack);
+
+ // 14h. The layout table has no duplicate characters. cells{} and KEYS{} are both
+ // keyed by character, so a repeat would silently keep only the last: two
+ // cells would render and both be clickable, but clicking the first would
+ // jump the stylus across the plate to the second. 14e can't see it — a
+ // duplicate inflates the drawn count and the declared count equally.
+ const dupes = await evl(`(()=>{
+ const flat = DUPRE.indexPlate.LAYOUT.flat().filter(Boolean);
+ const seen = new Set(), dup = new Set();
+ for (const c of flat) (seen.has(c) ? dup : seen).add(c);
+ return dup.size ? 'duplicated on the plate: ' + [...dup].join(' ') : 'ok';
+ })()`);
+ ok('R58 layout has no duplicate characters', dupes === 'ok', dupes);
+
+ // 14i. press() is the allowlist for R58 too. R57 has this check; without it,
+ // press('F1') reaching select() unfiltered would ship green.
+ const ixPress = await evl(`(()=>{
+ const h = document.getElementById('card-R58').dupre;
+ h.press('CLR');
+ ['F1', 'PRINT ', '', 'constructor', 'ZZ'].forEach(k => h.press(k));
+ return JSON.stringify([h.get(), h.selected()]);
+ })()`);
+ ok('R58 press() filters junk from any caller', ixPress.startsWith('[""'), ixPress);
+
+ // 15. R58's keymap comes OUT of the layout table rather than beside it, so
+ // relaying the plate can't leave a keybinding pointing at a character the
+ // plate no longer carries. Enter is the lever.
+ const ixKeys = await evl(`(()=>{
+ const K = DUPRE.indexPlate.KEYS, L = DUPRE.indexPlate.LAYOUT;
+ if (!K) return 'no KEYS';
+ const chars = L.flat().filter(Boolean);
+ const missing = chars.filter(c => K[c] !== c);
+ if (missing.length) return 'plate chars not mapped: ' + missing.join(' ');
+ if (K.Enter !== 'PRINT') return 'Enter -> ' + K.Enter + ', want PRINT';
+ const extra = Object.keys(K).filter(k => k !== 'Enter' && !chars.includes(k));
+ return extra.length ? 'maps keys not on the plate: ' + extra.join(' ') : 'ok';
+ })()`);
+ ok('R58 keymap is derived from the layout, Enter is the lever', ixKeys === 'ok', ixKeys);
+
+ // 15b. THE grammar again, now through the keyboard. Typing a letter must move
+ // the stylus and print NOTHING. If a keypress ever prints, the card has
+ // quietly become R57 with a nicer plate, and the one thing it exists to
+ // demonstrate is gone.
+ const ixType = await evl(`(()=>{
+ const pad = document.querySelector('#card-R58 .dupre-ix-pad');
+ const h = document.getElementById('card-R58').dupre;
+ const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
+ document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ pad.focus();
+ ['M','i','g'].forEach(send);
+ const afterTyping = h.get();
+ const resting = h.selected();
+ send('Enter');
+ return JSON.stringify([afterTyping, resting, h.get()]);
+ })()`);
+ ok('R58 typing selects, Enter prints', ixType === '["","g","g"]', ixType);
+
+ // 15c. The plate holds both cases, so nothing is uppercased on the way in:
+ // Shift picks the case because 'a' and 'A' are different cells. R57 has to
+ // uppercase; this one must not, and that difference is the plate's whole
+ // argument for having no shift key.
+ const ixCase = await evl(`(()=>{
+ const pad = document.querySelector('#card-R58 .dupre-ix-pad');
+ const h = document.getElementById('card-R58').dupre;
+ const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
+ document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ pad.focus();
+ send('a'); send('Enter');
+ send('A'); send('Enter');
+ return h.get();
+ })()`);
+ ok('R58 keeps case: a and A are different cells', ixCase === 'aA', ixCase);
+
+ // 15d. Space isn't on the plate, so it isn't ours: it must scroll the page like
+ // always. (The missing space cell is a known gap — the real Mignon has a
+ // separate space key.)
+ const ixSpace = await evl(`(()=>{
+ const pad = document.querySelector('#card-R58 .dupre-ix-pad');
+ pad.focus();
+ const e = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
+ pad.dispatchEvent(e);
+ return e.defaultPrevented ? 'swallowed space' : 'ok';
+ })()`);
+ ok('R58 leaves Space alone (not on the plate)', ixSpace === 'ok', ixSpace);
+
+ // 15e. Unfocused, it hears nothing — the contract's first rule, on the second
+ // instrument to take keys.
+ // Asserts nothing CHANGED, rather than expecting a cleared selection: CLR
+ // is fresh paper, and fresh paper doesn't move the operator's hand, so the
+ // stylus legitimately stays where the previous check left it.
+ const ixBlur = await evl(`(()=>{
+ const h = document.getElementById('card-R58').dupre;
+ document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ document.activeElement.blur();
+ const before = JSON.stringify([h.get(), h.selected()]);
+ ['Q','Z'].forEach(k => document.body.dispatchEvent(
+ new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })));
+ const after = JSON.stringify([h.get(), h.selected()]);
+ return before === after ? 'ok' : before + ' -> ' + after;
+ })()`);
+ ok('R58 ignores keys when unfocused', ixBlur === 'ok', ixBlur);
+
+ // 12. N20 split-flap — the flip is a mechanism, not a fade: cells step
+ // through the declared charset one flap at a time, retarget cleanly, and
+ // animate:false jumps straight to the target.
+ const flapApi = await evl(`(() => {
+ const h = document.querySelector('#card-N20').dupre;
+ return { reading: typeof h.reading, chars: typeof h.chars, set: typeof h.set };
+ })()`);
+ ok('N20 handle exposes reading() and chars', flapApi.reading === 'function' && flapApi.chars === 'string',
+ JSON.stringify(flapApi));
+
+ // Park the page's 1.5s demo ticker so it can't retarget mid-check
+ // (IH.flap is the ticker's handle reference; the card element keeps the real one).
+ await evl(`(() => { window.__realFlap = IH.flap; IH.flap = { next(){}, set(){}, tick(){} }; })()`);
+ // Let the board settle on a known grid first — the demo ticker left it
+ // anywhere, and every command cascades (there is deliberately no teleport
+ // path). setText is the deterministic seam; next() is random by design.
+ await evl(`document.querySelector('#card-N20').dupre.setText('SYSTEM\\nVOLUME\\nSIGNAL')`);
+ await sleep(6500);
+
+ // Start a cascade and sample the displayed reading every 15ms until stable.
+ await evl(`(() => {
+ const h = document.querySelector('#card-N20').dupre;
+ window.__n20 = { samples: [], anims: 0 };
+ h.setText('VOLUME\\nSIGNAL\\nRECORD'); // multi-step distances on several cells
+ window.__n20.n = 0;
+ window.__n20.timer = setInterval(() => {
+ window.__n20.samples.push(h.reading());
+ // Every 4th tick (keeps the callback light), count only WAAPI-driven
+ // animations: a CSSAnimation carries animationName, a WAAPI one doesn't,
+ // so the old flipdrop class swap can't satisfy this.
+ if (++window.__n20.n % 4 === 0 &&
+ document.querySelector('#card-N20').getAnimations({ subtree: true }).some(a => !a.animationName))
+ window.__n20.anims++;
+ }, 15);
+ })()`);
+ await sleep(6500);
+ const n20 = await evl(`(() => {
+ clearInterval(window.__n20.timer);
+ const h = document.querySelector('#card-N20').dupre;
+ return { samples: [...new Set(window.__n20.samples)], final: h.reading(), anims: window.__n20.anims,
+ chars: h.chars };
+ })()`);
+ ok('N20 cascade arrives at the commanded grid', n20.final === 'VOLUME\nSIGNAL\nRECORD', JSON.stringify(n20.final));
+ ok('N20 cascade passes through intermediate readings (never teleports)',
+ n20.samples.filter(s => s !== 'SYSTEM\nVOLUME\nSIGNAL' && s !== 'VOLUME\nSIGNAL\nRECORD').length >= 2,
+ n20.samples.length + ' distinct states');
+ ok('N20 flip runs WAAPI animations (a CSS class swap cannot satisfy this)', n20.anims > 0, 'anim samples ' + n20.anims);
+ {
+ // Cell 0 travels S->V; every observed transition must step +1 through the charset.
+ const seq = [...new Set(n20.samples.map(s => s[0]))];
+ let stepped = seq.length >= 3 ? 'ok' : 'too few states: ' + seq.join('');
+ for (let i = 1; i < seq.length && stepped === 'ok'; i++) {
+ const a = n20.chars.indexOf(seq[i - 1]), b = n20.chars.indexOf(seq[i]);
+ if (b !== (a + 1) % n20.chars.length) stepped = seq[i - 1] + '->' + seq[i] + ' is not one flap';
+ }
+ ok('N20 cell 0 steps one flap at a time in charset order', stepped === 'ok', stepped);
+ }
+
+ // Retarget mid-cascade: aim at word 2, then word 3 while still spinning; must land on 3.
+ await evl(`(() => {
+ const h = document.querySelector('#card-N20').dupre;
+ window.__n20r = { samples: [] };
+ window.__n20r.timer = setInterval(() => window.__n20r.samples.push(h.reading()), 15);
+ h.setText('SIGNAL\\nRECORD\\nONLINE'); setTimeout(() => h.setText('RECORD\\nONLINE\\nSTEREO'), 180);
+ })()`);
+ await sleep(7000);
+ const n20b = await evl(`(() => {
+ clearInterval(window.__n20r.timer);
+ const h = document.querySelector('#card-N20').dupre;
+ return { final: h.reading().split('\\n')[0], target: 'RECORD',
+ sawSync: window.__n20r.samples.some(s => s.split('\\n')[0] === 'SIGNAL'),
+ anims: document.querySelector('#card-N20').getAnimations({ subtree: true }).length };
+ })()`);
+ ok('N20 retarget mid-cascade lands on the new target', n20b.final === n20b.target, n20b.final);
+ ok('N20 retarget re-aims rather than queueing (SIGNAL never fully forms)', !n20b.sawSync, n20b.sawSync ? 'saw SIGNAL' : 'ok');
+ ok('N20 no cascade left running after arrival', n20b.anims === 0, 'live anims ' + n20b.anims);
+
+ // The demo scramble: next() sends each row to a random pool word — every
+ // row changes, rows stay mutually distinct, all drawn from the pool.
+ const n20s = await evl(`(() => {
+ const el = document.createElement('div'); document.body.appendChild(el);
+ const pool = ['SYSTEM','VOLUME','SIGNAL','RECORD','ONLINE','STEREO','FILTER','OUTPUT'];
+ const h = DUPRE.splitFlap(el, { rows: 3, cells: 6, words: pool, animate: false });
+ const grids = [h.reading().split('\\n')];
+ h.next(); grids.push(h.reading().split('\\n'));
+ h.next(); grids.push(h.reading().split('\\n'));
+ el.remove();
+ for (const g of grids) {
+ if (new Set(g).size !== 3) return 'rows not distinct: ' + g.join(',');
+ if (!g.every(w => pool.includes(w.trim()))) return 'word outside pool: ' + g.join(',');
+ }
+ for (let i = 1; i < grids.length; i++)
+ for (let r = 0; r < 3; r++)
+ if (grids[i][r] === grids[i - 1][r]) return 'row ' + r + ' kept its word: ' + grids[i][r];
+ return 'ok';
+ })()`);
+ ok('N20 scramble sends every row to a different pool word', n20s === 'ok', n20s);
+
+ // animate:false (the reduced-motion path) jumps straight to the target.
+ const n20c = await evl(`(() => {
+ const el = document.createElement('div'); document.body.appendChild(el);
+ const settles = [];
+ const h = DUPRE.splitFlap(el, { animate: false, onSettle: r => settles.push(r) });
+ h.set(1);
+ const r = h.reading(); el.remove();
+ return { r, settles };
+ })()`);
+ ok('N20 animate:false jumps straight to the target', n20c.r.trim() === 'LINK', n20c.r);
+ ok('N20 settle announces once per command', n20c.settles.length === 1 && n20c.settles[0].trim() === 'LINK',
+ JSON.stringify(n20c.settles));
+
+ // The card is a three-row six-letter board; the demo draws random pool words.
+ const n20d = await evl(`(() => {
+ const h = document.querySelector('#card-N20').dupre;
+ return { cells: document.querySelectorAll('#card-N20 .flapd').length,
+ lines: document.querySelectorAll('#card-N20 .flapline').length,
+ readLines: h.reading().split('\\n').length };
+ })()`);
+ ok('N20 board is three rows of six cells', n20d.cells === 18 && n20d.lines === 3 && n20d.readLines === 3,
+ JSON.stringify(n20d));
+
+ // Skin axis: dark (cream on black) and the inverse light (black on cream),
+ // switched by the chip rig via setStyle, computed ink actually inverting.
+ const n20e = await evl(`(() => {
+ const h = document.querySelector('#card-N20').dupre;
+ if (typeof h.setStyle !== 'function' || !DUPRE.splitFlap.STYLES || !DUPRE.splitFlap.STYLES.skin) return 'no skin axis';
+ const skins = Object.keys(DUPRE.splitFlap.STYLES.skin);
+ if (skins[0] !== 'paper') return 'paper must list first, got: ' + skins.join(',');
+ if (!['dark','white','light','paper'].every(k => skins.includes(k))) return 'skins are: ' + skins.join(',');
+ const ink = () => getComputedStyle(document.querySelector('#card-N20 .fh')).color;
+ const lum = c => { const m = c.match(/[0-9]+/g).map(Number); return m[0] + m[1] + m[2]; };
+ const paperDef = ink(); // the card's default skin is the white card, dark letters
+ if (lum(paperDef) >= 300) return 'default ink is not dark-on-paper: ' + paperDef;
+ if (!document.querySelector('#card-N20 .flap').classList.contains('flap-paper')) return 'default skin class is not paper';
+ h.setStyle('skin', 'white');
+ const white = ink();
+ if (white !== 'rgb(255, 255, 255)') return 'white skin ink is ' + white;
+ h.setStyle('skin', 'dark');
+ const cream = ink();
+ h.setStyle('skin', 'light');
+ const light = ink();
+ const gotClass = document.querySelector('#card-N20 .flap').classList.contains('flap-light');
+ h.setStyle('skin', 'paper');
+ const back = ink();
+ if (!gotClass) return 'light skin class missing';
+ if (!(lum(cream) > 400 && lum(cream) < 760 && lum(light) < 300)) return 'inks wrong: ' + cream + ' / ' + light;
+ if (back !== paperDef) return 'did not restore the paper default: ' + back;
+ return 'ok';
+ })()`);
+ ok('N20 skins: paper default first, white ink, cream, ivory — all switch and restore', n20e === 'ok', n20e);
+
+ const n20fnt = await evl(`(() => {
+ const h = document.querySelector('#card-N20').dupre;
+ if (!DUPRE.splitFlap.STYLES.font) return 'no font axis';
+ const fam = () => getComputedStyle(document.querySelector('#card-N20 .fh')).fontFamily;
+ const helv = fam(); // the card's default face is Helvetica
+ if (!/helvetica/i.test(helv)) return 'default face is not helvetica: ' + helv;
+ h.setStyle('font', 'mono');
+ const mono = fam();
+ h.setStyle('font', 'helv');
+ const back = fam();
+ if (!/berkeley|mono/i.test(mono) || /helvetica/i.test(mono)) return 'mono did not apply: ' + mono;
+ if (back !== helv) return 'did not restore the helv default: ' + back;
+ return 'ok';
+ })()`);
+ ok('N20 face: Helvetica default, Berkeley Mono one click away', n20fnt === 'ok', n20fnt);
+ const n20f = await evl(`(() => {
+ const chips = [...document.querySelectorAll('#card-N20 .famchips .fc, #card-N20 .famchips [title]')].map(c => c.title || c.textContent);
+ return chips.length ? chips.join(',') : 'no chips';
+ })()`);
+ ok('N20 card offers skin and font chips',
+ ['dark','white','light','paper','mono','helv'].every(t => n20f.includes(t)), n20f);
+ const n20h = await evl(`(() => {
+ const rd = document.querySelector('#card-N20 .wrd');
+ const slider = document.querySelector('#card-N20 .famchips input[type=range]');
+ if (rd && getComputedStyle(rd).display !== 'none') return 'readout still visible';
+ if (!slider) return 'slider not in the chips row';
+ return 'ok';
+ })()`);
+ ok('N20 board is the only display: readout hidden, slider rides with the chips', n20h === 'ok', n20h);
+
+ // Flap speed: the handle takes setFlapMs and the card's slider drives it.
+ const n20v = await evl(`(() => {
+ const h = document.querySelector('#card-N20').dupre;
+ if (typeof h.setFlapMs !== 'function' || typeof h.flapMs !== 'function') return 'no speed api';
+ const before = h.flapMs();
+ if (before !== 100) return 'default flap rate is ' + before + ', want 100';
+ h.setFlapMs(120);
+ if (h.flapMs() !== 120) return 'setFlapMs did not take: ' + h.flapMs();
+ const slider = document.querySelector('#card-N20 input[type=range]');
+ if (!slider) { h.setFlapMs(before); return 'no slider on the card'; }
+ slider.value = '90'; slider.dispatchEvent(new Event('input', { bubbles: true }));
+ const viaSlider = h.flapMs();
+ h.setFlapMs(before); slider.value = String(before);
+ return viaSlider === 90 ? 'ok' : 'slider did not drive setFlapMs: ' + viaSlider;
+ })()`);
+ ok('N20 flap speed: setFlapMs api + card slider drive the rate', n20v === 'ok', n20v);
+
+ // Unpark the ticker, restore the demo state, and measure the dwell: the
+ // board must settle, hold the reading ~2s, and only then take the next update.
+ await evl(`(() => {
+ IH.flap = window.__realFlap;
+ const h = document.querySelector('#card-N20').dupre;
+ window.__n20dwell = { settleAt: 0, changeAt: 0, settled: '' };
+ h.onSettle(r => { if (!window.__n20dwell.settleAt) { window.__n20dwell.settleAt = performance.now(); window.__n20dwell.settled = r; } });
+ window.__n20dwell.timer = setInterval(() => {
+ const d = window.__n20dwell;
+ if (d.settleAt && !d.changeAt && h.reading() !== d.settled) { d.changeAt = performance.now(); clearInterval(d.timer); }
+ }, 50);
+ h.set(0);
+ })()`);
+ await sleep(10500);
+ const dwell = await evl(`(() => {
+ const d = window.__n20dwell; clearInterval(d.timer);
+ return { settleAt: d.settleAt, delta: d.changeAt ? d.changeAt - d.settleAt : -1 };
+ })()`);
+ ok('N20 settles, then dwells ~2s before the next update',
+ dwell.settleAt > 0 && dwell.delta >= 1800 && dwell.delta <= 4000, 'delta ' + Math.round(dwell.delta));
+
+ // Skin chips carry visible swatch dots (the rig paints STYLES[..].dot;
+ // a string-valued STYLES entry leaves both chips transparent and identical).
+ const n20g = await evl(`(() => {
+ const chips = [...document.querySelectorAll('#card-N20 .famchips .fc')];
+ if (chips.length < 6) return 'chips missing: ' + chips.length;
+ // each chip is a miniature flap cell: board colour + a letter in the ink
+ const pair = c => getComputedStyle(c).backgroundColor + '/' + getComputedStyle(c, '::after').color;
+ const skins = chips.slice(0, 4).map(pair);
+ if (new Set(skins).size !== 4) return 'skin previews not distinct: ' + skins.join(' ');
+ if (chips.slice(0, 4).some(c => getComputedStyle(c, '::after').content === 'none')) return 'skin chip has no ink letter';
+ const fams = chips.slice(4).map(c => getComputedStyle(c, '::after').fontFamily);
+ if (!fams.some(f => /helvetica/i.test(f)) || fams[0] === fams[1]) return 'font previews wrong: ' + fams.join(' vs ');
+ const size = parseFloat(getComputedStyle(chips[0]).width);
+ return size >= 15 ? 'ok' : 'chips too small to read: ' + size;
+ })()`);
+ ok('N20 chips are miniature flap cells: board colour, ink letter, real face', n20g === 'ok', n20g);
+
+ // N24 jewels — the handle reads and drives lamp state. Waybar-bound: a
+ // consumer must be able to set a jewel without synthesizing clicks.
+ const jw = await evl(`(() => {
+ const h = document.getElementById('card-N24').dupre;
+ if (!h.get || !h.set) return 'no get/set on handle';
+ const init = h.get().join(',');
+ if (init !== '0,1,2,-1') return 'initial ' + init;
+ h.set(3, 1);
+ if (h.get()[3] !== 1) return 'set(3,1) not reflected: ' + h.get().join(',');
+ const el = document.querySelectorAll('#card-N24 .dupre-jewel')[3];
+ if (!el) return 'no .dupre-jewel elements';
+ if (el.classList.contains('dupre-dim')) return 'jewel 3 still dark after set(3,1)';
+ h.set(3, -1);
+ if (!el.classList.contains('dupre-dim')) return 'set(3,-1) did not dark';
+ if (h.get()[3] !== -1) return 'get after dark: ' + h.get().join(',');
+ return 'ok';
+ })()`);
+ ok('N24 jewels handle exposes get/set', jw === 'ok', jw);
+
+ // Under prefers-reduced-motion the board paints once and RESTS — the dwell
+ // chain must stay gated off, or the panel self-advances every 2s forever.
+ await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
+ await send('Page.reload');
+ await sleep(2500);
+ const rm1 = await evl(`document.querySelector('#card-N20').dupre.reading()`);
+ await sleep(5200);
+ const rm2 = await evl(`(() => ({ r: document.querySelector('#card-N20').dupre.reading(), anims: document.getAnimations().length }))()`);
+ ok('N20 rests under reduced motion (no self-advancing dwell chain)',
+ rm1 === rm2.r && rm2.anims === 0, JSON.stringify({ before: rm1, after: rm2.r, anims: rm2.anims }));
+
+ // Every card's ID badge is its own anchor: click it and the URL carries
+ // the card's deep link.
+ const anchors = await evl(`(() => {
+ const bad = [...document.querySelectorAll('.card .wname .no')]
+ .filter(n => n.tagName !== 'A' || n.getAttribute('href') !== '#' + n.closest('.card').id)
+ .map(n => n.textContent);
+ const count = document.querySelectorAll('.card .wname a.no').length;
+ if (bad.length) return 'not anchors: ' + bad.slice(0, 5).join(' ');
+ return count === document.querySelectorAll('.card').length ? 'ok' : 'anchor count ' + count;
+ })()`);
+ ok('every card ID badge links to its own anchor', anchors === 'ok', anchors);
+
+ // Batch-3 set() hardening: out-of-range input cannot wedge an instrument.
+ // presetBank clamps; dualKnob clamps both spindles. Built on detached hosts
+ // so the gallery's own cards stay untouched.
+ const clamps = await evl(`(() => {
+ const pb = DUPRE.presetBank(document.createElement('div'));
+ pb.set(99); const hi = pb.get();
+ pb.set(-5); const lo = pb.get();
+ const dk = DUPRE.dualKnob(document.createElement('div'));
+ dk.set(500, -500); const [o, i] = dk.get();
+ return JSON.stringify({ hi, lo, o, i });
+ })()`);
+ ok('presetBank and dualKnob set() clamp out-of-range input',
+ clamps === JSON.stringify({ hi: 3, lo: 0, o: 100, i: 0 }), clamps);
+
+ // Batch-4 set() hardening, same detached-host shape: vernierDial and
+ // flutedKnob clamp 0-100; filterBank clamps the band index (an out-of-range
+ // band lands on the last, it doesn't throw); multiBandDial clamps the band
+ // to its rings and keeps the current band when set() omits it.
+ const clamps4 = await evl(`(() => {
+ const vd = DUPRE.vernierDial(document.createElement('div'));
+ vd.set(500); const vHi = vd.get();
+ vd.set(-5); const vLo = vd.get();
+ const fk = DUPRE.flutedKnob(document.createElement('div'));
+ fk.set(500); const fHi = fk.get();
+ const fb = DUPRE.filterBank(document.createElement('div'));
+ fb.set(99, 30); const fbLast = fb.get()[11];
+ const mb = DUPRE.multiBandDial(document.createElement('div'));
+ mb.set(50, 99); const [, bHi] = mb.get();
+ mb.set(70); const [v2, bKeep] = mb.get();
+ return JSON.stringify({ vHi, vLo, fHi, fbLast, bHi, v2, bKeep });
+ })()`);
+ ok('batch-4 set() clamps: vernier/fluted 0-100, filterBank + multiBand band index',
+ clamps4 === JSON.stringify({ vHi: 100, vLo: 0, fHi: 100, fbLast: 30, bHi: 3, v2: 70, bKeep: 3 }), clamps4);
+
+ // The batch-4 rename holds end to end: a detached dipBank round-trips its
+ // word through the dupre- prefixed switch classes, and jogWheel's cached
+ // inner disc actually rotates on set.
+ const b4dom = await evl(`(() => {
+ const db = DUPRE.dipBank(document.createElement('div'));
+ db.set('101');
+ const jw = DUPRE.jogWheel(document.createElement('div'));
+ jw.set(10);
+ const inner = jw.el.querySelector('.dupre-inner');
+ return JSON.stringify({ word: db.get(), sw: db.el.querySelectorAll('.dupre-dipsw').length,
+ rot: inner ? inner.style.transform : 'missing' });
+ })()`);
+ ok('dipBank word round-trips and jogWheel rotates via dupre- classes',
+ b4dom === JSON.stringify({ word: '101000', sw: 6, rot: 'rotate(40deg)' }), b4dom);
+
+ // Batch-5 set() hardening, same detached-host shape: ledRow, pillSlide and
+ // discSwitch clamp their index (discSwitch used to throw); waveRegion keeps
+ // E at least 4 above S however set() is called, not only on the drag path.
+ // ledRow's default index is 5 only for the default program list — a caller's
+ // own items start at 0 (the consoleKeys default-active rule).
+ const clamps5 = await evl(`(() => {
+ const lr = DUPRE.ledRow(document.createElement('div'));
+ lr.set(99); const lrHi = lr.get();
+ lr.set(-3); const lrLo = lr.get();
+ const lrDef = DUPRE.ledRow(document.createElement('div')).get();
+ const lrOwn = DUPRE.ledRow(document.createElement('div'), { items: ['A', 'B', 'C'] }).get();
+ const ps = DUPRE.pillSlide(document.createElement('div'));
+ ps.set(9); const psHi = ps.get();
+ ps.set(-1); const psLo = ps.get();
+ let dsHi;
+ try { const ds = DUPRE.discSwitch(document.createElement('div')); ds.set(9); dsHi = ds.get(); }
+ catch (e) { dsHi = 'threw'; }
+ const wr = DUPRE.waveRegion(document.createElement('div'));
+ wr.set(80, 20); const { s: wrS, e: wrE } = wr.get();
+ wr.set(-10, 200); const { s: wrS2, e: wrE2 } = wr.get();
+ return JSON.stringify({ lrHi, lrLo, lrDef, lrOwn, psHi, psLo, dsHi, wrS, wrE, wrS2, wrE2 });
+ })()`);
+ ok('batch-5 set() clamps: ledRow/pillSlide/discSwitch index, waveRegion S/E order',
+ clamps5 === JSON.stringify({ lrHi: 7, lrLo: 0, lrDef: 5, lrOwn: 0, psHi: 2, psLo: 0, dsHi: 2, wrS: 80, wrE: 84, wrS2: 0, wrE2: 100 }), clamps5);
+
+ // entryKeypad buffer round-trip on a detached host: digits accumulate, the
+ // buffer caps at 6, keys outside the contract's set are ignored, ✗ clears,
+ // ✓ commits and clears.
+ const b5dom = await evl(`(() => {
+ const kp = DUPRE.entryKeypad(document.createElement('div'));
+ ['1', '2', '3'].forEach(k => kp.press(k)); const buf3 = kp.get();
+ kp.press('X'); const stray = kp.get();
+ ['4', '5', '6', '7'].forEach(k => kp.press(k)); const cap = kp.get();
+ kp.press('✗'); const clr = kp.get();
+ kp.press('1'); kp.press('✓'); const commit = kp.get();
+ return JSON.stringify({ buf3, stray, cap, clr, commit });
+ })()`);
+ 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);
+
+ // 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);
+
+ // batch-8 domain gates on detached hosts: the needle meters clamp set() to
+ // their documented 0-100, stripChart floors its history at two samples and
+ // survives a non-array set(), battCells floors its cell count at one.
+ const clamps8 = await evl(`(() => {
+ const xn = DUPRE.crossNeedle(document.createElement('div'));
+ xn.set(150); const xnHi = xn.get(); xn.set(-10); const xnLo = xn.get();
+ const th = DUPRE.thermometer(document.createElement('div'));
+ th.set(-5); const thLo = th.get();
+ const bd = DUPRE.bourdon(document.createElement('div'));
+ bd.set(200); const bdHi = bd.get();
+ const st = DUPRE.stripChart(document.createElement('div'), { samples: 1 });
+ st.set(null, 0.5);
+ const stPts = st.el.querySelector('polyline').getAttribute('points');
+ let cmTxt; const cm = DUPRE.corrMeter(document.createElement('div'), { onChange: (v, t) => cmTxt = t });
+ cm.set(200); const cmHi = cm.get();
+ const bc = DUPRE.battCells(document.createElement('div'), { cells: -3 });
+ const bcCells = bc.el.querySelectorAll('.dupre-cell').length;
+ bc.set(-10); const bcLo = bc.get();
+ const vu = DUPRE.mcVu(document.createElement('div'), { value: 5 });
+ const vuInit = vu.get();
+ const vuNdl = vu.el.querySelector('line[stroke-width="1.6"]').getAttribute('transform');
+ return JSON.stringify({ xnHi, xnLo, thLo, bdHi, stOk: !stPts.includes('NaN'), cmHi, cmTxt, bcCells, bcLo, vuInit, vuNdl });
+ })()`);
+ ok('batch-8 domain gates: needle-meter clamps, stripChart array/floor guards, battCells cell floor, mcVu init',
+ clamps8 === JSON.stringify({ xnHi: 100, xnLo: 0, thLo: 0, bdHi: 100, stOk: true,
+ cmHi: 100, cmTxt: '+1.00', bcCells: 1, bcLo: 0, vuInit: 1.02, vuNdl: 'rotate(43,75,112)' }), clamps8);
+
+ // batch-8 state through the prefixed DOM (catches rename stragglers): the
+ // battery lights dupre-on cells and tints dupre-warn under the threshold,
+ // the crossed needles and mercury column track set() through their renamed
+ // internals.
+ const b8dom = await evl(`(() => {
+ const bc = DUPRE.battCells(document.createElement('div'));
+ const nCells = bc.el.querySelectorAll('.dupre-cell').length;
+ const on62 = bc.el.querySelectorAll('.dupre-cell.dupre-on').length;
+ const warn62 = bc.el.querySelectorAll('.dupre-warn').length;
+ bc.set(20);
+ const on20 = bc.el.querySelectorAll('.dupre-cell.dupre-on').length;
+ const warn20 = bc.el.querySelectorAll('.dupre-cell.dupre-warn.dupre-on').length;
+ const xn = DUPRE.crossNeedle(document.createElement('div'));
+ xn.set(0);
+ const fwdT = xn.el.querySelector('.dupre-fwd').style.transform;
+ const rflT = xn.el.querySelector('.dupre-rfl').style.transform;
+ const th = DUPRE.thermometer(document.createElement('div'));
+ th.set(75);
+ const thH = th.el.querySelector('.dupre-thermo-fill').style.height;
+ const bd = DUPRE.bourdon(document.createElement('div'));
+ bd.set(50);
+ const bdT = bd.el.querySelector('.dupre-bourdon-ndl').style.transform;
+ const cm = DUPRE.corrMeter(document.createElement('div'));
+ cm.set(50);
+ const cmT = cm.el.querySelector('.dupre-corr-ndl').style.transform;
+ const stc = DUPRE.stripChart(document.createElement('div'));
+ stc.push(1);
+ const penT = stc.el.querySelector('.dupre-pen').style.top;
+ return JSON.stringify({ nCells, on62, warn62, on20, warn20, fwdT, rflT, thH, bdT, cmT, penT });
+ })()`);
+ ok('batch-8 renamed DOM tracks state: battery on/warn cells, needles and pen move',
+ b8dom === JSON.stringify({ nCells: 8, on62: 5, warn62: 0, on20: 2, warn20: 2,
+ fwdT: 'rotate(-42deg)', rflT: 'rotate(42deg)', thH: '75%', bdT: 'rotate(0deg)',
+ cmT: 'rotate(0deg)', penT: '3px' }), b8dom);
+
+ // batch-9 domain gates on detached hosts: the flight instruments clamp (or
+ // normalize) both set() and their build opts, badges coerces junk variants,
+ // the readout wraps its clock.
+ const clamps9 = await evl(`(() => {
+ const ai = DUPRE.attitudeIndicator(document.createElement('div'), { bank: -999, pitch: 5 });
+ const aiB = ai.get().bank;
+ ai.set(999, 999); const aiHi = ai.get();
+ const hb = DUPRE.headingBug(document.createElement('div'), { value: 450 });
+ const hbB = hb.get().cmd;
+ const hbNdl = hb.el.querySelector('g polygon').parentNode.getAttribute('transform');
+ hb.set(-30); const hbSet = hb.get().cmd;
+ const vt = DUPRE.verticalTape(document.createElement('div'), { value: 99 });
+ const vtB = vt.get(); vt.set(0); const vtLo = vt.get();
+ const vtT = vt.el.querySelector('g g').getAttribute('transform');
+ const tn = DUPRE.twinNeedle(document.createElement('div'), { fuel: 99, oil: -5 });
+ const tnB = tn.get();
+ let cmTxt; const cm = DUPRE.comfortMeter(document.createElement('div'),
+ { temp: 999, humidity: -40, onChange: (v, t) => cmTxt = t });
+ const cmB = cm.get();
+ const bg = DUPRE.badges(document.createElement('div'), { items: [['X', 7], ['Y', 'junk']] });
+ const bgVars = bg.get();
+ const tr = DUPRE.tabularReadout(document.createElement('div'), { secs: -10 });
+ const trB = tr.get();
+ return JSON.stringify({ aiB, aiHi, hbB, hbNdl, hbSet, vtB, vtLo, vtT, tnB, cmB, cmTxt, bgVars, trB });
+ })()`);
+ ok('batch-9 domain gates: flight-instrument clamps at build and set, badges variant coercion, readout wrap',
+ clamps9 === JSON.stringify({ aiB: -60, aiHi: { bank: 60, pitch: 20 }, hbB: 90,
+ hbNdl: 'rotate(90.0,65,65)', hbSet: 330, vtB: 35, vtLo: 5, vtT: 'translate(0,92.5)',
+ tnB: [4, 0], cmB: [100, 0], cmTxt: '100F · 0% RH · TOO WARM', bgVars: [1, 0], trB: 3590 }), clamps9);
+
+ // batch-9 state through the renamed DOM (catches rename stragglers): badges
+ // swap variant classes, the readout counts down through dupre-readout and
+ // carries a styled dupre-unit (the old .readout .u rule was dead — .u is a
+ // sibling, so the caption rendered unstyled), the engraved label counts
+ // through dupre-cnt, and the SVG flight instruments track set().
+ const b9dom = await evl(`(() => {
+ const bg = DUPRE.badges(document.createElement('div'));
+ const nBadge = bg.el.querySelectorAll('.dupre-badge').length;
+ const red0 = bg.el.querySelectorAll('.dupre-badge.dupre-red').length;
+ const ghost0 = bg.el.querySelectorAll('.dupre-badge.dupre-ghost').length;
+ bg.set(0, 1);
+ const red1 = bg.el.querySelectorAll('.dupre-badge.dupre-red').length;
+ const trHost = document.createElement('div'); document.body.appendChild(trHost);
+ const tr = DUPRE.tabularReadout(trHost);
+ const trTxt = tr.el.querySelector('.dupre-readout').textContent;
+ const unit = tr.el.querySelector('.dupre-unit');
+ const unitTxt = unit.textContent;
+ const unitDisp = getComputedStyle(unit).display;
+ tr.tick(); const trTick = tr.el.querySelector('.dupre-readout').textContent;
+ tr.el.querySelector('.dupre-readout').click(); tr.tick();
+ const trPaused = tr.el.querySelector('.dupre-readout').textContent;
+ trHost.remove();
+ const el = DUPRE.engravedLabel(document.createElement('div'));
+ const cnt0 = el.el.querySelector('.dupre-cnt').textContent;
+ el.set(7); const cnt7 = el.el.querySelector('.dupre-cnt').textContent;
+ el.el.click(); const cnt8 = el.el.querySelector('.dupre-cnt').textContent;
+ const tn = DUPRE.twinNeedle(document.createElement('div'));
+ tn.set(0, 4);
+ const tnF = tn.el.querySelector('polygon[fill="#e0523a"]').parentNode.getAttribute('transform');
+ const tnO = tn.el.querySelector('polygon[fill="#bfc4d0"]').parentNode.getAttribute('transform');
+ const cm = DUPRE.comfortMeter(document.createElement('div'));
+ cm.set(70, 80);
+ const humid = [...cm.el.querySelectorAll('text')].find(t => t.textContent === 'TOO HUMID').getAttribute('fill');
+ const ai = DUPRE.attitudeIndicator(document.createElement('div'));
+ ai.set(30, -10);
+ const aiT = ai.el.querySelector('g g').getAttribute('transform');
+ const rc = DUPRE.roundCrt(document.createElement('div'));
+ const p1 = rc.el.querySelector('polyline[stroke="#f4fcf4"]').getAttribute('points');
+ rc.tick();
+ const p2 = rc.el.querySelector('polyline[stroke="#f4fcf4"]').getAttribute('points');
+ const cr = DUPRE.chartRecorder(document.createElement('div'));
+ cr.reset();
+ const crH = cr.get();
+ const crD = cr.el.querySelector('path[stroke="#8f2416"]').getAttribute('d');
+ return JSON.stringify({ nBadge, red0, ghost0, red1, trTxt, unitTxt, unitDisp, trTick, trPaused,
+ cnt0, cnt7, cnt8, tnF, tnO, humid, aiT, crtMoves: p1 !== p2 && !p2.includes('NaN'), crH, crD });
+ })()`);
+ ok('batch-9 renamed DOM tracks state: badge variants, readout clock + styled unit, engraved count, instruments follow set()',
+ b9dom === JSON.stringify({ nBadge: 3, red0: 1, ghost0: 1, red1: 2, trTxt: '24:10',
+ unitTxt: 'timer', unitDisp: 'block', trTick: '24:09', trPaused: '24:09',
+ cnt0: '· 3', cnt7: '· 7', cnt8: '· 8', tnF: 'rotate(-170.0,60,62)', tnO: 'rotate(10.0,60,62)',
+ humid: '#c23a28', aiT: 'rotate(-30.0,65,65) translate(0,-16.0)',
+ crtMoves: true, crH: 0, crD: '' }), b9dom);
+
+ // batch-10 domain gate: the annunciator documents a 0..2 state per cell, so
+ // an out-of-range initial state or set() must clamp rather than paint an
+ // undefined class (the mcVu build-enforcement precedent). Without the clamp,
+ // CLS[9] is undefined and get() reads back 0 — this fails RED on that.
+ const clamps10 = await evl(`(() => {
+ const an = DUPRE.annunciator(document.createElement('div'), { cells: [['A', 9], ['B', -3], ['C', 1]] });
+ const anB = an.get();
+ an.set(0, 9); an.set(1, -5);
+ const anSet = an.get();
+ return JSON.stringify({ anB, anSet });
+ })()`);
+ ok('batch-10 domain gate: annunciator state clamps to 0..2 at build and set',
+ clamps10 === JSON.stringify({ anB: [2, 0, 1], anSet: [2, 0, 1] }), clamps10);
+
+ // batch-10 state through the renamed DOM (catches rename stragglers): the six
+ // extracted DOM builders each expose their handle and paint the dupre- classes
+ // — a JS/CSS name mismatch would render unstyled without throwing, so assert
+ // the class names directly. patchBay lights hot jacks and freqScale carries
+ // the compound dupre-fs-band unit label (dupre-band is the EQ's).
+ const b10dom = await evl(`(() => {
+ const ow = DUPRE.outputWell(document.createElement('div'));
+ const owRoot = ow.el.className, owSeed = ow.el.querySelectorAll('.dupre-ostep').length;
+ ow.push(['red', 'Err', 'boom']);
+ const owN = ow.el.querySelectorAll('.dupre-ostep').length;
+ const owEv = ow.el.querySelector('.dupre-ostep:last-child .dupre-ev').textContent;
+ const owRed = ow.el.querySelector('.dupre-ostep:last-child .dupre-lamp').classList.contains('dupre-red');
+ const to = DUPRE.toast(document.createElement('div'));
+ const toRoot = to.el.className; to.fire('ping'); const toTxt = to.el.textContent;
+ const tc = DUPRE.tapeCounter(document.createElement('div'));
+ const tcRoot = tc.el.className;
+ const tcWheels = tc.el.querySelectorAll('.dupre-cwheel').length;
+ const tcRed = tc.el.querySelectorAll('.dupre-redw').length;
+ tc.set(120); const tcV = tc.get();
+ const fq = DUPRE.freqScale(document.createElement('div'));
+ const fqBand = fq.el.querySelector('.dupre-fs-band').textContent;
+ const fqTicks = fq.el.querySelectorAll('.dupre-tick').length > 0;
+ const fqPtr = !!fq.el.querySelector('.dupre-fptr');
+ fq.set(50); const fqV = fq.get();
+ const pb = DUPRE.patchBay(document.createElement('div'));
+ const pbJacks = pb.el.querySelectorAll('.dupre-jack').length;
+ const pbHot = pb.el.querySelectorAll('.dupre-jack.dupre-hot').length;
+ const pbConns = pb.get();
+ const an = DUPRE.annunciator(document.createElement('div'));
+ const anCells = an.el.querySelectorAll('.dupre-acell').length;
+ const anWarn = an.el.querySelectorAll('.dupre-warn').length;
+ const anFault = an.el.querySelectorAll('.dupre-fault').length;
+ const anMcOn = an.el.querySelector('.dupre-mc').classList.contains('dupre-on');
+ return JSON.stringify({ owRoot, owSeed, owN, owEv, owRed, toRoot, toTxt,
+ tcRoot, tcWheels, tcRed, tcV, fqBand, fqTicks, fqPtr, fqV,
+ pbJacks, pbHot, pbConns, anCells, anWarn, anFault, anMcOn });
+ })()`);
+ ok('batch-10 renamed DOM tracks state: well/toast/counter/freqscale/patchbay/annunciator handles + dupre- classes',
+ b10dom === JSON.stringify({ owRoot: 'dupre-owell', owSeed: 2, owN: 3, owEv: 'boom', owRed: true,
+ toRoot: 'dupre-toastw', toTxt: 'ping', tcRoot: 'dupre-counter', tcWheels: 6, tcRed: 2, tcV: 120,
+ fqBand: 'MHz', fqTicks: true, fqPtr: true, fqV: 50, pbJacks: 8, pbHot: 4, pbConns: ['0-5', '2-7'],
+ anCells: 6, anWarn: 1, anFault: 1, anMcOn: true }), b10dom);
+
+ // late exceptions from interactions
+ const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
+ ok('no exceptions after interaction', errs2.length === 0);
+
+ if (shotPath) {
+ await evl(`window.scrollTo(0,0)`);
+ await sleep(300);
+ const shot = await send('Page.captureScreenshot', { format: 'png' });
+ writeFileSync(shotPath, Buffer.from(shot.result.data, 'base64'));
+ console.log('shot: ' + shotPath);
+ }
+} catch (e) {
+ console.error('PROBE ERROR: ' + e.message);
+ fails.push('probe-error');
+} finally {
+ chrome.kill();
+}
+process.exit(fails.length ? 1 : 0);