// 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('110 cards', cards === 110, `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); // 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);