aboutsummaryrefslogtreecommitdiff
path: root/tests/gallery-probes/probe.mjs
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-16 15:56:54 -0500
committerCraig Jennings <c@cjennings.net>2026-07-16 15:56:54 -0500
commita6e5161dd98987043c4112e3f26d33a3aed11f97 (patch)
tree17e4d4e851d43f4238d67f3a6cba4023e588bc80 /tests/gallery-probes/probe.mjs
parent10964001cf3b391011b7b48bd0d41f2e73ee261b (diff)
downloadarchsetup-a6e5161dd98987043c4112e3f26d33a3aed11f97.tar.gz
archsetup-a6e5161dd98987043c4112e3f26d33a3aed11f97.zip
feat(gallery): give the ABC keypad a keyboard and screen families
Twenty clicks for a passphrase was always the wrong answer. The plate takes focus on click and reads the keyboard now: lowercase normalises, Space maps to SPC and shows as ␣, Backspace to DEL, Enter to ENT. Every key routes through the same press() a click does, so the two paths can't drift. press() is where the filter belongs, not the keydown handler. I had it in the handler first, which guards the web and leaves the Emacs port wide open, since that port installs KEYS into a keymap and calls press directly with no handler in the stack. It gates on ACTIONS rather than KEYS because they're different sets: CLR is a real key on the plate that no keystroke reaches. The focus ring uses :focus, not :focus-visible. Chrome won't match :focus-visible on a mouse-driven focus of a non-text element, and clicking the plate is how it takes focus, so the ring would have shown only when tabbed to. On a 110-card page that means typing into a card with no sign it was listening. The window is a screen like the scope's or the marquee's, so it takes the screen families through the --scr-* vars with the shipped colours as fallbacks: nothing moves until a chip is clicked. Glass and ink both recolour, because a screen that changes its text and keeps its backlight isn't a screen. It offers all six families where its siblings carry five, since a passphrase window has no reason to prefer one phosphor.
Diffstat (limited to 'tests/gallery-probes/probe.mjs')
-rw-r--r--tests/gallery-probes/probe.mjs128
1 files changed, 128 insertions, 0 deletions
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
index 316816e..6f4184a 100644
--- a/tests/gallery-probes/probe.mjs
+++ b/tests/gallery-probes/probe.mjs
@@ -388,6 +388,134 @@ try {
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);