From 6bcd0da82c505a2ec22268adfced0004401b74a9 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 16 Jul 2026 16:43:29 -0500 Subject: feat(gallery): add the index typewriter The oldest way to type without a keyboard, and the first card where selecting and committing are separate acts. One hand walks a stylus over a printed plate, the other pulls a lever, and only the lever prints. You can hunt across every character and nothing happens until the second hand moves. Every other control in the kit fires on contact. The keyboard follows the same rule: typing selects, Enter is the lever. A keypress that printed would make this the ABC keypad with a nicer plate, which is the one thing it must not be. It also does no case folding, where the keypad must. This plate carries both cases as distinct cells, so Shift does exactly the work a shift key would on a machine that hasn't got one. KEYS and ACTIONS are derived from the layout table rather than kept beside it, so rewriting the table relays the plate, the keymap and the allowlist together. A binding aimed at a character the plate no longer carries isn't expressible. After the AEG Mignon Model 4, keeping the two things worth keeping: the extended set a keyboard skips, and both cases with no shift key. Its frequency order goes. Reading the plate is the interaction, and P U G Q / V I N A B isn't something you can read your way around, so ours is alphabetical with capitals beside lowercase at the same column offset. I expect to revisit the keys, so the layout is a table the geometry computes itself from. I built it at four, five and nine rows to check the gutter survives a rewrite in either direction. There's no space cell yet, so Space isn't ours and still scrolls the page. The real machine has a separate space key. --- docs/prototypes/panel-widget-gallery.html | 4 + docs/prototypes/widgets.js | 179 ++++++++++++++++- tests/gallery-probes/probe.mjs | 219 ++++++++++++++++++++- todo.org | 22 +++ .../2026-07-16-mignon-4-index-typewriter.jpg | Bin 0 -> 1323884 bytes .../references/2026-07-16-mignon-CREDITS.txt | 9 + .../references/2026-07-16-mignon-aeg-detail.jpg | Bin 0 -> 8986503 bytes 7 files changed, 430 insertions(+), 3 deletions(-) create mode 100644 working/retro-stereo-widgets/references/2026-07-16-mignon-4-index-typewriter.jpg create mode 100644 working/retro-stereo-widgets/references/2026-07-16-mignon-CREDITS.txt create mode 100644 working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 790a2da..25b10c6 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -1353,6 +1353,10 @@ 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. 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.'); +card(C,'R58','Index typewriter', + (st,rd)=>GW.indexPlate(st,{onChange:(v,t)=>rd(t)}), + 'type without a keyboard. One hand walks the stylus over a printed plate of characters, the other pulls the lever, and only the lever prints — hunt as long as you like and nothing happens until the second hand moves. Select and commit on two separate controls, which no other card in the kit does. Both cases live on the plate, so there is no shift key, and it carries what a keyboard skips: accents, fractions, the section mark. After the AEG Mignon Model 4 (1924), whose frequency-ordered keys we did not keep — reading the plate IS the interaction, so ours is alphabetical.'); + /* ============ METERS & GAUGES ============ */ const M=$('meters'); card(M,'10','Needle gauge', diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 6620ddf..70a63b3 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -399,6 +399,181 @@ GW.abcKeypad.ACTIONS = new Set([ ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 'SPC', 'DEL', 'ENT', 'CLR', ]); +/* R58 index typewriter — the oldest way to type without a keyboard. + After the AEG Mignon Model 4 (1924), the best of the index machines: one hand + walks a pointer over a printed plate of characters, the other pulls a lever, + and only the lever prints. Select and commit live on two separate controls, so + you can hunt as long as you like and nothing happens until the second hand + moves. That separation is the whole card — R57 is a keypad, this is not. + + Two things kept from the Mignon and one deliberately dropped. + Kept: the plate considered the characters a keyboard skips (accents, section + mark, fractions, a full punctuation ring), and it carries BOTH cases with no + shift key, which is how a keyless machine reaches a whole character set. + Dropped: its key order. The real plate runs P U G Q / V I N A B / L D E T M, + a frequency layout you cannot read your way around — and reading the plate is + the entire interaction. Ours is alphabetical, capitals beside lowercase at the + same column offset: find the letter, then pick the case. + The layout is a table rather than drawing, because Craig has already said the + keys will be revisited and a layout welded into the geometry never is. */ +GW.indexPlate = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const MAX = opts.max || 22; + const L = GW.indexPlate.LAYOUT; + const COLS = Math.max(...L.map(r => r.length)), ROWS = L.length; + /* Width is driven by the plate, not guessed: the lever and CLR live in a gutter + to its right. Sized from the layout table so a wider plate can't slide them + back on top of the characters (they were, at 300 wide — the last column and + the PRINT legend went under the lever). */ + const PX = 16, PY = 60, CW = 22, CH = 22, GUT = 46; + const PLATE_R = PX - 8 + COLS * CW + 14; + /* The gutter stack (lever, PRINT legend, CLR) is anchored to PY and needs 130px + whatever the plate does, so VH takes a floor. Without it a SHORTER table — + five rows is a plausible edit — shrinks VH until CLR rides up over the PRINT + legend and then the lever. Growth was always safe; shrink was the trap. */ + const VW = PLATE_R + GUT; + const VH = Math.max(PY - 10 + ROWS * CH + 16 + 16, PY + 130); + const s = stageSvg(host, 'rsvg ix-pad', VW, VH); + gradDef('ixPlate', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#efe6c4'], ['1', '#d8caa0']]); + gradDef('ixBody', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#26221c'], ['1', '#100e0b']]); + gradDef('ixSteel', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e6e9ef'], ['1', '#8d93a1']]); + /* body, then the paper the machine prints onto */ + svgEl(s, 'rect', { x: 2, y: 2, width: VW - 4, height: VH - 4, rx: 8, fill: 'url(#ixBody)', stroke: '#0a0908', 'stroke-width': 2 }); + svgEl(s, 'rect', { x: 14, y: 10, width: VW - 28, height: 30, rx: 2, + fill: 'var(--scr-bg1, #efe9da)', stroke: 'var(--scr-brd, #b3a883)', 'stroke-width': 1 }); + const paper = svgEl(s, 'text', { x: 22, y: 31, 'font-size': 13, 'letter-spacing': '.1em', + 'font-family': 'var(--mono)', fill: 'var(--scr-hi, #241d12)' }); + + /* the index plate: cream, rounded, characters in printed rings like the Mignon's */ + svgEl(s, 'rect', { x: PX - 8, y: PY - 10, width: COLS * CW + 14, height: ROWS * CH + 16, rx: 7, + fill: 'url(#ixPlate)', stroke: '#8d8268', 'stroke-width': 1.5 }); + + let sel = null, buf = ''; + /* null-prototype: cells is keyed by the plate's characters, and a plain {} would + resolve select('constructor') through Object.prototype and throw. */ + const cells = Object.create(null); + const render = () => { paper.textContent = (buf.length > 21 ? '‹' + buf.slice(-20) : buf.padEnd(21, ' ')).replace(/ /g, '␣'); }; + /* The stylus: a cone on a short shaft, hovering over the selected cell. The + Mignon's arm reaches back to a pivot, but a full arm drawn here crosses the + plate and hides the characters the operator is trying to read — the one thing + this widget must not do. A tip is enough to say "the pointer is here". */ + const arm = svgEl(s, 'g', {}); arm.setAttribute('class', 'ix-stylus'); + svgEl(arm, 'line', { x1: 0, y1: -9, x2: 0, y2: -20, stroke: 'url(#ixSteel)', 'stroke-width': 2.6 }); + svgEl(arm, 'circle', { cx: 0, cy: -21, r: 3.4, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': .6 }); + svgEl(arm, 'path', { d: 'M -3.6 -9 L 3.6 -9 L 0 0 Z', fill: 'url(#ixSteel)', stroke: '#41464f', 'stroke-width': .7 }); + arm.style.transition = 'transform .12s'; + arm.style.opacity = '0'; + + const select = c => { + const cell = cells[c]; if (!cell) return; + sel = c; + Object.values(cells).forEach(x => x.ring.setAttribute('stroke-opacity', '.35')); + cell.ring.setAttribute('stroke-opacity', '1'); + arm.style.opacity = '1'; + arm.setAttribute('transform', `translate(${cell.x},${cell.y - 9})`); + onChange(buf, 'stylus over ' + c); + }; + L.forEach((row, r) => row.forEach((c, i) => { + if (!c) return; + const x = PX + i * CW + CW / 2, y = PY + r * CH + CH / 2; + const g = svgEl(s, 'g', {}); g.setAttribute('class', 'ix-cell'); g.dataset.c = c; + g.style.cursor = 'pointer'; + const ring = svgEl(g, 'circle', { cx: x, cy: y, r: 8.6, fill: '#f7f2df', stroke: '#7a6a3e', 'stroke-width': 1.4, 'stroke-opacity': .35 }); + svgEl(g, 'text', { x, y: y + 3.6, 'text-anchor': 'middle', 'font-size': 9.5, 'font-weight': 600, + 'font-family': 'var(--mono)', fill: '#241d12' }).textContent = c; + cells[c] = { ring, x, y }; + g.addEventListener('click', () => press(c)); + })); + s.appendChild(arm); + + /* the lever: the only thing that prints. Lives in the gutter right of the plate */ + const LX = PLATE_R + GUT / 2; + const lever = svgEl(s, 'g', {}); lever.setAttribute('class', 'ix-lever'); + lever.style.cursor = 'pointer'; lever.style.transition = 'transform .08s'; + svgEl(lever, 'rect', { x: LX - 3.5, y: PY + 6, width: 7, height: 72, rx: 3.5, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': .8 }); + svgEl(lever, 'circle', { cx: LX, cy: PY + 2, r: 8, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': 1 }); + svgEl(s, 'text', { x: LX, y: PY + 94, 'text-anchor': 'middle', 'font-size': 6.5, 'letter-spacing': '.1em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'PRINT'; + const print = () => { + if (!sel) { onChange(buf, 'no character selected'); return; } + if (buf.length >= MAX) { onChange(buf, 'line full'); return; } + buf += sel; render(); onChange(buf, buf); + lever.style.transform = 'translateY(5px)'; + setTimeout(() => { lever.style.transform = ''; }, 90); + }; + lever.addEventListener('click', () => press('PRINT')); + s.appendChild(lever); + + /* fresh paper */ + const clr = svgEl(s, 'g', {}); clr.setAttribute('class', 'ix-clear'); clr.style.cursor = 'pointer'; + /* anchored to PY like the rest of the gutter stack, not to VH — mixing the two + is what let a shorter plate slide this up onto the PRINT legend */ + svgEl(clr, 'rect', { x: LX - 16, y: PY + 104, width: 32, height: 18, rx: 3, fill: '#3a332a', stroke: '#5c5348', 'stroke-width': 1 }); + svgEl(clr, 'text', { x: LX, y: PY + 117, 'text-anchor': 'middle', 'font-size': 7.5, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = 'CLR'; + const fresh = () => { buf = ''; render(); onChange(buf, 'fresh paper'); }; + clr.addEventListener('click', () => press('CLR')); + + /* THE single entry. Every caller goes through it — cell clicks, the lever, CLR, + the keyboard, and any port — so the mouse cannot quietly diverge from the keys. + Binding the handlers straight to select/print/fresh worked only because press + happened to be a pure dispatcher: the moment it grows a guard or a sound, the + primary input on this card would skip it and every probe would stay green. + Gated on ACTIONS, so it selects nothing it has no cell for. */ + const press = k => { + if (!GW.indexPlate.ACTIONS.has(k)) return; + if (k === 'PRINT') return print(); + if (k === 'CLR') return fresh(); + select(k); + }; + + /* Keyboard, per the README's keyboard contract: bound to this element, never to + the document. Typing SELECTS and Enter pulls the lever, which is the card's + whole grammar carried onto the keys — a keypress that printed would make this + R57 with a nicer plate. Nothing is uppercased on the way in, because the plate + holds both cases: Shift picks the case, since 'a' and 'A' are different cells. + Space isn't on the plate, so it isn't ours and still scrolls the page. */ + s.setAttribute('tabindex', '0'); + s.addEventListener('click', () => s.focus()); + s.addEventListener('keydown', e => { + if (e.ctrlKey || e.metaKey || e.altKey) return; + const k = GW.indexPlate.KEYS[e.key]; + if (!k) return; + e.preventDefault(); /* Enter would submit a form; a cell key has no default worth keeping */ + press(k); + }); + + render(); onChange('', 'point, then pull'); + return { el: s, get: () => buf, select, print, press, selected: () => sel }; +}; +/* The plate, as data. Capitals block beside lowercase at the same column offset, + so a letter and its case sit in the same row six columns apart. Digits and the + punctuation ring keep the Mignon's edges. Rewrite this table to relayout the + plate: nothing below reads it except the renderer. */ +GW.indexPlate.LAYOUT = [ + ['A','B','C','D','E','F', 'a','b','c','d','e','f'], + ['G','H','I','J','K','L', 'g','h','i','j','k','l'], + ['M','N','O','P','Q','R', 'm','n','o','p','q','r'], + ['S','T','U','V','W','X', 's','t','u','v','w','x'], + ['Y','Z','Ä','Ö','Ü','§', 'y','z','ä','ö','ü','ß'], + ['1','2','3','4','5','6', '7','8','9','0','½','¼'], + ['.',',',';',':','!','?', "'",'"','(',')','-','+'], +]; +/* Both tables are DERIVED from the layout, never maintained beside it: relaying + the plate must not leave a keybinding aimed at a character it no longer has. + Every plate character maps to itself (no case folding — the plate has both, so + Shift does the work a shift key would), and Enter is the lever. Space is + deliberately absent: there's no space cell yet, so Space isn't ours to claim. */ +GW.indexPlate.KEYS = (() => { + const m = {}; + for (const c of GW.indexPlate.LAYOUT.flat()) if (c) m[c] = c; + m.Enter = 'PRINT'; + return m; +})(); +/* Every argument press accepts. A superset of the KEYS values: CLR is a real + control that no keystroke reaches, the same shape as the keypad's. */ +GW.indexPlate.ACTIONS = new Set([...GW.indexPlate.LAYOUT.flat().filter(Boolean), 'PRINT', 'CLR']); + /* 03 horizontal fader — continuous 0-100 */ GW.faderH = function (host, opts = {}) { const onChange = opts.onChange || noop; @@ -4229,8 +4404,8 @@ const GW_CSS = ` :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} +.kp-pad,.ix-pad{outline:none} +.kp-pad:focus,.ix-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 6f4184a..5e95096 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('110 cards', cards === 110, `got ${cards}`); + 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()`); @@ -516,6 +516,223 @@ try { })()`); 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); diff --git a/todo.org b/todo.org index 1af8443..1a7d644 100644 --- a/todo.org +++ b/todo.org @@ -143,6 +143,28 @@ From the [[file:docs/design/2026-07-12-control-grammars-reference.org][taxonomy 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. 2. *Alphanumeric entry* — model the *industrial ABC-order keypad*. Craig's four references (=working/retro-stereo-widgets/references/2026-07-16-abc-keypad-*.png=, =-letter-drum-bank.png=) plus a survey superseded the teletype: the references are all ABC-order, not QWERTY, on stainless or membrane, with colour-coded function keys (yellow CANCEL, red CLEAR/NO, green ENTER/YES) that land on the kit's palette without translation. A faceplate, not furniture. 3. *Index typewriter* — stylus over a printed index plate, print lever commits (Hall 1881, AEG Mignon 1924). The survey's strongest find: select and commit on two separate controls is a grammar the kit has no exemplar of, and it is small and beautiful. Second card after the keypad. + + Craig's brief (2026-07-16, after reading the AEG Mignon Model 4 reference): *reproduce the device, keep its extended character set, treat the layout as ours.* What he admires is that the plate considered high-ASCII at all — accents, section mark, fractions, the full punctuation ring — and it carries both cases with no shift key, which is how a keyless machine reaches a whole character set. What he doesn't admire is the Mignon's key order: the real plate runs =P U G Q / V I N A B / L D E T M= (a frequency layout), which you cannot read your way around. "We'll revisit the keys and the layout. The best ideas will be preserved." + + So the layout ships as *data, not drawing* — a table the builder renders — because a revisable layout that requires touching the mechanism won't get revised. First pass: alphabetical, capitals block beside lowercase block at the same column offset (find the letter, then pick the case), the Mignon's two-block structure with a legible order. Expect it to change. + + References: =working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg= (Model 4, casing off, plate legible; CC BY-SA, Uwe Aranas, attribution required if reproduced) and =-mignon-4-index-typewriter.jpg= (museum context; public domain). Credits in =2026-07-16-mignon-CREDITS.txt=. Reference only, drawn from scratch. + +**** 2026-07-16 Thu @ 16:39:05 -0500 Built R58 index typewriter (gallery at 111) +Item 3 done. =GW.indexPlate=: click a cell to move the stylus, pull the lever to print, and only the lever prints. The first card where selecting and committing are separate acts. Keyboard follows the same rule — typing SELECTS, Enter is the lever — because a keypress that printed would make this R57 with a nicer plate. + +No case folding, unlike R57: the plate holds both cases as distinct cells, so Shift does the work a shift key would on a machine that has none. =KEYS= and =ACTIONS= are both *derived* from =LAYOUT= at load, so rewriting the table relays the plate, the keymap and the allowlist together — a binding aimed at a character the plate no longer carries isn't expressible. + +Craig's brief honoured: kept the extended set (Ä Ö Ü ä ö ü ß § ½ ¼ and the punctuation row) and the no-shift both-cases plate; dropped the Mignon's frequency order for alphabetical, capitals beside lowercase at the same column offset. + +Review (subagent) cleared the grammar — no path where selecting prints, none where the lever prints anything but the resting selection — and found five defects, all fixed: +- *Clicks bypassed press().* The contract says click and key must both route through it; R57 obeys, R58 had three direct handler bindings. No divergence yet, only because press was a pure dispatcher — add a guard to it and the mouse, the primary input on this card, would silently skip it with every probe green. Exactly the drift the rule exists to prevent, in the second card written against the rule. +- *Check 14c could not fail.* =/ss$/= on the readout also matches 'sss', so it passed even if selecting printed — blind to the one bug the card is about. Now exact, against the buffer. +- *cells{} collided on duplicate layout characters* and resolved =select('constructor')= through Object.prototype. Now =Object.create(null)=, plus a check that the table has no duplicates (14e couldn't see it: a duplicate inflates the drawn and declared counts equally). +- *Geometry broke on SHRINK.* The lever and legend anchor to the plate's top, CLR anchored to the viewBox, so a five-row table rode CLR up over the PRINT legend. Growth was always safe; shrink was the trap. VH now takes a floor, and I verified it by building the plate at 4, 5 and 9 rows rather than trusting the arithmetic: 190/192/280, no collisions. +- *No allowlist coverage on press().* R57 had it, R58 didn't. + +Known gap, Craig's to weigh when he revisits the keys: no space cell, so Space isn't in KEYS and still scrolls the page (correct per the contract). You can type Mignon-4 but not "Mignon 4". The real machine has a separate space key. 4. *Chorded keyset* — six keys, no key per letter; the chord IS the character (Microwriter 1978, Perkins Brailler 1951). The most compact honest password field. The kit names the chorded grammar already (R50 is its safety form) but holds no chorded text control. Third, if a compact field is wanted. 5. *Swatch picker* — model the signal-lamp lens turret or theatrical gel wheel; a rotary whose detents are coloured lenses. Undecided whether it earns a card or stays page furniture. 6. *Legend switch* — the lit pushbutton whose cap legend and colour ARE the state, press to step (MIL-STD-1472 catalogs it; the doc already cites the standard). The validation lamps are one. diff --git a/working/retro-stereo-widgets/references/2026-07-16-mignon-4-index-typewriter.jpg b/working/retro-stereo-widgets/references/2026-07-16-mignon-4-index-typewriter.jpg new file mode 100644 index 0000000..f0dee72 Binary files /dev/null and b/working/retro-stereo-widgets/references/2026-07-16-mignon-4-index-typewriter.jpg differ diff --git a/working/retro-stereo-widgets/references/2026-07-16-mignon-CREDITS.txt b/working/retro-stereo-widgets/references/2026-07-16-mignon-CREDITS.txt new file mode 100644 index 0000000..7e53ad8 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-16-mignon-CREDITS.txt @@ -0,0 +1,9 @@ +2026-07-16-mignon-4-index-typewriter.jpg + Source: https://commons.wikimedia.org/wiki/File:Mignon_4,_index_typewriter.jpg + Licence: Public domain + +2026-07-16-mignon-aeg-detail.jpg + Source: https://commons.wikimedia.org/wiki/File:AEG-MIGNON-typewriter-02.jpg + Credit: Photo by CEphoto, Uwe Aranas + Licence: CC BY-SA 3.0 — attribution required if this image is reproduced. + Reference only; the widget is drawn from scratch, not traced. diff --git a/working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg b/working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg new file mode 100644 index 0000000..1ee47cd Binary files /dev/null and b/working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg differ -- cgit v1.2.3