aboutsummaryrefslogtreecommitdiff
path: root/tests/gallery-probes/probe.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/gallery-probes/probe.mjs')
-rw-r--r--tests/gallery-probes/probe.mjs753
1 files changed, 753 insertions, 0 deletions
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
new file mode 100644
index 0000000..5e95096
--- /dev/null
+++ b/tests/gallery-probes/probe.mjs
@@ -0,0 +1,753 @@
+// CDP probe for the widget 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. defaults: size=2 (M), card count
+ ok('default size 2', await evl(`document.body.dataset.size`) === '2');
+ const cards = await evl(`document.querySelectorAll('.card').length`);
+ ok('111 cards', cards === 111, `got ${cards}`);
+
+ // 2. zoom actually scales: card visual width at 3x vs 1x
+ await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`);
+ const w3 = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
+ await evl(`document.querySelector('.szbar .key[data-sz="1"]').click()`);
+ const w1 = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
+ ok('3x wider than 1x', w3 > w1 * 1.8, `w3=${Math.round(w3)} w1=${Math.round(w1)}`);
+ ok('size chip flips state', await evl(`document.body.dataset.size`) === '1');
+
+ // 3. behavioral at 3x: fader drag on card 03 (horizontal fader) changes readout
+ await evl(`document.querySelector('.szbar .key[data-sz="3"]').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('.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 3x', before !== after && after !== '—', `'${before}' -> '${after}'`);
+
+ // 4. behavioral at 3x: 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 3x', 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 .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 .key')].filter(b => /\\b(on|green|red)\\b/.test(b.className));
+ return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('key ','') : '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 .key')];
+ keys.find(b => b.textContent === 'MUTED').click();
+ const lit = keys.filter(b => /\\b(on|green|red)\\b/.test(b.className));
+ return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('key ','') : '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 = GW.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 = GW.slideToggle.PRESETS, S = GW.slideToggle.STYLES;
+ if (!P) return 'no PRESETS';
+ const AX = GW.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 = GW.slideToggle.PRESETS.dark, S = GW.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 widget 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 widget AND re-syncs the axis chips, so the card
+ // never shows a combination the widget 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 widget 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 .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 .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 .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 .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 .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 .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 .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 .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 .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 widget's intent and
+ // the two bindings would drift. (README, keyboard contract.)
+ const keysTable = await evl(`(()=>{
+ const K = GW.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. A widget 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 .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 .kp-pad');
+ const key = k => [...document.querySelectorAll('#card-R57 .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 .kp-pad');
+ const key = k => [...document.querySelectorAll('#card-R57 .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').gw.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').gw.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.gw;
+ if (!h || !h.press) return 'no handle';
+ const key = k => [...card.querySelectorAll('.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(GW.abcKeypad.KEYS).filter(([,v]) => !GW.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
+ // widget that swallows either breaks something it cannot see.
+ const defaults = await evl(`(()=>{
+ const pad = document.querySelector('#card-R57 .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 .kp-pad');
+ const key = k => [...document.querySelectorAll('#card-R57 .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
+ // widget for showing the operator where the pointer is.
+ const grammar = await evl(`(()=>{
+ const cell = c => document.querySelector('#card-R58 .ix-cell[data-c="' + c + '"]');
+ const lever = document.querySelector('#card-R58 .ix-lever');
+ const buf = () => document.getElementById('card-R58').gw.get();
+ document.querySelector('#card-R58 .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 .ix-cell[data-c="' + c + '"]');
+ const lever = document.querySelector('#card-R58 .ix-lever');
+ document.querySelector('#card-R58 .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 .ix-cell[data-c="' + c + '"]');
+ const lever = document.querySelector('#card-R58 .ix-lever');
+ document.querySelector('#card-R58 .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').gw.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.
+ const charset = await evl(`(()=>{
+ const have = new Set([...document.querySelectorAll('#card-R58 .ix-cell')].map(c => c.dataset.c));
+ const need = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ', ...'abcdefghijklmnopqrstuvwxyz',
+ ...'0123456789', 'ä','ö','ü','Ä','Ö','Ü','ß','§','½','¼'];
+ const missing = need.filter(c => !have.has(c));
+ return missing.length ? 'missing: ' + missing.join(' ') : 'ok';
+ })()`);
+ ok('R58 plate carries both cases, digits and the extended set', charset === 'ok', charset);
+
+ // 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 = GW.indexPlate && GW.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 .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 .ix-cell')].map(c => c.getBBox());
+ const right = Math.max(...cells.map(b => b.x + b.width));
+ const hits = [];
+ for (const sel of ['.ix-lever', '.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('.ix-lever'), clr = bb('.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 .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 = GW.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').gw;
+ 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 = GW.indexPlate.KEYS, L = GW.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 .ix-pad');
+ const h = document.getElementById('card-R58').gw;
+ const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
+ document.querySelector('#card-R58 .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 .ix-pad');
+ const h = document.getElementById('card-R58').gw;
+ const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
+ document.querySelector('#card-R58 .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 .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
+ // widget 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').gw;
+ document.querySelector('#card-R58 .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);
+
+ // 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);