diff options
Diffstat (limited to 'docs/prototypes/widgets.js')
| -rw-r--r-- | docs/prototypes/widgets.js | 179 |
1 files changed, 177 insertions, 2 deletions
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)} |
