aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/widgets.js
diff options
context:
space:
mode:
Diffstat (limited to 'docs/prototypes/widgets.js')
-rw-r--r--docs/prototypes/widgets.js179
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)}