@tn3270/gui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,799 @@
1
+ // packages/frontend/dist/keyboard3279.js
2
+ var ch = (label, row, x, shiftLabel) => ({
3
+ label,
4
+ ...shiftLabel === void 0 ? {} : { shiftLabel },
5
+ action: { kind: "type", text: label },
6
+ region: "main",
7
+ row,
8
+ x,
9
+ width: 1,
10
+ name: shiftLabel === void 0 ? label : `${shiftLabel} ${label}`
11
+ });
12
+ var ch2 = (label, region, row, x, name) => ({
13
+ label,
14
+ action: { kind: "type", text: label },
15
+ region,
16
+ row,
17
+ x,
18
+ width: 1,
19
+ name
20
+ });
21
+ var dead = (label, region, row, x, width, name) => ({ label, action: void 0, region, row, x, width, name });
22
+ var SHIFT_NAME = "Shift";
23
+ var shift = (row, x, width, side) => ({
24
+ // `Shft`, NOT `Shift`: the LEFT cap is 1.25 units, which is 31px usable, and five capitals at
25
+ // the main block's 10px type need ~38px. The real keyboard prints `Shift` in full because its
26
+ // cap is physically wider than one unit of this layout's pitch; four characters is the honest
27
+ // abbreviation at this size. The NAME stays `Shift (...)`, which is what the view's `data-key`
28
+ // and both harnesses address -- a label is presentation, a name is the identifier.
29
+ label: "Shft",
30
+ action: void 0,
31
+ region: "main",
32
+ row,
33
+ x,
34
+ width,
35
+ // The side is in the name for the tooltip and for a screen reader, but `keyboardUi.ts` matches
36
+ // on the PREFIX, so both caps drive one state.
37
+ name: `${SHIFT_NAME} (${side}, one-shot)`
38
+ });
39
+ var key = (label, action, region, row, x, width, name) => ({ label, action, region, row, x, width, name });
40
+ var pfRow = (row, first) => Array.from({ length: 12 }, (_, i) => key(`PF${first + i}`, { kind: "pf", n: first + i }, "pf", row, i, 1, `PF${first + i}`));
41
+ var letters = (text, row, x) => [...text].map((c, i) => ch(c, row, x + i));
42
+ var KEYBOARD_3279 = Object.freeze([
43
+ /*
44
+ THE PF STRIP: TWO ROWS ABOVE THE MAIN KEYBOARD, PF13-24 ON TOP OF PF1-12.
45
+
46
+ ## THE POSITION IS A DECISION, NOT THE ONLY OPTION -- CONFIRMED BY THE USER 2026-10-22
47
+
48
+ **"There's also a common layout that puts PF13-24 in a block to the right of the numeric
49
+ keypad but I prefer the two rows above the main keyboard."** Both arrangements shipped on real
50
+ 3270-family hardware, and this one is what the reference 5271 has -- so the two-band layout in
51
+ `ui.css` (PF strip across the top, the other four regions in a row beneath) is matching the
52
+ photographed unit AND the user's stated preference, which happen to agree.
53
+
54
+ **RECORDED BECAUSE IT WOULD OTHERWISE LOOK LIKE AN UNEXAMINED DEFAULT.** Someone who knows the
55
+ right-hand-block variant could "fix" this toward it in good faith; they should know it was
56
+ considered and declined. If it is ever wanted as an OPTION, the change is in the view's band
57
+ assignment (`keyboardUi.ts` decides which band a region joins, from its name) and not in this
58
+ table -- the geometry here is per-region, so a PF block beside the numpad needs no new
59
+ coordinates, only a different container.
60
+
61
+ ## THE ROW ORDER WITHIN THE STRIP
62
+
63
+ PF13-24 above PF1-12 is the hardware's order in the photograph, and it matches both
64
+ `KEYPAD_KEYS` (which opens `pfRow(0, 13)` then `pfRow(1, 1)`) and c3270
65
+ (`Common/c3270/keypad.labels:2` and `:4`). Reversing it would be a silent relearn for anyone
66
+ used to either. The table's test pins the first label of each row rather than only the count,
67
+ which is what catches that.
68
+ */
69
+ ...pfRow(0, 13),
70
+ ...pfRow(1, 1),
71
+ /*
72
+ THE LEFT SPECIAL BAR: two columns, five rows.
73
+
74
+ `Attn` and `Clear` are real 3270 keys and work. `Cursr Sel` is a real 3270 key that does NOT:
75
+ `AID.SELECT` exists in core, but `keypad.ts` records the key as dropped by decision because it
76
+ "needs the field-intensity parse and belongs with the light-pen work". It is drawn inert, and
77
+ its tooltip is the place that says it is deferred rather than impossible.
78
+
79
+ `Esc`, `Print Screen`, `Scroll Lock` and `Pause` are PC keys present on this 122-key unit and
80
+ are not 3270 functions. They get no action because they SHOULD not have one.
81
+
82
+ THE TWO BLANK CAPS AT THE BOTTOM carry `Xfer` and nothing. The user spotted them -- "since
83
+ there are two unlabelled keys on the left hand special-key bar, one of those could get the
84
+ `Xfer` label" -- and there is precedent: `KEYPAD_KEYS` already carries `Xfer` as a deliberate
85
+ non-3270 addition, and `keypad.ts` argues a transfer button "is a perfectly sensible thing to
86
+ click" even though `applyAction` throws on the kind, because the front end intercepts it
87
+ exactly as it intercepts the chord.
88
+ */
89
+ /*
90
+ THE LEFT SPECIAL BAR -- REWRITTEN 2026-10-22, AND FOUR OF ITS CAPS WERE SIMPLY WRONG.
91
+
92
+ I had `Esc`, `Print Screen`, `Scroll Lock` and `Pause` here: PC keys, read off a blurred
93
+ photograph and believed because they looked like a plausible PC-ish column. The sharp reference
94
+ (`DSC08836-scaled.jpeg`) shows what is actually there, and it is all 3270:
95
+
96
+ Attn | Clear
97
+ Cursr | (blank)
98
+ Sel |
99
+ Ex Sel| Erase EOF
100
+ Print | Test <- `Test` is the SHIFTED legend on a blank cap
101
+ (blk) | (blk)
102
+
103
+ **TWO OF THEM ARE FUNCTIONS WE ALREADY IMPLEMENT**, so they become LIVE KEYS rather than grey
104
+ scenery: `Erase EOF` is `eraseEOF`, which the compact keypad has carried all along as `ErEOF`.
105
+ That is the most valuable thing this photograph bought -- not geometry, but two working keys
106
+ where I had drawn dead ones.
107
+
108
+ `Ex Sel` IS *NOT* IMPLEMENTED, and the distinction matters: it is Erase Input on some units and
109
+ a field-select on others, and `keypad.ts` records Erase Input as `ErInp` separately. Rather
110
+ than guess which this cap is, it stays inert with its real name -- the same discipline the
111
+ unreadable caps got.
112
+
113
+ `Print`'s SHIFTED LEGEND IS `Ident`, and the blank cap beside it carries `Test`. Both were read
114
+ by the user on this photograph. Neither has an action here, so they are recorded as legends on
115
+ inert caps: documented as existing and unimplemented rather than silently absent.
116
+
117
+ `Attn`'s OWN SHIFTED LEGEND IS STILL UNREADABLE even here -- it is front-printed and blurred in
118
+ every photograph so far -- as is the row-1 blank's. Those two remain unidentified.
119
+ */
120
+ key("Attn", { kind: "attn" }, "special-left", 0, 0, 1, "Attention"),
121
+ key("Clr", { kind: "clear" }, "special-left", 0, 1, 1, "Clear"),
122
+ dead("Cur\nSel", "special-left", 1, 0, 1, "Cursor Select (not implemented: light-pen work)"),
123
+ dead("", "special-left", 1, 1, 1, "blank, right of Cursor Select"),
124
+ dead("Ex\nSel", "special-left", 2, 0, 1, "Ex Sel (not implemented: ambiguous function)"),
125
+ // A REAL 3270 FUNCTION WE IMPLEMENT -- the compact keypad has carried it as `ErEOF` all along.
126
+ key("Ers\nEOF", { kind: "eraseEOF" }, "special-left", 2, 1, 1, "Erase EOF"),
127
+ {
128
+ label: "Prnt",
129
+ shiftLabel: "Idnt",
130
+ action: void 0,
131
+ region: "special-left",
132
+ row: 3,
133
+ x: 0,
134
+ width: 1,
135
+ name: "Print (shifted: Ident, not implemented)"
136
+ },
137
+ {
138
+ label: "",
139
+ shiftLabel: "Test",
140
+ action: void 0,
141
+ region: "special-left",
142
+ row: 3,
143
+ x: 1,
144
+ width: 1,
145
+ name: "blank (shifted: Test, not implemented)"
146
+ },
147
+ /*
148
+ THE TWO BLANK CAPS AT THE BOTTOM, AND `Xfer` GOES BACK ON ONE OF THEM.
149
+
150
+ It had moved to the cursor cluster's third cap on the user's reasoning that the cap had no
151
+ unshifted function -- but this photograph IDENTIFIES that cap: it carries the
152
+ `a`-with-a-squiggle glyph, which is delete-to-end-of-line. So it is a real key after all, and
153
+ `Xfer` returns here, to a cap this file has CONFIRMED blank under maximum zoom.
154
+ */
155
+ key("Xfer", { kind: "transferForm" }, "special-left", 4, 0, 1, "File transfer"),
156
+ dead("", "special-left", 4, 1, 1, "blank, bottom right of the special bar"),
157
+ /*
158
+ THE MAIN BLOCK, five rows.
159
+
160
+ ROW 0, the number row: `| @ # $ % not & * ( )` over `1`-`0`, then `_`/`-`, `+`/`=`, Backspace.
161
+ THE NOT SIGN ON 6 IS THE PROOF THIS IS A 3270 LAYOUT and not a PC one; a PC has `^` there.
162
+ `1` carries `|`, where a PC has `!`.
163
+ */
164
+ ch("1", 0, 0, "|"),
165
+ ch("2", 0, 1, "@"),
166
+ ch("3", 0, 2, "#"),
167
+ ch("4", 0, 3, "$"),
168
+ ch("5", 0, 4, "%"),
169
+ /*
170
+ `cent` OVER `6`, CORRECTED 2026-10-22 FROM A SECOND PHOTOGRAPH.
171
+
172
+ I first read this as U+00AC NOT SIGN off a blurred cap, and reasoned about EBCDIC to justify
173
+ it. A sharp photograph of another 122-key 3279 -- an IBM 4978, a DIFFERENT layout but the same
174
+ typewriter character set -- shows `cent` unambiguously over `6`, and re-zooming the original
175
+ cap is consistent with that rather than with a not-sign.
176
+
177
+ TWO KEYBOARDS AGREEING BEATS ONE BLURRED CAP PLUS AN ARGUMENT, which is the lesson: the
178
+ EBCDIC reasoning was sound and still produced the wrong glyph, because it explained a reading
179
+ rather than testing it.
180
+ */
181
+ ch("6", 0, 5, "\xA2"),
182
+ ch("7", 0, 6, "&"),
183
+ ch("8", 0, 7, "*"),
184
+ ch("9", 0, 8, "("),
185
+ /*
186
+ `)` OVER `0` -- NO LONGER AN INFERENCE, CONFIRMED 2026-10-22.
187
+
188
+ The cap is BLANK on the first reference unit (no legend at all under maximum zoom, while
189
+ `_`/`-` beside it is legible, so it is missing or worn smooth), and `)` was originally guessed
190
+ from the `9`/`(` pattern. A sharp photograph of an IBM 4978 shows `)` over `0` directly, so
191
+ the guess is now a reading -- from a different keyboard, which is worth stating.
192
+ */
193
+ ch("0", 0, 9, ")"),
194
+ ch("-", 0, 10, "_"),
195
+ /*
196
+ `+` OVER `=`, CONFIRMED by the 4978's own `+`/`=` cap -- though note that keyboard carries an
197
+ EXTRA cap to the right of it, a bullet over plus-minus, which this one does not have. Two
198
+ 122-key 3279 keyboards, two different furniture layouts; the CHARACTER SET agrees where both
199
+ have the key, which is exactly how far a cross-check can be trusted.
200
+ */
201
+ ch("=", 0, 11, "+"),
202
+ // BACKSPACE IS 3 UNITS, which is what closes row 0 at 15 -- and the photograph shows it as the
203
+ // widest cap on that row, running to the right edge above the Return key.
204
+ key("\u2190", { kind: "backspace" }, "main", 0, 12, 3, "Backspace"),
205
+ /*
206
+ ROWS 1-4, AS A TRUE RECTANGLE -- REWRITTEN 2026-10-22 FROM A SHARP REFERENCE PHOTOGRAPH
207
+ (`DSC08836-scaled.jpeg`), which is the same 5271 and finally legible.
208
+
209
+ ## EVERY ROW ENDS AT EXACTLY 15 UNITS, AND THAT IS WHY
210
+
211
+ The block used to end at 14, 15, 13.75, 13.5 and 14 units -- ragged by up to 1.5 units, which
212
+ the user saw immediately: "can we make the main keyboard an actual rectangle?" A real keyboard
213
+ is a rectangle because its leading keys STAGGER and its edge keys are WIDE, not because it has
214
+ dead space, so that is what this now models.
215
+
216
+ ## THE STAGGER IS MEASURED, NOT EYEBALLED
217
+
218
+ `Z` MUST CENTRE BETWEEN `A` AND `S`, which the user named as the test. With Caps at 1.75 units
219
+ `A` centres at 2.25 and `S` at 3.25, so `Z` must centre at 2.75 -- which a 2.25-unit left
220
+ Shift gives exactly. Tab 1.5, Caps 1.75, Shift 2.25 is the classic ANSI-ish progression and it
221
+ falls out of that one requirement rather than being copied from a PC.
222
+
223
+ ## THE RETURN KEY SPANS TWO ROWS, WHICH IS WHAT CLOSES THE RECTANGLE
224
+
225
+ The photograph shows the bent-arrow Return as one tall cap covering the QWERTY and home rows,
226
+ with a SEPARATE small `Enter` on the bottom row. Without the span, row 2 ends 1.25 units short
227
+ and no amount of widening the punctuation keys looks right. `height: 2` is declared on its top
228
+ row and the home row simply has no cap at that `x` -- see `KeyCap.height`.
229
+
230
+ IT IS DRAWN AS A RECTANGLE AND NOT AS THE INVERTED L THE HARDWARE HAS, which is the one place
231
+ this table knowingly departs from the photograph. The L's overhanging half cannot be made
232
+ clickable without covering the `{` below it, and the user's call (2026-10-23) was that a
233
+ key-shaped dead region is worse than an oversized gap. The cap's own comment has the detail.
234
+ */
235
+ // ROW 1: Tab, QWERTY, two punctuation caps, and the TALL Return.
236
+ key("\u2192|", { kind: "tab" }, "main", 1, 0, 1.5, "Tab"),
237
+ ...letters("QWERTYUIOP", 1, 1.5),
238
+ // `!` over a NOT SIGN -- confirmed again on this photograph, right of `P`.
239
+ ch("\xAC", 1, 11.5, "!"),
240
+ // A BROKEN BAR (U+00A6) over the backslash, distinct from the solid `|` on the `1` key.
241
+ ch("\\", 1, 12.5, "\xA6"),
242
+ /*
243
+ RETURN, TWO ROWS TALL AND 1.5 UNITS WIDE.
244
+
245
+ `newline`, NOT `enter`, and that is the 3270 distinction this table has carried from the start:
246
+ `Enter` is the AID that submits the screen and has its own cap on row 4 (and another on the
247
+ numeric pad); this one moves the cursor to the next input field. `keymap.ts` records that no
248
+ TUI chord could express Newline because Ctrl-J IS 0x0a -- a cap has no such spelling problem,
249
+ which is half the reason this surface is worth having.
250
+ */
251
+ {
252
+ label: "\u21B5",
253
+ action: { kind: "newline" },
254
+ /*
255
+ 1.25 UNITS WIDE, WHICH IS THE GEOMETRY THAT KEEPS THE `{` CLICKABLE.
256
+
257
+ It was width 1.5 -- and that OVERLAPPED the `{` cap below it by 0.25 units, because the home
258
+ row's content runs further right than the QWERTY row's (13.75 against 13.5, Caps being 1.75
259
+ where Tab is 1.5). The user saw the `{` lose its border.
260
+
261
+ A `clip-path` notch in `ui.css` was the first fix and it was NOT ENOUGH: a clip changes what
262
+ is PAINTED and not what the box covers, so the Return still sat on top of the `{`. The
263
+ harness said `OVERLAPS by 6px` once it compared boxes instead of hit-testing.
264
+
265
+ 1.25 UNITS REMOVES THE OVERLAP AND KEEPS THE RECTANGLE: the home row is 13.75 + 1.25 = 15,
266
+ and the QWERTY row is 13.5 of content, a 0.25-unit gap, then the same 1.25 = 15.
267
+
268
+ THE `x` IS NOT WHAT DOES THAT, AND SAYING SO HERE IS THE POINT -- this comment claimed it was
269
+ until a mutation (2026-10-23) showed otherwise. `ui.css` lifts a tall cap out of flow with
270
+ `position: absolute; right: 0`, so its `x` is NEVER READ and the WIDTH alone sets its left
271
+ edge. 13.75 is recorded because it is where the key really is on the hardware and because
272
+ `x` is how every other cap is placed; change the WIDTH if the left edge must move.
273
+
274
+ AND THE 0.25-UNIT GAP IS LEFT EMPTY, by the user's decision on 2026-10-23. The hardware key
275
+ is an inverted L that fills it; drawn here, the overhang is a region that looks like a key
276
+ and cannot be clicked -- the `{` needs the box to stay narrow. An oversized gap reads as
277
+ deliberate where a dead half-key does not. See `ui.css` for the full note.
278
+ */
279
+ region: "main",
280
+ row: 1,
281
+ x: 13.75,
282
+ width: 1.25,
283
+ height: 2,
284
+ name: "Newline"
285
+ },
286
+ // ROW 2: Caps (inert), ASDFGHJKL, three punctuation caps. The Return above covers 13.5-15.
287
+ dead("\u21EA", "main", 2, 0, 1.75, "Caps Lock (not implemented: see the header)"),
288
+ ...letters("ASDFGHJKL", 2, 1.75),
289
+ ch(";", 2, 10.75, ":"),
290
+ ch("'", 2, 11.75, '"'),
291
+ ch("{", 2, 12.75, "}"),
292
+ /*
293
+ ROW 3: a 2.25-UNIT SHIFT, ZXCVBNM, three punctuation caps, and a WIDE right Shift.
294
+
295
+ BOTH SHIFT CAPS DRIVE THE SAME ONE-SHOT STATE -- neither is distinguishable to us, and they are
296
+ matched by NAME in `canvas/src/keyboardUi.ts`. The left cap's printed legend is `>`/`<` on the
297
+ hardware; it is drawn as `Shft` so the surface reads as a keyboard rather than a puzzle.
298
+ */
299
+ shift(3, 0, 2.25, "left"),
300
+ ...letters("ZXCVBNM", 3, 2.25),
301
+ ch(",", 3, 9.25, "'"),
302
+ ch(".", 3, 10.25, ":"),
303
+ ch("/", 3, 11.25, "?"),
304
+ shift(3, 12.25, 2.75, "right"),
305
+ /*
306
+ ROW 4: Reset, Alt, the spacebar, Alt, a blank, and `Enter`.
307
+
308
+ `Reset` CARRIES `DvCntl` AS ITS SHIFTED LEGEND -- front-printed on the cap, read on this
309
+ photograph and identified by the user as Device Control. We have no action for it, so it is
310
+ recorded as a `shiftLabel` on a cap whose unshifted function we DO implement: pressing Shift
311
+ re-letters it, and clicking it still sends `reset`. That is honest -- the shifted function is
312
+ documented as existing and unimplemented, rather than silently absent.
313
+ */
314
+ {
315
+ label: "Reset",
316
+ shiftLabel: "DvCn",
317
+ action: { kind: "reset" },
318
+ region: "main",
319
+ row: 4,
320
+ x: 0,
321
+ width: 1.5,
322
+ name: "Reset (shifted: Device Control, not implemented)"
323
+ },
324
+ dead("Alt", "main", 4, 1.5, 1.25, "Alt, left (not a 3270 key)"),
325
+ {
326
+ label: "",
327
+ action: { kind: "type", text: " " },
328
+ region: "main",
329
+ row: 4,
330
+ x: 2.75,
331
+ width: 7,
332
+ name: "Space"
333
+ },
334
+ dead("Alt", "main", 4, 9.75, 1.25, "Alt, right (not a 3270 key)"),
335
+ dead("", "main", 4, 11, 1.25, "blank, right of the spacebar"),
336
+ key("Enter", { kind: "enter" }, "main", 4, 12.25, 2.75, "Enter"),
337
+ /*
338
+ THE CURSOR CLUSTER.
339
+
340
+ `Dup`+`PA1` and `Field Mark`+`PA2` are DOUBLE-LEGEND CAPS on the hardware -- the lower legend
341
+ is the key's alternate function. We bind the UPPER one, because `Dup` and `Field Mark` are
342
+ typed characters this client implements (`keymap.ts`: "Dup and Field Mark are TYPED CHARACTERS,
343
+ not AIDs") while PA1/PA2 already have caps in the keypad and chords of their own. The lower
344
+ legend is drawn in the label so the cap matches the photograph.
345
+
346
+ THE THREE GLYPH KEYS ABOVE THE ARROWS COULD NOT BE READ at this resolution -- a left-pointing
347
+ arrow, a house-like glyph and a third mark. They are INERT with descriptive names, per this
348
+ file's header rule, and are the first thing to fix if a better photograph turns up.
349
+ */
350
+ /*
351
+ THE CURSOR CLUSTER, AS A DIAMOND -- REWRITTEN 2026-10-22 FROM THE SHARP REFERENCE.
352
+
353
+ The user prefers this to the modern inverted-T and the photograph shows why: the arrows sit in
354
+ a 3x3 diamond with a CENTRE CAP between them, which is what the hardware actually has.
355
+
356
+ Dup/PA1 | FieldMark/PA2 | PA3
357
+ BackTab | Home | DelEOL
358
+ | up |
359
+ left | (centre) | right
360
+ | down |
361
+
362
+ ## WHAT THIS PHOTOGRAPH IDENTIFIED, ALL OF IT NEW
363
+
364
+ - THE THIRD CAP OF ROW 1 is the `a`-with-a-squiggle glyph, which the user read as
365
+ delete-to-end-of-line. We have no such action -- `delete` is a single character and
366
+ `eraseEOF` erases to the end of the FIELD, not the line -- so it is inert with its real
367
+ name. **AND THAT IS WHY `Xfer` MOVED OFF IT**: it is a real key, not a spare.
368
+ - THE CENTRE CAP carries a DIAGONAL ARROW, which the user read as cursor-to-top-left. That is
369
+ plausibly `Home` -- but `Home` already has its own cap two rows up, and a keyboard does not
370
+ usually spend two caps on one function. Inert, with the ambiguity recorded rather than
371
+ guessed away.
372
+ - THE SHIFTED LEGENDS ON LEFT AND RIGHT ARE DOUBLE ARROWS, which the user read as
373
+ beginning/end of line. No action exists for either, so they are `shiftLabel`s on live cursor
374
+ keys: Shift re-letters the cap, and clicking still moves the cursor one position. The
375
+ shifted function is documented as existing and unimplemented.
376
+ */
377
+ key("Dup\nPA1", { kind: "dup" }, "cursor", 0, 0, 1, "Dup"),
378
+ key("Fld\nMrk\nPA2", { kind: "fieldMark" }, "cursor", 0, 1, 1, "Field Mark"),
379
+ key("PA3", { kind: "pa", n: 3 }, "cursor", 0, 2, 1, "PA3"),
380
+ /*
381
+ REVERSE TAB: `U+21E4 LEFTWARDS ARROW TO BAR`, AND THE DIRECTION WAS MIRRORED BEFORE.
382
+
383
+ I had `←|` -- bar on the RIGHT, as if the arrow left a tab stop. The user caught it and the
384
+ reference photograph is unambiguous: the BAR IS AT THE LEFT with the arrowhead terminating
385
+ into it, because you are going back TO a tab stop, not back FROM one. The semantics were
386
+ backwards, not just the drawing.
387
+ */
388
+ key("\u21E4", { kind: "backTab" }, "cursor", 1, 0, 1, "Back Tab (cursor cluster)"),
389
+ /*
390
+ `U+21A5 UPWARDS ARROW FROM BAR` FOR INSERT -- AND THIS CAP WAS MISIDENTIFIED AS `Home`.
391
+
392
+ The glyph is a lowercase `a` beneath a WIDE CARET, which the user reads as Insert; the caret is
393
+ the classic insertion mark and the `a` is the character being inserted. I had drawn it as a
394
+ house and called it Home, which was a guess from the shape alone.
395
+
396
+ `toggleInsert` IS AN ACTION WE HAVE, so this becomes a working key rather than scenery -- the
397
+ compact keypad carries it as `Ins`.
398
+
399
+ THE GLYPH IS AN APPROXIMATION, NOT A TRANSCRIPTION: there is no Unicode point for an `a` under
400
+ a caret, so this is the closest available stand-in, and an SVG of the real mark is the someday
401
+ fix.
402
+
403
+ `U+2041 CARET INSERTION POINT`, AND IT IS THE THIRD CHOICE -- the first two were rejected for
404
+ different reasons, which is why all three are recorded.
405
+
406
+ 1. `U+21A5 UPWARDS ARROW FROM BAR` was mine, and it is the wrong SHAPE: a thin solid arrow
407
+ with a bar at its foot, where a 12x crop of the cap shows a wide HOLLOW caret.
408
+ 2. `U+21E7 UPWARDS WHITE ARROW` was the user's suggestion and matches the shape exactly --
409
+ but **U+21E7 IS THE SHIFT KEY**, universally, and this keyboard HAS two Shift caps. A
410
+ glyph that means Shift on a key that means Insert is worse than one that merely looks
411
+ wrong: it is actively misleading, and the shape being right makes the confusion more
412
+ likely rather than less.
413
+ 3. `U+2041 CARET INSERTION POINT` is the proofreader's insertion mark, so right shape and right
414
+ meaning -- and it DOES NOT RENDER. Measured in the harness: 2.3px against 7.5px for a
415
+ glyph known to be present, i.e. a near-blank fallback. General Punctuation is not what the
416
+ symbol fonts in `ui.css` were chosen for.
417
+ 4. `U+2303 UP ARROWHEAD` measures 7.5px, exactly matching the reference glyph, and it is a
418
+ hollow caret that means "control/up" rather than Shift. Right shape, available, and
419
+ unclaimed on this keyboard.
420
+
421
+ THE LESSON IS THAT A GLYPH HAS THREE PROPERTIES and all of them matter: what it looks like,
422
+ what it already means elsewhere, and whether the font actually has it. Choice 1 failed the
423
+ first, choice 2 the second, choice 3 the third.
424
+
425
+ STILL AN APPROXIMATION: the hardware draws an `a` tucked under the caret's apex, and no
426
+ Unicode point does that. An SVG remains the someday fix.
427
+ */
428
+ key("\u2303", { kind: "toggleInsert" }, "cursor", 1, 1, 1, "Insert (approximate glyph)"),
429
+ /*
430
+ `U+219B RIGHTWARDS ARROW WITH STROKE` FOR DELETE CHARACTER, SHIFTED TO TWO OF THEM.
431
+
432
+ The cap is a lowercase `a` with a squiggle struck diagonally up-and-right, and its shifted
433
+ legend is visibly LONGER -- the user reads it as the squiggle running through three `a`s, i.e.
434
+ the same operation extended to the end of the line.
435
+
436
+ I HAD `U+232B ERASE TO THE LEFT`, WHICH WAS WRONG IN MEANING: the user points out the stroke
437
+ goes up and to the RIGHT, so this deletes forward, not backward. `delete` is exactly that
438
+ action and we have it, so the unshifted key is LIVE; the shifted form is
439
+ delete-to-end-of-line, which we do NOT have, and the doubled glyph records it the way the
440
+ doubled arrows record start/end of line on the cursor keys.
441
+
442
+ BOTH GLYPHS ARE APPROXIMATIONS, not transcriptions -- no Unicode point draws an `a` with a
443
+ diagonal strike. Doubling for the shifted form follows the hardware's own idiom and this
444
+ table's existing one.
445
+ */
446
+ {
447
+ label: "\u219B",
448
+ shiftLabel: "\u219B\u219B",
449
+ action: { kind: "delete" },
450
+ region: "cursor",
451
+ row: 1,
452
+ x: 2,
453
+ width: 1,
454
+ name: "Delete character (shifted: delete to end of line, not implemented)"
455
+ },
456
+ // THE DIAMOND. `up` is centred on its own row; `left`, the centre cap and `right` share the
457
+ // next; `down` is centred below. The row offsets come from the table and `keyboardUi.ts`
458
+ // renders them -- see its note on `padding-left`.
459
+ {
460
+ label: "\u2191",
461
+ action: { kind: "up" },
462
+ region: "cursor",
463
+ row: 2,
464
+ x: 1,
465
+ width: 1,
466
+ name: "Cursor up"
467
+ },
468
+ {
469
+ label: "\u2190",
470
+ shiftLabel: "\u219E",
471
+ action: { kind: "left" },
472
+ region: "cursor",
473
+ row: 3,
474
+ x: 0,
475
+ width: 1,
476
+ name: "Cursor left (shifted: start of line, not implemented)"
477
+ },
478
+ dead("\u2196", "cursor", 3, 1, 1, "diagonal arrow, function unidentified"),
479
+ {
480
+ label: "\u2192",
481
+ shiftLabel: "\u21A0",
482
+ action: { kind: "right" },
483
+ region: "cursor",
484
+ row: 3,
485
+ x: 2,
486
+ width: 1,
487
+ name: "Cursor right (shifted: end of line, not implemented)"
488
+ },
489
+ {
490
+ label: "\u2193",
491
+ action: { kind: "down" },
492
+ region: "cursor",
493
+ row: 4,
494
+ x: 1,
495
+ width: 1,
496
+ name: "Cursor down"
497
+ },
498
+ /*
499
+ THE NUMERIC KEYPAD.
500
+
501
+ The digits are typed characters. The right-hand column could not be fully resolved: the top
502
+ cap reads like `Space`, below it a tab-like glyph, then `-`, then a tall `Enter`. `Enter` is
503
+ legible and is bound; the other three are INERT with descriptive names rather than guessed.
504
+ */
505
+ // `ch()` builds a `main` cap, so the region is overridden here rather than adding a parameter
506
+ // for one caller. The digits are the same typed characters as the main block's.
507
+ ...[..."789"].map((c, i) => ({ ...ch(c, 0, i), region: "numpad" })),
508
+ ...[..."456"].map((c, i) => ({ ...ch(c, 1, i), region: "numpad" })),
509
+ ...[..."123"].map((c, i) => ({ ...ch(c, 2, i), region: "numpad" })),
510
+ { ...ch("0", 3, 0), region: "numpad", width: 2, name: "0 (numeric pad)" },
511
+ { ...ch(".", 3, 2), region: "numpad", name: ". (numeric pad)" },
512
+ /*
513
+ THE NUMERIC PAD'S RIGHT-HAND COLUMN -- READ AT LAST, 2026-10-22, FROM THE ORIGINAL PHOTOGRAPH.
514
+
515
+ These three were inert with `unread` names because I could not resolve them. A 6x crop of the
516
+ SAME image settles all three: `Space`, a FIELD TAB arrow, and `-`, with `Enter` below. **The
517
+ pixels were always there; I gave up on the zoom too early**, which is worth recording next to
518
+ the entries it cost.
519
+
520
+ A SECOND KEYBOARD CONFIRMED NOTHING HERE, and that is the interesting part: the IBM 3270 PC
521
+ (the layout this one otherwise matches exactly) has a PC NUMPAD in this position -- 7/Home,
522
+ 8/up, 9/PgUp, with Esc/NumLk/ScrLk above. It is a PC with 3270 emulation, so its numpad is a
523
+ PC's. Transplanting those legends would have been the worst kind of cross-check: a keyboard
524
+ that agrees everywhere else, differing silently in exactly the place I needed.
525
+ */
526
+ key("Spc", { kind: "type", text: " " }, "numpad", 0, 3, 1, "Space (numeric pad)"),
527
+ // FIELD TAB, the same `→|` glyph the main block's Tab carries.
528
+ key("\u2192|", { kind: "tab" }, "numpad", 1, 3, 1, "Tab (numeric pad)"),
529
+ ch2("-", "numpad", 2, 3, "- (numeric pad)"),
530
+ key("Ent", { kind: "enter" }, "numpad", 3, 3, 1, "Enter (numeric pad)")
531
+ ]);
532
+
533
+ // packages/canvas/dist/keyboardUi.js
534
+ var isShiftCap = (cap) => cap.name.startsWith(SHIFT_NAME);
535
+ function createKeyboardUi(deps) {
536
+ let armed = false;
537
+ const shiftButtons = [];
538
+ const dualCaps = [];
539
+ const paintShift = () => {
540
+ for (const button of shiftButtons) {
541
+ if (armed)
542
+ deps.setAttr(button, "data-armed", "true");
543
+ else
544
+ deps.removeAttr(button, "data-armed");
545
+ }
546
+ for (const cap of dualCaps) {
547
+ deps.setText(cap.el, armed ? cap.shifted : cap.plain);
548
+ }
549
+ };
550
+ const regions = /* @__PURE__ */ new Map();
551
+ for (const cap of KEYBOARD_3279) {
552
+ const rows = regions.get(cap.region) ?? /* @__PURE__ */ new Map();
553
+ const row = rows.get(cap.row) ?? [];
554
+ row.push(cap);
555
+ rows.set(cap.row, row);
556
+ regions.set(cap.region, rows);
557
+ }
558
+ const bands = deps.create("div");
559
+ deps.setAttr(bands, "class", "keyboard-bands");
560
+ const topBand = deps.create("div");
561
+ deps.setAttr(topBand, "class", "keyboard-band");
562
+ deps.setAttr(topBand, "id", "keyboard-band-pf");
563
+ const lowerBand = deps.create("div");
564
+ deps.setAttr(lowerBand, "class", "keyboard-band");
565
+ deps.setAttr(lowerBand, "id", "keyboard-band-main");
566
+ deps.append(bands, topBand);
567
+ deps.append(bands, lowerBand);
568
+ deps.append(deps.root, bands);
569
+ for (const [region, rows] of regions) {
570
+ const section = deps.create("div");
571
+ deps.setAttr(section, "class", "keyboard-region");
572
+ deps.setAttr(section, "id", `keyboard-${region}`);
573
+ for (const [, caps] of [...rows].sort((a, b) => a[0] - b[0])) {
574
+ const line = deps.create("div");
575
+ deps.setAttr(line, "class", "keyboard-row");
576
+ const firstX = Math.min(...caps.map((c) => c.x));
577
+ if (firstX > 0) {
578
+ deps.setAttr(line, "style", `padding-left:calc(${firstX} * (var(--kb-pitch) + 2px))`);
579
+ }
580
+ for (const cap of caps) {
581
+ const button = deps.create("button");
582
+ deps.setAttr(button, "type", "button");
583
+ deps.setAttr(button, "data-key", cap.name);
584
+ const height = cap.height ?? 1;
585
+ deps.setAttr(button, "style", height > 1 ? `--kb-w:${cap.width};--kb-h:${height}` : `--kb-w:${cap.width}`);
586
+ deps.setText(button, cap.label);
587
+ deps.setTitle(button, cap.name);
588
+ if (isShiftCap(cap)) {
589
+ shiftButtons.push(button);
590
+ deps.setAttr(button, "data-shift", "true");
591
+ deps.onClick(button, () => {
592
+ armed = !armed;
593
+ paintShift();
594
+ });
595
+ deps.append(line, button);
596
+ continue;
597
+ }
598
+ if (cap.action === void 0) {
599
+ deps.setAttr(button, "disabled", "");
600
+ deps.append(line, button);
601
+ continue;
602
+ }
603
+ const action = cap.action;
604
+ const shiftLabel = cap.shiftLabel;
605
+ if (shiftLabel !== void 0) {
606
+ dualCaps.push({ el: button, plain: cap.label, shifted: shiftLabel });
607
+ }
608
+ const isCharacter = action.kind === "type";
609
+ deps.onClick(button, () => {
610
+ if (isCharacter && armed) {
611
+ armed = false;
612
+ paintShift();
613
+ deps.sendAction(shiftLabel !== void 0 ? { kind: "type", text: shiftLabel } : action);
614
+ return;
615
+ }
616
+ deps.sendAction(action);
617
+ });
618
+ deps.append(line, button);
619
+ }
620
+ deps.append(section, line);
621
+ }
622
+ deps.append(region === "pf" ? topBand : lowerBand, section);
623
+ }
624
+ return {
625
+ shiftArmed: () => armed,
626
+ disarmShift: () => {
627
+ armed = false;
628
+ paintShift();
629
+ }
630
+ };
631
+ }
632
+
633
+ // packages/canvas/dist/keys.js
634
+ function layoutCharacter(e) {
635
+ if (e.getModifierState?.("AltGraph") !== true)
636
+ return null;
637
+ if ([...e.key].length === 1)
638
+ return { kind: "type", text: e.key };
639
+ return null;
640
+ }
641
+ var NAMED = Object.freeze({
642
+ Enter: { kind: "enter" },
643
+ ArrowLeft: { kind: "left" },
644
+ ArrowRight: { kind: "right" },
645
+ ArrowUp: { kind: "up" },
646
+ ArrowDown: { kind: "down" },
647
+ Home: { kind: "home" },
648
+ Backspace: { kind: "backspace" },
649
+ Delete: { kind: "delete" },
650
+ // vt220 Select/End bound to EraseEOF, following c3270 -- a CHOICE, not a derivation.
651
+ End: { kind: "eraseEOF" },
652
+ // x3270's Toggle(insertMode), fb-x3270:210.
653
+ Insert: { kind: "toggleInsert" }
654
+ });
655
+ var MODIFIERS = /* @__PURE__ */ new Set([
656
+ "Shift",
657
+ "Control",
658
+ "Alt",
659
+ "Meta",
660
+ "CapsLock",
661
+ "AltGraph",
662
+ "NumLock",
663
+ "ScrollLock"
664
+ ]);
665
+ var CTRL = Object.freeze({
666
+ c: { kind: "clear" },
667
+ r: { kind: "reset" },
668
+ u: { kind: "eraseInput" },
669
+ "]": { kind: "quit" },
670
+ a: { kind: "attn" },
671
+ d: { kind: "dup" },
672
+ f: { kind: "fieldMark" },
673
+ k: { kind: "toggleKeypad" },
674
+ // The full 122-key keyboard, 2026-10-22. Free in both mappers -- the taken chords are
675
+ // C R U ] A D F K T -- and claiming it also suppresses Firefox's bookmarks sidebar, which is
676
+ // correct: `renderer.ts` calls `preventDefault` only for keys we claimed, so claiming it is
677
+ // what makes it ours. Intercepted CLIENT-SIDE in both DOM front ends, exactly as `toggleKeypad`
678
+ // is, and the gateway returns before `applyAction` for it -- see `web/src/main.ts`.
679
+ b: { kind: "toggleKeyboard" },
680
+ /**
681
+ * Ctrl-T, the transfer form -- `BINDING_INTENT`'s own spelling, now that the GUI has a dialog.
682
+ *
683
+ * THE GUI HAD THIS CHORD DEAD WHILE THE WINDOW WORKED, which is how this was found: a real
684
+ * Electron run with `TN3270_GUI_KEYS=Ctrl+T` printed `keys: sent Ctrl+T` and NO `action:` line
685
+ * at all, where a click on the `Xfer` keypad button opened the window fine. The keypad route
686
+ * went through `KEYPAD_KEYS` and never through this table, so the one that was missing was also
687
+ * the one no harness drove.
688
+ *
689
+ * ACCEPTED BY THE WEB FRONT END TOO, since this mapper is shared, and that is safe rather than
690
+ * merely tolerable: `protocol.ts:129-131` REJECTS the kind at decode with a per-client `error`
691
+ * frame, and `web/src/main.ts:151-154` answers a decode failure on that one socket without
692
+ * touching the session or the process. The browser could already produce this kind from the
693
+ * `Xfer` button it draws today, so this adds a second route to an answer that already exists --
694
+ * no new failure mode, and the refusal names its reason. When stage 4 gives the gateway a
695
+ * transfer path that rejection becomes an interception, and this entry needs no change.
696
+ */
697
+ t: { kind: "transferForm" }
698
+ });
699
+ var PA_CODES = Object.freeze({
700
+ Digit1: { kind: "pa", n: 1 },
701
+ Digit2: { kind: "pa", n: 2 },
702
+ Digit3: { kind: "pa", n: 3 }
703
+ });
704
+ function actionForKey(e) {
705
+ if (MODIFIERS.has(e.key))
706
+ return null;
707
+ if (e.shiftKey && !e.altKey && e.ctrlKey !== e.metaKey && e.code === "KeyC") {
708
+ return { kind: "requestCopy" };
709
+ }
710
+ if (e.ctrlKey && !e.altKey && !e.metaKey) {
711
+ return CTRL[e.key.toLowerCase()] ?? null;
712
+ }
713
+ if (e.altKey && !e.ctrlKey && !e.metaKey) {
714
+ if (e.code === "KeyK")
715
+ return { kind: "toggleKeypad" };
716
+ if (e.code === "KeyM")
717
+ return { kind: "geometryMenu" };
718
+ return PA_CODES[e.code] ?? layoutCharacter(e);
719
+ }
720
+ if (e.metaKey || e.altKey)
721
+ return layoutCharacter(e);
722
+ if (e.key === "Tab")
723
+ return e.shiftKey ? { kind: "backTab" } : { kind: "tab" };
724
+ const fn = /^F(\d{1,2})$/.exec(e.key);
725
+ if (fn !== null) {
726
+ const n = Number(fn[1]);
727
+ if (n < 1 || n > 12)
728
+ return null;
729
+ return { kind: "pf", n: e.shiftKey ? n + 12 : n };
730
+ }
731
+ const named = NAMED[e.key];
732
+ if (named !== void 0)
733
+ return named;
734
+ if ([...e.key].length === 1)
735
+ return { kind: "type", text: e.key };
736
+ return null;
737
+ }
738
+
739
+ // packages/canvas/dist/forwardKeys.js
740
+ function isEditableTarget(target) {
741
+ const tag = typeof target?.tagName === "string" ? target.tagName : "";
742
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target?.isContentEditable === true;
743
+ }
744
+ function forwardKeys(deps) {
745
+ deps.on("keydown", (raw) => {
746
+ const e = raw;
747
+ if (isEditableTarget(e.target))
748
+ return;
749
+ const action = actionForKey(e);
750
+ if (action === null)
751
+ return;
752
+ e.preventDefault();
753
+ deps.sendAction(action);
754
+ });
755
+ }
756
+
757
+ // packages/gui/dist/keyboardBoot.js
758
+ var root = document.getElementById("keyboard-root");
759
+ if (root === null) {
760
+ throw new Error("keyboard window: #keyboard-root is missing from keyboard.html");
761
+ }
762
+ var bridge = window.tn3270keyboard;
763
+ if (bridge === void 0) {
764
+ throw new Error("keyboard window: the tn3270keyboard bridge is absent (preload failed to load?)");
765
+ }
766
+ createKeyboardUi({
767
+ root,
768
+ create: (tag) => document.createElement(tag),
769
+ append: (parent, child) => {
770
+ parent.appendChild(child);
771
+ },
772
+ setText: (el, text) => {
773
+ el.textContent = text;
774
+ },
775
+ setTitle: (el, title) => {
776
+ el.title = title;
777
+ },
778
+ setAttr: (el, name, value) => {
779
+ el.setAttribute(name, value);
780
+ },
781
+ removeAttr: (el, name) => {
782
+ el.removeAttribute(name);
783
+ },
784
+ onClick: (el, fn) => {
785
+ el.addEventListener("click", fn);
786
+ },
787
+ sendAction: (action) => {
788
+ bridge.sendAction(action);
789
+ }
790
+ });
791
+ forwardKeys({
792
+ on: (type, handler) => {
793
+ window.addEventListener(type, handler);
794
+ },
795
+ sendAction: (action) => {
796
+ bridge.sendAction(action);
797
+ }
798
+ });
799
+ //# sourceMappingURL=keyboardBoot.js.map