From a6e5161dd98987043c4112e3f26d33a3aed11f97 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 16 Jul 2026 15:56:54 -0500 Subject: feat(gallery): give the ABC keypad a keyboard and screen families MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/prototypes/panel-widget-gallery.html | 11 ++- docs/prototypes/widgets.js | 61 +++++++++++++- tests/gallery-probes/probe.mjs | 128 ++++++++++++++++++++++++++++++ todo.org | 23 +++++- 4 files changed, 216 insertions(+), 7 deletions(-) diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 455b0ff..790a2da 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -684,12 +684,12 @@ const INFO={ origin:'PDP-11/70 and IMSAI front panels.',difficulty:'Intuitive to watch, expert to read.', prefer:'A spinner says busy; blinkenlights say HOW busy and with what rhythm.', period:'1965-80 minicomputer era; the icon of computing at work.'}, -'R57':{input:'Click keys to type; DEL takes back one character, CLR wipes the lot, ENT commits. Click-only, so it ports to Emacs unchanged.', +'R57':{input:'Click keys to type, or click the plate and type on the keyboard (letters, digits, space, Backspace, Enter). DEL takes back one character, CLR wipes the lot, ENT commits.', solves:'Free alphanumeric text on a panel that has no keyboard — the one job the rest of the kit cannot do.', use:'Specialty. Shines where a panel must accept a string it could not know in advance: a passphrase, an SSID, a callsign, a label.', - limits:'Slow by design — a 20-character passphrase is 20 clicks. Any real keyboard beats it for volume; this is for panels that have none.', + limits:'Clicking is slow by design: a 20-character passphrase is 20 clicks, which is why the plate also takes the keyboard when focused. It is for panels with no keyboard of their own, and a real one still beats it for volume.', origin:'Fleet, kiosk and access-control keypads — a membrane plate for the colour-coded function keys, a stainless one for the letters-left layout and the backspace.', - difficulty:'Intuitive to use, tedious past a few words.', + difficulty:'Intuitive to use. Clicking is tedious past a few words, which is what the keyboard is for.', prefer:'Text is genuinely unbounded. R16 is the digits-only sibling and is faster where the alphabet is not needed.', ref:'../../working/retro-stereo-widgets/references/2026-07-16-abc-keypad-membrane-color.png'}, 'R56':{input:'Drag the left half for temperature, the right for humidity. Drag-only, two surfaces.', @@ -1351,7 +1351,7 @@ card(C,'R51','Voice-loop keyset', 'attention management, not selection. Each loop is independent: click to monitor (green bar, flickering with activity), again to talk (amber, exclusive — one voice out), again to drop. Flight controllers ran a dozen of these at once. After a mission-control comm keyset.'); card(C,'R57','ABC entry keypad', (st,rd)=>GW.abcKeypad(st,{onChange:(v,t)=>rd(t)}), - 'letters on a faceplate. A-Z laid out alphabetically, not QWERTY — the industrial convention wherever the operator is not assumed to touch-type. Type into the window; DEL takes back one character, CLR wipes the lot, ENT commits. R16 enters digits; this is its alphanumeric sibling, and the only card in the kit that takes free text. After fleet and kiosk keypads: the membrane plate\'s colour-coded CLR / ENT, the stainless plate\'s letters-left arrangement and its backspace. Its CANCEL is dropped — that plate is a whole terminal with a transaction to abandon, where this is one control in a panel that owns its own dismiss.'); + 'letters on a faceplate. A-Z laid out alphabetically, not QWERTY — the industrial convention wherever the operator is not assumed to touch-type. Click the keys, or click the plate and type: it takes the keyboard once focused, and every key routes through the same handle a click does. DEL takes back one character, CLR wipes the lot, ENT commits. R16 enters digits; this is its alphanumeric sibling, and the only card in the kit that takes free text. After fleet and kiosk keypads: the membrane plate\'s colour-coded CLR / ENT, the stainless plate\'s letters-left arrangement and its backspace. Its CANCEL is dropped — that plate is a whole terminal with a transaction to abandon, where this is one control in a panel that owns its own dismiss.'); /* ============ METERS & GAUGES ============ */ const M=$('meters'); @@ -1653,6 +1653,9 @@ screenChips('R17',$('card-R17').gw.el,['green','amber','red','blue','vfd'],'gree screenChips('R19',$('card-R19').gw.el,['white','green','amber','blue','vfd'],'white'); screenChips('R31',$('card-R31').gw.el,['amber','green','red','blue','vfd'],'amber'); screenChips('N11',$('card-N11').gw.el,['green','amber','red','blue','vfd'],'green'); +/* all six, where its siblings each carry five: a passphrase window has no reason + to prefer one phosphor, so it offers the whole set including the marquee cyan */ +screenChips('R57',$('card-R57').gw.el,['amber','green','red','blue','vfd','white'],'amber'); BOOST.forEach(no=>{const rd=document.getElementById('rd-'+no); if(rd)rd.closest('.card').querySelector('.stagew').classList.add('boost');}); diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 48820cd..6620ddf 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -267,8 +267,14 @@ GW.abcKeypad = function (host, opts = {}) { gradDef('abcAmb', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'var(--amber-grad-top)'], ['1', 'var(--gold)']]); /* faceplate + the recessed window the legend prints into */ svgEl(s, 'rect', { x: 2, y: 2, width: 228, height: 222, rx: 7, fill: '#17140f', stroke: '#0a0908', 'stroke-width': 2 }); - svgEl(s, 'rect', { x: 12, y: 10, width: 208, height: 30, rx: 4, fill: '#0a0806', stroke: '#2c261d', 'stroke-width': 2 }); - const disp = svgEl(s, 'text', { x: 20, y: 31, 'font-size': 14, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' }); + /* The window is a screen like the scope's or the marquee's, so it takes the + screen families through the --scr-* vars, shipped colours as the fallbacks: + nothing moves until a chip is clicked. Both the glass and the ink recolour — + a screen that changes its text and keeps its backlight isn't a screen. */ + svgEl(s, 'rect', { class: 'kp-win', x: 12, y: 10, width: 208, height: 30, rx: 4, + fill: 'var(--scr-bg1, #0a0806)', stroke: 'var(--scr-brd, #2c261d)', 'stroke-width': 2 }); + const disp = svgEl(s, 'text', { x: 20, y: 31, 'font-size': 14, 'letter-spacing': '.14em', + 'font-family': 'var(--mono)', fill: 'var(--scr-hi, var(--gold-hi))' }); let buf = ''; /* The window shows the tail: a passphrase outruns the plate long before MAX. @@ -282,7 +288,14 @@ GW.abcKeypad = function (host, opts = {}) { const render = () => { disp.textContent = buf.length > 13 ? '‹' + show(buf.slice(-12)) : show(buf).padEnd(13, '·'); }; + /* press is the allowlist, not the keydown handler above it. Filtering only in + the handler guards the web and leaves the Emacs port wide open: that port + installs KEYS into a keymap and calls press directly, with no handler in the + stack, so an unguarded press would append a stray 'F1' as literal text. + Gated on ACTIONS rather than on KEYS because they are different sets — CLR + is a real key on the plate that no keystroke maps to. */ const press = k => { + if (!GW.abcKeypad.ACTIONS.has(k)) return; if (k === 'ENT') { onChange(buf, buf ? 'ENTER · ' + buf : 'empty'); return; } if (k === 'DEL') { buf = buf.slice(0, -1); render(); onChange(buf, buf || 'empty'); return; } if (k === 'CLR') { buf = ''; render(); onChange(buf, 'cleared'); return; } @@ -345,9 +358,46 @@ GW.abcKeypad = function (host, opts = {}) { press(k); }); }); + /* Keyboard, per the README's keyboard contract. The listener is bound to the + pad's own focusable element, never to the document: a global binding would + type into this card from anywhere on a 110-card page and fight the gallery's + own Escape handler. GW.slideRule is the precedent. */ + s.setAttribute('class', 'rsvg kp-pad'); + s.setAttribute('tabindex', '0'); + s.addEventListener('click', () => s.focus()); + s.addEventListener('keydown', e => { + if (e.ctrlKey || e.metaKey || e.altKey) return; /* leave shortcuts alone */ + const name = e.key === ' ' ? 'Space' : (e.key.length === 1 ? e.key.toUpperCase() : e.key); + const k = GW.abcKeypad.KEYS[name]; + if (!k) return; /* not on the plate: let it bubble */ + /* Spend preventDefault only where there is a default worth killing — Space + scrolls the page, Backspace can navigate back. Tab and Escape are never + ours: Tab is how the page stays navigable, Escape belongs to the audit + stepper, and neither reaches here anyway because they are not in KEYS. */ + if (name === 'Space' || name === 'Backspace') e.preventDefault(); + press(k); + }); render(); onChange('', 'type a passphrase'); return { el: s, get: () => buf, press }; }; +/* The plate's keys, declared as a table so every target reads the same intent. + Deliberately not a function over a DOM event: the Emacs port installs this + into a keymap and never sees a keydown, so a function would force it to + re-derive what the widget accepts and the two bindings would drift apart. + This is also press()'s allowlist — press appends whatever it is handed, so + without the table a stray 'F1' would land in the buffer as text. */ +GW.abcKeypad.KEYS = (() => { + const m = {}; + for (const c of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') m[c] = c; + m.Space = 'SPC'; m.Backspace = 'DEL'; m.Enter = 'ENT'; + return m; +})(); +/* The plate's whole vocabulary — every argument press accepts, from any caller. + A superset of the KEYS values: CLR is on the plate but no keystroke reaches it + (Escape is the obvious candidate and belongs to the gallery's audit stepper). */ +GW.abcKeypad.ACTIONS = new Set([ + ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 'SPC', 'DEL', 'ENT', 'CLR', +]); /* 03 horizontal fader — continuous 0-100 */ GW.faderH = function (host, opts = {}) { @@ -4174,6 +4224,13 @@ const GW_CSS = ` .switch.red::before{content:"OFF";order:1;color:var(--cream)} .switch.red::after{order:2} +/* The ABC keypad takes keys, so it must show when it is the one listening: an + unlit focus state means typing vanishes into a card you thought was live. + :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 + gets focus here, so the ring would have appeared only when tabbed to. */ +.kp-pad{outline:none} +.kp-pad:focus{outline:2px solid var(--gold-hi);outline-offset:3px;border-radius:9px} .key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; border-radius:8px;padding:8px 12px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} 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); diff --git a/todo.org b/todo.org index bd56197..1af8443 100644 --- a/todo.org +++ b/todo.org @@ -173,7 +173,28 @@ Review (subagent, since I wrote it all) cleared the widget itself — buffer log Third vacuous check in one day, and the shape is consistent: checks written in the builder's own vocabulary inherit its assumptions and confirm what the code does rather than what it should do. The one that mattered was found by someone reading the render arithmetic cold. -Next: item 3, the index typewriter. +**** 2026-07-16 Thu @ 14:42 -0500 Keyboard contract recorded, R57 built against it +Craig asked whether keyboard input is the widget's job or its container's, and picked "record the contract first, then build against it" — it's a decision about all 110 cards, not one. + +The kit already held the answer in two halves. =GW.slideRule= takes arrow keys scoped to its *own focusable element* (the browser arbitrates focus, which is why it never fights the gallery's global Escape handler at line 1157) — that's the web-correct pattern. But Emacs can't do that at all: the SVG region is an image and never sees a keypress, so the mode's keymap must own delivery and call =press=. Same split as the tick contract, which the README already states: the page owns the ambient resource (the clock there, focus here), the builder exposes a handle. + +Contract written into [[file:docs/prototypes/README.org][the widget-library README]] beside the tick contract: *the target owns focus and delivery, the builder declares what it accepts* via a =KEYS= table. Deliberately a table and not a function over a DOM event — the Emacs port installs it into a keymap and never sees a keydown, so a function would force it to re-derive the widget's intent and the two bindings would drift. Five rules, each one a bug otherwise shipped: never listen on =document=; spend =preventDefault= only where there's a default worth killing (Space scrolls, Backspace navigates back); let Tab and Escape bubble (Tab is how the page stays navigable, Escape belongs to the audit stepper); =press= filters rather than trusts (it appends whatever it's handed, so a stray F1 would land as text); click and key both route through =press= so they can't drift. A widget with no =KEYS= table takes no keys, which is most of them — the kit is click-first and that's what makes it port. + +R57 built against it: =tabindex=, focus on click, element-scoped keydown, gold focus ring (an unlit focus state means typing vanishes into a card you thought was live). Five checks written first and confirmed red, including the contract's own rules — unfocused keys ignored (the no-document-listener rule, tested behaviourally), Space and Backspace claimed, Tab/Escape/F1 let through, unmapped keys dropped. Audited the kit: no document-level key listener anywhere in widgets.js, so the contract holds retroactively. + +Spec sheet reworded. "Click-only, so it ports to Emacs unchanged" stopped being true, and the honest version is the opposite: keys are the *more* native idiom in Emacs, so the card ports better, not worse. + +Review (subagent) found two High defects in the keyboard work, both fixed: +- *The contract's own rule was unmet by the commit that recorded it.* The README says "press filters, it does not trust", and the filter went into the keydown handler instead — leaving press wide open for the one caller the table exists to serve, since the Emacs port installs KEYS into a keymap and calls press directly with no handler in the stack. Now gated on =GW.abcKeypad.ACTIONS= (the plate's whole vocabulary), which is a superset of the KEYS values because CLR is a real key no keystroke reaches. +- *The focus ring never appeared on the path anyone uses.* =:focus-visible= doesn't match a mouse-driven focus on a non-text element in Chrome, and clicking the plate IS how it takes focus, so the ring showed only when tabbed to. Now =:focus=. +Also: two more weak checks. One claimed click and key can't drift while only reading a string (now enters the same sequence both ways and compares buffer + readout); one asserted Tab/Escape/F1 aren't swallowed, which couldn't fail because those keys return before the preventDefault (now also checks 'A' and Enter, which are mapped and reach the same code path). + +*** 2026-07-16 Thu @ 15:20 -0500 Screen families on the keypad window, and a probe that was lying +Craig: offer the display in every colour, including the vfd marquee cyan. The window now takes the =--scr-*= vars with the shipped colours as fallbacks, so the default is pixel-identical until a chip is clicked (=--gold-hi= IS the amber family's =--scr-hi=, which is what makes that exact rather than close). Both the glass and the ink recolour — a screen that changes its text and keeps its backlight isn't a screen. R57 offers all six families where its siblings each carry five: a passphrase window has no reason to prefer one phosphor. + +Found a real defect while adding the checks, and a nasty one: *probe-fams.mjs had no try/finally*, so a failing check orphaned its headless browser. The next run then connected to the survivor on the same port and reported results from a STALE page — old widgets.js, chips already clicked. It cost a confusing debug loop: R10's default ink read as green and R57's chips read as absent, neither of which was true of the actual page. A probe that answers from the previous run is worse than one that crashes. Wrapped the checks in try/finally like its two siblings already had, and swept nine orphaned chromes (the pattern is bracketed on the debug port, so it cannot touch Craig's daily browser, which has none). + +Craig called the ABC entry keypad done at 110 cards. Banked from the survey, not cards yet: CDU/MCDU scratchpad + line-select keys (a staged-commit *flow*, not a new selector), joystick scroll-and-fire alphabet, multi-tap/T9 letter cycling (the most practical small-panel password entry), trackball gesture (Atari Quantum), keypunch program drum (IBM 029), Enigma lampboard (a *feedback* idiom — steal the 26-lamp grid as a readout, not as entry), Teletype Model 15 tape perforator. -- cgit v1.2.3