aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/design/2026-07-12-control-grammars-reference.org2
-rw-r--r--docs/prototypes/panel-widget-gallery.html11
-rw-r--r--docs/prototypes/widgets.js108
-rw-r--r--tests/gallery-probes/probe.mjs150
-rw-r--r--todo.org32
5 files changed, 298 insertions, 5 deletions
diff --git a/docs/design/2026-07-12-control-grammars-reference.org b/docs/design/2026-07-12-control-grammars-reference.org
index c6509ac..beee4f5 100644
--- a/docs/design/2026-07-12-control-grammars-reference.org
+++ b/docs/design/2026-07-12-control-grammars-reference.org
@@ -192,5 +192,5 @@ That is the cheapest completeness test available: a catalogue that cannot build
* Related
-- Gallery: [[file:../prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]] (109 cards, spec sheets carry input model + period).
+- Gallery: [[file:../prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]] (110 cards, spec sheets carry input model + period).
- Source survey: takuzu =working/research/ui-components/historical-panel-components.org= (processed 2026-07-12; build + banked + stale verdicts recorded in todo.org).
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html
index 007b4cd..455b0ff 100644
--- a/docs/prototypes/panel-widget-gallery.html
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -684,6 +684,14 @@ 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.',
+ 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.',
+ 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.',
+ 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.',
solves:'A categorical verdict from two values — the crossing point falls into a printed judgment.',
use:'Common on weather stations and comfort meters. Shines when the pair only matters as a combined condition: comfort, safe-operating region, duty envelope.',
@@ -1341,6 +1349,9 @@ card(C,'R50','Two-hand safety control',
card(C,'R51','Voice-loop keyset',
(st,rd)=>GW.voiceLoop(st,{onChange:(v,t)=>rd(t)}),
'<b>attention management, not selection.</b> 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)}),
+ '<b>letters on a faceplate.</b> 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.');
/* ============ METERS & GAUGES ============ */
const M=$('meters');
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index 147c782..48820cd 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -241,6 +241,114 @@ GW.consoleKeys.DEFAULT_KEYS = [
{ label: 'SCAN' }, { label: 'LIVE', tone: 'green' }, { label: 'MUTED', tone: 'red' },
];
+/* R57 ABC entry keypad — alphanumeric text on a faceplate.
+ Modelled on the industrial membrane keypad (reference: fleet/kiosk gear,
+ 2026-07-16-abc-keypad-membrane-color.png): a 0-9 block beside A-Z laid out
+ ALPHABETICALLY, not QWERTY. That is the convention wherever the operator
+ can't be assumed to touch-type, and it is what makes this a faceplate rather
+ than a keyboard — R16's alphanumeric sibling.
+ Departures from the photo, all deliberate and all noted at their site:
+ - Its letters are blue. The kit has no blue control colour (blue appears
+ only as a screen phosphor and a jewel lens), so the letters take the
+ standard pale keycap and the digits a darker one — which keeps the
+ photo's two-tone digit/letter grouping without importing a foreign hue.
+ CLEAR/NO and ENTER/YES keep the reference's red and green, which are
+ already --fail and --pass.
+ - Letters left, digits right (the stainless reference's arrangement).
+ - DEL added, CANCEL dropped. See the layout block below for both. */
+GW.abcKeypad = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const MAX = opts.max || 16;
+ const s = stageSvg(host, 'rsvg', 232, 226);
+ gradDef('abcKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#dbd8cf'], ['1', '#a29d92']]);
+ gradDef('abcNum', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#6f6a61'], ['1', '#464239']]);
+ gradDef('abcRed', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d98a6f'], ['1', 'var(--fail)']]);
+ gradDef('abcGrn', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#9cbf5e'], ['1', 'var(--pass)']]);
+ 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)' });
+
+ let buf = '';
+ /* The window shows the tail: a passphrase outruns the plate long before MAX.
+ Spaces are drawn as ␣ rather than as themselves. SVG collapses whitespace,
+ so a real space is invisible — and past the truncation boundary there are no
+ pad dots left for it to displace, which makes SPACE a keypress with no
+ feedback at all: press, see nothing, press again, and now carry two spaces
+ you can't see in a passphrase you can't read back. Only the DISPLAY is
+ substituted; buf keeps the real character. */
+ const show = t => t.replace(/ /g, '␣');
+ const render = () => {
+ disp.textContent = buf.length > 13 ? '‹' + show(buf.slice(-12)) : show(buf).padEnd(13, '·');
+ };
+ const press = k => {
+ 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; }
+ if (buf.length >= MAX) { onChange(buf, 'full · ' + buf); return; }
+ buf += (k === 'SPC' ? ' ' : k); render(); onChange(buf, buf);
+ };
+ /* [label, col, row, width-in-cols, tone].
+ Letters left, numbers right — the stainless reference's arrangement rather
+ than the membrane's. The membrane photo puts its digit block on the left,
+ which lands A-L in columns 3-5 while M-X starts at column 0: the alphabet
+ stops column-aligning with itself halfway down and the eye has to jump.
+ This way A-Z reads straight down the plate in one continuous block and the
+ digits keep their own quadrant. */
+ const L = [];
+ const DIG = ['1','2','3','4','5','6','7','8','9'];
+ [...'ABCDEFGHIJKL'].forEach((c, i) => L.push([c, i % 3, Math.floor(i / 3), 1, 'key']));
+ [...'MNOPQRSTUVWX'].forEach((c, i) => L.push([c, i % 6, 4 + Math.floor(i / 6), 1, 'key']));
+ /* DEL earns its own key: without a backspace the only exit from a typo is CLR,
+ which on a long passphrase means retyping the lot.
+ The three function keys read as a ladder, so the colour says what a key
+ costs before you read its legend: amber DEL takes one character back, red
+ CLR throws the whole entry away, green ENT commits it. Plain caps do
+ nothing you'd regret. (The stainless reference's backspace is plain steel,
+ but that plate has no other coloured key to be graded against.)
+ DEL sits in the block beside the digits and CLR is exiled to the far corner,
+ which is the opposite of where they started. Two reasons, pulling the same
+ way: DEL is the one you reach for constantly and CLR is the one you reach
+ for almost never, and a mis-hit on DEL costs one character where a mis-hit
+ on CLR costs the whole entry. Frequency and blast radius both say the safe
+ key gets the good spot.
+ The reference plate also carries CANCEL, and this one deliberately doesn't.
+ On that device the plate IS the whole terminal, so CANCEL has a transaction
+ to abandon; here the keypad is one control inside a panel that owns its own
+ dismiss, which left CANCEL doing exactly what CLR does. A key that means
+ nothing the panel doesn't already mean is a key worth removing. */
+ L.push(['Y', 0, 6, 1, 'key'], ['Z', 1, 6, 1, 'key'], ['SPC', 2, 6, 3, 'key'],
+ ['CLR', 5, 6, 1, 'red']);
+ DIG.forEach((d, i) => L.push([d, 3 + (i % 3), Math.floor(i / 3), 1, 'num']));
+ L.push(['DEL', 3, 3, 1, 'amb'], ['0', 4, 3, 1, 'num'], ['ENT', 5, 3, 1, 'grn']);
+ const FILL = { key: 'url(#abcKey)', num: 'url(#abcNum)', red: 'url(#abcRed)', grn: 'url(#abcGrn)', amb: 'url(#abcAmb)' };
+ const INK = { key: '#2b2721', num: '#efeae0', red: 'var(--cream)', grn: 'var(--cream)', amb: 'var(--panel)' };
+ /* every function key wears a three-letter legend, so they read as one family
+ and each fits a single-column cap; only SPACE has the width to spell out */
+ const FACE = { SPC: 'SPACE' };
+ /* per-key rotation: a real plate is printed, not typeset — the legends sit a
+ fraction off true. Same trick R16 uses, keyed off the index so it is stable. */
+ const jitter = i => [-1.4, .9, -.6, 1.2, 0, -1.1, .7, -.9, 1.3, .4, -.5, 1][i % 12];
+ L.forEach(([k, c, r, w, tone], i) => {
+ const x = 14 + c * 35.5, y = 50 + r * 24.5, wd = w * 35.5 - 5.5;
+ const g = svgEl(s, 'g', {}); g.setAttribute('class', 'kp-key'); g.dataset.k = k;
+ g.style.cursor = 'pointer'; g.style.transition = 'transform .07s';
+ svgEl(g, 'rect', { x, y, width: wd, height: 20, rx: 3.5, fill: FILL[tone], stroke: '#4e4a42', 'stroke-width': 1, 'stroke-opacity': .8 });
+ const t = svgEl(g, 'text', { x: x + wd / 2, y: y + 14.5, 'text-anchor': 'middle',
+ 'font-size': w > 1 ? 8 : 11, 'font-weight': 700, 'font-family': 'var(--mono)', fill: INK[tone],
+ transform: `rotate(${jitter(i)},${x + wd / 2},${y + 10})` });
+ t.textContent = FACE[k] || k;
+ g.addEventListener('click', () => {
+ g.style.transform = 'translateY(1.5px)';
+ setTimeout(() => { g.style.transform = ''; }, 80);
+ press(k);
+ });
+ });
+ render(); onChange('', 'type a passphrase');
+ return { el: s, get: () => buf, press };
+};
+
/* 03 horizontal fader — continuous 0-100 */
GW.faderH = function (host, opts = {}) {
const onChange = opts.onChange || noop;
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
index d196377..316816e 100644
--- a/tests/gallery-probes/probe.mjs
+++ b/tests/gallery-probes/probe.mjs
@@ -68,7 +68,7 @@ try {
// 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('109 cards', cards === 109, `got ${cards}`);
+ 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()`);
@@ -240,6 +240,154 @@ try {
})()`);
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);
+
// 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 83ed2e6..3fdf673 100644
--- a/todo.org
+++ b/todo.org
@@ -113,7 +113,7 @@ Every card now carries a collapsible "spec sheet" (a details element under the n
*** 2026-07-14 Tue @ 02:00:49 -0500 Judged the two 2026-07-13 cross-needle references covered
The weather-station comfort meter is R56's form exactly (crossing lands in a printed categorical verdict; five zones vs R56's three is content, not mechanism). The SWR cross-needle meter is N13's defining mechanism (the crossing derives a number off printed iso-curves — forward/reflected watts → SWR). No new cards; both references stay filed in working/retro-stereo-widgets/references/.
*** DOING Widget validation pass
-Craig walks all 109 cards; the lamps are his (click cycles off → amber → green, per-card localStorage key =gv-<no>=). Gate change with the option-1 approval (2026-07-12): the lamps no longer gate the widgets.js extraction (lossless, done) — they gate per-widget Emacs ports and the final catalogue blessing.
+Craig walks all 110 cards; the lamps are his (click cycles off → amber → green, per-card localStorage key =gv-<no>=). Gate change with the option-1 approval (2026-07-12): the lamps no longer gate the widgets.js extraction (lossless, done) — they gate per-widget Emacs ports and the final catalogue blessing.
Runs as a *joint loop* (Craig, 2026-07-16): he walks a batch of 10-15 and reports card numbers + what's wrong; Claude fixes them in one pass, gates on the three probes, he reloads and re-walks. The lamps track progress only — they never recorded *what* was wrong with a card, so the defects live in this task body as they surface.
@@ -137,7 +137,7 @@ Also found: the gallery cannot build its own chrome from its own kit (swatch chi
Still open, recorded in the doc: the full seven-axis classification, the card-by-card audit behind the first-pass cell assignments, and the display side.
-*** TODO Build the four widgets the taxonomy found :feature:design:
+*** DOING Build the four widgets the taxonomy found :feature:design:
From the [[file:docs/design/2026-07-12-control-grammars-reference.org][taxonomy brainstorm]] (2026-07-16). Craig's gate: stick to the aesthetic — model a period control, don't invent a software-native one. Reference photo first per the usual pipeline, then judge, then card.
1. *Dynamic list* — model the jukebox wallbox title-strip rack (page-flip browse, rich slotted strips, select one). Fills the most damning cell; the bt and net panels both want it. Alternative model: Rolodex card spinner, better for long sets and weaker at showing several rows at once.
@@ -149,6 +149,32 @@ From the [[file:docs/design/2026-07-12-control-grammars-reference.org][taxonomy
Items 1 and 2 fill the two empty cells and are wanted by live panels today; 3 and 4 are the survey's genuinely-new grammars; 5 and 6 come from the gallery-can't-build-itself finding and are lower stakes.
+**** 2026-07-16 Thu @ 13:53:34 -0500 Built R57 ABC entry keypad — the text x alphanumeric cell is filled (gallery at 110)
+Item 2 done. Modelled on the membrane reference (=working/retro-stereo-widgets/references/2026-07-16-abc-keypad-membrane-color.png=): a 0-9 block beside A-Z in alphabetical order, CLR / ENT / CANCEL, typed text in an amber window, 16-char cap with the window showing the tail. The kit's first free-text control, and R16's alphanumeric sibling.
+
+Two deliberate departures from the photo, both recorded in the builder comment: its letters are *blue*, and the kit has no blue control colour (blue is only ever a screen phosphor or a jewel lens), so letters take the standard pale keycap and digits a darker one — which keeps the photo's two-tone digit/letter grouping without importing a foreign hue. CANCEL is amber, borrowed from the sibling reference whose CANCEL is yellow; CLEAR/NO and ENTER/YES keep the reference's red and green, which are already =--fail= and =--pass=.
+
+TDD: four probe checks written first and confirmed red (A-Z in alphabetical order — a QWERTY drift would silently lose the idiom; a full 0-9 block; typed characters accumulate in order; ENT commits while CLR empties). All green, plus the card count bumped 109 → 110. Verified visually in both the empty and typed states rather than trusting the green run.
+
+Craig then caught a layout flaw the membrane reference itself carries: with the digit block on the left, A-L sits in columns 3-5 while M-X starts at column 0, so the alphabet stops column-aligning with itself halfway down and the eye has to jump. Swapped to letters-left / digits-right, which is the *stainless* reference's arrangement — so the card is now a synthesis of the two photos (membrane colour-coding, stainless layout) and reads A-Z down one unbroken block. Card note and spec-sheet origin updated to credit both.
+
+That swap also exposed a hole in my own checks: the A-Z check reads DOM order, which the builder controls by push order, so it would pass with every key rendered in the wrong place. Added a geometric check (letters left of digits, function block on the digit side, and A/D/G/J/M/S/Y sharing one x) that pins the layout Craig asked for.
+
+Craig then caught the missing backspace: with only CLR, one mistyped character costs the whole entry. Added DEL, and on his call it sits in the digit block wearing amber while CLR is exiled to the far corner in red. Both swaps pull the same way — DEL is the key you reach for constantly and costs one character, CLR is the one you reach for almost never and costs the entry, so the safe key gets the good spot and the colour grades the cost before you read the legend. The three function keys now read as a ladder: amber takes one back, red throws it all away, green commits.
+
+And he caught that CLR and CANCEL did the same thing — both just emptied the buffer, differing only in the readout string. Dropped CANCEL. The reference plate carries it because on that device the plate IS the whole terminal and has a transaction to abandon; here the keypad is one control inside a panel that owns its own dismiss, so CANCEL meant nothing the panel didn't already mean. SPACE widened to fill, DEL took the rightmost slot, and the now-unused amber gradient and tone came out with it. The only colour left is CLR and ENT, the two irreversible keys.
+
+Adding the DEL check also caught order-dependence in my own suite: it emptied the buffer that the ENT/CLR check had been inheriting from the check above it — the same defect the reviewer found in check 9 this morning. That check now types its own buffer.
+
+Review (subagent, since I wrote it all) cleared the widget itself — buffer logic, layout arithmetic, and the gradient id space all traced clean — and found three real defects, all fixed before the commit:
+- *A typed space was invisible past 13 characters.* SVG collapses trailing whitespace, and past the display's truncation boundary there are no pad dots left for a space to displace, so SPACE became a keypress with no feedback: press, see nothing, press again, and carry two spaces you can't see in a passphrase you can't read back. The window now draws spaces as =␣= while the buffer keeps the real character. Verified by eye that the glyph isn't a tofu box.
+- *The ENT check could not fail.* It typed NET5, pressed ENT, and asserted =/NET5/= — which typing the 5 had already made true. Deleting the whole ENT branch still passed, because the key then fell through to =buf += 'ENT'= and NET5 still matched. It now asserts the commit signal itself.
+- *The DEL check asserted half its name*, computing the past-empty state and never looking at it.
+
+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.
+
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.
Two flags carried from the survey: no true A-Z *thumbwheel switch* could be verified (Digitran/Grayhill mil-spec wheels are 0-9 or hex only), so the letter-drum reference is a *combination lock* and a card must say so rather than imply a switch that may not exist; and the trackball-gesture details are single-sourced.
@@ -160,7 +186,7 @@ The cross's cell assignments were read off card names and spec sheets, not audit
:SPEC_ID: 3ac0d42c-db1a-4d21-bce4-e63785fef0ba
:LAST_REVIEWED: 2026-07-13
:END:
-The panel widget gallery ([[file:docs/prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]]) grows into a retro-instrument component catalogue: reference photos of period hardware → gallery cards (the visual + behavioral spec) → reusable components for three targets (emacs svg.el, web/React, waybar). Tokens single-sourced in [[file:docs/prototypes/tokens.json][tokens.json]] (gen_tokens.py emits web/waybar/elisp); svg.el proof widget shipped (gallery-widget.el, needle gauge). Reference photos live in [[file:working/retro-stereo-widgets/][working/retro-stereo-widgets/]]. Collection converged at R56 (109 cards, all behaviorally verified; probes in [[file:tests/gallery-probes/][tests/gallery-probes/]]).
+The panel widget gallery ([[file:docs/prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]]) grows into a retro-instrument component catalogue: reference photos of period hardware → gallery cards (the visual + behavioral spec) → reusable components for three targets (emacs svg.el, web/React, waybar). Tokens single-sourced in [[file:docs/prototypes/tokens.json][tokens.json]] (gen_tokens.py emits web/waybar/elisp); svg.el proof widget shipped (gallery-widget.el, needle gauge). Reference photos live in [[file:working/retro-stereo-widgets/][working/retro-stereo-widgets/]]. Collection converged at R56, then reopened at R57 as the taxonomy found empty cells (110 cards, all behaviorally verified; probes in [[file:tests/gallery-probes/][tests/gallery-probes/]]).
Build runs per the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DOING; reviewed + decomposed 2026-07-12): web extraction first (ungated, lossless), then demand-gated Emacs/waybar ports. Banked variant/composition ledger lives in the 2026-07-11/12 session archive.
*** 2026-07-12 Sun @ 10:14:10 -0500 Wrote the component-generation spec (DRAFT)