1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
|
// 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);
|