@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.
- package/LICENSE +21 -0
- package/README.md +50 -2
- package/app/BUNDLED +1 -0
- package/app/atlas.bin +0 -0
- package/app/atlas.json +1 -0
- package/app/connect.html +27 -0
- package/app/connectBoot.js +64 -0
- package/app/connectBoot.js.map +7 -0
- package/app/connectPreload.cjs +19 -0
- package/app/connectPreload.cjs.map +7 -0
- package/app/icon.png +0 -0
- package/app/index.html +6 -0
- package/app/keyboard.html +10 -0
- package/app/keyboardBoot.js +799 -0
- package/app/keyboardBoot.js.map +7 -0
- package/app/keyboardPreload.cjs +18 -0
- package/app/keyboardPreload.cjs.map +7 -0
- package/app/keypad.html +10 -0
- package/app/keypadBoot.js +501 -0
- package/app/keypadBoot.js.map +7 -0
- package/app/keypadPreload.cjs +18 -0
- package/app/keypadPreload.cjs.map +7 -0
- package/app/main.js +11808 -0
- package/app/main.js.map +7 -0
- package/app/package.json +9 -0
- package/app/preload.cjs +37 -0
- package/app/preload.cjs.map +7 -0
- package/app/renderer.js +559 -0
- package/app/renderer.js.map +7 -0
- package/app/transfer.html +15 -0
- package/app/transferBoot.js +531 -0
- package/app/transferBoot.js.map +7 -0
- package/app/transferPreload.cjs +33 -0
- package/app/transferPreload.cjs.map +7 -0
- package/app/ui.css +843 -0
- package/bin/launch.d.mts +10 -0
- package/bin/launch.mjs +56 -0
- package/bin/tn3270-gui.mjs +97 -0
- package/package.json +29 -4
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../frontend/src/keyboard3279.ts", "../../canvas/src/keyboardUi.ts", "../../canvas/src/keys.ts", "../../canvas/src/forwardKeys.ts", "../src/keyboardBoot.ts"],
|
|
4
|
+
"sourcesContent": ["import type { Action } from './keymap.js';\n\n/**\n * The 122-key 3279 keyboard, as DATA.\n *\n * ## WHY A SECOND TABLE RATHER THAN AN EXTENSION OF `KEYPAD_KEYS`\n *\n * The two answer different questions. `KEYPAD_KEYS` is \"which keys are hard to type\" -- 48 entries,\n * consumed by all three front ends, and the TUI renders it as a name-and-chord LIST in a\n * 29-column overlay. This is \"what does a 3279 keyboard look like\" -- ~122 entries with real\n * geometry, consumed only by the two DOM front ends, because the TUI cannot draw a keyboard:\n * c3270's own keypad is 78 columns wide and that front end refuses to draw below 24x80. The user\n * said so directly when asking for this: \"the TUI on the other hand is much more restrictive, so\n * I am not suggesting a full virtual keyboard for it.\"\n *\n * `keypadView.ts` set this precedent for the same reason -- `KEYPAD_KEYS`' cell coordinates were\n * the wrong model for a DOM layout -- and its header's argument applies here with more force: the\n * layout is WRITTEN DOWN, not derived, because deriving it would make it agree with itself by\n * construction and be unable to catch a change.\n *\n * ## THE GEOMETRY IS IN KEY UNITS, NOT PIXELS\n *\n * One unit is one standard cap. `Enter`, the spacebar and several others are wider, so a cap\n * carries `x` and `width` in units and the CSS multiplies by whatever pitch it picks. Pixels here\n * would freeze a font size into a data table.\n *\n * ## THE TRANSCRIPTION WAS AUDITED AND CORRECTED AGAINST A SECOND KEYBOARD\n *\n * **The user spotted the hole on 2026-10-22: \"the keyboard picture is clearly missing the 0/) so\n * maybe some of the other keys are also supposed to have caps that have been lost.\"** They were\n * right, and the audit it triggered found FIVE wrong pairs plus one physically blank cap. They\n * then supplied a sharp photograph of an **IBM 4978** -- a different 122-key 3279 layout with the\n * same typewriter character set -- which settled every one.\n *\n * **WHAT WAS WRONG, because the pattern of error is the useful part:**\n *\n * - **`6` carried a NOT SIGN; it is a CENT SIGN.** I read a blurred cap and then justified it\n * with EBCDIC reasoning about the logical-not operator. The reasoning was sound and still\n * produced the wrong glyph, because it explained a reading instead of testing one.\n * - **The cap right of `P` carried `cent`/`!`; it is `!`/`NOT SIGN`.** So both glyphs were real\n * and both were in the wrong places -- the most misleading kind of error, since each looked\n * defensible alone.\n * - **The home row's third cap carried `(`/`)`; it is `{`/`}`.** Flagged as suspect because it\n * duplicated `9`/`0`.\n * - **The bottom row carried `,`/`;` and `.`/`:`; they are `,`/`'` and `.`/`:`.** Flagged for\n * duplicating the home row's `;`/`:`.\n * - **`1` and `\\` both claimed a solid `|`.** The backslash cap is a BROKEN bar (U+00A6), which\n * EBCDIC distinguishes.\n * - **`0`'s cap is genuinely BLANK on the first unit** -- no legend at all under maximum zoom,\n * while `_`/`-` beside it is legible, so it is missing or worn smooth. `)` was inferred from\n * the `9`/`(` pattern and the 4978 then confirmed it.\n *\n * **TWO LESSONS WORTH KEEPING.** First, a glyph pair appearing TWICE on one keyboard is a\n * reliable detector: it found three of the five. Second, **the detector's PREDICTIONS were\n * unreliable** -- the audit guessed `,`/`<` and `.`/`>` from PC-adjacent habit and was wrong, so\n * guessing at a correction would have shipped a second error that no longer smelled. Duplication\n * tells you WHERE to look, not WHAT the answer is.\n *\n * ## A THIRD SOURCE, AND IT MATCHES THIS LAYOUT EXACTLY\n *\n * The user then supplied a photograph of an **IBM 3270 PC** whose keyboard is the SAME layout as\n * the first: PF13-24 above PF1-12, the two-column special bar, `Dup`/`PA1` and `Field Mark`/`PA2`\n * in the cursor cluster, the same arrow arrangement. From it:\n *\n * - **`PA3` was recorded as BLANK and is not** -- the cap reads `PA3`, completing the set beside\n * the PA1 and PA2 that ride the Dup and Field Mark caps.\n *\n * **AND IT CONTAINS A TRAP WORTH THE WARNING: its NUMERIC PAD IS A PC's.** `7`/`Home`, `8`/up,\n * `9`/`PgUp`, with `Esc`/`NumLk`/`ScrLk` above -- because a 3270 PC is a PC with 3270 emulation.\n * A keyboard that agrees everywhere else and differs silently in exactly the place you need is\n * the worst kind of cross-check, so NOTHING was taken from it there.\n *\n * **THE NUMERIC PAD WAS SETTLED FROM THE ORIGINAL PHOTOGRAPH INSTEAD, at 6x: `Space`, a FIELD TAB\n * arrow, `-` and `Enter` down the right-hand column.** Three of the five `unread` caps had been\n * resolvable all along -- **I gave up on the zoom too early**, which is the lesson that belongs\n * next to them.\n *\n * ## EVERY CAP IS NOW READ -- ZERO UNREADABLE, AND THE ROUTE IS THE LESSON\n *\n * The last two flanked `Home` and had survived all three photographs. They fell to a **10x crop of\n * the FIRST image with contrast and an unsharp mask**: an arrow pointing left into a bar and one\n * pointing right into a bar, the standard 3270 back-tab and field-tab glyphs, flanking Home\n * exactly as the cluster's logic predicts.\n *\n * **THE PIXELS WERE ALWAYS SUFFICIENT; THE PROCESSING WAS NOT.** Plain LANCZOS upscaling left them\n * a smudge through two rounds of \"still unreadable\", while sharpening resolved them in one. The\n * rule for next time: **exhaust the image you have before asking for another one.**\n *\n * Unreadable caps went 5 -> 2 -> 0 in a day, and the test that tracked the count is now inverted:\n * zero is the FLOOR, and an `unread` cap reappearing means a key was marked unreadable rather\n * than read.\n *\n * The 4978 also has keys this layout lacks (a `~`/`\\` cap, a bullet over plus-minus,\n * `LINE DUP`/`FIELD DUP`/`REP`), and none were transplanted: two units agree on the character set\n * where both have a key, and that is exactly how far a cross-check can be trusted.\n *\n * ## LEGENDS COME FROM THE PHOTOGRAPH, AND THIS IS NOT A PC KEYBOARD\n *\n * Transcribed from `s-l960.png`, an **IBM 5271 Converged Keyboard** -- the user identified it on\n * 2026-10-22, and the name matters more than it looks: \"122-key 3279 keyboard\" describes several\n * distinct units, while *Converged Keyboard* names ONE, a 122-key Model F sold with the 3270 PC\n * and the 3179/3180 terminals. Deskthority's Model F page confirms the family and says the layout\n * \"was arguably inspired by the DEC LK201, and seems to have been the basis for the layout of the\n * later Model M Enhanced Keyboard\" -- but carries no cap-level legends, so it cannot settle the\n * two caps still open.\n *\n * **SEARCH FOR `5271 Converged Keyboard` RATHER THAN FOR A DESCRIPTION** if a fourth source is\n * ever wanted. That is the lesson of the identification: the two caps still unread are unread\n * because I was searching by appearance, and a part number is a key into documentation that a\n * description is not. It is the 3270 TYPEWRITER\n * layout: the number row reads `| @ # $ % not & * ( )` over `1`-`0`, so `6` carries a NOT SIGN\n * where a PC has `^`, and `1` carries `|` where a PC has `!`. **Never infer a legend from a PC\n * keyboard.** Where the photograph could not be resolved, the cap is INERT with its `name` set and\n * says so at the entry -- an invented legend on historical hardware is exactly the\n * plausible-but-wrong detail this project keeps catching.\n *\n * ## WHAT IS DELIBERATELY NOT HERE\n *\n * No `toggleKeypad`, `toggleKeyboard`, `geometryMenu` or `quit` on any cap: `applyAction` throws\n * on all four, so a drawn button would be a button whose only effect is to throw -- and\n * `toggleKeyboard`'s would toggle the surface it is drawn on. (`geometryMenu` is not a 3279 key at\n * all: a terminal's model was its hardware.) Pinned by this table's own test.\n */\n\nexport const KEYBOARD_REGIONS = [\n 'pf', 'special-left', 'main', 'cursor', 'numpad',\n] as const;\n\nexport type KeyboardRegion = typeof KEYBOARD_REGIONS[number];\n\nexport interface KeyCap {\n /** What is printed on the cap, unshifted. `''` for a genuinely blank cap. */\n readonly label: string;\n /**\n * The shifted legend, on a dual-glyph cap only.\n *\n * ABSENT ON A LETTER, deliberately: `A` sends `A` unshifted on this keyboard, so a `shiftLabel`\n * of `A` would make Shift a no-op that still consumed its one-shot arm.\n */\n readonly shiftLabel?: string;\n /**\n * What the cap does, or `undefined` for an INERT cap -- a key that exists on the hardware and\n * will never do anything here.\n *\n * DRAWN RATHER THAN OMITTED, at the user's direction: a gap where `Esc` belongs is less honest\n * than a key that is plainly scenery. `ui.css` styles these in `GrayText` on a recessed cap\n * rather than with the house `opacity: 0.5`, and says there why.\n */\n readonly action?: Action | undefined;\n readonly region: KeyboardRegion;\n /** Row within the region, from 0 at the top. */\n readonly row: number;\n /** Left edge in KEY UNITS, within the region's row. */\n readonly x: number;\n /** Width in KEY UNITS. 1 is a standard cap. */\n readonly width: number;\n /**\n * Height in KEY UNITS, for a cap that spans rows. Absent means 1.\n *\n * ADDED 2026-10-22 FOR THE RETURN KEY, which the sharp reference photograph shows spanning the\n * QWERTY and home rows -- and that span is what makes the main block a RECTANGLE rather than a\n * ragged stack. Without it row 2 ends 1.25 units short of row 1.\n *\n * A TALL CAP IS DECLARED ON ITS TOP ROW and is simply absent from the rows beneath, so a row's\n * own width calculation does not have to know about it. `keyboardUi.ts` renders the span with a\n * grid-row rule; the row below leaves the space empty by having no cap at that `x`.\n */\n readonly height?: number;\n /** What this key is called in words: the tooltip, and the accessible name. */\n readonly name: string;\n}\n\n/**\n * A plain typed character.\n *\n * `{ kind: 'type' }` ALREADY EXISTS and is what the real-keyboard path produces\n * (`canvas/src/keys.ts`), so no new action kind was needed for any character cap -- which is most\n * of why this table was cheap to add.\n *\n * THE CONDITIONAL SPREAD IS REQUIRED, not stylistic: `exactOptionalPropertyTypes` is on in\n * `tsconfig.base.json`, under which `shiftLabel: undefined` does NOT satisfy `shiftLabel?: string`.\n */\nconst ch = (\n label: string, row: number, x: number, shiftLabel?: string,\n): KeyCap => ({\n label,\n ...(shiftLabel === undefined ? {} : { shiftLabel }),\n action: { kind: 'type', text: label },\n region: 'main', row, x, width: 1,\n name: shiftLabel === undefined ? label : `${shiftLabel} ${label}`,\n});\n\n/** A typed character outside the main block, with an explicit name. */\nconst ch2 = (\n label: string, region: KeyboardRegion, row: number, x: number, name: string,\n): KeyCap => ({\n label, action: { kind: 'type', text: label }, region, row, x, width: 1, name,\n});\n\n/** An inert cap: drawn, named, and does nothing. */\nconst dead = (\n label: string, region: KeyboardRegion, row: number, x: number, width: number, name: string,\n): KeyCap => ({ label, action: undefined, region, row, x, width, name });\n\n/**\n * The name BOTH Shift caps carry, and the way the view recognises them.\n *\n * ## SHIFT HAS NO `Action`, AND THAT IS THE DESIGN RATHER THAN A GAP\n *\n * It is PURE SURFACE STATE: arming it sends nothing to the host, nothing crosses the socket or the\n * IPC boundary, and the `Action` union needs no `shift` member. So its cap is INERT as far as this\n * table is concerned (`action: undefined`) and `canvas/src/keyboardUi.ts` gives it behaviour by\n * matching this exact name.\n *\n * MATCHED BY NAME RATHER THAN BY LABEL, because the left cap's printed legend in the photograph is\n * `>`/`<` rather than the word -- and because BOTH caps must drive the SAME one-shot state, neither\n * being distinguishable to us. `SHIFT_NAME` is exported so the view cannot drift from a string\n * literal typed twice.\n *\n * IT IS NOT STYLED AS AN INERT CAP even though `action` is `undefined`: the view sets\n * `data-shift=\"true\"` on it, and `ui.css` keys the grey scenery style off `[disabled]`, which the\n * view does NOT set here. An inert cap and an actionable-but-actionless cap look different on\n * purpose.\n */\nexport const SHIFT_NAME = 'Shift';\n\nconst shift = (row: number, x: number, width: number, side: string): KeyCap => ({\n // `Shft`, NOT `Shift`: the LEFT cap is 1.25 units, which is 31px usable, and five capitals at\n // the main block's 10px type need ~38px. The real keyboard prints `Shift` in full because its\n // cap is physically wider than one unit of this layout's pitch; four characters is the honest\n // abbreviation at this size. The NAME stays `Shift (...)`, which is what the view's `data-key`\n // and both harnesses address -- a label is presentation, a name is the identifier.\n label: 'Shft',\n action: undefined,\n region: 'main', row, x, width,\n // The side is in the name for the tooltip and for a screen reader, but `keyboardUi.ts` matches\n // on the PREFIX, so both caps drive one state.\n name: `${SHIFT_NAME} (${side}, one-shot)`,\n});\n\n/** An actionable cap that is not a typed character. */\nconst key = (\n label: string, action: Action, region: KeyboardRegion,\n row: number, x: number, width: number, name: string,\n): KeyCap => ({ label, action, region, row, x, width, name });\n\n/** `PF13`-`PF24` on top, `PF1`-`PF12` below -- see the note at the `pf` block. */\nconst pfRow = (row: number, first: number): KeyCap[] => Array.from(\n { length: 12 },\n (_, i) => key(\n `PF${first + i}`, { kind: 'pf', n: first + i }, 'pf', row, i, 1, `PF${first + i}`,\n ),\n);\n\n/** One row of letters, evenly spaced from `x`. */\nconst letters = (text: string, row: number, x: number): KeyCap[] =>\n [...text].map((c, i) => ch(c, row, x + i));\n\nexport const KEYBOARD_3279: readonly KeyCap[] = Object.freeze([\n /*\n THE PF STRIP: TWO ROWS ABOVE THE MAIN KEYBOARD, PF13-24 ON TOP OF PF1-12.\n\n ## THE POSITION IS A DECISION, NOT THE ONLY OPTION -- CONFIRMED BY THE USER 2026-10-22\n\n **\"There's also a common layout that puts PF13-24 in a block to the right of the numeric\n keypad but I prefer the two rows above the main keyboard.\"** Both arrangements shipped on real\n 3270-family hardware, and this one is what the reference 5271 has -- so the two-band layout in\n `ui.css` (PF strip across the top, the other four regions in a row beneath) is matching the\n photographed unit AND the user's stated preference, which happen to agree.\n\n **RECORDED BECAUSE IT WOULD OTHERWISE LOOK LIKE AN UNEXAMINED DEFAULT.** Someone who knows the\n right-hand-block variant could \"fix\" this toward it in good faith; they should know it was\n considered and declined. If it is ever wanted as an OPTION, the change is in the view's band\n assignment (`keyboardUi.ts` decides which band a region joins, from its name) and not in this\n table -- the geometry here is per-region, so a PF block beside the numpad needs no new\n coordinates, only a different container.\n\n ## THE ROW ORDER WITHIN THE STRIP\n\n PF13-24 above PF1-12 is the hardware's order in the photograph, and it matches both\n `KEYPAD_KEYS` (which opens `pfRow(0, 13)` then `pfRow(1, 1)`) and c3270\n (`Common/c3270/keypad.labels:2` and `:4`). Reversing it would be a silent relearn for anyone\n used to either. The table's test pins the first label of each row rather than only the count,\n which is what catches that.\n */\n ...pfRow(0, 13),\n ...pfRow(1, 1),\n\n /*\n THE LEFT SPECIAL BAR: two columns, five rows.\n\n `Attn` and `Clear` are real 3270 keys and work. `Cursr Sel` is a real 3270 key that does NOT:\n `AID.SELECT` exists in core, but `keypad.ts` records the key as dropped by decision because it\n \"needs the field-intensity parse and belongs with the light-pen work\". It is drawn inert, and\n its tooltip is the place that says it is deferred rather than impossible.\n\n `Esc`, `Print Screen`, `Scroll Lock` and `Pause` are PC keys present on this 122-key unit and\n are not 3270 functions. They get no action because they SHOULD not have one.\n\n THE TWO BLANK CAPS AT THE BOTTOM carry `Xfer` and nothing. The user spotted them -- \"since\n there are two unlabelled keys on the left hand special-key bar, one of those could get the\n `Xfer` label\" -- and there is precedent: `KEYPAD_KEYS` already carries `Xfer` as a deliberate\n non-3270 addition, and `keypad.ts` argues a transfer button \"is a perfectly sensible thing to\n click\" even though `applyAction` throws on the kind, because the front end intercepts it\n exactly as it intercepts the chord.\n */\n /*\n THE LEFT SPECIAL BAR -- REWRITTEN 2026-10-22, AND FOUR OF ITS CAPS WERE SIMPLY WRONG.\n\n I had `Esc`, `Print Screen`, `Scroll Lock` and `Pause` here: PC keys, read off a blurred\n photograph and believed because they looked like a plausible PC-ish column. The sharp reference\n (`DSC08836-scaled.jpeg`) shows what is actually there, and it is all 3270:\n\n Attn | Clear\n Cursr | (blank)\n Sel |\n Ex Sel| Erase EOF\n Print | Test <- `Test` is the SHIFTED legend on a blank cap\n (blk) | (blk)\n\n **TWO OF THEM ARE FUNCTIONS WE ALREADY IMPLEMENT**, so they become LIVE KEYS rather than grey\n scenery: `Erase EOF` is `eraseEOF`, which the compact keypad has carried all along as `ErEOF`.\n That is the most valuable thing this photograph bought -- not geometry, but two working keys\n where I had drawn dead ones.\n\n `Ex Sel` IS *NOT* IMPLEMENTED, and the distinction matters: it is Erase Input on some units and\n a field-select on others, and `keypad.ts` records Erase Input as `ErInp` separately. Rather\n than guess which this cap is, it stays inert with its real name -- the same discipline the\n unreadable caps got.\n\n `Print`'s SHIFTED LEGEND IS `Ident`, and the blank cap beside it carries `Test`. Both were read\n by the user on this photograph. Neither has an action here, so they are recorded as legends on\n inert caps: documented as existing and unimplemented rather than silently absent.\n\n `Attn`'s OWN SHIFTED LEGEND IS STILL UNREADABLE even here -- it is front-printed and blurred in\n every photograph so far -- as is the row-1 blank's. Those two remain unidentified.\n */\n key('Attn', { kind: 'attn' }, 'special-left', 0, 0, 1, 'Attention'),\n key('Clr', { kind: 'clear' }, 'special-left', 0, 1, 1, 'Clear'),\n dead('Cur\\nSel', 'special-left', 1, 0, 1, 'Cursor Select (not implemented: light-pen work)'),\n dead('', 'special-left', 1, 1, 1, 'blank, right of Cursor Select'),\n dead('Ex\\nSel', 'special-left', 2, 0, 1, 'Ex Sel (not implemented: ambiguous function)'),\n // A REAL 3270 FUNCTION WE IMPLEMENT -- the compact keypad has carried it as `ErEOF` all along.\n key('Ers\\nEOF', { kind: 'eraseEOF' }, 'special-left', 2, 1, 1, 'Erase EOF'),\n {\n label: 'Prnt',\n shiftLabel: 'Idnt',\n action: undefined,\n region: 'special-left', row: 3, x: 0, width: 1,\n name: 'Print (shifted: Ident, not implemented)',\n },\n {\n label: '',\n shiftLabel: 'Test',\n action: undefined,\n region: 'special-left', row: 3, x: 1, width: 1,\n name: 'blank (shifted: Test, not implemented)',\n },\n /*\n THE TWO BLANK CAPS AT THE BOTTOM, AND `Xfer` GOES BACK ON ONE OF THEM.\n\n It had moved to the cursor cluster's third cap on the user's reasoning that the cap had no\n unshifted function -- but this photograph IDENTIFIES that cap: it carries the\n `a`-with-a-squiggle glyph, which is delete-to-end-of-line. So it is a real key after all, and\n `Xfer` returns here, to a cap this file has CONFIRMED blank under maximum zoom.\n */\n key('Xfer', { kind: 'transferForm' }, 'special-left', 4, 0, 1, 'File transfer'),\n dead('', 'special-left', 4, 1, 1, 'blank, bottom right of the special bar'),\n\n /*\n THE MAIN BLOCK, five rows.\n\n ROW 0, the number row: `| @ # $ % not & * ( )` over `1`-`0`, then `_`/`-`, `+`/`=`, Backspace.\n THE NOT SIGN ON 6 IS THE PROOF THIS IS A 3270 LAYOUT and not a PC one; a PC has `^` there.\n `1` carries `|`, where a PC has `!`.\n */\n ch('1', 0, 0, '|'),\n ch('2', 0, 1, '@'),\n ch('3', 0, 2, '#'),\n ch('4', 0, 3, '$'),\n ch('5', 0, 4, '%'),\n /*\n `cent` OVER `6`, CORRECTED 2026-10-22 FROM A SECOND PHOTOGRAPH.\n\n I first read this as U+00AC NOT SIGN off a blurred cap, and reasoned about EBCDIC to justify\n it. A sharp photograph of another 122-key 3279 -- an IBM 4978, a DIFFERENT layout but the same\n typewriter character set -- shows `cent` unambiguously over `6`, and re-zooming the original\n cap is consistent with that rather than with a not-sign.\n\n TWO KEYBOARDS AGREEING BEATS ONE BLURRED CAP PLUS AN ARGUMENT, which is the lesson: the\n EBCDIC reasoning was sound and still produced the wrong glyph, because it explained a reading\n rather than testing it.\n */\n ch('6', 0, 5, '\u00A2'),\n ch('7', 0, 6, '&'),\n ch('8', 0, 7, '*'),\n ch('9', 0, 8, '('),\n /*\n `)` OVER `0` -- NO LONGER AN INFERENCE, CONFIRMED 2026-10-22.\n\n The cap is BLANK on the first reference unit (no legend at all under maximum zoom, while\n `_`/`-` beside it is legible, so it is missing or worn smooth), and `)` was originally guessed\n from the `9`/`(` pattern. A sharp photograph of an IBM 4978 shows `)` over `0` directly, so\n the guess is now a reading -- from a different keyboard, which is worth stating.\n */\n ch('0', 0, 9, ')'),\n ch('-', 0, 10, '_'),\n /*\n `+` OVER `=`, CONFIRMED by the 4978's own `+`/`=` cap -- though note that keyboard carries an\n EXTRA cap to the right of it, a bullet over plus-minus, which this one does not have. Two\n 122-key 3279 keyboards, two different furniture layouts; the CHARACTER SET agrees where both\n have the key, which is exactly how far a cross-check can be trusted.\n */\n ch('=', 0, 11, '+'),\n // BACKSPACE IS 3 UNITS, which is what closes row 0 at 15 -- and the photograph shows it as the\n // widest cap on that row, running to the right edge above the Return key.\n key('\u2190', { kind: 'backspace' }, 'main', 0, 12, 3, 'Backspace'),\n\n /*\n ROWS 1-4, AS A TRUE RECTANGLE -- REWRITTEN 2026-10-22 FROM A SHARP REFERENCE PHOTOGRAPH\n (`DSC08836-scaled.jpeg`), which is the same 5271 and finally legible.\n\n ## EVERY ROW ENDS AT EXACTLY 15 UNITS, AND THAT IS WHY\n\n The block used to end at 14, 15, 13.75, 13.5 and 14 units -- ragged by up to 1.5 units, which\n the user saw immediately: \"can we make the main keyboard an actual rectangle?\" A real keyboard\n is a rectangle because its leading keys STAGGER and its edge keys are WIDE, not because it has\n dead space, so that is what this now models.\n\n ## THE STAGGER IS MEASURED, NOT EYEBALLED\n\n `Z` MUST CENTRE BETWEEN `A` AND `S`, which the user named as the test. With Caps at 1.75 units\n `A` centres at 2.25 and `S` at 3.25, so `Z` must centre at 2.75 -- which a 2.25-unit left\n Shift gives exactly. Tab 1.5, Caps 1.75, Shift 2.25 is the classic ANSI-ish progression and it\n falls out of that one requirement rather than being copied from a PC.\n\n ## THE RETURN KEY SPANS TWO ROWS, WHICH IS WHAT CLOSES THE RECTANGLE\n\n The photograph shows the bent-arrow Return as one tall cap covering the QWERTY and home rows,\n with a SEPARATE small `Enter` on the bottom row. Without the span, row 2 ends 1.25 units short\n and no amount of widening the punctuation keys looks right. `height: 2` is declared on its top\n row and the home row simply has no cap at that `x` -- see `KeyCap.height`.\n\n IT IS DRAWN AS A RECTANGLE AND NOT AS THE INVERTED L THE HARDWARE HAS, which is the one place\n this table knowingly departs from the photograph. The L's overhanging half cannot be made\n clickable without covering the `{` below it, and the user's call (2026-10-23) was that a\n key-shaped dead region is worse than an oversized gap. The cap's own comment has the detail.\n */\n\n // ROW 1: Tab, QWERTY, two punctuation caps, and the TALL Return.\n key('\u2192|', { kind: 'tab' }, 'main', 1, 0, 1.5, 'Tab'),\n ...letters('QWERTYUIOP', 1, 1.5),\n // `!` over a NOT SIGN -- confirmed again on this photograph, right of `P`.\n ch('\u00AC', 1, 11.5, '!'),\n // A BROKEN BAR (U+00A6) over the backslash, distinct from the solid `|` on the `1` key.\n ch('\\\\', 1, 12.5, '\u00A6'),\n /*\n RETURN, TWO ROWS TALL AND 1.5 UNITS WIDE.\n\n `newline`, NOT `enter`, and that is the 3270 distinction this table has carried from the start:\n `Enter` is the AID that submits the screen and has its own cap on row 4 (and another on the\n numeric pad); this one moves the cursor to the next input field. `keymap.ts` records that no\n TUI chord could express Newline because Ctrl-J IS 0x0a -- a cap has no such spelling problem,\n which is half the reason this surface is worth having.\n */\n {\n label: '\u21B5',\n action: { kind: 'newline' },\n /*\n 1.25 UNITS WIDE, WHICH IS THE GEOMETRY THAT KEEPS THE `{` CLICKABLE.\n\n It was width 1.5 -- and that OVERLAPPED the `{` cap below it by 0.25 units, because the home\n row's content runs further right than the QWERTY row's (13.75 against 13.5, Caps being 1.75\n where Tab is 1.5). The user saw the `{` lose its border.\n\n A `clip-path` notch in `ui.css` was the first fix and it was NOT ENOUGH: a clip changes what\n is PAINTED and not what the box covers, so the Return still sat on top of the `{`. The\n harness said `OVERLAPS by 6px` once it compared boxes instead of hit-testing.\n\n 1.25 UNITS REMOVES THE OVERLAP AND KEEPS THE RECTANGLE: the home row is 13.75 + 1.25 = 15,\n and the QWERTY row is 13.5 of content, a 0.25-unit gap, then the same 1.25 = 15.\n\n THE `x` IS NOT WHAT DOES THAT, AND SAYING SO HERE IS THE POINT -- this comment claimed it was\n until a mutation (2026-10-23) showed otherwise. `ui.css` lifts a tall cap out of flow with\n `position: absolute; right: 0`, so its `x` is NEVER READ and the WIDTH alone sets its left\n edge. 13.75 is recorded because it is where the key really is on the hardware and because\n `x` is how every other cap is placed; change the WIDTH if the left edge must move.\n\n AND THE 0.25-UNIT GAP IS LEFT EMPTY, by the user's decision on 2026-10-23. The hardware key\n is an inverted L that fills it; drawn here, the overhang is a region that looks like a key\n and cannot be clicked -- the `{` needs the box to stay narrow. An oversized gap reads as\n deliberate where a dead half-key does not. See `ui.css` for the full note.\n */\n region: 'main', row: 1, x: 13.75, width: 1.25, height: 2,\n name: 'Newline',\n },\n\n // ROW 2: Caps (inert), ASDFGHJKL, three punctuation caps. The Return above covers 13.5-15.\n dead('\u21EA', 'main', 2, 0, 1.75, 'Caps Lock (not implemented: see the header)'),\n ...letters('ASDFGHJKL', 2, 1.75),\n ch(';', 2, 10.75, ':'),\n ch('\\'', 2, 11.75, '\"'),\n ch('{', 2, 12.75, '}'),\n\n /*\n ROW 3: a 2.25-UNIT SHIFT, ZXCVBNM, three punctuation caps, and a WIDE right Shift.\n\n BOTH SHIFT CAPS DRIVE THE SAME ONE-SHOT STATE -- neither is distinguishable to us, and they are\n matched by NAME in `canvas/src/keyboardUi.ts`. The left cap's printed legend is `>`/`<` on the\n hardware; it is drawn as `Shft` so the surface reads as a keyboard rather than a puzzle.\n */\n shift(3, 0, 2.25, 'left'),\n ...letters('ZXCVBNM', 3, 2.25),\n ch(',', 3, 9.25, '\\''),\n ch('.', 3, 10.25, ':'),\n ch('/', 3, 11.25, '?'),\n shift(3, 12.25, 2.75, 'right'),\n\n /*\n ROW 4: Reset, Alt, the spacebar, Alt, a blank, and `Enter`.\n\n `Reset` CARRIES `DvCntl` AS ITS SHIFTED LEGEND -- front-printed on the cap, read on this\n photograph and identified by the user as Device Control. We have no action for it, so it is\n recorded as a `shiftLabel` on a cap whose unshifted function we DO implement: pressing Shift\n re-letters it, and clicking it still sends `reset`. That is honest -- the shifted function is\n documented as existing and unimplemented, rather than silently absent.\n */\n {\n label: 'Reset',\n shiftLabel: 'DvCn',\n action: { kind: 'reset' },\n region: 'main', row: 4, x: 0, width: 1.5,\n name: 'Reset (shifted: Device Control, not implemented)',\n },\n dead('Alt', 'main', 4, 1.5, 1.25, 'Alt, left (not a 3270 key)'),\n {\n label: '', action: { kind: 'type', text: ' ' },\n region: 'main', row: 4, x: 2.75, width: 7, name: 'Space',\n },\n dead('Alt', 'main', 4, 9.75, 1.25, 'Alt, right (not a 3270 key)'),\n dead('', 'main', 4, 11, 1.25, 'blank, right of the spacebar'),\n key('Enter', { kind: 'enter' }, 'main', 4, 12.25, 2.75, 'Enter'),\n\n /*\n THE CURSOR CLUSTER.\n\n `Dup`+`PA1` and `Field Mark`+`PA2` are DOUBLE-LEGEND CAPS on the hardware -- the lower legend\n is the key's alternate function. We bind the UPPER one, because `Dup` and `Field Mark` are\n typed characters this client implements (`keymap.ts`: \"Dup and Field Mark are TYPED CHARACTERS,\n not AIDs\") while PA1/PA2 already have caps in the keypad and chords of their own. The lower\n legend is drawn in the label so the cap matches the photograph.\n\n THE THREE GLYPH KEYS ABOVE THE ARROWS COULD NOT BE READ at this resolution -- a left-pointing\n arrow, a house-like glyph and a third mark. They are INERT with descriptive names, per this\n file's header rule, and are the first thing to fix if a better photograph turns up.\n */\n /*\n THE CURSOR CLUSTER, AS A DIAMOND -- REWRITTEN 2026-10-22 FROM THE SHARP REFERENCE.\n\n The user prefers this to the modern inverted-T and the photograph shows why: the arrows sit in\n a 3x3 diamond with a CENTRE CAP between them, which is what the hardware actually has.\n\n Dup/PA1 | FieldMark/PA2 | PA3\n BackTab | Home | DelEOL\n | up |\n left | (centre) | right\n | down |\n\n ## WHAT THIS PHOTOGRAPH IDENTIFIED, ALL OF IT NEW\n\n - THE THIRD CAP OF ROW 1 is the `a`-with-a-squiggle glyph, which the user read as\n delete-to-end-of-line. We have no such action -- `delete` is a single character and\n `eraseEOF` erases to the end of the FIELD, not the line -- so it is inert with its real\n name. **AND THAT IS WHY `Xfer` MOVED OFF IT**: it is a real key, not a spare.\n - THE CENTRE CAP carries a DIAGONAL ARROW, which the user read as cursor-to-top-left. That is\n plausibly `Home` -- but `Home` already has its own cap two rows up, and a keyboard does not\n usually spend two caps on one function. Inert, with the ambiguity recorded rather than\n guessed away.\n - THE SHIFTED LEGENDS ON LEFT AND RIGHT ARE DOUBLE ARROWS, which the user read as\n beginning/end of line. No action exists for either, so they are `shiftLabel`s on live cursor\n keys: Shift re-letters the cap, and clicking still moves the cursor one position. The\n shifted function is documented as existing and unimplemented.\n */\n key('Dup\\nPA1', { kind: 'dup' }, 'cursor', 0, 0, 1, 'Dup'),\n key('Fld\\nMrk\\nPA2', { kind: 'fieldMark' }, 'cursor', 0, 1, 1, 'Field Mark'),\n key('PA3', { kind: 'pa', n: 3 }, 'cursor', 0, 2, 1, 'PA3'),\n\n /*\n REVERSE TAB: `U+21E4 LEFTWARDS ARROW TO BAR`, AND THE DIRECTION WAS MIRRORED BEFORE.\n\n I had `\u2190|` -- bar on the RIGHT, as if the arrow left a tab stop. The user caught it and the\n reference photograph is unambiguous: the BAR IS AT THE LEFT with the arrowhead terminating\n into it, because you are going back TO a tab stop, not back FROM one. The semantics were\n backwards, not just the drawing.\n */\n key('\u21E4', { kind: 'backTab' }, 'cursor', 1, 0, 1, 'Back Tab (cursor cluster)'),\n /*\n `U+21A5 UPWARDS ARROW FROM BAR` FOR INSERT -- AND THIS CAP WAS MISIDENTIFIED AS `Home`.\n\n The glyph is a lowercase `a` beneath a WIDE CARET, which the user reads as Insert; the caret is\n the classic insertion mark and the `a` is the character being inserted. I had drawn it as a\n house and called it Home, which was a guess from the shape alone.\n\n `toggleInsert` IS AN ACTION WE HAVE, so this becomes a working key rather than scenery -- the\n compact keypad carries it as `Ins`.\n\n THE GLYPH IS AN APPROXIMATION, NOT A TRANSCRIPTION: there is no Unicode point for an `a` under\n a caret, so this is the closest available stand-in, and an SVG of the real mark is the someday\n fix.\n\n `U+2041 CARET INSERTION POINT`, AND IT IS THE THIRD CHOICE -- the first two were rejected for\n different reasons, which is why all three are recorded.\n\n 1. `U+21A5 UPWARDS ARROW FROM BAR` was mine, and it is the wrong SHAPE: a thin solid arrow\n with a bar at its foot, where a 12x crop of the cap shows a wide HOLLOW caret.\n 2. `U+21E7 UPWARDS WHITE ARROW` was the user's suggestion and matches the shape exactly --\n but **U+21E7 IS THE SHIFT KEY**, universally, and this keyboard HAS two Shift caps. A\n glyph that means Shift on a key that means Insert is worse than one that merely looks\n wrong: it is actively misleading, and the shape being right makes the confusion more\n likely rather than less.\n 3. `U+2041 CARET INSERTION POINT` is the proofreader's insertion mark, so right shape and right\n meaning -- and it DOES NOT RENDER. Measured in the harness: 2.3px against 7.5px for a\n glyph known to be present, i.e. a near-blank fallback. General Punctuation is not what the\n symbol fonts in `ui.css` were chosen for.\n 4. `U+2303 UP ARROWHEAD` measures 7.5px, exactly matching the reference glyph, and it is a\n hollow caret that means \"control/up\" rather than Shift. Right shape, available, and\n unclaimed on this keyboard.\n\n THE LESSON IS THAT A GLYPH HAS THREE PROPERTIES and all of them matter: what it looks like,\n what it already means elsewhere, and whether the font actually has it. Choice 1 failed the\n first, choice 2 the second, choice 3 the third.\n\n STILL AN APPROXIMATION: the hardware draws an `a` tucked under the caret's apex, and no\n Unicode point does that. An SVG remains the someday fix.\n */\n key('\u2303', { kind: 'toggleInsert' }, 'cursor', 1, 1, 1, 'Insert (approximate glyph)'),\n /*\n `U+219B RIGHTWARDS ARROW WITH STROKE` FOR DELETE CHARACTER, SHIFTED TO TWO OF THEM.\n\n The cap is a lowercase `a` with a squiggle struck diagonally up-and-right, and its shifted\n legend is visibly LONGER -- the user reads it as the squiggle running through three `a`s, i.e.\n the same operation extended to the end of the line.\n\n I HAD `U+232B ERASE TO THE LEFT`, WHICH WAS WRONG IN MEANING: the user points out the stroke\n goes up and to the RIGHT, so this deletes forward, not backward. `delete` is exactly that\n action and we have it, so the unshifted key is LIVE; the shifted form is\n delete-to-end-of-line, which we do NOT have, and the doubled glyph records it the way the\n doubled arrows record start/end of line on the cursor keys.\n\n BOTH GLYPHS ARE APPROXIMATIONS, not transcriptions -- no Unicode point draws an `a` with a\n diagonal strike. Doubling for the shifted form follows the hardware's own idiom and this\n table's existing one.\n */\n {\n label: '\u219B', shiftLabel: '\u219B\u219B', action: { kind: 'delete' },\n region: 'cursor', row: 1, x: 2, width: 1,\n name: 'Delete character (shifted: delete to end of line, not implemented)',\n },\n\n // THE DIAMOND. `up` is centred on its own row; `left`, the centre cap and `right` share the\n // next; `down` is centred below. The row offsets come from the table and `keyboardUi.ts`\n // renders them -- see its note on `padding-left`.\n {\n label: '\u2191', action: { kind: 'up' },\n region: 'cursor', row: 2, x: 1, width: 1, name: 'Cursor up',\n },\n {\n label: '\u2190', shiftLabel: '\u219E', action: { kind: 'left' },\n region: 'cursor', row: 3, x: 0, width: 1,\n name: 'Cursor left (shifted: start of line, not implemented)',\n },\n dead('\u2196', 'cursor', 3, 1, 1, 'diagonal arrow, function unidentified'),\n {\n label: '\u2192', shiftLabel: '\u21A0', action: { kind: 'right' },\n region: 'cursor', row: 3, x: 2, width: 1,\n name: 'Cursor right (shifted: end of line, not implemented)',\n },\n {\n label: '\u2193', action: { kind: 'down' },\n region: 'cursor', row: 4, x: 1, width: 1, name: 'Cursor down',\n },\n\n /*\n THE NUMERIC KEYPAD.\n\n The digits are typed characters. The right-hand column could not be fully resolved: the top\n cap reads like `Space`, below it a tab-like glyph, then `-`, then a tall `Enter`. `Enter` is\n legible and is bound; the other three are INERT with descriptive names rather than guessed.\n */\n // `ch()` builds a `main` cap, so the region is overridden here rather than adding a parameter\n // for one caller. The digits are the same typed characters as the main block's.\n ...[...'789'].map((c, i) => ({ ...ch(c, 0, i), region: 'numpad' as const })),\n ...[...'456'].map((c, i) => ({ ...ch(c, 1, i), region: 'numpad' as const })),\n ...[...'123'].map((c, i) => ({ ...ch(c, 2, i), region: 'numpad' as const })),\n { ...ch('0', 3, 0), region: 'numpad' as const, width: 2, name: '0 (numeric pad)' },\n { ...ch('.', 3, 2), region: 'numpad' as const, name: '. (numeric pad)' },\n /*\n THE NUMERIC PAD'S RIGHT-HAND COLUMN -- READ AT LAST, 2026-10-22, FROM THE ORIGINAL PHOTOGRAPH.\n\n These three were inert with `unread` names because I could not resolve them. A 6x crop of the\n SAME image settles all three: `Space`, a FIELD TAB arrow, and `-`, with `Enter` below. **The\n pixels were always there; I gave up on the zoom too early**, which is worth recording next to\n the entries it cost.\n\n A SECOND KEYBOARD CONFIRMED NOTHING HERE, and that is the interesting part: the IBM 3270 PC\n (the layout this one otherwise matches exactly) has a PC NUMPAD in this position -- 7/Home,\n 8/up, 9/PgUp, with Esc/NumLk/ScrLk above. It is a PC with 3270 emulation, so its numpad is a\n PC's. Transplanting those legends would have been the worst kind of cross-check: a keyboard\n that agrees everywhere else, differing silently in exactly the place I needed.\n */\n key('Spc', { kind: 'type', text: ' ' }, 'numpad', 0, 3, 1, 'Space (numeric pad)'),\n // FIELD TAB, the same `\u2192|` glyph the main block's Tab carries.\n key('\u2192|', { kind: 'tab' }, 'numpad', 1, 3, 1, 'Tab (numeric pad)'),\n ch2('-', 'numpad', 2, 3, '- (numeric pad)'),\n key('Ent', { kind: 'enter' }, 'numpad', 3, 3, 1, 'Enter (numeric pad)'),\n]);\n", "/*\n A DEEP PATH, NOT THE BARE SPECIFIER, AND IT IS FORCED. `web/static/index.html`'s import map\n resolves `@tn3270/frontend` to `keypadView.js` -- ONE SPECIFIER CANNOT NAME TWO FILES -- and that\n module exports no `KEYBOARD_3279`. Importing it bare makes THIS module fail to evaluate in the\n browser, and the only symptom is `window.tn3270` never being assigned: measured as a blank\n canvas with `Cannot read properties of undefined (reading 'onAtlas')` in the console, and `0/7`\n from `browser-clicks.mjs`, which reads like a total regression rather than a bad import.\n\n `transferUi.ts` imports `@tn3270/frontend/dist/transferForm.js` for exactly the same reason, and\n every such path needs its OWN entry in that import map.\n*/\nimport {\n KEYBOARD_3279, SHIFT_NAME, type KeyCap,\n} from '@tn3270/frontend/dist/keyboard3279.js';\nimport type { Action } from '@tn3270/frontend/dist/keymap.js';\n\n/**\n * The full 122-key 3279 keyboard, built into a DOM.\n *\n * ## THE SAME INJECTED-DOM SHAPE AS `keypadUi.ts`, FOR THE SAME REASON\n *\n * Nothing here touches a real `document`: the caller supplies `create`/`append`/`setText` and the\n * rest. That is what lets this be unit-tested in `environment: 'node'` with a hand-rolled fake,\n * and jsdom is deliberately not a dependency of this repo. `KEYBOARD_3279` in `packages/frontend`\n * stays the single authority on which keys exist, what each does and where it sits -- this file\n * decides none of that, so a cap corrected in the table appears here with no edit.\n *\n * ## THIS FILE OWNS EXACTLY ONE PIECE OF BEHAVIOUR: THE ONE-SHOT SHIFT\n *\n * Everything else is layout. The Shift rules are the user's, verbatim: *\"if you hit it the next\n * regular character you type acts as if you'd shifted it, and then shift unselects. Basically the\n * same as Caps Lock but it unsets itself after the next character.\"* They live HERE rather than in\n * the table or in either front end because they are logic, and logic in a shared view is logic\n * both front ends get and one test covers -- the same split `transferUi.ts` uses.\n */\n\nexport interface KeyboardDeps {\n /** The element the regions are appended to. */\n readonly root: HTMLElement;\n readonly create: (tag: string) => HTMLElement;\n readonly append: (parent: HTMLElement, child: HTMLElement) => void;\n readonly setText: (el: HTMLElement, text: string) => void;\n readonly setTitle: (el: HTMLElement, title: string) => void;\n readonly setAttr: (el: HTMLElement, name: string, value: string) => void;\n readonly removeAttr: (el: HTMLElement, name: string) => void;\n readonly onClick: (el: HTMLElement, fn: () => void) => void;\n /** Fire a key's action at the session. */\n readonly sendAction: (action: Action) => void;\n}\n\nexport interface KeyboardUi {\n /** Is the one-shot Shift armed? For a caller that wants to paint something of its own. */\n shiftArmed(): boolean;\n /**\n * Clear the arm.\n *\n * CALLED WHEN THE SURFACE CLOSES, which is the sixth of the user's rules: a keyboard reopened\n * later must not still be holding a Shift nobody remembers pressing. The overlay's `onHide`\n * is the caller.\n */\n disarmShift(): void;\n}\n\n/** Is this the Shift cap? Matched on the name PREFIX, so left and right both qualify. */\nconst isShiftCap = (cap: KeyCap): boolean => cap.name.startsWith(SHIFT_NAME);\n\n/**\n * Build the keyboard into `deps.root` and return its Shift state.\n *\n * NOT IDEMPOTENT, and it does not need to be: both callers build once. The GUI window builds at\n * load; the web overlay builds lazily on its first toggle and then never again -- rebuilding 117\n * buttons per keystroke would be invisible work that also discards focus.\n */\nexport function createKeyboardUi(deps: KeyboardDeps): KeyboardUi {\n /**\n * The one-shot arm. A single boolean, and deliberately not a count or a mode enum: there is one\n * level of Shift on this keyboard and no latch.\n */\n let armed = false;\n\n /** Every Shift button, so arming can paint both of them. */\n const shiftButtons: HTMLElement[] = [];\n\n /**\n * Every dual-glyph cap, with both of its legends, so arming Shift can RE-LETTER them.\n *\n * THE USER'S IDEA, 2026-10-22: \"rather than trying to display both glyphs on the same key at the\n * same time, maybe display the unshifted one by default and if the user clicks shift or caps\n * lock, go to the shifted one?\"\n *\n * Half of that was already true -- only `cap.label` was ever drawn, so the shifted legend sat in\n * the data unseen. This is the other half: the caps now SHOW what they will send.\n *\n * SHIFT ONLY, NOT CAPS LOCK, and the reason is specific rather than conservative: on this\n * keyboard the LETTERS carry no `shiftLabel` (`A` sends `A` unshifted), so Caps Lock would have\n * nothing to re-letter. It would be a latch whose only visible effect is on punctuation, which is\n * exactly what Shift already does. It stays inert.\n */\n const dualCaps: Array<{ el: HTMLElement; plain: string; shifted: string }> = [];\n\n const paintShift = (): void => {\n for (const button of shiftButtons) {\n // AN ATTRIBUTE RATHER THAN A CLASS, so `ui.css` can key off `[data-armed='true']` and a\n // harness can read the state without parsing a class list.\n if (armed) deps.setAttr(button, 'data-armed', 'true');\n else deps.removeAttr(button, 'data-armed');\n }\n /*\n RE-LETTER EVERY DUAL-GLYPH CAP, so the keyboard shows what it will send.\n\n `setText` RATHER THAN A CSS TRICK: the shifted legend is DATA (`KeyCap.shiftLabel`), and a\n stylesheet cannot reach it. Both legends live in `dualCaps` so this never has to look a cap\n up by its current text -- which would break the moment a cap's two glyphs were ever equal.\n\n IT IS DRIVEN FROM THE SAME `armed` FLAG as the Shift caps' own paint, in the same function,\n so the ring on Shift and the legends on the keys cannot disagree about whether Shift is on.\n */\n for (const cap of dualCaps) {\n deps.setText(cap.el, armed ? cap.shifted : cap.plain);\n }\n };\n\n // Group the caps by region, then by row, PRESERVING THE TABLE'S ORDER within a row -- the table\n // is frozen and already in left-to-right order, and sorting here would be a second opinion\n // about the layout.\n const regions = new Map<string, Map<number, KeyCap[]>>();\n for (const cap of KEYBOARD_3279) {\n const rows = regions.get(cap.region) ?? new Map<number, KeyCap[]>();\n const row = rows.get(cap.row) ?? [];\n row.push(cap);\n rows.set(cap.row, row);\n regions.set(cap.region, rows);\n }\n\n /*\n TWO BANDS, BECAUSE THE HARDWARE HAS TWO -- ADDED 2026-10-22 AFTER A MEASURED DEFECT.\n\n The regions were appended straight to the root as plain `div`s, so all five STACKED\n VERTICALLY: the user's screenshots show a tall column their very wide monitor still could not\n display in one go. There was no horizontal layout at all, so the stacking was not a\n narrow-screen fallback -- it was the only thing the markup could do, at every width.\n\n The PF strip runs across the TOP on its own; the left special bar, the main block, the cursor\n cluster and the numeric pad sit in a ROW beneath it. `ui.css` lays each band out with flex and\n explains the arithmetic (24 key units for the lower band, 12 for the PF strip).\n\n THE BAND A REGION BELONGS TO IS DECIDED HERE, FROM THE REGION NAME, rather than in the CSS:\n a stylesheet cannot group siblings, and a `:first-child` rule keyed on document order would\n silently reorganise the keyboard the day a region was added to the table.\n */\n const bands = deps.create('div');\n deps.setAttr(bands, 'class', 'keyboard-bands');\n const topBand = deps.create('div');\n deps.setAttr(topBand, 'class', 'keyboard-band');\n deps.setAttr(topBand, 'id', 'keyboard-band-pf');\n const lowerBand = deps.create('div');\n deps.setAttr(lowerBand, 'class', 'keyboard-band');\n deps.setAttr(lowerBand, 'id', 'keyboard-band-main');\n deps.append(bands, topBand);\n deps.append(bands, lowerBand);\n deps.append(deps.root, bands);\n\n for (const [region, rows] of regions) {\n const section = deps.create('div');\n deps.setAttr(section, 'class', 'keyboard-region');\n // PREFIXED, like the keypad's ids: this element shares a document with the transfer form and\n // the keypad in the web overlay's case, and `keyboard-main` cannot collide where `main` might.\n deps.setAttr(section, 'id', `keyboard-${region}`);\n\n for (const [, caps] of [...rows].sort((a, b) => a[0] - b[0])) {\n const line = deps.create('div');\n deps.setAttr(line, 'class', 'keyboard-row');\n /*\n THE ROW'S LEFT OFFSET, WHICH THE TABLE HAS ALWAYS CARRIED AND NOTHING USED.\n\n A `keyboard-row` is a flex container, so every cap packed FLUSH LEFT and `KeyCap.x` was\n ignored entirely -- the geometry happened to look right only because every other row\n starts at 0. The visible symptom, which the user caught on a screenshot: the cursor\n cluster's `up` arrow sits at x=1 to be directly above `down`, and it was drawn hard against\n the left edge instead, so the inverted-T read as an L.\n\n EMITTED AS A PADDING ON THE ROW rather than a margin on the first cap: a margin would be\n one more thing the cap's own width calculation had to avoid, and a row that starts late is\n exactly what padding means. Set for EVERY row from the table's own minimum `x`, so a future\n row with an offset needs no further change here -- today only `cursor:2` has one.\n */\n const firstX = Math.min(...caps.map((c) => c.x));\n if (firstX > 0) {\n // THE SAME ARITHMETIC THE CAP WIDTHS USE: `n` units spans `n` caps and the `n` gaps that\n // precede the next one, so an offset of `n` is `n * (pitch + gap)`. Consistent with\n // `ui.css`'s width formula, which absorbs the `n-1` gaps INSIDE a wide cap.\n deps.setAttr(line, 'style', `padding-left:calc(${firstX} * (var(--kb-pitch) + 2px))`);\n }\n for (const cap of caps) {\n const button = deps.create('button');\n // `type=button` EXPLICITLY: a bare <button> inside a form defaults to type=submit, which\n // would turn a PF key into a page reload if a form ever shared this document.\n deps.setAttr(button, 'type', 'button');\n /*\n THE HANDLE A HARNESS USES IS THE `name`, NOT THE LABEL, and that is a correction this\n table's own history forced: several caps' printed legends are glyphs the reference\n photograph could not resolve, one cap is the SPACEBAR whose label is empty, and `Enter`\n appears twice. A name is unique, readable and stable; a label is none of the three.\n */\n deps.setAttr(button, 'data-key', cap.name);\n // THE WIDTH TRAVELS AS A CUSTOM PROPERTY so `ui.css` multiplies it by one pitch variable.\n // Key units in, pixels decided there -- see `keyboard3279.ts` on why the table holds units.\n /*\n WIDTH AND HEIGHT BOTH TRAVEL AS CUSTOM PROPERTIES, so `ui.css` owns the pitch.\n\n `--kb-h` IS NEW, 2026-10-22, FOR THE RETURN KEY, which the reference photograph shows\n spanning the QWERTY and home rows -- and that span is what makes the main block a\n rectangle. A tall cap is declared on its TOP row and simply absent from the rows beneath,\n so a row's own width arithmetic never has to know about it; the CSS lifts it out of flow\n and gives it the height of two caps plus the gap between them.\n */\n const height = cap.height ?? 1;\n deps.setAttr(button, 'style',\n height > 1\n ? `--kb-w:${cap.width};--kb-h:${height}`\n : `--kb-w:${cap.width}`);\n deps.setText(button, cap.label);\n deps.setTitle(button, cap.name);\n\n if (isShiftCap(cap)) {\n shiftButtons.push(button);\n /*\n SHIFT IS NEITHER `disabled` NOR ACTIONABLE, which is why it is handled before the\n inert branch below. Its `action` is `undefined` in the table -- arming it sends nothing\n anywhere -- but it is a LIVE control, so it must not get the grey scenery styling that\n `[disabled]` selects. An inert cap and an actionless-but-live cap look different on\n purpose.\n */\n deps.setAttr(button, 'data-shift', 'true');\n deps.onClick(button, () => {\n armed = !armed;\n paintShift();\n });\n deps.append(line, button);\n continue;\n }\n\n if (cap.action === undefined) {\n /*\n AN INERT CAP: DRAWN, DISABLED, AND IT DOES NOT TOUCH THE ARM.\n\n `disabled` is what makes the click a genuine no-op and keeps keyboard and\n screen-reader behaviour correct; `ui.css` styles it in `GrayText` on a recessed cap\n rather than with the house `opacity: 0.5`, and says there why at length.\n\n NO CLICK HANDLER AT ALL, deliberately. A handler that returned early would be a second\n place for the no-op to be wrong, and `disabled` already stops the event.\n */\n deps.setAttr(button, 'disabled', '');\n deps.append(line, button);\n continue;\n }\n\n const action = cap.action;\n const shiftLabel = cap.shiftLabel;\n // REGISTERED FOR RE-LETTERING. Only caps with BOTH legends qualify: a letter has no\n // `shiftLabel`, and re-lettering it to itself would be work with no effect.\n if (shiftLabel !== undefined) {\n dualCaps.push({ el: button, plain: cap.label, shifted: shiftLabel });\n }\n /*\n IS THIS A REGULAR CHARACTER? -- AND THE ANSWER IS THE ACTION'S KIND, NOT THE LEGEND.\n\n `{kind:'type'}` is what \"a character\" means in this table: 40 caps type (the 26 letters,\n `Space`, the numeric pad's digits and `.`/`-`, and the 20 dual-glyph punctuation caps),\n and every other kind is a control key or an AID.\n\n THIS LINE IS THE FIX FOR A STICKY SHIFT THE USER FOUND IN THE GUI WINDOW, 2026-10-23,\n and the bug was a PROXY rather than a typo. The handler below used to ask\n `shiftLabel !== undefined` to mean \"is this a regular character?\", because the rule it\n implements excuses control keys. But a LETTER has no `shiftLabel` -- `A` shifted is\n still `A` -- so all 40 character caps without a shifted legend fell through the\n consuming branch and LEFT THE ARM SET. Shift became a latch, which is the one thing the\n spec says it is not: *\"the next regular character you type ... and then shift unselects\"*.\n */\n const isCharacter = action.kind === 'type';\n deps.onClick(button, () => {\n /*\n THE ONE-SHOT SHIFT, AND THE ORDER OF THESE BRANCHES IS THE SPECIFICATION.\n\n A CHARACTER CONSUMES THE ARM. If it has a `shiftLabel` it sends that; if it has none\n -- a letter, `Space`, a numpad digit -- it sends its own text unchanged, because there\n is nothing else to send. EITHER WAY THE ARM IS SPENT, which is rule 2 read as the user\n wrote it: *\"the next regular character\"*, not \"the next character that looks different\n when shifted\".\n\n A CONTROL KEY LEAVES THE ARM ALONE -- a PF key, `Enter`, a cursor arrow. That is rule\n 3, and it is the half most likely to be got wrong in the other direction: 3270 has no\n shifted `PF3`, so eating the arm there would make the modifier feel broken -- press\n Shift, reach for a character, and find it unshifted because a PF key stole the arm on\n the way. The tests assert both halves, in both directions.\n */\n if (isCharacter && armed) {\n armed = false;\n paintShift();\n // THE SHIFTED TEXT IF THERE IS ONE, otherwise this cap's own action untouched. A\n // letter re-sent as `{kind:'type', text: cap.label}` would be the same bytes by a\n // longer route, so the original action is reused and only the arm is spent.\n deps.sendAction(\n shiftLabel !== undefined ? { kind: 'type', text: shiftLabel } : action);\n return;\n }\n deps.sendAction(action);\n });\n deps.append(line, button);\n }\n deps.append(section, line);\n }\n // THE PF STRIP GOES IN THE TOP BAND, everything else in the lower one.\n deps.append(region === 'pf' ? topBand : lowerBand, section);\n }\n\n return {\n shiftArmed: () => armed,\n disarmShift: () => {\n armed = false;\n paintShift();\n },\n };\n}\n", "import type { Action } from '@tn3270/frontend';\n\n/**\n * Chromium `KeyboardEvent` to a named 3270 action.\n *\n * The GUI's counterpart to the terminal keymap, and deliberately NOT derived from it: that\n * table is measured byte sequences and this one matches `key` names, so a shared\n * abstraction over both would be an untested third thing. What IS shared is the `Action`\n * vocabulary from `@tn3270/frontend`, and `BINDING_INTENT` there records which key is meant\n * to do what -- `keys.test.ts` checks this mapper against it, so a key added to one front\n * end is visibly missing from the other.\n *\n * ## RETURNS null RATHER THAN GUESSING\n *\n * An unknown key must do nothing. Falling through to `type` would put the literal string\n * \"AudioVolumeUp\" into a field, and a bare `Shift` would type \"Shift\" -- both worse than\n * ignoring the key. An unmapped Ctrl chord is likewise dropped rather than typing its\n * letter: Ctrl-Z has no 3270 meaning, and inserting a bare \"z\" is not a reasonable guess.\n *\n * ## Ctrl-C IS CLEAR, Ctrl-] QUITS\n *\n * Correct for a 3270 and surprising to everyone: Clear is an AID a user needs constantly to\n * dismiss VM's `MORE...` state, so the usual instinct for escaping cannot be the way out.\n * The window says so at startup, as the TUI's banner does.\n */\nexport interface KeyLike {\n readonly key: string;\n /**\n * The PHYSICAL key. Load-bearing for Alt chords: on macOS, Option-1 reports\n * `key === '\u00A1'`, so an `e.key` binding works on Linux and fails on a Mac.\n */\n readonly code: string;\n readonly ctrlKey: boolean;\n readonly altKey: boolean;\n readonly metaKey: boolean;\n readonly shiftKey: boolean;\n /**\n * `KeyboardEvent.getModifierState`. OPTIONAL, and the optionality is the point: a real\n * event always has it, but `Electron.sendInputEvent` synthesizes events that do not, and\n * `keyspec.ts` documents that an unrecognized spelling arrives as `key: ''` with no\n * methods at all. Code here must therefore treat its absence as \"no AltGraph\", never\n * call it unguarded.\n */\n readonly getModifierState?: (modifier: string) => boolean;\n}\n\n/**\n * Does this event carry a LAYOUT CHARACTER -- a character reachable only through a\n * layout-shift modifier?\n *\n * ## THE BUG THIS FIXES\n *\n * On a German, Spanish or Nordic layout the C programmer's characters live behind AltGr:\n * `{` is AltGr+7, `}` is AltGr+0, and `[`, `]`, `@`, `\\` and `~` are AltGr chords too. Those\n * arrive with `altKey` set, so the Alt branch below dropped every one of them and a whole\n * class of keyboard could not type a brace into a field at all.\n *\n * `getModifierState('AltGraph')` is the portable signal (MDN,\n * `KeyboardEvent.getModifierState`): true for **Windows AltGr**, for **macOS Option**, and\n * for the **GTK level-3/5 shift** on Linux. Plain `altKey` cannot stand in for it, because\n * Windows reports AltGr AS ctrlKey+altKey -- so a `ctrlKey && altKey` test would swallow\n * genuine Ctrl-Alt chords, and an `altKey` test would steal Alt+digit from the PA keys.\n *\n * ## WHY THIS IS A FALLBACK AND NOT A BRANCH TAKEN FIRST\n *\n * MEASURED, and the first version of this fix got it wrong: on **macOS, Option sets\n * AltGraph too**, so checking it before the bindings made Option-1 type `\u00A1` instead of\n * sending PA1 and Option-K type `\u02DA` instead of toggling the keypad. That is precisely the\n * defect the notes on `KeyLike.code` and the PA tests already warn about -- a change that\n * works on Linux and silently breaks a Mac -- and the existing tests passed through it\n * because none of them set `getModifierState`.\n *\n * So the order is: **every binding gets its chance first**, and only a keystroke that no\n * binding claimed is reconsidered as a layout character. Option-1 still reaches PA1,\n * AltGr+7 still types `{`, because `Digit7` is in no table.\n *\n * NOT DRIVEN BY THE Xvfb HARNESS, and `keys.mjs` cannot be extended to cover it:\n * `sendInputEvent`'s modifier list (`electron.d.ts:8961`) has no `altgr`, so there is no way\n * to synthesize one of these events from the harness. `keys.test.ts` is the whole of the\n * cover, which is why it states both platforms' shapes explicitly.\n */\nfunction layoutCharacter(e: KeyLike): Action | null {\n if (e.getModifierState?.('AltGraph') !== true) return null;\n // The same single-code-point rule the printable path uses, so a dead key (`key: 'Dead'`)\n // or an F-key with AltGr held still types nothing.\n if ([...e.key].length === 1) return { kind: 'type', text: e.key };\n return null;\n}\n\n/** Keys whose `key` value is a name rather than a character. */\nconst NAMED: Readonly<Record<string, Action>> = Object.freeze({\n Enter: { kind: 'enter' },\n ArrowLeft: { kind: 'left' },\n ArrowRight: { kind: 'right' },\n ArrowUp: { kind: 'up' },\n ArrowDown: { kind: 'down' },\n Home: { kind: 'home' },\n Backspace: { kind: 'backspace' },\n Delete: { kind: 'delete' },\n // vt220 Select/End bound to EraseEOF, following c3270 -- a CHOICE, not a derivation.\n End: { kind: 'eraseEOF' },\n // x3270's Toggle(insertMode), fb-x3270:210.\n Insert: { kind: 'toggleInsert' },\n});\n\n/**\n * Modifier keys arrive as key events in their own right and mean nothing alone.\n * Without this, holding Shift types \"Shift\" into the field.\n */\nconst MODIFIERS: ReadonlySet<string> = new Set([\n 'Shift', 'Control', 'Alt', 'Meta', 'CapsLock', 'AltGraph', 'NumLock', 'ScrollLock',\n]);\n\n/**\n * The Ctrl chords a 3270 uses. Anything else with Ctrl held is dropped.\n *\n * `d`, `f` and `k` are c3270's own, from the keymap a terminal build reads:\n * `Ctrl<Key>d: Dup()`, `Ctrl<Key>f: FieldMark()`, `Ctrl<Key>k: Keypad()`\n * (Common/fb-c3270:186-187, :191). Matching the terminal keymap here is the point -- an\n * operator moving between the TUI and a window should not have to learn two sets.\n */\nconst CTRL: Readonly<Record<string, Action>> = Object.freeze({\n c: { kind: 'clear' },\n r: { kind: 'reset' },\n u: { kind: 'eraseInput' },\n ']': { kind: 'quit' },\n a: { kind: 'attn' },\n d: { kind: 'dup' },\n f: { kind: 'fieldMark' },\n k: { kind: 'toggleKeypad' },\n // The full 122-key keyboard, 2026-10-22. Free in both mappers -- the taken chords are\n // C R U ] A D F K T -- and claiming it also suppresses Firefox's bookmarks sidebar, which is\n // correct: `renderer.ts` calls `preventDefault` only for keys we claimed, so claiming it is\n // what makes it ours. Intercepted CLIENT-SIDE in both DOM front ends, exactly as `toggleKeypad`\n // is, and the gateway returns before `applyAction` for it -- see `web/src/main.ts`.\n b: { kind: 'toggleKeyboard' },\n /**\n * Ctrl-T, the transfer form -- `BINDING_INTENT`'s own spelling, now that the GUI has a dialog.\n *\n * THE GUI HAD THIS CHORD DEAD WHILE THE WINDOW WORKED, which is how this was found: a real\n * Electron run with `TN3270_GUI_KEYS=Ctrl+T` printed `keys: sent Ctrl+T` and NO `action:` line\n * at all, where a click on the `Xfer` keypad button opened the window fine. The keypad route\n * went through `KEYPAD_KEYS` and never through this table, so the one that was missing was also\n * the one no harness drove.\n *\n * ACCEPTED BY THE WEB FRONT END TOO, since this mapper is shared, and that is safe rather than\n * merely tolerable: `protocol.ts:129-131` REJECTS the kind at decode with a per-client `error`\n * frame, and `web/src/main.ts:151-154` answers a decode failure on that one socket without\n * touching the session or the process. The browser could already produce this kind from the\n * `Xfer` button it draws today, so this adds a second route to an answer that already exists --\n * no new failure mode, and the refusal names its reason. When stage 4 gives the gateway a\n * transfer path that rejection becomes an interception, and this entry needs no change.\n */\n t: { kind: 'transferForm' },\n});\n\n/** Physical digit keys that carry the PA keys when Alt is held. Same shape as CTRL. */\nconst PA_CODES: Readonly<Record<string, Action>> = Object.freeze({\n Digit1: { kind: 'pa', n: 1 },\n Digit2: { kind: 'pa', n: 2 },\n Digit3: { kind: 'pa', n: 3 },\n});\n\nexport function actionForKey(e: KeyLike): Action | null {\n if (MODIFIERS.has(e.key)) return null;\n\n /**\n * COPY, on `Ctrl-Shift-C` (Linux, Windows) and `Cmd-Shift-C` (macOS). 2026-10-27.\n *\n * ## BEFORE THE `CTRL` LOOKUP, AND THAT ORDER IS THE WHOLE TRICK\n *\n * That lookup is case-folded, so `Ctrl-Shift-C` used to mean Clear just as `Ctrl-C` does --\n * DRIVEN, not assumed: both returned `{kind:'clear'}` before this branch existed. Taking the\n * shifted form for copy therefore requires getting in first; a `copy` entry in `CTRL` could not\n * work at all. Plain `Ctrl-C` is untouched and still Clear, which is the property the fold\n * exists to protect, and the comment on the lookup now says which of the two jobs it still has.\n *\n * ## WHY NOT THE NATIVE `copy` EVENT, WHICH IS HOW PASTE WORKS\n *\n * MEASURED, Electron 44.3.0 under Xvfb, in a real Chromium window with NO text selected in the\n * document -- our case, since the canvas selection is `renderer.ts`'s module-level\n * `anchor`/`focus` (`:88-89`) and not a DOM Range; there is no `getSelection` or `createRange`\n * CALL anywhere in `packages/canvas` or `packages/web` -- a claim about calls and not about\n * mentions, which matters because grepping for it finds only this sentence:\n *\n * Ctrl-C -> copy event FIRES, clipboardData present\n * Cmd-C -> no copy event\n * Ctrl-Shift-C -> no copy event\n *\n * So the native copy gesture on Linux is `Ctrl-C` -- the Clear AID -- and adopting it the way\n * paste adopted the native `paste` event would collide with the one binding this project has\n * decided must stay Clear on every platform. `menu.ts:56-72` records that decision AND records\n * rejecting the conditional form, \"Ctrl-C copies when a selection exists and Clears when it does\n * not\", because it makes a DESTRUCTIVE AID depend on invisible state. Paste got a free ride\n * because `keys.ts` never claimed `Ctrl-V`; copy gets none, so it needs an explicit chord.\n *\n * The same measurement is why macOS needs a binding rather than the browser default: `Cmd-C`\n * copies NOTHING from a canvas. A `null` from this mapper means \"we declined it\", never \"the\n * platform handles it\" -- the reading that made an earlier claim in this feature wrong.\n *\n * (2026-11-02: THAT `Cmd-C` ROW WAS MEASURED ON LINUX CHROMIUM, which binds no copy command to\n * Meta+C. On macOS the Edit menu's Copy command does raise the event, and since 2026-11-02\n * `forwardKeys.ts`'s `forwardCopy` listens for it. So `Cmd-C` now copies in the web gateway, but\n * through the event and not through this mapper. `Ctrl-C` still cannot reach it: the Clear claim\n * below cancels the keystroke, and that cancellation suppresses the copy event.)\n *\n * ## `requestCopy`, NOT `copy`\n *\n * `copy` carries a rect and this function is a pure function of one key event, so it cannot\n * see a selection -- the union member's own note in `frontend/src/keymap.ts` has the detail.\n * The renderer, which holds the rectangle, is what turns the intent into a real `copy`.\n *\n * ## `e.code`, AND NEITHER SPELLING OF `e.key` WOULD DO\n *\n * `e.key.toLowerCase()` is the fold itself: it is what makes `Ctrl-Shift-C` indistinguishable\n * from `Ctrl-C`, so the one chord whose discriminator IS Shift cannot be matched through it.\n * Raw `e.key === 'C'` fails differently -- `e.key` is the shifted, LAYOUT-DEPENDENT form, the\n * hazard `KeyLike.code`'s note records, and Shift inverts caps lock, so an operator with caps\n * lock on reports `key: 'c'` with `shiftKey` set. Matching the physical key is what `Alt-K`\n * above does, and for the same reason.\n *\n * ## THE MODIFIER GUARDS\n *\n * `!altKey` leaves `Ctrl-Alt-Shift-C` with the window manager, following this file's rule that\n * an Alt or Meta chord is the OS's. It is also what keeps the Windows AltGr path intact: Windows\n * reports AltGr as ctrlKey+altKey, so an AltGr chord over `KeyC` must fall through to\n * `layoutCharacter` below rather than be eaten here.\n *\n * `ctrlKey !== e.metaKey` IS AN EXCLUSIVE OR AND NOT A LOOSE `(ctrlKey || metaKey)`, which is\n * the form the plan for this task proposed. It demands EXACTLY ONE of the two, so it rejects\n * both edges a disjunction would admit: `Ctrl-Cmd-Shift-C`, a chord belonging to the OS by the\n * same rule the bail below states -- and NEITHER modifier, where a bare `Shift-C` must stay\n * typed text. The Alt-K branch spells the same exclusion `!ctrlKey && !metaKey`, because no\n * branch here may claim a chord another one half-matches.\n */\n if (e.shiftKey && !e.altKey && e.ctrlKey !== e.metaKey && e.code === 'KeyC') {\n return { kind: 'requestCopy' };\n }\n\n if (e.ctrlKey && !e.altKey && !e.metaKey) {\n // Case-folded so that Ctrl-Shift-R, Ctrl-Shift-U and every other entry here reach their\n // unshifted meaning -- caps lock, or a held shift, must not make a chord inert. `Ctrl-Shift-C`\n // is now the ONE exception and is claimed by the copy branch above, which is why that branch\n // has to run first; plain `Ctrl-C` is still Clear. `?? null` is what drops Ctrl-Z rather than\n // typing \"z\".\n return CTRL[e.key.toLowerCase()] ?? null;\n }\n // The PA keys, on Alt+digit as x3270 and c3270 both have them (Common/fb-c3270:43-45).\n // Matched on e.code and not e.key: see the note on KeyLike.code. Checked BEFORE the bail\n // below, which is what used to make every PA key unreachable in this front end. The\n // `!ctrlKey && !metaKey` guard leaves Ctrl-Alt-digit and Cmd-Alt-digit falling through to\n // that bail, where they are `null` unless AltGraph says the keystroke is a layout\n // character -- which is how Windows AltGr+7, reported as ctrlKey+altKey, reaches `{`.\n if (e.altKey && !e.ctrlKey && !e.metaKey) {\n // Alt-K is how c3270's _WIN32 keymap spells `Keypad()` (Common/fb-c3270:48-49, which binds\n // both `k` and `K`), so it is honored here alongside the Ctrl-K its terminal keymap uses\n // (:191). A terminal cannot have both -- Alt-K arrives there as `ESC k` and this project\n // keeps new bindings off the ESC path -- but a real KeyboardEvent carries no such ambiguity.\n //\n // `e.code`, NOT `e.key.toLowerCase()`, and for the reason the note on `KeyLike.code` gives:\n // Option-K on macOS reports `key === '\u02DA'`, exactly as Option-1 reports `'\u00A1'`. A `key`-based\n // test here would work on Linux, fail on a Mac and pass every test written on Linux --\n // the same defect that once made the PA keys unreachable. Matching the physical key also\n // makes Alt-Shift-K work, as c3270's pair of `k`/`K` bindings does.\n //\n // Checked BEFORE PA_CODES so a future Alt entry in that table cannot shadow it silently.\n if (e.code === 'KeyK') return { kind: 'toggleKeypad' };\n // Alt-M, the model/oversize chooser, 2026-11-04 -- `e.code` for the reason Alt-K gives just\n // above: Option-M on macOS reports `key === '\u00B5'`, so a `key` test would pass on Linux and fail\n // on a Mac. Shadows nothing: `KeyM` is in no other Alt entry, PA_CODES holds only digits, and\n // the Ctrl and Meta branches never reach here. The cost is that Option-M no longer types `\u00B5`,\n // the trade Option-K already makes. Not `Ctrl-Shift-M`, which both browsers keep for themselves.\n if (e.code === 'KeyM') return { kind: 'geometryMenu' };\n // `??` and not `return ... ?? null`: a key no PA entry claims may still be a layout\n // character. This is the macOS Option path -- Option-1 matched `Digit1` above and never\n // reaches here, while Option-8 on a German Mac has no binding and types `{`.\n return PA_CODES[e.code] ?? layoutCharacter(e);\n }\n\n // A Meta or Alt chord belongs to the window or the OS, never to the field. Cmd-digit is\n // deliberately NOT a PA: that is where menu accelerators live.\n //\n // THE WINDOWS AltGr PATH ENDS HERE, because Windows reports AltGr as ctrlKey+altKey and so\n // misses both branches above. A genuine Ctrl-Alt chord has no AltGraph state and still\n // returns null, which is what keeps Ctrl-Alt-K with the window manager.\n if (e.metaKey || e.altKey) return layoutCharacter(e);\n\n if (e.key === 'Tab') return e.shiftKey ? { kind: 'backTab' } : { kind: 'tab' };\n\n const fn = /^F(\\d{1,2})$/.exec(e.key);\n if (fn !== null) {\n const n = Number(fn[1]);\n if (n < 1 || n > 12) return null; // F13+ is not a 3270 key\n // Shifted Fn is PF(n+12), the c3270 convention the terminal keymap also follows.\n return { kind: 'pf', n: e.shiftKey ? n + 12 : n };\n }\n\n const named = NAMED[e.key];\n if (named !== undefined) return named;\n\n // Exactly one code point means a printable key; `key` is already the shifted form.\n if ([...e.key].length === 1) return { kind: 'type', text: e.key };\n return null;\n}\n", "import { actionForKey, type KeyLike } from './keys.js';\nimport type { Action } from '@tn3270/frontend';\n\n/**\n * Forward a window's real keystrokes -- and, in the browser, its real pastes -- to the session.\n *\n * TWO LISTENERS WITH DIFFERENT AUDIENCES, which is worth knowing before reading either: the\n * `keydown` half (`forwardKeys`) exists for the GUI's CHILD WINDOWS and runs in every front end,\n * while the `paste` half (`forwardPaste`) is for the WEB GATEWAY and refuses to install anywhere\n * else. They share `isEditableTarget` and nothing else. The paragraph below is the first half's\n * history; `forwardPaste`'s own docstring carries the second's, including the measurement that\n * makes its gate load-bearing.\n *\n * ## WHY THIS EXISTS: THE GUI'S CHILD WINDOWS WERE DEAF\n *\n * `renderer.ts` has the product's only other `keydown` listener and it lives in the CANVAS\n * window. The GUI's keypad and keyboard are separate `BrowserWindow`s, so with either focused the\n * physical keyboard did nothing at all -- not a dropped chord, silence. The user met it the day\n * the keyboard window shipped: a window full of keys is an invitation to type.\n *\n * **The web gateway never had THIS KEYDOWN problem and needs `forwardKeys` itself for nothing.**\n * Its surfaces are `<div>` overlays in the canvas page, so one `window` listener already covers\n * them. That asymmetry is Electron's, and it is the GUI's alone.\n *\n * (SCOPED TO `forwardKeys`, 2026-10-26, because this line said the gateway \"needs nothing from\n * this module\" and that became false while the diff that falsified it never touched it: the\n * gateway already imported `isEditableTarget` from here, and now takes `forwardPaste` too. This\n * is the summary-line-outlives-its-correction shape -- the heading five lines above was written\n * in the same edit and contradicted this one.)\n *\n * ## INJECTED DEPS, LIKE `keypadUi.ts` AND `keyboardUi.ts`\n *\n * Nothing here touches a real `window`: the caller supplies `on` and `sendAction`. That is what\n * lets this be unit-tested in `environment: 'node'` with a hand-rolled fake, and jsdom is\n * deliberately not a dependency of this repo.\n *\n * ## IT IMPORTS ONLY `keys.js`, WHICH IS THE WHOLE REASON IT IS A NEW FILE\n *\n * The obvious alternative was to export the listener from `renderer.ts`. That file value-imports\n * `blit.js` and `selection.js`, so a child window importing it would drag the whole rasteriser\n * into a document that has no canvas -- and every new specifier needs its own entry in two import\n * maps. `keys.js` has ZERO runtime imports, so each window's graph grows by exactly one file.\n */\nexport interface ForwardKeysDeps {\n /** Register the listener. The real caller passes `window.addEventListener`. */\n readonly on: (type: string, handler: (e: never) => void) => void;\n /** Deliver an action. The real caller passes its window's bridge. */\n readonly sendAction: (action: Action) => void;\n}\n\n/**\n * Is this event's target a thing the operator is typing INTO?\n *\n * ## EXTRACTED FROM `renderer.ts` RATHER THAN COPIED, 2026-10-23\n *\n * That file had this inline and now imports it. Two copies of a rule that must agree is how they\n * diverge, and this one is load-bearing: MEASURED 2026-10-07, without it typing into the web\n * gateway's transfer form sent every character TO THE MAINFRAME and left the field empty, because\n * `actionForKey` claims any single-code-point key as `{kind:'type'}` without knowing what was\n * focused, and the `preventDefault` that follows suppressed the character locally.\n *\n * ## CHECKED HERE AND NOT INSIDE `actionForKey`\n *\n * `keys.ts` is deliberately DOM-free -- `KeyLike` carries six scalars and no target, which is what\n * lets its cases run without jsdom. The target is a property of the EVENT, so the code that owns\n * real events owns this question.\n *\n * ## `BUTTON` IS DELIBERATELY NOT IN THE LIST\n *\n * Every cap on both surfaces is a `<button>`, and one of them holds focus after every click. A\n * predicate widened to \"any form control\" would make the keyboard window deaf again the moment an\n * operator clicked a key -- which is the exact bug this module exists to fix. The list is the\n * three TEXT-ENTRY tags plus `contentEditable`, and nothing else.\n */\nexport function isEditableTarget(\n target: { tagName?: unknown; isContentEditable?: unknown } | null,\n): boolean {\n const tag = typeof target?.tagName === 'string' ? target.tagName : '';\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'\n || target?.isContentEditable === true;\n}\n\n/** The shape this module reads off a real `KeyboardEvent`. */\ntype KeyEventLike = KeyLike & {\n readonly target: { tagName?: unknown; isContentEditable?: unknown } | null;\n readonly preventDefault: () => void;\n};\n\n/**\n * Install the listener. Called once per window, at load.\n *\n * ALL CHORDS WORK EVERYWHERE, which is the user's decision of 2026-10-23 and not an oversight:\n * `Ctrl-B` pressed in the keyboard window raises `toggleKeyboard`, which `main.ts` answers by\n * showing and focusing the window that is already open -- a harmless self-focus.\n * `openKeyboardWindow` is idempotent precisely so a repeated chord cannot stack a second window.\n * Special-casing the two toggle chords would add a second place for the rule to be wrong.\n */\nexport function forwardKeys(deps: ForwardKeysDeps): void {\n deps.on('keydown', (raw: never) => {\n const e = raw as unknown as KeyEventLike;\n if (isEditableTarget(e.target)) return;\n const action = actionForKey(e);\n if (action === null) return;\n // `preventDefault` ONLY for keys we CLAIMED, so shortcuts we do not use keep working and Tab\n // does not move focus out of the window. `renderer.ts` makes the same call for the same reason.\n e.preventDefault();\n deps.sendAction(action);\n });\n}\n\n/** The shape this module reads off a real `ClipboardEvent`. */\ntype PasteEventLike = {\n readonly target: { tagName?: unknown; isContentEditable?: unknown } | null;\n readonly clipboardData?: { getData: (format: string) => string } | null;\n readonly preventDefault: () => void;\n};\n\nexport interface ForwardPasteDeps {\n /**\n * Register the listener. The real caller passes `window.addEventListener`.\n *\n * THE HANDLER IS TYPED `PasteEventLike` AND NOT `never`, WHICH DIVERGES FROM `ForwardKeysDeps`\n * ABOVE ON PURPOSE. `never` costs the listener body its type checking: every field read has to\n * go through a `raw as unknown as ...` cast, and after that cast the compiler cannot tell\n * `clipboardData` from a typo. Naming the shape instead means `tsc` checks the body, and it\n * costs nothing elsewhere -- `PasteEventLike` is a local type that emits fine into the `.d.ts`,\n * and this file still pulls in no DOM lib types. The renderer's own\n * `handler as (e: Event) => void` is unavoidable either way (`TS2769` with `never`, `TS2345`\n * with this), so the count of casts goes from two to one rather than to zero.\n */\n readonly on: (type: string, handler: (e: PasteEventLike) => void) => void;\n /** Deliver the action. The real caller passes its window's bridge. */\n readonly sendAction: (action: Action) => void;\n /**\n * `location.protocol` of the document installing this. THE WHOLE SAFETY GATE; see below.\n *\n * PASSED IN RATHER THAN READ HERE for the reason the module header gives about every other\n * capability: these tests run under `environment: 'node'`, where there is no `location` at all,\n * and a gate nothing can drive is a gate nobody has checked.\n */\n readonly protocol: string;\n /**\n * Forget the renderer's selection, called ONLY once a paste has been claimed.\n *\n * A HOOK AND NOT AN IMPORT, because the selection is `renderer.ts`-local state by that file's\n * own deliberate design -- it is not in the `DrawList` and main cannot see it. This module\n * stays DOM-free and state-free, which is what keeps it testable.\n */\n readonly clearSelection: () => void;\n}\n\n/**\n * THE HOSTS WHOSE `paste` ACTION SOMEBODY ANSWERS. Nothing else may raise one.\n *\n * `http:`/`https:` is the WEB GATEWAY and only the web gateway: `web/src/main.ts` intercepts\n * `{kind:'paste'}` before `applyAction` and calls `applyPaste`. Electron's own page is `file:`\n * (MEASURED 2026-10-26: a `loadFile` window reports `location.protocol === 'file:'`), and the\n * gateway can never be `file:` -- `socketUrl` builds a `ws:`/`wss:` URL from the document's own\n * href, so a `file:`-loaded page has no socket to send on in the first place.\n */\nconst PASTE_HOSTS: ReadonlySet<string> = new Set(['http:', 'https:']);\n\n/**\n * A PASTE, from the browser's own `Ctrl-V`. 2026-10-26.\n *\n * ## WHY AN EVENT AND NOT A CHORD OR A BUTTON\n *\n * `navigator.clipboard.readText()` would need a permission prompt in Chromium and does not exist\n * in Firefox. The `paste` event hands over the text with NO prompt in every browser, and the\n * native `Ctrl-V` is already the gesture an operator reaches for -- so this adds no chord to\n * `keys.ts` and needs no menu bar, which a browser tab does not have.\n *\n * ## IT IS INSTALLED ONLY ON THE WEB GATEWAY, AND THAT GATE IS NOT CAUTION -- IT IS A FIX\n *\n * `renderer.ts` IS LOADED BY TWO FRONT ENDS: `web/static/index.html` serves it to a browser, and\n * `gui/index.html`'s `<script type=\"module\" src=\"../canvas/dist/renderer.js\">` pulls the same\n * file into the ELECTRON GUI. (BY TAG AND NOT BY LINE: that document is five lines long, so any\n * comment added to it moves the line -- and `d4fce66` retired this feature's line citations for\n * exactly that.) A `paste` action raised\n * in the GUI reaches `gui/src/main.ts`'s `ipcMain.on('action')`, which intercepts `quit`,\n * `toggleKeypad`, `toggleKeyboard`, `geometryMenu`, `transferForm`, `copy` and `requestCopy` --\n * and NOT `paste` -- then falls\n * through to `applyAction`, which THROWS on it by design (`frontend/src/actions.ts`: the front\n * end owns its own clipboard).\n *\n * MEASURED 2026-10-26, Electron 44.3.0 under Xvfb, because the cost of guessing here is the\n * operator's whole session:\n * 1. A `paste` EVENT really does reach the page in an Electron window, by FIVE routes, each\n * `isTrusted: true` and carrying the real clipboard text: `Ctrl+V`, `Ctrl+Shift+V`,\n * `Shift+Insert`, a middle-click, and `webContents.paste()`. The menu accelerator does NOT\n * swallow the keystroke -- a run with no application menu at all saw the same events.\n * 2. An uncaught throw inside an `ipcMain.on` listener does not merely log. It FREEZES THE MAIN\n * PROCESS EVENT LOOP: a 400ms heartbeat timer logged nothing at all afterwards and the\n * process had to be killed, where the identical run with the throw removed logged its eight\n * beats and exited 0. There is no `uncaughtException` handler in `gui/src/main.ts`.\n * 3. AND THE TWO HALVES REALLY DO JOIN UP IN THE SHIPPING CLIENT, which is the measurement\n * that matters and the only one taken against `packages/gui` itself. With the gate removed\n * and a live X11 clipboard owner, the real GUI under `TN3270_GUI_KEYS='Ctrl+v,Ctrl+a'`\n * NEVER EXITED: it ignored its harness's 60s timeout and the SIGTERM that followed, and had\n * to be `kill -9`ed -- no `action:` line, no `renderer[3]`, just a frozen client. WITH the\n * gate it exits 0 on the same keys, raises no paste, and the trailing `Ctrl+A` still logs\n * `{\"kind\":\"attn\"}` -- which is the liveness probe, since an absence alone would also be\n * what a dead process produced.\n * So an UNGATED listener here would have handed every Electron operator a frozen app on `Ctrl+V`.\n * (An X11 selection dies with the process that owns it, so a counterfactual run needs a second\n * Electron alive holding the clipboard -- a first attempt measured an EMPTY clipboard instead and\n * saw no paste at all, which would have read as the hazard being unreachable.)\n *\n * ## WHY THE GUI DOES NOT INSTEAD GET A `paste` ARM, WHICH LOOKS LIKE THE OBVIOUS FIX\n *\n * It would DOUBLE-PASTE on Linux and Windows, and that is measured too. `gui/src/menu.ts` already\n * binds Paste to `Ctrl+Shift+V` off `darwin`, wired to `onPaste` -> `applyPaste`. In the run\n * above, `Ctrl+Shift+V` fired the page's `paste` event AND the menu's `click` -- and\n * `preventDefault()` on the event did NOT suppress the accelerator (`defaultPrevented: true` in\n * the page, `MENU_PASTE_CLICK` immediately after). An arm answering the action would therefore\n * apply the same clipboard twice for one keystroke, into a 3270 field, where a silently\n * half-doubled dataset name may be a VALID DIFFERENT NAME -- the exact failure `pasteAction.ts`\n * is written to avoid.\n *\n * The GUI has a WORKING paste already (that menu item), so the gate costs it nothing today. What\n * it does cost is the GUI's native `Ctrl+V`, which stays unbound there -- deliberately left for\n * the user to decide on, since fixing it means reconciling the accelerator with the event rather\n * than adding a handler.\n *\n * **THE INVARIANTS THE NEXT PERSON MUST NOT BREAK.** The gate is the only thing standing between\n * a stray `paste` event and a frozen main process, and THREE separate things keep it sufficient:\n * 1. `applyAction` still THROWS on `paste`. If that goes, re-measure before widening\n * `PASTE_HOSTS` -- the throw is what makes a missing arm loud instead of silent.\n * 2. `gui/src/main.ts` still has no `paste` arm. If one is added, the double-paste measured\n * above applies and the accelerator has to be reconciled first.\n * 3. **THE ELECTRON GUI'S `TN3270_GUI_URL` MODE STAYS A SEPARATE, EARLIER PATH.** This is the\n * one the gate does NOT cover and the easiest to break by accident, so it is named rather\n * than left to be rediscovered. That mode is an Electron window loading the gateway over\n * `http:` -- so it PASSES `PASTE_HOSTS` and does install this listener. VERIFIED 2026-10-26\n * that it is nonetheless safe, and safe for a reason that is nothing to do with the\n * protocol: the URL branch `return`s out of `app.whenReady()` BEFORE either\n * `Menu.setApplicationMenu` or `ipcMain.on('action')` is reached, so that window has no\n * action listener and no Paste accelerator at all, and its `window.tn3270` is the GATEWAY's\n * own socket bridge (the Electron preload is deliberately omitted in URL mode), whose\n * `sendAction` reaches the gateway's real `paste` arm. Correct -- but by STATEMENT ORDER in\n * another package.\n * SO: MOVING THE MENU OR THE `ipcMain.on('action')` REGISTRATION ABOVE THAT EARLY RETURN --\n * an ordinary-looking tidy-up, since both look like setup that belongs at the top -- would\n * re-arm the freeze for URL mode with this gate untouched and nothing red anywhere. If that\n * ordering changes, this mode needs its own answer, not a wider allowlist.\n *\n * ## IT MUST SKIP FORM CONTROLS, AND THAT IS MEASURED RATHER THAN CAUTIOUS\n *\n * This listener goes on `window`, and the gateway's transfer form has real text inputs. The\n * 2026-10-07 bug in the other direction is recorded on `renderer.ts`'s `keydown` listener: typing\n * into that form sent every character TO THE MAINFRAME and left the field empty. A paste into the\n * `Local file` field would do exactly the same, so `isEditableTarget` guards both.\n *\n * ## IT SENDS `paste`, NOT `type`\n *\n * `keymap.ts` records the whole reason: `type` reaches `Keyboard.typeString`, which would send\n * every `\\n` and `\\f` into the field AS A CHARACTER (EBCDIC 0x25 and 0x0c, MDT set), so the next\n * AID carries the corruption to the host as field data. `pasteString`'s x3270 semantics are what\n * the `paste` kind buys. (An earlier draft of this feature's plan said `\\n` became ENTER and\n * submitted the panel. That is x3270's `kybd.c`, not this codebase -- `core/src/keyboard.ts` has\n * no AID path at all. Corruption, not submission.)\n *\n * ## A HUGE PASTE DROPS THE CONNECTION, AND IT IS WORTH KNOWING WHERE THAT COMES FROM\n *\n * There is NO LENGTH CHECK HERE and that is deliberate, but the consequence is not obvious from\n * this file. MEASURED 2026-10-26: the JSON envelope around the text is 53 bytes, and\n * `wsserver.ts`'s `MAX_MESSAGE_BYTES` is 8192 -- so a paste of up to 8139 ASCII characters is\n * delivered, 8140 is 8193 bytes, and `parseFrame` throws on the cap. `Connection` answers that by\n * CLOSING THE CLIENT rather than throwing (deliberately: a throw out of a `data` handler would\n * end the gateway process and disconnect every other operator's session), so what the operator\n * sees is \"The connection to the gateway closed. Reload to reconnect.\" -- with nothing said about\n * length, and no `error` frame, because the socket is already gone.\n *\n * SO A MYSTERY DISCONNECT AFTER A BIG PASTE IS THIS, NOT A GATEWAY BUG. The 3270 session itself\n * survives -- the registry's grace window holds it and a reload reattaches -- so the outcome is\n * handled, just by dropping the connection instead of refusing the paste.\n *\n * NO CLIENT-SIDE LENGTH CHECK IS ADDED, and the reason is that it would be a second copy of a\n * limit this file cannot see: `MAX_MESSAGE_BYTES` lives in `packages/web`, the renderer has no\n * import from there (by hard constraint -- see this file's header), and the real budget depends on\n * UTF-8 byte length rather than character count. A threshold hardcoded here would drift from the\n * server's the first time either moved, and would reject pastes the gateway would have accepted.\n * If this is worth fixing it belongs at the GATEWAY, which knows its own cap and can answer with\n * the `error` frame the protocol already has.\n */\nexport function forwardPaste(deps: ForwardPasteDeps): void {\n // BEFORE `on`, so a host we do not serve gets NO LISTENER AT ALL rather than one that returns.\n // A registered-but-inert listener would be one edit away from being the hazard again, and this\n // way the absence is what the test asserts.\n if (!PASTE_HOSTS.has(deps.protocol)) return;\n deps.on('paste', (e) => {\n if (isEditableTarget(e.target)) return;\n const text = e.clipboardData?.getData('text/plain') ?? '';\n /*\n AN EMPTY CLIPBOARD IS NOT A PASTE: it would type nothing and warn about nothing, so claiming\n it would take the operator's selection away for no visible reason.\n\n THE PREDICATE IS `text === ''` AND NOT \"TEXT THAT WOULD TYPE NOTHING\", which is a NEAR MISS\n deliberately not chased. MEASURED 2026-10-26 against the real `pasteString`: a `'\\r'`-only\n or `'\\r\\r\\r'` clipboard returns `{typed: 0, dropped: 0}` with no warning and the cursor\n unmoved -- indistinguishable from empty, so it DOES cost the selection for no visible\n effect, which is the thing the paragraph above says must not happen.\n\n It is left alone because the obvious tightening is wrong: `'\\n'` ALSO types nothing\n (`{typed: 0, dropped: 0}`) and yet is a REAL paste -- measured, it moves the cursor to the\n next unprotected field (0 -> 80 on a fresh 3278-2). So \"would type nothing\" does not\n separate the two, and any trim that did would be a second copy of `pasteString`'s drop set\n living in a DOM listener, free to drift from the one in `frontend/src/paste.ts`. A\n CR-only clipboard costing a highlight is the cheaper defect.\n */\n if (text === '') return;\n // `preventDefault` ONLY once we have claimed it, matching the `keydown` listeners' rule: a\n // paste we are not handling must keep its default behavior.\n e.preventDefault();\n // ANY ACTION THAT DOES SOMETHING INVALIDATES THE SELECTION, for the reason `renderer.ts`'s\n // `keydown` listener gives at length: the screen is about to change under it, and a highlight\n // over changed text would offer a copy of something never seen. AFTER the two returns above,\n // so a paste we declined leaves the selection alone -- the same ordering, for the same reason.\n deps.clearSelection();\n deps.sendAction({ kind: 'paste', text });\n });\n}\n\n/** The shape this module reads off a real `copy` `ClipboardEvent`. */\ntype CopyEventLike = {\n readonly target: { tagName?: unknown; isContentEditable?: unknown } | null;\n readonly clipboardData?: { setData: (format: string, data: string) => void } | null;\n readonly preventDefault: () => void;\n};\n\nexport interface ForwardCopyDeps {\n /** Register the listener. The real caller passes `window.addEventListener`. */\n readonly on: (type: string, handler: (e: CopyEventLike) => void) => void;\n /** `location.protocol`, gated on exactly as `ForwardPasteDeps.protocol` is. */\n readonly protocol: string;\n /** The prefetched text for the CURRENT selection, or `undefined`. See `copyCache.ts`. */\n readonly cached: () => string | undefined;\n /** The renderer's own `copySelection`: the async fallback, which `Ctrl-Shift-C` also reaches. */\n readonly copySelection: () => boolean;\n /**\n * Called once for every copy this listener took responsibility for, by either path. The\n * `Ctrl-Shift-C` arm reads it to know whether its `execCommand('copy')` reached here, and falls\n * back itself if not -- a browser that silently ignores `execCommand` still copies.\n */\n readonly onHandled: () => void;\n /**\n * Called after the CACHED path set its text and cancelled the event -- the one route on which\n * this listener knows the copy succeeded. OPTIONAL. 2026-11-03: `renderer.ts` uses it to take\n * down a \"copy failed\" notice, so the operator sees the retry worked. Not `onHandled`, which\n * fires on the fallback too, where nothing has been copied yet.\n */\n readonly onCopied?: () => void;\n}\n\n/**\n * THE BROWSER'S OWN COPY EVENT -- `Cmd-C` on macOS, Edit > Copy anywhere, and since 2026-11-02 the\n * `execCommand('copy')` that `renderer.ts`'s `Ctrl-Shift-C` arm raises. 2026-11-02.\n *\n * TWO PATHS, CHOSEN BY WHETHER THE TEXT IS ALREADY HERE. The text lives at the gateway, because\n * only the gateway can extract it, so the renderer PREFETCHES it when a drag ends (`copyCache.ts`).\n * - CACHED: the text is set on the event's own `clipboardData`, synchronously, inside the event.\n * That needs no secure context and no activation window, which is what makes copy work at a\n * plain-`http` LAN address, where `navigator.clipboard` does not exist.\n * - NOT CACHED -- the prefetch has not answered yet, or failed; the selection changed or was\n * cleared before its answer landed; or the event carries no `clipboardData` -- a second\n * trigger for `copySelection`, the async path. The text goes to the gateway as\n * `{kind:'copy', rect}` and comes back as `copyText`, exactly as before.\n * MEASURED 2026-10-27 in Chromium: the `copy` event fires `isTrusted` with no DOM selection, so it\n * reaches the page even though nothing is selected in the document.\n *\n * ## `Ctrl-C` CANNOT REACH THIS, AND THAT IS WHAT KEEPS CLEAR WORKING\n *\n * `keys.ts` claims `Ctrl-C` as Clear and `renderer.ts` calls `preventDefault` on it. MEASURED\n * 2026-10-27: that suppresses the `copy` event entirely, so on Linux and Windows the native copy key\n * stays the Clear AID: only the menu route and `Ctrl-Shift-C`'s own `execCommand('copy')` (raised\n * by `renderer.ts`'s `copyByChord`) arrive here. `Cmd-C` is unclaimed, so it does arrive.\n *\n * ## `preventDefault` ONLY WHEN TEXT WAS SET\n *\n * Under the Clipboard API spec, cancelling a `copy` tells the browser to write the event's own\n * `clipboardData`. On the cached path that is the point: the data was just set, and without the\n * cancel the browser would ignore it and copy the (empty) DOM selection instead. On the fallback\n * path it must NOT happen, and the paste listener's shape must not be copied there: `clipboardData`\n * is empty because the text is still at the gateway, and on a LAN page there is no clipboard API to\n * overwrite it afterwards. MEASURED 2026-11-02 in Electron's Chromium: a cancelled, empty copy did\n * NOT blank the clipboard. Firefox and Safari are UNMEASURED, so the fallback rule is a precaution\n * and not a fix for a known failure. The unit test pins it; the browser harness cannot, since\n * Chromium behaves the same either way. Left alone, the default copies an empty DOM selection,\n * which does nothing.\n *\n * ## THE GATE IS `PASTE_HOSTS`, FOR A WEAKER REASON THAN PASTE'S\n *\n * A stray `copy` action in the Electron GUI would not freeze main (`gui/src/main.ts` has a `copy`\n * arm), but the GUI already copies through its menu accelerator, `Cmd+C` on macOS, so a listener\n * there could only make one keystroke copy twice. The web gateway is the only host that needs it.\n */\nexport function forwardCopy(deps: ForwardCopyDeps): void {\n if (!PASTE_HOSTS.has(deps.protocol)) return;\n deps.on('copy', (e) => {\n // The transfer form's text fields must copy their own selection, not the screen's.\n if (isEditableTarget(e.target)) return;\n deps.onHandled();\n const text = deps.cached();\n // `''` FALLS BACK TOO: set and cancelled, it would write an EMPTY clipboard. `copyCache` never\n // holds one, but this module does not lean on that.\n if (text !== undefined && text !== '' && e.clipboardData) {\n // THE SYNCHRONOUS PATH: no secure context needed, no activation window. `preventDefault` is\n // what makes the browser write the data just set -- correct here, and only here.\n e.clipboardData.setData('text/plain', text);\n e.preventDefault();\n deps.onCopied?.();\n return;\n }\n // Nothing cached for this selection, or no `clipboardData` to set it on: the async path. NO\n // `preventDefault`, for the reason in the docstring. The return value is discarded, as\n // `copyByChord` discards it: no selection means nothing to copy, not worth a message.\n deps.copySelection();\n });\n}\n", "import { createKeyboardUi } from '@tn3270/canvas/dist/keyboardUi.js';\nimport { forwardKeys } from '@tn3270/canvas/dist/forwardKeys.js';\n\n/**\n * The full-keyboard window's entry point: the real DOM, handed to the testable view.\n *\n * ## EVERY LINE HERE IS UNTESTABLE, AND THAT IS WHY THERE ARE SO FEW OF THEM\n *\n * `vitest.config.ts` sets `environment: 'node'`, so nothing in this repo can execute a line of\n * this file -- it reads `document` in its module body. `keyboardUi.ts` holds every decision\n * (including the one-shot Shift, which is the only behaviour in the whole surface); this only\n * adapts primitives. `keypadBoot.ts` is this file's precedent in every respect.\n *\n * THE OTHER HALF OF THAT LESSON: an untestable boot file must be LOADED to be believed, never\n * reasoned about. `clicks.mjs` is what loads this one.\n *\n * ## THE THROWS REACH A HUMAN, WHICH IS WHY THEY ARE THROWS\n *\n * `main.ts` forwards this window's `console-message` to stdout as `keyboard[level]`, so an\n * uncaught throw here prints with a file and line rather than vanishing. A silent `return` would\n * leave a window with a title and no buttons -- indistinguishable from a styling bug, and the\n * exact shape this repo keeps meeting.\n *\n * NO CLEVER GUARD ANYWHERE IN THIS FILE, for the recorded reason: this project's one BLANK WINDOW\n * bug was a TDZ read on an uninitialised `const`, and **optional chaining does not guard a TDZ\n * read** (`ui?.x` still throws). So there are two hard failures with messages, and nothing is\n * declared after something that reads it.\n */\nconst root = document.getElementById('keyboard-root');\nif (root === null) {\n throw new Error('keyboard window: #keyboard-root is missing from keyboard.html');\n}\n\n/**\n * The preload's bridge, read ONCE into a local.\n *\n * NOT RE-READ PER CLICK, deliberately: `contextBridge.exposeInMainWorld` defines a non-writable\n * property, so it cannot change under us, and reading it here means a missing preload fails at\n * LOAD time with the message below rather than on the operator's first key press.\n */\nconst bridge = (window as unknown as {\n tn3270keyboard?: { sendAction: (action: unknown) => void };\n}).tn3270keyboard;\nif (bridge === undefined) {\n // A MISSING BRIDGE IS A LOUD FAILURE AND NOT A DEAD KEYBOARD. The preload is a `.cjs` loaded by\n // absolute path from main; if it failed to load, every one of the 118 caps would silently do\n // nothing -- which looks like a protocol problem and is not one.\n throw new Error('keyboard window: the tn3270keyboard bridge is absent (preload failed to load?)');\n}\n\n/**\n * THE RETURN VALUE IS DISCARDED, AND THAT IS A REAL DIFFERENCE FROM THE WEB SURFACE.\n *\n * `createKeyboardUi` returns a `KeyboardUi` whose `disarmShift()` exists for the sixth of the\n * user's Shift rules: a surface reopened later must not still hold a Shift nobody remembers\n * pressing. The web gateway calls it from its overlay's `onHide`, because hiding an overlay\n * leaves the built DOM in place with its arm intact.\n *\n * A WINDOW HAS NO SUCH STATE TO CLEAR. Closing it DESTROYS the renderer, so reopening runs this\n * module again from scratch and the arm cannot survive -- the rule is satisfied structurally\n * rather than by a handler. Said here rather than left as a dangling expression, because \"the\n * return value is ignored\" and \"the rule is unimplemented\" look identical in a diff.\n */\ncreateKeyboardUi({\n root,\n create: (tag) => document.createElement(tag),\n append: (parent, child) => { parent.appendChild(child); },\n setText: (el, text) => { el.textContent = text; },\n setTitle: (el, title) => { el.title = title; },\n setAttr: (el, name, value) => { el.setAttribute(name, value); },\n removeAttr: (el, name) => { el.removeAttribute(name); },\n onClick: (el, fn) => { el.addEventListener('click', fn); },\n sendAction: (action) => { bridge.sendAction(action); },\n});\n\n/**\n * AND THE PHYSICAL KEYBOARD, so this window is not deaf while it has focus.\n *\n * The user's requirement, 2026-10-23: *\"typing should send keystrokes to the terminal emulator\n * whether the keyboard window or the terminal window has focus.\"* Before this, the product's only\n * `keydown` listener was `renderer.ts`'s, in the CANVAS window -- so with this window focused a\n * physical key did nothing at all, which is a worse failure than a wrong action because nothing on\n * screen explains it.\n *\n * THE SAME BRIDGE THE CAPS USE, so a typed key and a clicked cap take one path to the session:\n * `ipcMain.on('action')` reads no sender, so main's existing interceptions answer both.\n */\nforwardKeys({\n on: (type, handler) => { window.addEventListener(type, handler as () => void); },\n sendAction: (action) => { bridge.sendAction(action); },\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// packages/gui/dist/keyboardPreload.cjs
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
var electron_1 = require("electron");
|
|
6
|
+
electron_1.contextBridge.exposeInMainWorld("tn3270keyboard", {
|
|
7
|
+
/**
|
|
8
|
+
* Fire a key's action at the session.
|
|
9
|
+
*
|
|
10
|
+
* `send`, NOT `invoke`: there is nothing to answer. The screen repaints by the frame that main
|
|
11
|
+
* pushes to the CANVAS window afterwards, which is a different window entirely -- so a reply
|
|
12
|
+
* here would be a promise resolving to nothing, and awaiting it would only hide that.
|
|
13
|
+
*/
|
|
14
|
+
sendAction: (action) => {
|
|
15
|
+
electron_1.ipcRenderer.send("action", action);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
//# sourceMappingURL=keyboardPreload.cjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/keyboardPreload.cts"],
|
|
4
|
+
"sourcesContent": ["import { contextBridge, ipcRenderer } from 'electron';\n\n/**\n * The full-keyboard window's bridge. A `.cts` FILE ON PURPOSE -- an ESM preload fails with \"Cannot\n * use import statement outside a module\" and the bridge then SILENTLY NEVER APPEARS, leaving a\n * window whose 118 caps all do nothing and no error anywhere. See `preload.cts`,\n * `transferPreload.cts` and `keypadPreload.cts`, which carry the same note for the same measured\n * reason.\n *\n * `tsconfig.json` already includes `src/**\\/*.cts`, so `tsc` emits `dist/keyboardPreload.cjs` with\n * no build-script step -- the same path the other three take.\n *\n * ## ONE FUNCTION, WHICH IS THE WHOLE SURFACE\n *\n * A key press is exactly the `sendAction` the canvas window already has, so this window needs\n * nothing else: no `invoke`, no reply, no events. It reuses main's EXISTING `ipcMain.on('action')`\n * handler, which is why this feature adds no new IPC channel anywhere.\n *\n * ## A SEPARATE PRELOAD FROM THE KEYPAD'S, AND NOT MERELY FOR SYMMETRY\n *\n * `keypadPreload.cts` exposes `tn3270keypad` and this exposes `tn3270keyboard`. Reusing the\n * keypad's file would mean the keyboard window's boot module read a property named for the other\n * surface -- which works and is a lie in a diff, the kind this repo pays for later. The cost of a\n * second file is nine lines of real code.\n *\n * ## THE EXPOSED NAME IS UNIQUE, AND THAT IS MEASURED RATHER THAN STYLISTIC\n *\n * `exposeInMainWorld` defines a NON-WRITABLE property, and `main.ts:161-164` records the measured\n * failure from a collision: \"Cannot assign to read only property 'tn3270' of object '#<Window>'\".\n * The canvas window is `tn3270`, the transfer window `tn3270transfer` and the keypad\n * `tn3270keypad`, so this is `tn3270keyboard`.\n *\n * `contextIsolation` stays on and `nodeIntegration` off. This window sits in front of a logged-on\n * host session and sends it AIDs, so it is the last place to grant Node access.\n */\ncontextBridge.exposeInMainWorld('tn3270keyboard', {\n /**\n * Fire a key's action at the session.\n *\n * `send`, NOT `invoke`: there is nothing to answer. The screen repaints by the frame that main\n * pushes to the CANVAS window afterwards, which is a different window entirely -- so a reply\n * here would be a promise resolving to nothing, and awaiting it would only hide that.\n */\n sendAction: (action: unknown): void => { ipcRenderer.send('action', action); },\n});\n"],
|
|
5
|
+
"mappings": ";;;;AAAA,IAAA,aAAA,QAAA,UAAA;AAmCA,WAAA,cAAc,kBAAkB,kBAAkB;;;;;;;;EAQhD,YAAY,CAAC,WAAyB;AAAG,eAAA,YAAY,KAAK,UAAU,MAAM;EAAG;CAC9E;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/app/keypad.html
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<!-- Generated by packages/gui/scripts/bundle.mjs from packages/gui/keypad.html; edit that. -->
|
|
3
|
+
<html><head><meta charset="utf-8"><title>Keypad</title>
|
|
4
|
+
<link rel="stylesheet" href="./ui.css">
|
|
5
|
+
</head>
|
|
6
|
+
<body class="ui-surface">
|
|
7
|
+
<h1>Keypad</h1>
|
|
8
|
+
<div id="keys"></div>
|
|
9
|
+
<script type="module" src="./keypadBoot.js"></script>
|
|
10
|
+
</body></html>
|