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
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
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);
|