diff options
Diffstat (limited to 'docs/prototypes/widgets.js')
| -rw-r--r-- | docs/prototypes/widgets.js | 424 |
1 files changed, 413 insertions, 11 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index c12e5b7..70a63b3 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -126,30 +126,77 @@ GW.slideToggle = function (host, opts = {}) { if (!o) return; for (const [k, v] of Object.entries(o.vars)) sw.style.setProperty(k, v); }; - setStyle('on', opts.onStyle || 'amber'); - setStyle('off', opts.offStyle || 'dark'); - setStyle('offText', opts.offText || 'white'); - setStyle('thumb', opts.thumb || 'light'); + /* opts.preset names an intent; per-axis opts still win over it, so a caller can + take 'armed' and swap only the thumb. setPreset re-applies all four axes at + once and returns the axis map so a host UI can resync its own controls. */ + const preset = GW.slideToggle.PRESETS[opts.preset] || GW.slideToggle.PRESETS.panel; + /* AXIS_ORDER is load-bearing, not cosmetic: onText overrides the ink that the + `on` style sets, so applying the axes in any other order silently drops it. */ + const setPreset = name => { + const p = GW.slideToggle.PRESETS[name]; + if (!p) return null; + for (const axis of GW.slideToggle.AXIS_ORDER) if (p[axis]) setStyle(axis, p[axis]); + return p; + }; + const pick = { on: opts.onStyle, onText: opts.onText, off: opts.offStyle, + offText: opts.offText, thumb: opts.thumb }; + for (const axis of GW.slideToggle.AXIS_ORDER) setStyle(axis, pick[axis] || preset[axis]); let on; const set = v => { on = !!v; sw.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); }; sw.addEventListener('click', () => set(!on)); set(opts.on !== undefined ? opts.on : true); - return { el: sw, get: () => on, set, setStyle }; + return { el: sw, get: () => on, set, setStyle, setPreset }; +}; +/* named presets — a combination that carries intent, which is what a consumer + actually reaches for; the STYLES axes below are how one is built. The `on` + tone does the semantic work and the thumb material backs it: a touchscreen + pill wears plastic, a run or armed switch wears metal. */ +GW.slideToggle.AXIS_ORDER = ['on', 'onText', 'off', 'offText', 'thumb']; +GW.slideToggle.PRESETS = { + panel: { on: 'amber', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' }, + run: { on: 'green', onText: 'panel', off: 'dark', offText: 'dim', thumb: 'chrome' }, + armed: { on: 'red', onText: 'cream', off: 'dark', offText: 'white', thumb: 'chrome' }, + caution: { on: 'warn', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' }, + /* dark — neither state lights the pill; the legend alone carries the state, + green for on and Control red ("OFF marks") for off. The chrome thumb stays + the position affordance, since with both pills dark it is the only one. */ + dark: { on: 'dark', onText: 'green', off: 'dark', offText: 'red', thumb: 'chrome' }, }; /* named styles per axis: dot = swatch color for pickers, vars = --sw-* overrides */ GW.slideToggle.STYLES = { on: { amber: { dot: 'var(--gold-hi)', vars: { '--sw-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--sw-on-brd': 'var(--gold-hi)', '--sw-on-ink': 'var(--panel)' } }, green: { dot: '#8fb944', vars: { '--sw-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--sw-on-brd': '#a9c95f', '--sw-on-ink': 'var(--panel)' } }, + /* red/warn make the ENGAGED state the notable one — mute, record, airplane + mode. Before them the only red was on the `off` axis, which paints the + disengaged state: the opposite claim. Palette semantics: --fail is + "muted keys", --amber-warn is "warn cells", distinct from panel amber. */ + red: { dot: 'var(--fail)', vars: { '--sw-on-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-on-brd': 'var(--fail)', '--sw-on-ink': 'var(--cream)' } }, + warn: { dot: 'var(--amber-warn)', vars: { '--sw-on-bg': 'linear-gradient(180deg,#f7d193,var(--amber-warn))', '--sw-on-brd': 'var(--amber-warn)', '--sw-on-ink': 'var(--panel)' } }, dark: { dot: '#242019', vars: { '--sw-on-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-on-brd': 'var(--slate)', '--sw-on-ink': 'var(--cream)' } }, }, off: { dark: { dot: '#242019', vars: { '--sw-off-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-off-brd': 'var(--slate)' } }, red: { dot: 'var(--fail)', vars: { '--sw-off-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-off-brd': 'var(--fail)' } }, }, + /* onText overrides the ink the `on` style sets. The two are not independent — + ink must contrast with its own background, which is why each `on` atom still + carries a sensible default; naming onText is how a caller says "keep the + pill, change the legend". That is what makes the `dark` preset possible: + both states unlit, the legend colour carrying the state on its own. */ + onText: { + panel: { dot: 'var(--panel)', vars: { '--sw-on-ink': 'var(--panel)' } }, + cream: { dot: 'var(--cream)', vars: { '--sw-on-ink': 'var(--cream)' } }, + green: { dot: 'var(--sevgrn)', vars: { '--sw-on-ink': 'var(--sevgrn)' } }, + }, offText: { white: { dot: 'var(--cream)', vars: { '--sw-off-ink': 'var(--cream)' } }, + /* Control red — the palette's "OFF marks" ink, so a red OFF legend is the + kit's existing grammar rather than a new claim. */ red: { dot: '#e0523a', vars: { '--sw-off-ink': '#e0523a' } }, + /* steel is the kit's engraved-caption ink: lets an off toggle recede where a + panel carries many of them and eight cream OFFs would all shout equally. */ + dim: { dot: 'var(--steel)', vars: { '--sw-off-ink': 'var(--steel)' } }, black: { dot: '#14110e', vars: { '--sw-off-ink': '#14110e' } }, }, thumb: { @@ -160,10 +207,14 @@ GW.slideToggle.STYLES = { }, }; -/* 02 console keys — mutually exclusive push buttons; {label, red} per key */ +/* 02 console keys — mutually exclusive push buttons; {label, tone} per key. + tone picks the engaged look: undefined = gold (the default lit key), 'green' = + run green, 'red' = terracotta. Reading order runs safe -> live -> muted, and + LIVE takes green because that is what --pass means everywhere else in the kit + (the palette names it "run lamps, gear greens, monitor bars, LIVE lamps"). */ GW.consoleKeys = function (host, opts = {}) { const onChange = opts.onChange || noop; - const keys = opts.keys || [{ label: 'LIVE' }, { label: 'SCAN' }, { label: 'MUTED', red: true }]; + const keys = opts.keys || GW.consoleKeys.DEFAULT_KEYS; const wrap = document.createElement('span'); host.appendChild(wrap); const btns = keys.map(k => { const b = document.createElement('button'); b.className = 'key'; b.textContent = k.label; @@ -171,14 +222,357 @@ GW.consoleKeys = function (host, opts = {}) { }); let idx; const set = i => { - idx = i; - btns.forEach((b, j) => { b.classList.remove('on', 'red'); if (j === i) b.classList.add(keys[i].red ? 'red' : 'on'); }); - onChange(i, keys[i].label); + idx = Math.max(0, Math.min(keys.length - 1, i)); + btns.forEach((b, j) => { + b.classList.remove('on', 'green', 'red'); + if (j === idx) b.classList.add(keys[idx].tone || 'on'); + }); + onChange(idx, keys[idx].label); }; btns.forEach((b, i) => b.addEventListener('click', () => set(i))); - set(opts.active || 0); + /* The default set engages LIVE, not the first key: it is the meaningful + resting state for a console and it shows the green. A caller supplying its + own keys gets the first one unless it says otherwise. */ + set(opts.active !== undefined ? opts.active + : (keys === GW.consoleKeys.DEFAULT_KEYS ? 1 : 0)); return { el: wrap, get: () => idx, set }; }; +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 }); + /* 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. + 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, '·'); + }; + /* 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; } + 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); + }); + }); + /* 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', +]); + +/* 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 = {}) { @@ -4005,12 +4399,20 @@ 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,.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)} .key:hover{color:var(--gold);border-color:var(--gold)} .key:active{transform:translateY(1px)} .key.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.green{color:var(--cream);background:linear-gradient(180deg,#9cbf5e,var(--pass));border-color:var(--pass);font-weight:700} .key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} .key.off{opacity:.4} |
