aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/design/2026-07-12-control-grammars-reference.org152
-rw-r--r--docs/prototypes/README.org45
-rw-r--r--docs/prototypes/panel-widget-gallery.html134
-rw-r--r--docs/prototypes/widgets.js424
4 files changed, 734 insertions, 21 deletions
diff --git a/docs/design/2026-07-12-control-grammars-reference.org b/docs/design/2026-07-12-control-grammars-reference.org
index 4d5e028..beee4f5 100644
--- a/docs/design/2026-07-12-control-grammars-reference.org
+++ b/docs/design/2026-07-12-control-grammars-reference.org
@@ -30,7 +30,7 @@ No single canonical registry of control grammars exists. The concept is split ac
* Proposed axes for the classification brainstorm
-Starting point, to be argued in the brainstorm, not settled here:
+Starting point, to be argued in the brainstorm, not settled here. Argued 2026-07-16 — see "The brainstorm, run" below, which adds an eighth axis and records what the cross found.
1. Elemental task (Foley) :: select / position / orient / path / quantify / text — plus the display-side tasks the gallery adds (indicate state, indicate quantity, indicate history, indicate spatial status).
2. Input model :: none (display) / click / click-step / drag-1D / drag-2D / held / composed. Already on every spec sheet; drives Emacs portability directly.
@@ -42,7 +42,155 @@ Starting point, to be argued in the brainstorm, not settled here:
The gap-finding move the brainstorm exists for: cross axes 1×4 and look for empty cells — a use-case with no component is a cell the catalogue cannot fill.
+* The brainstorm, run (2026-07-16)
+
+The gap-finding cross, run against the 109-card kit. What follows is what it found, the axis it needed that the seven above don't carry, and the period models proposed for each empty cell. Build decisions live in todo.org; this section is the map they come from.
+
+** Axis 8 — set stability (new, and the one that finds the hole)
+
+*Fixed* (the option set is knowable when the panel is built) versus *dynamic* (the set is discovered at run time and changes without the panel changing).
+
+The seven axes above cannot find the kit's largest gap, because axis 4 lumps all one-of-N together: a chicken-head selector whose stops are cut in metal and a WiFi network list sit in the same cell, though nothing about the first tells you how to build the second. Crossing task x cardinality alone therefore reports the cell as densely covered. Only set stability separates them.
+
+The axis is invisible from inside hardware, which is why it was missing. *Every* one-of-N control in this kit has its positions engraved at manufacture — silkscreened legends, cut detents, printed scales. A faceplate physically cannot hold a set that changes, so a catalogue derived from period hardware inherits a blind spot exactly at dynamic sets. The kit's consumers are software panels (net, bt, audio, maint) whose central objects are dynamic sets: available networks, paired devices, running services. The blind spot lands precisely on what the consumers most need.
+
+** The cross, run — elemental task (Foley) x cardinality x set stability
+
+First pass, scoped to the Controls section (the input side, where the cross bites). Counts are card counts; =--= is an empty cell. Display-side tasks are summarized below rather than celled, since the gaps there are not where the interest is.
+
+| Elemental task | bin | 1-of-N fixed | 1-of-N dyn | m-of-N | m<->m | cont | 2-axis | compound |
+|----------------+-----+--------------+------------+--------+-------+------+--------+----------|
+| select | 10 | 13 | -- | 3 | 2 | -- | -- | -- |
+|----------------+-----+--------------+------------+--------+-------+------+--------+----------|
+| quantify | -- | -- | -- | -- | -- | 13 | -- | 2 |
+|----------------+-----+--------------+------------+--------+-------+------+--------+----------|
+| position | -- | -- | -- | -- | -- | -- | 2 | -- |
+|----------------+-----+--------------+------------+--------+-------+------+--------+----------|
+| orient | -- | -- | -- | -- | -- | -- | 2 | -- |
+|----------------+-----+--------------+------------+--------+-------+------+--------+----------|
+| path | -- | -- | -- | -- | -- | -- | 1 | -- |
+|----------------+-----+--------------+------------+--------+-------+------+--------+----------|
+| text (digits) | -- | -- | -- | -- | -- | -- | -- | 3 |
+|----------------+-----+--------------+------------+--------+-------+------+--------+----------|
+| text (alpha) | -- | -- | -- | -- | -- | -- | -- | -- |
+|----------------+-----+--------------+------------+--------+-------+------+--------+----------|
+
+Cell contents, first pass:
+
+- select x binary :: 01, 07, 08, N01, R03, R24, R29, R38, R48, R50.
+- select x 1-of-N fixed :: 02, 06, 24, N03, N06, R06, R20, R22, R27, R28, R33, R34, R42.
+- select x 1-of-N dynamic :: *empty*.
+- select x many-of-N :: N09, R51, R52.
+- select x many-to-many :: N28, R37.
+- quantify x continuous :: 03, 04, 05, N05, N07, N08, N10, R02, R04, R12, R14, R18, R23.
+- quantify x compound :: N04, R49.
+- position / orient / path :: R19, R26, R43, R44.
+- text x digits :: R16, R39, R41.
+- text x alphanumeric :: *empty*.
+
+Display side (Meters, Indicators — roughly 55 cards) covers indicate-state, indicate-quantity, indicate-history and indicate-spatial-status densely; no empty cell worth a card was found there. Left uncelled deliberately.
+
+** Findings — the two empty cells
+
+*Foley names six irreducible tasks. The kit serves five and a half.*
+
+1. *select x one-of-N, dynamic set.* The bt panel lists paired devices; the net panel lists available networks, each row carrying an SSID, a signal strength and a lock state. The catalogue meant to describe those panels has no widget for the thing those panels most obviously are. Nearest misses: N03 (a fixed, small preset bank) and R20 (a drum of values, not rich rows). Neither is it.
+2. *text x alphanumeric.* The kit enters digits three ways and text zero ways. The net panel needs a WiFi password.
+
+Both are needed by live consumers today, which is what makes them the two that sting.
+
+** Why hardware had no dynamic-set control, and what it did instead
+
+The period answer to a set that changes was never a control. It was *a re-labelable slot*: hardware supplied a holder and let a human write the label.
+
+- Jukebox title-strip rack (Seeburg wallbox) :: printed strips slotted into a rack and replaced when the records change; a page-flip lever browses them; a letter-plus-number selector picks one. A scrollable list of rich rows with selection, built in 1950.
+- Rolodex :: spin through rich cards; add and remove at will.
+- Mixing-console scribble strip :: tape and grease pencil over each channel.
+- Telephone switchboard subscriber strips :: relabeled as subscribers came and went.
+
+The shared grammar is worth naming, because it is the aesthetic-honest answer to every dynamic set: *the slot is manufactured, the label is not.* A device list rendered as a rack of slotted, hand-labeled strips is not a skeuomorphic joke — "somebody wrote these because the set changes" is exactly what a device list means.
+
+** Period models proposed per gap
+
+One row per candidate model, so each model keeps its own verdict.
+
+| Gap | Model to build from | Verdict |
+|-------------------------+---------------------------------------------+----------------------------------------------|
+| select x 1-of-N dynamic | Jukebox wallbox title-strip rack | First pick. Page-flip browse, rich strips, |
+| | | select one. |
+|-------------------------+---------------------------------------------+----------------------------------------------|
+| select x 1-of-N dynamic | Rolodex card spinner | Alternative. Better for long sets, weaker at |
+| | | showing several rows at once. |
+|-------------------------+---------------------------------------------+----------------------------------------------|
+| text x alphanumeric | Industrial ABC-order keypad | *First pick*, superseding the teletype — see |
+| | | the survey below. A faceplate, not a |
+| | | machine. |
+|-------------------------+---------------------------------------------+----------------------------------------------|
+| text x alphanumeric | Index typewriter (stylus + print lever) | Strongest novel idiom found: select and |
+| | | commit are two separate controls. |
+|-------------------------+---------------------------------------------+----------------------------------------------|
+| text x alphanumeric | Chorded keyset (Microwriter / Brailler) | Six keys, no key per letter. The most |
+| | | compact honest answer. |
+|-------------------------+---------------------------------------------+----------------------------------------------|
+| text x alphanumeric | Letter-drum bank (combination lock) | Craig's reference. Fixed length, so wrong |
+| | | for a password; right for a callsign. |
+|-------------------------+---------------------------------------------+----------------------------------------------|
+| text x alphanumeric | Teletype (ASR-33) keyboard + line | Demoted. Honest but it is a machine, not a |
+| | | faceplate, and QWERTY is the least |
+| | | panel-native option. |
+|-------------------------+---------------------------------------------+----------------------------------------------|
+| text x alphanumeric | Dymo embossing wheel | Charming and faceplate-native; painful past |
+| | | about eight characters. A descendant of the |
+| | | index typewriter, mechanism differs. |
+|-------------------------+---------------------------------------------+----------------------------------------------|
+| swatch pick (chrome) | Signal-lamp lens turret, or the theatrical | A rotary whose detents are coloured lenses; |
+| | gel wheel | the sample is the label. Joins the kit's |
+| | | existing rotary family. |
+|-------------------------+---------------------------------------------+----------------------------------------------|
+| cycling state button | Legend switch (MIL-STD-1472) | Lit pushbutton whose cap legend and colour |
+| | | ARE the state; press to step. The kit has |
+| | | lit keys (02), not this. |
+|-------------------------+---------------------------------------------+----------------------------------------------|
+
+** Survey: how period hardware entered letters (2026-07-16)
+
+Craig fed four reference photos (filed in =working/retro-stereo-widgets/references/= as =2026-07-16-abc-keypad-stainless.png=, =-abc-keypad-membrane-fkeys.png=, =-abc-keypad-membrane-color.png=, =-letter-drum-bank.png=) and asked for a wider search. Both moves changed the recommendation.
+
+*The references beat the teletype.* All three keypad photos are *ABC-order*, not QWERTY: A-Z laid out alphabetically with a separate 0-9 block, on stainless or membrane, with colour-coded function keys (yellow CANCEL, red CLEAR/NO, green ENTER/YES, blue HELP, F1-F4). That is the industrial convention wherever the operator cannot be assumed to touch-type — fleet, kiosk, access control. It is a faceplate rather than a machine, it panel-mounts, and its colour coding lands on the kit's palette without translation (terracotta for CLEAR/NO, run green for ENTER/YES, amber for CANCEL). The teletype is honest but it is furniture; the ABC keypad is the panel-native answer and supersedes it.
+
+*The survey found idioms genuinely unlike a keypad.* Ranked by distinctness:
+
+- Index typewriter :: Hall Type-Writer No. 1 (1881), AEG Mignon Model 4 (1924-30s). One hand walks a *stylus over a printed index plate*, the other pulls a *print lever* to commit. Select and commit are two separate physical controls — an idiom nothing in the kit has. Arbitrary length. Reads unmistakably as period hardware at small size. The Dymo wheel is a descendant, but stylus-on-index plus lever is a different mechanism from rotate-dial plus squeeze. *Strongest find.*
+- Chorded keyset :: Microwriter (1978-80s, six keys — one per finger plus two thumb), Stenotype (patents 1911/1913), Perkins Brailler (1951, six keys as dots 1-2-3 / 4-5-6). No key per letter at all; a simultaneous *chord* is the character. Six keys plus a live chord readout is the most compact honest answer for a password field, and the kit already names the chorded grammar (R50 is its safety-hardware form) without holding a chorded *text* control.
+- CDU / MCDU scratchpad + line-select keys :: Boeing/Airbus FMS, ~1980s-present. Text lands in a *scratchpad buffer* and stays uncommitted until a line-select key beside the target field both commits it and says where it goes. A different *flow*, not a different selector — its character source is an ABC keypad. The screen-flanking LSK bezel is iconic.
+- Joystick scroll-and-fire :: Asteroids (1979); Defender/Stargate allowed more than three characters. Up/down steps the alphabet, fire commits and advances. The three-initial limit was an obscenity policy, not a technical one. Generalises to any shaft-encoder-scrolls-an-alphabet control.
+- Multi-tap / T9 :: multi-tap early 1990s; T9 from Tegic, 1995, first shipped on the Benefon iO and Samsung Anycall. Twelve keys, letters cycled by repeat press with a timeout commit. The most *practical* small-panel password entry found, and the letter-cycling state animates well.
+- Trackball gesture :: Atari Quantum (1982) — the player encircles letters, and top scorers draw their initials freehand. Genuinely unlike everything else; needs a drawing surface, so it is big.
+- Keypunch + program drum :: IBM 029 (1964). A punched card on a *program drum* templates the fields — auto-duplicate, force numeric/alpha, skip. The column ruler plus field mask is a different framing; the machine is huge.
+- Keyboard + lampboard :: Enigma (1920s-45). A keypress lights exactly one of 26 lamps and the operator hand-copies it. Judged a *feedback* idiom, not a selector — the 26-lamp alphabet grid is worth stealing as a readout for some other card, not as text entry.
+- Keyboard perforator :: Teletype Model 15 (1930-63), Model 19 with integral perforator. Keying punches tape offline; compose and transmit are separated in time. The punched-tape strip is a fine visual but the keyboard is ASR-33-adjacent.
+
+*Two flags from the survey, carried rather than smoothed over:*
+
+- No true A-Z *thumbwheel switch* could be verified — Digitran and Grayhill mil-spec coded wheels are 0-9 or hex (0-9 A-F) only. Craig's letter-drum reference is a *combination lock*, not a thumbwheel switch. The drum bank is real, but its lineage is locks, and a card should say so rather than implying a switch that may not exist.
+- The trackball-gesture and Quantum details are single-sourced.
+
+*Recommendation.* Build the ABC keypad first — it fills the empty cell, two live panels want it, and it is what the references show. Then the index typewriter as the second card, because it is the one genuinely new *grammar* (select on one control, commit on another) and it is small and beautiful. The chorded keyset is the third if a compact password field is wanted. Everything else is banked.
+
+** The gallery cannot build its own chrome from its own kit
+
+The last two rows are a finding in their own right. The gallery's own controls — the style-chip swatch rows, the screen-family chips, the validation lamps that cycle off/amber/green — are all bespoke HTML with no card behind them. The size toggle is a segmented control the kit already has (06) and does not use.
+
+That is the cheapest completeness test available: a catalogue that cannot build its own interface from itself has holes, and it names them for free. Worth re-running whenever the gallery's chrome grows.
+
+** Still open
+
+- The full seven-axis classification is not settled. Only axes 1 (elemental task) and 4 (cardinality), plus the new axis 8, were argued; grammar, state authority, persistence and era are untouched.
+- The cell assignments above are a first pass from card names and spec sheets, not a card-by-card audit. Confidence of completeness needs the audit.
+- The display side is uncelled.
+- Whether the chrome gaps (swatch, legend switch) earn cards, or stay page furniture, is undecided.
+
* Related
-- Gallery: [[file:../prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]] (100 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/README.org b/docs/prototypes/README.org
index 651636a..1cb75c6 100644
--- a/docs/prototypes/README.org
+++ b/docs/prototypes/README.org
@@ -60,6 +60,51 @@ Widget-owned animation (the R17 scope trace, R31 radar sweep, R44 servo
chase, R52 pseudo-PC, R53 day clock, R10 TIME page) runs inside the builder,
gated on =prefers-reduced-motion=.
+** Keyboard contract (widgets that take keys)
+
+Sibling of the tick contract above, and the same split: focus is an ambient
+resource like the clock — exactly one thing holds it across the whole page —
+so *the target owns focus and delivery, the builder declares what it accepts*.
+Neither half works alone. Only the page knows which widget is live; only the
+widget knows that Backspace means DEL.
+
+*The builder declares* a =KEYS= table on itself, mapping a canonical key name
+to the argument its handle's =press= takes:
+
+: GW.abcKeypad.KEYS = { A:'A', ..., '0':'0', ..., Space:'SPC', Backspace:'DEL', Enter:'ENT' }
+
+One declaration, wired natively per target:
+
+- *Web* — the builder attaches a =keydown= listener *to its own focusable
+ element* (=tabindex=0=, focused on click). =GW.slideRule= is the precedent:
+ arrows step it, scoped to its own element. The browser's focus system does
+ the arbitration, which is why it doesn't fight the gallery's own global
+ Escape handler.
+- *Emacs* — the SVG region is an image and never sees a keypress, so the mode
+ installs the same table into its keymap and calls =press=. This is why the
+ declaration is a table and not a function over a DOM event: a port that
+ can't read the widget's intent has to re-derive it, and then the two drift.
+- *waybar* — GTK focus, same shape.
+
+Rules, each of them a bug someone will otherwise ship:
+
+- *Never listen on =document= or =window=.* A widget that does fights every
+ sibling and the page's own handlers; on the gallery, every keystroke
+ anywhere would type into whichever card bound last.
+- *=preventDefault= only what has a default worth suppressing* — Space scrolls
+ the page, Backspace can navigate back. Nothing else.
+- *Let Tab and Escape bubble.* Tab is how the page is navigable at all, and
+ the gallery's audit stepper owns Escape. A widget that swallows either
+ breaks something it can't see.
+- *=press= filters, it does not trust.* A handle that appends whatever it is
+ handed will cheerfully append "F1". The table is the allowlist.
+- *Click and key must land in the same place* — both route through =press=, so
+ the two paths cannot drift apart or fire different events.
+
+A widget with no =KEYS= table takes no keys, which is most of them: the kit is
+click-first and that is what makes it port. Keys are additive, and in Emacs
+they are the *more* native idiom, not the less.
+
** Style options
Widgets with named style axes take them as constructor opts backed by a
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html
index 9740c46..25b10c6 100644
--- a/docs/prototypes/panel-widget-gallery.html
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -126,6 +126,10 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1}
background:#221f1b;border:1px solid #33302b}
.vtally .vn{margin-left:auto;color:var(--cream);font-variant-numeric:tabular-nums}
.vtally .vtot{border-top:1px solid var(--wash);margin-top:3px;padding-top:4.5px;color:var(--steel)}
+.vtally .vcopy{margin-top:5px;width:100%;font:inherit;font-size:.7rem;letter-spacing:.04em;
+ background:transparent;color:var(--steel);border:1px solid var(--wash);border-radius:3px;
+ padding:2.5px 0;cursor:pointer}
+.vtally .vcopy:hover{color:var(--gold-hi);border-color:var(--gold)}
.stagew{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:14px 12px;
min-height:78px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap}
.wrd{color:var(--gold-hi);font-size:.8rem;letter-spacing:.06em;font-variant-numeric:tabular-nums;
@@ -680,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, or click the plate and type on the keyboard (letters, digits, space, Backspace, Enter). DEL takes back one character, CLR wipes the lot, ENT commits.',
+ 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:'Clicking is slow by design: a 20-character passphrase is 20 clicks, which is why the plate also takes the keyboard when focused. It is for panels with no keyboard of their own, and a real one still beats it for volume.',
+ 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. Clicking is tedious past a few words, which is what the keyboard is for.',
+ 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.',
@@ -1039,6 +1051,68 @@ const INFO={
const INFO_FIELDS=[['input','input'],['solves','solves'],['use','use'],['limits','limits'],
['origin','origin'],['difficulty','difficulty'],['prefer','prefer when'],['period','period']];
const VLAMP_TITLES={off:'validation: not done',amber:'validation: in progress',green:'validation: done'};
+/* VSTATUS — the baked validation record: the durable half of the walk.
+ Lamp clicks write localStorage, which is per-browser-profile and dies with a
+ cache clear, so the walk's progress is baked back into source periodically
+ (the "copy for source" control under the tally emits this block; paste it
+ here). Only amber/green are listed — off is the default and stays implicit.
+ Precedence is localStorage first, VSTATUS second: the live walk wins on the
+ machine doing it, and the baked record fills in on a fresh profile, after a
+ clear, or on the other daily driver. 'off' is a stored value like any other,
+ so deliberately un-checking a card beats a baked green rather than reverting
+ to it on reload. */
+const VSTATUS={
+ "01": "green",
+ "06": "green",
+ "25": "green",
+ "R05": "green",
+ "12": "green",
+ "R07": "amber",
+ "18": "green",
+ "R10": "green",
+ "R11": "green",
+ "R52": "amber"
+};
+/* GVexport — the live lamp state in VSTATUS shape, ready to paste back above.
+ Reads the lamps rather than localStorage so what you copy is what you see.
+ Builds the text by hand rather than via JSON.stringify: stringify orders
+ canonical integer keys ('12') numerically ahead of everything else ('01' has
+ a leading zero, 'R05' isn't numeric), which would reshuffle the baked block
+ on every bake and bury each walk's real change in diff noise. Card order is
+ DOM order, so the block grows in place. */
+function GVexport(){
+ const rows=[];
+ document.querySelectorAll('.vlamp').forEach(l=>{
+ const v=l.dataset.v;
+ if(v!=='off') rows.push(' "'+l.closest('.card').id.slice(5)+'": "'+v+'"');});
+ return '{\n'+rows.join(',\n')+'\n}';
+}
+/* Hand the export over selected, and let the reader press Ctrl+C themselves.
+ The page deliberately never tries to copy on their behalf, because from a
+ file:// origin neither programmatic path works here (Chrome 150, 2026-07-16):
+ - navigator.clipboard.writeText rejects NotAllowedError, "Write permission
+ denied" — a file origin can't hold the clipboard-write permission.
+ - execCommand('copy') returns TRUE and writes nothing. Worse than failing,
+ because the return value invites the page to claim success it didn't have.
+ A hand-typed Ctrl+C works fine, so this is specific to a page copying on the
+ user's behalf, NOT a broken clipboard: a Wayland-set clipboard crosses to X11
+ and Emacs reads it correctly (sentinel-verified). Selecting the text is
+ therefore the whole job — the user's own copy is the one that works, and it
+ lands in the real clipboard where every app can reach it.
+ Do NOT "improve" this by reading the X/Wayland PRIMARY selection instead:
+ PRIMARY holds whatever was last selected ANYWHERE, so it silently returns
+ unrelated content (it produced a private SMS while this was being built). */
+function gvCopy(btn){
+ const flash=m=>{btn.textContent=m;setTimeout(()=>btn.textContent='copy for source',2600);};
+ const ta=document.createElement('textarea');
+ ta.value=GVexport();
+ ta.style.cssText='position:fixed;left:12px;bottom:12px;z-index:70;width:320px;height:170px;'+
+ 'font:12px ui-monospace,monospace;background:#14120f;color:#e8dcc0;border:1px solid #7d5c16;'+
+ 'border-radius:4px;padding:6px';
+ document.body.appendChild(ta); ta.select();
+ flash('press Ctrl+C ▸');
+ ta.addEventListener('blur',()=>ta.remove());
+}
/* index tally: recounts every card lamp; setV calls it on every state change.
Clicking a row jumps to the next card in that state (cycles), so a count
that disagrees with a visual scan can be audited card by card. */
@@ -1052,10 +1126,12 @@ function updateVTally(){
`<div class="vrow" data-v="green"><span class="vdot" data-v="green"></span>done<span class="vn">${n.green}</span></div>`+
`<div class="vrow" data-v="amber"><span class="vdot" data-v="amber"></span>in progress<span class="vn">${n.amber}</span></div>`+
`<div class="vrow" data-v="off"><span class="vdot"></span>not done<span class="vn">${n.off}</span></div>`+
- `<div class="vrow vtot">total<span class="vn">${lamps.length}</span></div>`;
+ `<div class="vrow vtot">total<span class="vn">${lamps.length}</span></div>`+
+ `<button class="vcopy" type="button">copy for source</button>`;
el.querySelectorAll('.vrow[data-v]').forEach(row=>row.addEventListener('click',()=>{
VJUMP[row.dataset.v]=0; auditNext(row.dataset.v);
}));
+ el.querySelector('.vcopy').addEventListener('click',e=>gvCopy(e.target));
}
/* audit stepper: a row click jumps to the first card in that state and docks a
floating pill; clicking the pill advances through the rest without scrolling
@@ -1090,7 +1166,7 @@ function card(host, no, name, html, note){
`<div class="opts"></div><div class="wnote">${note}</div>`;
const lamp=c.querySelector('.vlamp'), VKEY='gv-'+no, VSTATES=['off','amber','green'];
const setV=v=>{lamp.dataset.v=v;lamp.title=VLAMP_TITLES[v];updateVTally();};
- setV(localStorage.getItem(VKEY)||'off');
+ setV(localStorage.getItem(VKEY)||VSTATUS[no]||'off');
lamp.addEventListener('click',e=>{e.stopPropagation();
const v=VSTATES[(VSTATES.indexOf(lamp.dataset.v)+1)%3];
setV(v);localStorage.setItem(VKEY,v);});
@@ -1115,7 +1191,7 @@ card(C,'01','Slide toggle',
'<b>on / off, the touchscreen way.</b> The one born-digital control in the kit — the smartphone pill toggle the live net/bt/audio panels use. Click to flip; readout shows the state. Physical cousins: the bat-handle (R03) and the rocker (N01).');
card(C,'02','Console key',
(st,rd)=>GW.consoleKeys(st,{onChange:(i,t)=>rd(t)}),
- '<b>physical push button.</b> Mutually exclusive — click one to engage. Gold = live, terracotta = muted.');
+ '<b>physical push button.</b> Mutually exclusive — click one to engage. Green = live, terracotta = muted, gold = any other engaged key.');
card(C,'03','Horizontal fader',
(st,rd)=>GW.faderH(st,{value:68,onChange:(v,t)=>rd(t)}),
'<b>continuous 0-100.</b> Per-device volume, brightness. Drag the gold cap; readout tracks.');
@@ -1273,6 +1349,13 @@ 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. 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)}),
+ '<b>type without a keyboard.</b> 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');
@@ -1574,6 +1657,9 @@ screenChips('R17',$('card-R17').gw.el,['green','amber','red','blue','vfd'],'gree
screenChips('R19',$('card-R19').gw.el,['white','green','amber','blue','vfd'],'white');
screenChips('R31',$('card-R31').gw.el,['amber','green','red','blue','vfd'],'amber');
screenChips('N11',$('card-N11').gw.el,['green','amber','red','blue','vfd'],'green');
+/* all six, where its siblings each carry five: a passphrase window has no reason
+ to prefer one phosphor, so it offers the whole set including the marquee cyan */
+screenChips('R57',$('card-R57').gw.el,['amber','green','red','blue','vfd','white'],'amber');
BOOST.forEach(no=>{const rd=document.getElementById('rd-'+no);
if(rd)rd.closest('.card').querySelector('.stagew').classList.add('boost');});
@@ -1585,25 +1671,57 @@ BOOST.forEach(no=>{const rd=document.getElementById('rd-'+no);
m=>ids.has(m)?`<a class="xref" href="#card-${m}">${m}</a>`:m);});})();
/* style chips: shared demo rig for builders with constructor style opts —
- reads a builder's STYLES table and drives setStyle on the live card instance */
-function styleChips(no,STYLES,AXES){
+ reads a builder's STYLES table and drives setStyle on the live card instance.
+ PRESETS (optional) adds a leading group of named combinations: a preset chip
+ applies all axes at once and resyncs the axis chips beneath it, and changing
+ any axis afterwards clears the preset — the chips must never claim a preset
+ the widget has since diverged from. */
+function styleChips(no,STYLES,AXES,PRESETS,defPreset){
const h=$('card-'+no)?.gw; if(!h)return;
const cardEl=h.el.closest('.card');
const row=document.createElement('div'); row.className='famchips';
+ const sel={}, sync={}, groups=[]; let clearPreset=()=>{};
+ /* Re-apply every axis on any change rather than just the one clicked: axes are
+ not independent (onText overrides the ink `on` sets), so a lone setStyle can
+ drop a selection the chips still show as lit — the card would then display a
+ combination the widget isn't in. Applying all of them in AXES order makes
+ the outcome independent of click order. */
+ const applyAll=()=>{for(const [,axis] of AXES) if(sel[axis]) h.setStyle(axis,sel[axis]);};
for(const [label,axis,def] of AXES){
+ sel[axis]=def;
const g=document.createElement('span'); g.className='fgroup';
const lab=document.createElement('span'); lab.className='lab'; lab.textContent=label; g.appendChild(lab);
const chips=[];
for(const [name,o] of Object.entries(STYLES[axis])){
const b=document.createElement('span'); b.className='fc'+(name===def?' on':'');
b.style.background=o.dot; b.title=name; chips.push(b);
- b.addEventListener('click',()=>{h.setStyle(axis,name);
- chips.forEach(x=>x.classList.toggle('on',x===b));});
+ b.addEventListener('click',()=>{sel[axis]=name; applyAll();
+ chips.forEach(x=>x.classList.toggle('on',x===b)); clearPreset();});
+ g.appendChild(b);}
+ sync[axis]=name=>{sel[axis]=name; chips.forEach(x=>x.classList.toggle('on',x.title===name));};
+ groups.push(g);}
+ if(PRESETS&&h.setPreset){
+ const g=document.createElement('span'); g.className='fgroup';
+ const lab=document.createElement('span'); lab.className='lab'; lab.textContent='preset'; g.appendChild(lab);
+ const chips=[];
+ for(const [name,p] of Object.entries(PRESETS)){
+ const b=document.createElement('span'); b.className='fc'+(name===defPreset?' on':''); b.title=name;
+ /* the on-tone is the preset's identity, so it doubles as the swatch */
+ b.style.background=STYLES.on[p.on].dot; chips.push(b);
+ b.addEventListener('click',()=>{
+ const applied=h.setPreset(name); if(!applied)return;
+ chips.forEach(x=>x.classList.toggle('on',x===b));
+ for(const [axis,style] of Object.entries(applied)) sync[axis]?.(style);});
g.appendChild(b);}
+ clearPreset=()=>chips.forEach(x=>x.classList.remove('on'));
row.appendChild(g);}
+ groups.forEach(g=>row.appendChild(g));
cardEl.querySelector('.opts').appendChild(row);
}
-styleChips('01',GW.slideToggle.STYLES,[['on','on','amber'],['off','off','dark'],['off text','offText','white'],['thumb','thumb','light']]);
+styleChips('01',GW.slideToggle.STYLES,
+ [['on','on','amber'],['on text','onText','panel'],['off','off','dark'],
+ ['off text','offText','white'],['thumb','thumb','light']],
+ GW.slideToggle.PRESETS,'panel');
styleChips('R05',GW.filterBank.STYLES,[['panel','panel','silver'],['shape','caps','block'],['color','capColor','black']]);
styleChips('06',GW.segmented.STYLES,[['accent','accent','amber']]);
styleChips('25',GW.slideRule.STYLES,[['face','skin','warm']]);
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}