@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": ["../../canvas/src/keys.ts", "../../canvas/src/forwardKeys.ts", "../../canvas/src/blit.ts", "../../canvas/src/selection.ts", "../../canvas/src/copyCache.ts", "../../canvas/src/renderer.ts"],
|
|
4
|
+
"sourcesContent": ["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 type { Rgb } from '@tn3270/core';\nimport type { AtlasGeometry } from './geometry.js';\nimport type { DrawList } from './drawlist.js';\n\n/**\n * Draw a `DrawList` onto a 2D canvas context.\n *\n * ## THE ATLAS IS COVERAGE, SO EVERY COLOR NEEDS ITS OWN TINTED COPY\n *\n * `atlas.bin` is alpha only. Tinting per CELL would mean a composite operation 1920 times\n * a frame; tinting per COLOR means at most sixteen, because that is how many a 3279 has.\n * So each color gets one pre-tinted `ImageBitmap`-alike, built on demand and cached. The\n * cache is bounded by the palette, not by the screen, which is why it can be unbounded in\n * code without being unbounded in fact.\n *\n * ## SMOOTHING OFF, INTEGER SCALE ONLY\n *\n * `imageSmoothingEnabled = false` and an integer `scale`. This is what keeps a bitmap font\n * pixel-exact, and it is also what makes screenshot goldens byte-reproducible -- there is no\n * hinting and no subpixel antialiasing to vary between machines. A fractional scale would\n * smear the glyphs and make every golden machine-specific.\n *\n * Kept free of `document` and `window` so the arithmetic is testable: the caller supplies\n * the context and a factory for offscreen surfaces.\n */\nexport interface Surface {\n readonly width: number;\n readonly height: number;\n}\n\n/** The 2D context operations this needs, named so a test can supply a recorder. */\nexport interface Ctx2D {\n fillStyle: string;\n imageSmoothingEnabled: boolean;\n /**\n * Composition mode, for the SELECTION HIGHLIGHT only.\n *\n * A string and not a union of the 26 canvas modes: narrowing it would buy nothing here (the one\n * caller passes a literal) and would make this interface a second, drifting copy of a DOM type.\n * `blit` itself never touches it -- it is here so `renderer.ts` can set `difference` without a\n * cast, which is what inverts a selection for every palette scheme without a per-scheme table.\n */\n globalCompositeOperation: string;\n /**\n * Save and restore the drawing state, so the selection highlight's `difference` mode and fill\n * color cannot leak into the next frame's blit. Paired, always.\n */\n save(): void;\n restore(): void;\n fillRect(x: number, y: number, w: number, h: number): void;\n drawImage(\n image: unknown, sx: number, sy: number, sw: number, sh: number,\n dx: number, dy: number, dw: number, dh: number,\n ): void;\n}\n\nexport interface BlitOptions {\n readonly atlas: AtlasGeometry;\n /** Integer multiple. 1 draws one atlas pixel per canvas pixel. */\n readonly scale: number;\n /** Left offset in canvas pixels, for letterboxing. */\n readonly offsetX: number;\n readonly offsetY: number;\n /**\n * A pre-tinted atlas for one color, or `undefined` if it is not ready yet.\n *\n * Returning undefined SKIPS the glyph for this frame rather than drawing a placeholder.\n * An earlier version returned an empty `ImageData`, which `drawImage` rejects outright --\n * \"the provided value is not of type ... ImageBitmap\" -- so every cell threw and the\n * window drew backgrounds only. The caller repaints when the tint finishes building.\n */\n readonly tinted: (color: Rgb) => unknown | undefined;\n /**\n * Atlas columns with NO INK, whose `drawImage` can be skipped. From `blankColumns`.\n *\n * Necessary rather than an optimisation detail: an untouched 3270 cell holds EBCDIC\n * 0x00, which maps to CG 0 (`null`), NOT to CG 16 (`space`). An earlier version tested\n * only for space and therefore stamped all 1920 cells of a blank screen -- caught by a\n * test asserting an empty screen issues no draws.\n */\n readonly blank: ReadonlySet<number>;\n}\n\nexport const rgbCss = ([r, g, b]: Rgb): string => `rgb(${r},${g},${b})`;\n\n/** Cache key for a tinted atlas. Colors are triples, so a string key is the simplest. */\nexport const tintKey = ([r, g, b]: Rgb): string => `${r},${g},${b}`;\n\n/**\n * The largest integer scale whose letterboxed screen fits `within`, minimum 1.\n *\n * Takes a plain SIZE rather than a `DrawList`, because that is all it reads -- and main\n * needs it before a frame exists, to size the window to the screen the host just described.\n *\n * Stated as a rule rather than \"fit to the window\": a fractional fit would smear a bitmap\n * font, and refusing to draw at all when the window is small would be worse than drawing\n * small. 1 is the floor because there is no half-pixel glyph.\n */\nexport function bestScale(list: Surface, within: Surface): number {\n const byWidth = Math.floor(within.width / list.width);\n const byHeight = Math.floor(within.height / list.height);\n return Math.max(1, Math.min(byWidth, byHeight));\n}\n\n/** Where to put the screen so it sits centerd in `within` at `scale`. */\nexport function center(list: Surface, within: Surface, scale: number): { x: number; y: number } {\n return {\n x: Math.max(0, Math.floor((within.width - list.width * scale) / 2)),\n y: Math.max(0, Math.floor((within.height - list.height * scale) / 2)),\n };\n}\n\nexport function blit(ctx: Ctx2D, list: DrawList, opts: BlitOptions): void {\n const { atlas, scale, offsetX, offsetY } = opts;\n ctx.imageSmoothingEnabled = false;\n\n const w = atlas.cellWidth * scale;\n const h = atlas.cellHeight * scale;\n\n for (const cell of list.cells) {\n const dx = offsetX + cell.x * scale;\n const dy = offsetY + cell.y * scale;\n\n // Background first, always -- including for a blank cell, or a previous frame's glyph\n // shows through. The 3270 has no transparency.\n ctx.fillStyle = rgbCss(cell.bg);\n ctx.fillRect(dx, dy, w, h);\n\n if (!opts.blank.has(cell.glyph)) {\n const image = opts.tinted(cell.fg);\n if (image !== undefined) {\n ctx.drawImage(\n image,\n cell.glyph * atlas.cellWidth, 0, atlas.cellWidth, atlas.cellHeight,\n dx, dy, w, h,\n );\n }\n }\n\n if (cell.underline) {\n ctx.fillStyle = rgbCss(cell.fg);\n ctx.fillRect(dx, dy + h - scale, w, scale);\n }\n\n if (cell.cursor) {\n // A block cursor drawn as a reverse bar over the bottom third: visible over a glyph\n // without hiding it, which a full reverse block does.\n ctx.fillStyle = rgbCss(cell.fg);\n ctx.fillRect(dx, dy + h - Math.max(scale, Math.floor(h / 4)), w, Math.max(scale, Math.floor(h / 4)));\n }\n }\n}\n\n/**\n * Which atlas columns contain no ink at all.\n *\n * Computed from the coverage bitmap rather than from a list of \"blank-looking\" codes,\n * because there is more than one: EBCDIC 0x00 (an untouched cell) is CG 0 `null`, EBCDIC\n * 0x40 is CG 16 `space`, and the font has others. Guessing the set is what made the first\n * version of this file stamp every cell of an empty screen.\n *\n * Worth the single startup pass over ~54 KB: a 3270 screen is 1920 cells and a logon panel\n * is mostly empty, so this removes the large majority of per-frame composites.\n */\nexport function blankColumns(bytes: Uint8Array, atlas: AtlasGeometry): ReadonlySet<number> {\n const stride = atlas.cols * atlas.cellWidth;\n const blank = new Set<number>();\n for (let col = 0; col < atlas.cols; col++) {\n let inked = false;\n for (let y = 0; y < atlas.cellHeight && !inked; y++) {\n const base = y * stride + col * atlas.cellWidth;\n for (let x = 0; x < atlas.cellWidth; x++) {\n if (bytes[base + x] !== 0) { inked = true; break; }\n }\n }\n if (!inked) blank.add(col);\n }\n return blank;\n}\n", "/**\n * Rectangular selection geometry, pure and renderer-agnostic.\n *\n * ## WHY RECTANGULAR AND NOT LINEAR\n *\n * A 3270 panel is COLUMNAR -- datasets, LRECLs and option lists sit in columns, and the common\n * want is one column of a list without the labels either side of it. Linear selection on a fixed\n * grid also has a question with no good answer: whether to include the trailing spaces at the end\n * of each row. x3270 selects rectangularly by default on a 3270 screen.\n *\n * ## THIS FILE IS REACHED BY THE RENDERER, SO IT MUST STAY VALUE-IMPORT-FREE OF WORKSPACE PACKAGES\n *\n * `renderer.ts` imports `normalizeRect` and `movedPastThreshold`, and a browser cannot resolve a bare\n * specifier like `@tn3270/core` -- the window goes BLANK WITH NO ERROR, which\n * `renderer-imports.test.ts` exists to catch. The `ResolvedCell` import below is therefore an\n * `import type`, which erases at compile time; `extractText` itself runs in MAIN, never in the\n * renderer. Do not add a value import to this file.\n */\n\nimport type { ResolvedCell } from '@tn3270/core';\n\n/** A cell address in screen coordinates. Row and column are 0-based. */\nexport interface CellAddr {\n readonly row: number;\n readonly col: number;\n}\n\n/** An inclusive rectangle of cells. */\nexport interface CellRect {\n readonly top: number;\n readonly left: number;\n readonly bottom: number;\n readonly right: number;\n}\n\n/**\n * Two cell addresses into an inclusive rectangle.\n *\n * Normalizing is what makes all four drag directions behave identically: an up-left drag selects\n * exactly what the equivalent down-right drag does.\n */\nexport function normalizeRect(anchor: CellAddr, focus: CellAddr): CellRect {\n return {\n top: Math.min(anchor.row, focus.row),\n left: Math.min(anchor.col, focus.col),\n bottom: Math.max(anchor.row, focus.row),\n right: Math.max(anchor.col, focus.col),\n };\n}\n\n/**\n * How far, in CSS pixels on EITHER axis, the pointer must move from the press before a press is a\n * drag. Chromium's own drag threshold off macOS (`kDragThresholdX/Y` in Blink's\n * `mouse_event_manager.cc`: 4, and 3 on macOS), so a press here turns into a selection at about the\n * distance a press on ordinary page text starts one.\n */\nexport const DRAG_THRESHOLD_PX = 4;\n\n/**\n * Has the pointer moved far enough from the press to make it a DRAG rather than a click?\n *\n * ## CLICK OR DRAG IS DECIDED BY MOVEMENT, NOT BY THE RECTANGLE'S SIZE\n *\n * The rule this replaced, `isEmptyRect` (2026-10-05 to 2026-11-03), called every 1x1 rectangle a\n * click, so that a stray click -- including the clicks that place the cursor -- would not arm a\n * one-character copy. It did that, and it ALSO made a single character impossible to select: the\n * minimum selection was two cells, found by the user in Firefox and Safari on 2026-11-03. Movement\n * keeps the first property and drops the second: a click with ordinary jitter stays under the\n * threshold and selects nothing, and a drag that starts and ends on one character selects it.\n *\n * PER AXIS AND `>=`, exactly as Blink's `DragThresholdExceeded` measures it, rather than a\n * Euclidean distance: the point is to agree with what the browser itself calls a drag.\n *\n * The two points must be in the SAME coordinate space; `renderer.ts` uses `clientX`/`clientY` for\n * both, and says why.\n */\nexport function movedPastThreshold(\n press: { readonly x: number; readonly y: number },\n now: { readonly x: number; readonly y: number },\n): boolean {\n return Math.abs(now.x - press.x) >= DRAG_THRESHOLD_PX\n || Math.abs(now.y - press.y) >= DRAG_THRESHOLD_PX;\n}\n\n/**\n * Clipboard text for a rectangle, from the RESOLVED cells.\n *\n * ## WHY THIS TAKES `ResolvedCell[]` AND NOT A `DrawList`\n *\n * A draw list has no characters. `DrawCell` is `{x, y, glyph, ...}` and `glyph` is a CG-order atlas\n * column (`drawlist.ts:111-121`), so text cannot be recovered from it -- reversing those indices\n * would be a second, drifting copy of `cg.ts`'s mapping. `ResolvedCell` is where `text` lives, and\n * `main.ts` already computes it every frame for the draw list.\n *\n * ## `hidden` IS HONORED HERE AND THAT IS NOT OPTIONAL\n *\n * `ResolvedCell.text` is still the real character when `hidden` is set -- core leaves redaction to\n * the consumer precisely because only the consumer knows what to substitute. A hidden cell yields a\n * SPACE, the same substitution `drawlist.ts:114` makes when drawing. Omitting this check puts a\n * password on the clipboard.\n *\n * Trailing whitespace is trimmed PER LINE, which is what makes a copied column paste usefully;\n * interior spaces are kept, because they carry a panel's alignment.\n */\nexport function extractText(\n cells: readonly ResolvedCell[], cols: number, rect: CellRect,\n): string {\n const rows = Math.ceil(cells.length / cols);\n // CLAMPED, so a rectangle running off the grid yields short lines rather than reading\n // `undefined` out of the array and stringifying it.\n const top = Math.max(0, rect.top);\n const bottom = Math.min(rows - 1, rect.bottom);\n const left = Math.max(0, rect.left);\n const right = Math.min(cols - 1, rect.right);\n\n const lines: string[] = [];\n for (let row = top; row <= bottom; row++) {\n let line = '';\n for (let col = left; col <= right; col++) {\n const cell = cells[row * cols + col];\n if (cell === undefined) continue;\n line += cell.hidden ? ' ' : cell.text;\n }\n lines.push(line.replace(/\\s+$/, ''));\n }\n return lines.join('\\n');\n}\n", "import type { CellRect } from './selection.js';\n\n/**\n * THE PREFETCHED TEXT OF THE CURRENT SELECTION, so a `copy` event can hand it over synchronously.\n * 2026-11-02.\n *\n * WHY IT EXISTS: the browser's own copy path (`clipboardData.setData` inside a `copy` event) needs\n * no secure context and no user-activation window, but it needs the text NOW, and only the gateway\n * can extract it. So the renderer asks when a drag ends and keeps the answer here. See\n * `docs/superpowers/specs/2026-11-02-fetch-ahead-copy-design.md` for the measurement behind it.\n *\n * DOM-FREE AND STATE-ONLY, like `selection.ts`, so it is tested under `environment: 'node'`.\n *\n * THE GENERATION, NOT TIMING, IS WHAT KEEPS A STALE ANSWER OUT. `invalidate()` and every `begin()`\n * bump it, and `accept` drops any answer whose generation is not current -- including one for the\n * same rect over a screen that has since changed.\n *\n * IN `invalidate()` THE THREE LINES ARE NOT EQUIVALENT, MEASURED 2026-11-03 by keeping each one\n * alone against two probes (text accepted BEFORE the invalidate; an answer accepted AFTER it):\n * - the gen bump alone leaves already-accepted text readable -- `take` never looks at `gen`;\n * - clearing `text` alone lets a late `accept` for the old gen repopulate it, and `take` then\n * returns it, because `asked` still names the rect;\n * - clearing `asked` alone passes both, since `take` refuses without it -- the one line that\n * suffices by itself.\n * The other two are kept anyway: the gen bump is what stops a late answer landing in `text` at all,\n * which keeps the state honest for whoever reads it next, and clearing `text` drops the string.\n */\nexport interface CopyCache {\n /** A drag ended on `rect`. Returns the generation its answer must carry. */\n begin(rect: CellRect): number;\n /** The answer for generation `gen`. Ignored unless `gen` is still current, or if `text` is empty. */\n accept(gen: number, text: string): void;\n /** The selection is gone or the screen changed. A pending answer becomes stale. */\n invalidate(): void;\n /** The text for `rect`, or `undefined`. Does not consume: the selection survives a copy. */\n take(rect: CellRect): string | undefined;\n}\n\nexport function createCopyCache(): CopyCache {\n let gen = 0;\n let asked: CellRect | undefined;\n let text: string | undefined;\n const same = (a: CellRect, b: CellRect): boolean =>\n a.top === b.top && a.left === b.left && a.bottom === b.bottom && a.right === b.right;\n return {\n begin(rect) {\n gen += 1;\n asked = { ...rect };\n text = undefined;\n return gen;\n },\n accept(g, t) {\n // EMPTY ('') IS NOT CACHED: see the test. Only a one-row all-blank rect extracts '';\n // `extractText` joins rows with '\\n', so a blank multi-row rect extracts '\\n', which IS\n // cached here, matching the old path (`web/src/main.ts`'s `copy` arm sends when\n // `text !== ''`). An uncached '' copy falls back, which sends nothing.\n if (g !== gen || t === '') return;\n text = t;\n },\n invalidate() {\n gen += 1;\n asked = undefined;\n text = undefined;\n },\n take(rect) {\n if (asked === undefined || text === undefined || !same(asked, rect)) return undefined;\n return text;\n },\n };\n}\n", "import type { Rgb } from '@tn3270/core';\nimport type { AtlasGeometry } from './geometry.js';\nimport type { DrawList } from './drawlist.js';\nimport { actionForKey } from './keys.js';\nimport { forwardCopy, forwardPaste, isEditableTarget } from './forwardKeys.js';\nimport { blit, bestScale, center, tintKey, type Ctx2D } from './blit.js';\n// FROM THE MODULE AND NOT FROM THE BARREL (`./index.js`), which would be the natural reach and\n// would BLANK THIS WINDOW WITH NO ERROR: the barrel re-exports `drawlist.js`, which value-imports\n// `@tn3270/core`, and a browser has no bundler to resolve a bare specifier.\n// `renderer-imports.test.ts` walks this file's built graph to catch exactly that. `selection.ts`\n// itself is safe here -- its only workspace import is an `import type`, which erases.\nimport { normalizeRect, movedPastThreshold, type CellAddr, type CellRect } from './selection.js';\n// The same rule as `selection.js` above, and served from `BROWSER_MODULES` for the same reason.\n// `copyCache.ts`'s only import is `import type { CellRect }`, which erases.\nimport { createCopyCache } from './copyCache.js';\n\n/**\n * The renderer: a canvas, key events, and nothing else.\n *\n * ## EVERY RUNTIME IMPORT HERE IS RELATIVE, AND THAT IS A HARD CONSTRAINT\n *\n * This file is loaded by the browser as a module from `file://`. A browser cannot resolve a\n * bare specifier like `@tn3270/core`, and this project has no bundler -- measured, not\n * assumed: an earlier version imported `drawList` and died with \"Failed to resolve module\n * specifier\", leaving a blank window and no clue anywhere. So the draw list arrives finished\n * over IPC, the atlas arrives as bytes over IPC, and the only runtime imports are\n * `./blit.js`, `./keys.js`, `./selection.js`, `./forwardKeys.js` and `./copyCache.js`, whose own\n * imports are either `import type` and thus erased or (for `forwardKeys.js`) `./keys.js`, already\n * here. EACH ONE IS ALSO LISTED IN `BROWSER_MODULES` (`assets.ts`), without which the web front end\n * serves a 404 for it and the canvas goes black instead -- measured twice now, for\n * `selection.js` and again for `forwardKeys.js` on 2026-10-23. ADDING AN IMPORT HERE IS A CHANGE\n * TO THE WEB GATEWAY, and the test that fails on the omission lives in `web`, so run the whole\n * suite rather than this package's.\n *\n * IF YOU ADD A RUNTIME IMPORT FROM A WORKSPACE PACKAGE HERE, THE WINDOW GOES BLANK.\n * Compute it in main and send the result instead.\n *\n * ## THE TINT CACHE\n *\n * The atlas is coverage, not color. Each color gets one pre-tinted copy, built on first\n * use and kept: a 3279 has sixteen colors, so the cache is bounded by the palette rather\n * than by the screen. Tinting per cell would be 1920 composites a frame.\n */\n\n/** What the preload bridge puts on `window`. See preload.cts. */\ndeclare global {\n interface Window {\n tn3270: {\n onAtlas(fn: (atlas: AtlasMessage) => void): void;\n onFrame(fn: (list: DrawList) => void): void;\n onError(fn: (message: string) => void): void;\n sendAction(action: unknown): void;\n /** OPTIONAL: the gateway's bridge has it, Electron's preload does not. See bridgecore.ts. */\n fetchSelection?(rect: CellRect): Promise<string>;\n /** OPTIONAL, the same way: a failed clipboard write, painted as a notice. See bridgecore.ts. */\n onCopyFailed?(fn: (message: string) => void): void;\n };\n }\n}\n\ninterface AtlasMessage {\n geometry: AtlasGeometry;\n /** Structured-cloned, so this arrives as a Uint8Array. */\n coverage: Uint8Array;\n blank: number[];\n}\n\nconst canvas = document.querySelector<HTMLCanvasElement>('#screen');\nif (canvas === null) throw new Error('no #screen canvas in the document');\nconst real = canvas.getContext('2d');\nif (real === null) throw new Error('no 2D context: the window cannot draw');\n// `Ctx2D` narrows `fillStyle` to string so blit.ts needs no DOM types and stays testable\n// with a recorder. Sound because nothing here assigns a gradient or a pattern: a 3270 cell\n// is a flat color.\nconst ctx = real as unknown as Ctx2D;\n\nlet atlas: AtlasMessage | undefined;\nlet blank: ReadonlySet<number> = new Set();\nlet last: DrawList | undefined;\n/**\n * The selection, in CELL coordinates, with `anchor` where the drag began and `focus` where it is\n * now. Both null means no selection.\n *\n * RENDERER-LOCAL AND DELIBERATELY NOT IN THE `DrawList`: the draw list comes from main, and\n * selection is a display concern the renderer owns. Main learns about it only when a copy is asked\n * for, and then only as a RECTANGLE -- it cannot learn the text from here, because a `DrawCell`\n * carries a CG-order atlas glyph and no character. (Contrast the KEYPAD, which IS in the draw\n * list -- because `gui/src/main.ts` sizes the window from `list.height`, so a renderer-owned\n * keypad would be clipped.)\n *\n * `dragging` is separate from \"has a selection\" ON PURPOSE: the mouse release ends the drag but\n * must KEEP the selection, because the operator presses Copy afterwards. Clearing on release would\n * make every copy impossible.\n *\n * `pressAt` AND `moved` DECIDE CLICK OR DRAG, 2026-11-03. `pressAt` is where the button went down,\n * in `clientX`/`clientY`; `moved` latches true once the pointer has gone `movedPastThreshold` from\n * it, and only then is there a selection -- see `selectedRect()`. Before this the rule was the\n * rectangle's SIZE (`isEmptyRect`: 1x1 is a click), which made a single character unselectable.\n */\nlet anchor: CellAddr | null = null;\nlet focus: CellAddr | null = null;\nlet dragging = false;\nlet pressAt: { x: number; y: number } | null = null;\nlet moved = false;\n/**\n * The selection's prefetched text, 2026-11-02 -- see `copyCache.ts`. Invalidated exactly where the\n * selection dies, which is `clearSelection()` below and a new press on the screen, so a frame from\n * the host (whose `onFrame` calls `clearSelection()`) can never leave text for a vanished screen.\n *\n * ONLY EVER FILLED ON THE GATEWAY'S PAGE: `prefetchSelection` returns at once when the bridge has\n * no `fetchSelection`, which is the Electron GUI's normal `file:` window, so there it stays empty\n * and every copy gesture takes the path it took before this existed. Electron's `TN3270_GUI_URL`\n * mode is NOT that case: it loads the gateway's page with the gateway's bridge (no preload), so\n * the cache fills and `copyByChord` runs `execCommand` there. That is web behaviour by design --\n * the window is a browser on the gateway, and its copy reaches the gateway's `selection` arm.\n */\nconst copyCache = createCopyCache();\n/**\n * Set by `forwardCopy`'s `onHandled` whenever its listener took a copy event. Read only by\n * `copyByChord`, to learn whether its `execCommand('copy')` reached that listener.\n *\n * DECLARED HERE, ABOVE EVERY CLOSURE THAT TOUCHES IT, so it has no temporal dead zone to reason\n * about: a `let` does not hoist the way the function declarations that read it do.\n */\nlet copyHandled = false;\n\n/**\n * Forget the selection. One function and not two assignments at four call sites, so a later edit\n * cannot clear one half and leave the other -- this project has been bitten five times by\n * one-path-clears-and-another-doesn't, most recently when closing a parent window skipped the\n * child's close handler.\n */\nfunction clearSelection(): void {\n anchor = null;\n focus = null;\n dragging = false;\n pressAt = null;\n moved = false;\n copyCache.invalidate();\n}\n\n/**\n * The current selection's rectangle, or `null` when there is none.\n *\n * THE ONE PREDICATE FOR \"IS THERE A SELECTION\", read by the highlight in `paint`, by\n * `prefetchSelection`, by `copySelection` and by `cachedSelection`, so the four can never disagree\n * -- a highlight with nothing to copy, or a copy of a rectangle nobody can see. Until 2026-11-03\n * three of them each called `isEmptyRect` on their own `normalizeRect`, and `cachedSelection` did\n * not, which was harmless only because the cache is never filled for an empty rect.\n *\n * `moved` IS PART OF IT, and that is the whole fix: a press that has not left the threshold is a\n * click, whatever the rectangle, and a press that has is a selection even when it never left its\n * cell. A function and not a cached value, so it cannot go stale against the three variables.\n */\nfunction selectedRect(): CellRect | null {\n if (anchor === null || focus === null || !moved) return null;\n return normalizeRect(anchor, focus);\n}\n/**\n * True while the canvas holds an ERROR MESSAGE rather than a screen.\n *\n * `onError` repaints the canvas as text but leaves `last` alone, so without this a click at a\n * button's former position still passed the keypad guard: it sent the action into a session that is\n * failing or already gone, and its `paint(last)` WIPED THE ONLY EXPLANATION OF THE FAILURE off the\n * screen, restoring a stale one. Someone who double-clicked a `.app` has no console to recover it\n * from -- the same argument `onError` itself is written on.\n */\nlet errored = false;\n/**\n * True while what `errored` covers is only a COPY NOTICE (`onCopyFailed`), not a real error.\n * 2026-11-03.\n *\n * A notice keeps the selection and its cached text -- nothing on the screen changed -- so three\n * things may lift it that must never lift a real error: a later copy that succeeds repaints the\n * screen with its highlight, a press on the canvas dismisses it and starts a drag, and a drag's\n * prefetch is not refused. A real error (`onError`) always sets this false, so a socket-closed\n * message is never painted over by a copy or a click.\n */\nlet noticeOnly = false;\n\nconst tints = new Map<string, ImageBitmap>();\nconst building = new Set<string>();\n\n/** One tinted atlas per color: coverage written into the alpha channel. */\nfunction tintOf(color: Rgb): ImageBitmap | undefined {\n if (atlas === undefined) return undefined;\n const key = tintKey(color);\n const got = tints.get(key);\n if (got !== undefined) return got;\n if (building.has(key)) return undefined;\n building.add(key);\n\n const w = atlas.geometry.cols * atlas.geometry.cellWidth;\n const h = atlas.geometry.cellHeight;\n const data = new Uint8ClampedArray(w * h * 4);\n for (let i = 0; i < atlas.coverage.length; i++) {\n data[i * 4] = color[0];\n data[i * 4 + 1] = color[1];\n data[i * 4 + 2] = color[2];\n data[i * 4 + 3] = atlas.coverage[i]!;\n }\n void createImageBitmap(new ImageData(data, w, h)).then((bmp) => {\n tints.set(key, bmp);\n building.delete(key);\n // A color arriving late must not leave a hole: repaint once it is ready.\n if (last !== undefined) paint(last);\n });\n return undefined;\n}\n\nfunction paint(list: DrawList): void {\n last = list;\n if (atlas === undefined || canvas === null) return; // nothing to draw with yet\n\n const within = { width: window.innerWidth, height: window.innerHeight };\n const scale = bestScale(list, within);\n const at = center(list, within, scale);\n\n /**\n * THE CANVAS IS AT LEAST AS BIG AS THE DRAWING, NEVER JUST THE VIEWPORT.\n *\n * MEASURED against live VM/370 through the web gateway, 2026-09-16: a model-4 screen is 43 rows,\n * which with the OIA is 44 x 14 = 616px, and in an 800x600 viewport the OIA row was **entirely off\n * the bottom of the capture** -- the whole operator status line gone, silently, with no error\n * anywhere. That is the same bug the Electron GUI hit on ITS first live run, and there main can fix\n * it by resizing the window. A BROWSER PAGE CANNOT RESIZE ITS WINDOW, so the renderer has to stop\n * losing the data instead: sizing the canvas to the drawing lets the page scroll to reach it.\n *\n * `bestScale` floors at 1 and `center` clamps its offsets at 0, so neither of them saves this.\n *\n * NO EFFECT ON ELECTRON, which is why it is safe to change a file both front ends share: main sets\n * the content size to exactly `list.width * scale` by `list.height * scale`, so the `max` picks the\n * viewport and this line does what it always did. Both GUI goldens still match byte for byte.\n */\n canvas.width = Math.max(within.width, list.width * scale);\n canvas.height = Math.max(within.height, list.height * scale);\n ctx.fillStyle = '#000';\n ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n const options = {\n atlas: atlas.geometry,\n scale,\n offsetX: at.x,\n offsetY: at.y,\n blank,\n // undefined skips the glyph this frame; tintOf repaints when the bitmap is ready.\n tinted: (color: Rgb): unknown | undefined => tintOf(color),\n };\n blit(ctx, list, options);\n\n // The OIA goes through the SAME blitter and atlas -- see the note on DrawList.oia. Passed\n // as a one-row list so the blitter's only job stays \"draw these cells\".\n if (list.oia !== undefined) {\n blit(ctx, { cells: list.oia.cells, width: list.width, height: list.height }, options);\n }\n\n // THE KEYPAD'S BLIT AND ITS PRESS HIGHLIGHT WERE HERE AND ARE GONE, 2026-10-06. Neither front\n // end draws a keypad into the canvas now -- Electron opens a window of real HTML controls, the\n // browser shows an overlay -- so `DrawList` has no `keypad` region to blit and there is no\n // `pressed` button to wash over. The zero-latency press feedback that block existed for is now\n // the browser's own `:active` styling, which costs no round trip either.\n //\n // What went with it: a `KeypadButton | undefined` module-local, an identity check against the\n // current frame's buttons (so a model switch mid-press could not highlight a stale rectangle),\n // and the `rgba(0,0,0,0.35)` wash that had to be re-derived once already when `keypad.ts` moved\n // to inverse video. None of it has a consumer; see the note in `index.ts`.\n\n /**\n * THE SELECTION HIGHLIGHT, inverse video over the selected cells.\n *\n * `difference` COMPOSITION RATHER THAN A FIXED INVERSE COLOR, which is what makes one line work\n * for every palette scheme: it inverts whatever is already under it, so there is no per-scheme\n * inverse table to keep in step with `palette.ts`. Drawn as an OVERLAY and not by re-blitting the\n * glyphs, so a selection can never change which character is on screen.\n *\n * GATED ON `selectedRect()`, so a plain click -- a press the pointer did not move past\n * `movedPastThreshold` -- draws nothing. Without that every click would flash a one-cell block.\n * A drag that moved but stayed in one cell DOES draw its cell: that is a one-character selection.\n *\n * THE GOLDENS MUST NOT MOVE because of this. `shot.mjs`'s three cases have no selection, so this\n * block cannot execute in them; if a golden does move, that is a real failure meaning the\n * highlight draws when nothing is selected, and NOT a golden to regenerate.\n *\n * LAST, after the keypad and its press wash, for the same reason the press highlight is last\n * among those: it sits over what it marks. It cannot stray onto the keypad or the OIA anyway --\n * `cellAt` bounds every cell it returns to the screen region.\n */\n const rect = selectedRect();\n if (rect !== null && atlas !== undefined) {\n const g = atlas.geometry;\n ctx.save();\n ctx.globalCompositeOperation = 'difference';\n ctx.fillStyle = '#ffffff';\n ctx.fillRect(\n at.x + rect.left * g.cellWidth * scale,\n at.y + rect.top * g.cellHeight * scale,\n (rect.right - rect.left + 1) * g.cellWidth * scale,\n (rect.bottom - rect.top + 1) * g.cellHeight * scale,\n );\n ctx.restore();\n }\n}\n\nwindow.tn3270.onAtlas((message) => {\n atlas = message;\n blank = new Set(message.blank);\n // This repaint also PAINTS OVER an error message, so the click guard lifts with it -- inside the\n // `if`, because with no frame yet there is nothing to paint and the message stays up.\n if (last !== undefined) { errored = false; noticeOnly = false; paint(last); }\n});\n\n// A frame means the session is drawing again, so whatever `onError` put up is both gone and stale.\n//\n// AND IT INVALIDATES THE SELECTION, because a new frame means the cells those coordinates named\n// now hold different text. Keeping the highlight across a repaint would offer the operator a copy\n// of text that is no longer there -- and main extracts from the CURRENT snapshot, so the rectangle\n// would silently pick up whatever replaced it.\nwindow.tn3270.onFrame((list) => { errored = false; noticeOnly = false; clearSelection(); paint(list); });\n\n/**\n * Paint `message` in red over the whole canvas. The ONE painter for both `onError` and\n * `onCopyFailed`, so the two cannot drift in how a message looks.\n *\n * Failures must be VISIBLE: someone who double-clicked a .app has no console. This is the\n * one place canvas text is used, deliberately -- an error message is our own chrome, is\n * never compared against a golden, and must stay readable at any window size.\n *\n * Sets `errored`, so stray clicks cannot act on a screen the operator cannot see; the caller\n * decides `noticeOnly` and the selection.\n */\nfunction paintMessage(message: string): void {\n if (canvas === null || real === null) return;\n errored = true;\n canvas.width = window.innerWidth;\n canvas.height = window.innerHeight;\n real.fillStyle = '#000';\n real.fillRect(0, 0, canvas.width, canvas.height);\n real.fillStyle = '#ff5555';\n real.font = '15px monospace';\n let y = 40;\n for (const line of wrap(message, Math.max(20, Math.floor(canvas.width / 9)))) {\n real.fillText(line, 20, y);\n y += 21;\n }\n}\n\nwindow.tn3270.onError((message) => {\n // A REAL ERROR: the screen the operator was looking at is gone or failing.\n noticeOnly = false;\n // THE SELECTION DIES WITH THE SCREEN IT DESCRIBED, and its prefetched text with it (`941949d`).\n // The message paints over the whole canvas, so the highlight is no longer visible -- yet without\n // this, Cmd-C or the chord after a socket close would copy cached text from a screen the\n // operator can no longer see. Nothing needs the selection to survive: the mousedown guard\n // refuses new drags while a real error is up, and the next frame would clear it anyway.\n //\n // THIS RUNS IN THE ELECTRON GUI TOO, which registers `onError` through `preload.cts` and paints\n // its own failures here; there it clears a selection that `__tn3270Copy` (the Edit menu) could\n // otherwise still read. UNCHANGED for real errors by 2026-11-03's fix, which moved only the\n // web gateway's \"copy failed\" reports off this path, onto `onCopyFailed` below.\n clearSelection();\n paintMessage(message);\n});\n\n/*\n A FAILED COPY IS A NOTICE, NOT A SCREEN FAILURE, 2026-11-03. Only the web gateway's bridge has\n this (`bridgecore.ts`'s `onCopyFailed`); Electron copies through IPC and never reports this way.\n\n IT KEEPS THE SELECTION AND ITS CACHED TEXT, and that is the fix. A copy pressed before the\n prefetch answered takes the async fallback, which on a plain-`http` LAN page has no clipboard\n API -- and Firefox's Edit > Copy made then is refused with `NotAllowedError` the same way. The\n remedy is to press copy again once the prefetch has landed, when the cached path needs no secure\n context. Through `onError` the second press found `clearSelection()` had run: no selection, no\n text, nothing copied, and a re-drag refused while `errored`.\n\n WHAT STILL INVALIDATES THE SELECTION is everything that really changes the screen, none of\n which is touched here: every frame (`onFrame`), every claimed key, a paste, a new press.\n*/\nwindow.tn3270.onCopyFailed?.((message) => {\n // AN ERROR ALREADY UP STAYS THE STORY: a copy notice must not downgrade a socket-closed message\n // into something a click dismisses. Unreachable today (a closed socket answers no copy), kept so\n // the invariant does not rest on that.\n if (errored && !noticeOnly) return;\n noticeOnly = true;\n paintMessage(message);\n});\n\nwindow.addEventListener('keydown', (e) => {\n /**\n * A KEYSTROKE AIMED AT A REAL FORM CONTROL IS NOT FOR THE 3270 SCREEN.\n *\n * MEASURED 2026-10-07 by the `browser-clicks.mjs` transfer phase, which found this as a live\n * bug: typing into the web gateway's transfer form sent every character TO THE MAINFRAME and\n * left the field empty. This listener is on `window`, so it sees events whose target is\n * `#transfer-localFile`; `actionForKey` claims any single-code-point key as `{kind:'type'}`\n * without knowing what was focused; and the `preventDefault()` below then suppressed the\n * character the operator was trying to type. Proved three ways -- no `input` event at all, the\n * gateway's own action log showing `{\"kind\":\"type\",\"text\":\"a\"}` for a letter typed into a text\n * field, and the character arriving correctly once propagation stopped at the overlay.\n *\n * NOT A WEB-ONLY FIX. `renderer.js` is loaded by `gui/index.html` too, and the Electron GUI's\n * windows are separate `BrowserWindow`s only by luck of this feature's design -- the keypad\n * overlay already puts real `<button>`s in the SAME document as the canvas, and a future\n * in-window control would have hit this immediately.\n *\n * CHECKED BEFORE `actionForKey` RATHER THAN INSIDE IT, because `keys.ts` is deliberately\n * DOM-free: `KeyLike` carries six scalar fields and no target, which is what lets its 26 cases\n * run under `environment: 'node'` with no jsdom. The target is a property of the EVENT, so the\n * code that owns real events owns this question.\n *\n * The predicate counts `isContentEditable` for completeness; nothing here uses it today.\n */\n // THE PREDICATE NOW LIVES IN `forwardKeys.ts` and is shared with the GUI's child windows, which\n // need the identical rule. The long note above is why it exists; this is now the only copy.\n if (isEditableTarget(e.target as { tagName?: unknown; isContentEditable?: unknown } | null)) {\n return;\n }\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\n // Tab does not move focus out of the canvas.\n e.preventDefault();\n /*\n COPY IS INTERCEPTED HERE AND MUST NOT FALL THROUGH TO `clearSelection()`.\n\n `requestCopy` is an INTENT and not a wire action: `actionForKey` raises it from\n `Ctrl-Shift-C` / `Cmd-Shift-C` knowing only that keys were pressed, and this file is the only\n place that knows WHICH CELLS are selected. `copyByChord` consumes it, so the kind never reaches\n `sendAction`: with the selection's text prefetched it raises the browser's own `copy` event and\n `forwardCopy` sets the text synchronously (2026-11-02), and otherwise it falls back to\n `copySelection`, which turns it into the real `{kind:'copy', rect}` as before.\n\n THE ORDER OF THESE LINES IS THE BEHAVIOR. Every other claimed key clears the selection, because\n the screen is about to change and a highlight over changed text would offer a copy of something\n never seen. A copy changes NOTHING and is the one claimed kind that READS the selection -- so\n clearing first would destroy the rectangle a moment before building it, and the prefetched\n text with it (`clearSelection()` invalidates the cache). `copyByChord` would find nothing\n cached, and `copySelection` would find `anchor === null`, return false and copy nothing.\n Both paths read the selection, so the argument is unchanged by the cache. Moving this arm below\n `clearSelection()` STILL COMPILES AND STILL TYPECHECKS while silently breaking the gesture,\n which is why `test/renderer-imports.test.ts` pins the order in the built artifact.\n\n NO RESULT IS KEPT, unlike the global's boolean: `copyByChord` returns nothing. A chord pressed\n with nothing selected is not worth a message -- the menu item needs to tell that\n from \"copied\" because a harness asserts on it; a keystroke has nobody to tell.\n\n A FRONT END THAT FORGOT THIS ARM FAILS LOUDLY RATHER THAN BEING SILENTLY INERT, which is why\n the kind has no handler downstream: `applyAction` THROWS on `requestCopy` and the web gateway\n refuses it at decode with a per-client `error` frame. The two-step shape here is the one\n `transferForm` already uses -- a renderer-side intent that becomes a different action -- and\n NOT `toggleKeypad`, which is a single kind terminally intercepted in main.\n */\n if (action.kind === 'requestCopy') { copyByChord(); return; }\n // ANY OTHER KEYSTROKE THAT DOES SOMETHING INVALIDATES THE SELECTION, for the reason above.\n //\n // AFTER the `action === null` return above, so a key we did NOT claim leaves the selection alone:\n // the Copy accelerator itself arrives as a keystroke, and clearing on unclaimed keys would wipe\n // the selection a moment before main asked for it. On macOS the Electron menu consumes that\n // accelerator before the page sees it, so it never reaches this listener at all; a browser has no\n // menu, and there `Ctrl-Shift-C` is CLAIMED and handled by the arm above. (Before 2026-10-27 it\n // was unclaimed on Linux and survived by not being in `actionForKey`'s table. It is in the table\n // now, so the selection survives a copy by the explicit exemption above and not by accident.)\n clearSelection();\n window.tn3270.sendAction(action);\n});\n\n/*\n THE BROWSER'S OWN `Ctrl-V`, 2026-10-26.\n\n THE RULES AND THE SAFETY GATE ARE IN `forwardKeys.ts`, not here, and the gate is the reason this\n is a call rather than an inline `window.addEventListener('paste', ...)`. THIS FILE IS LOADED BY\n TWO FRONT ENDS -- `gui/index.html`'s `<script src=\"../canvas/dist/renderer.js\">` tag pulls this\n same file into the Electron GUI, cited by tag because that document is five lines long -- and\n an ungated listener would raise `{kind:'paste'}` there, where nothing intercepts it and\n `applyAction` throws: MEASURED to FREEZE the main process event loop, not merely log. The\n injected `protocol` is what confines it to the web gateway. Read `forwardPaste`'s docstring\n before changing either side; it records what was measured and the invariant that keeps it true.\n\n `location.protocol` IS READ HERE AND PASSED IN, because this is the file that owns real browser\n globals -- the same division that keeps `forwardKeys.ts` testable under `environment: 'node'`.\n*/\nforwardPaste({\n on: (type, handler) => { window.addEventListener(type, handler as (e: Event) => void); },\n sendAction: (action) => { window.tn3270.sendAction(action); },\n protocol: location.protocol,\n clearSelection,\n});\n\n// THE BROWSER'S OWN COPY EVENT, `Cmd-C` on macOS, Edit > Copy, and `copyByChord`'s\n// `execCommand('copy')`, 2026-11-02. It sets the prefetched text when there is some and falls back\n// to `copySelection` when not. Same gate as paste; `forwardCopy`'s docstring says why it cancels\n// the event only when text was set, and why `Ctrl-C` cannot reach it and so stays Clear.\n// `cachedSelection` and `copySelection` are function declarations further down, and hoist;\n// `copyHandled` is declared above, beside the selection, so nothing here touches a TDZ.\nforwardCopy({\n on: (type, handler) => { window.addEventListener(type, handler as (e: Event) => void); },\n protocol: location.protocol,\n cached: cachedSelection,\n copySelection,\n onHandled: () => { copyHandled = true; },\n onCopied: copiedFromCache,\n});\n\n/**\n * A copy just succeeded from the cache. If only a copy NOTICE is up, take it down and repaint the\n * screen -- the selection is still set, so its highlight draws -- so the operator can SEE that this\n * press worked where the last one failed. 2026-11-03. A real error is never painted over.\n *\n * CALLED INSIDE THE `copy` EVENT, after `setData` and `preventDefault`, and painting there is safe:\n * `paint` is plain synchronous canvas drawing that touches neither the event nor the selection\n * nor the cache, so the clipboard write the browser performs after the handler returns is\n * unaffected by it. Synchronous enough, too: the canvas commits at the next rendering\n * opportunity like any other paint. A glyph whose tint is not built yet is repainted by\n * `tintOf`'s own callback, but every tint was built when the screen was first drawn.\n */\nfunction copiedFromCache(): void {\n if (!errored || !noticeOnly || last === undefined) return;\n errored = false;\n noticeOnly = false;\n paint(last);\n}\n\n/**\n * A click on a keypad button.\n *\n * THE INVERSE OF THE DRAWING ARITHMETIC: the draw list is in scale-1 pixels and `paint` multiplies\n * by `scale` and adds the centring offset, so a click subtracts that offset and divides by the\n * scale. Getting it backwards puts the hit some multiple of the scale away from the finger, and at\n * scale 1 it would look correct -- which is why `browser-shot.mjs` runs at a size where the scale is\n * 1 and the click harness must not.\n *\n * `mousedown`, not `click`: the press highlight should appear under the finger, and a `click` only\n * arrives after release.\n *\n * `offsetX`/`offsetY` and NOT `clientX - getBoundingClientRect().left`, and the reason is NOT that\n * one is element-relative and the other is not: it is that `offsetX` and `at` are measured from the\n * SAME ORIGIN, the canvas's own box, so the expression has NO SCROLL TERM AT ALL to get wrong. The\n * web page really does scroll -- `paint` sizes the canvas to the drawing and\n * `web/static/index.html:10` is `overflow:auto` -- and the rect form would reach the same number\n * only because the rect moves with the scroll.\n *\n * Two things a reader will suspect, neither of which is a problem here. `devicePixelRatio`: the\n * backing store and the CSS box are 1:1 at any dpr, because `paint` assigns `canvas.width` from\n * `window.innerWidth`, which is already CSS pixels. A CSS `width`/`height` on the canvas WOULD break\n * this -- `offsetX` would be in CSS pixels of a stretched box and would need a\n * `canvas.width / rect.width` factor, as would the rect form -- and both pages style the canvas\n * `display:block` and nothing else. A `transform: scale()` is NOT that case: `offsetX` stays in the\n * element's own untransformed space, where the rect form does not, so there it is the better API\n * rather than an equally wrong one.\n *\n * Primary button only. `mousedown` fires for the right and middle buttons too, and a keypad that\n * sent `clear` or a PF key to a live host on a right-click -- while the context menu opened over\n * it -- would be a misfire the operator never asked for.\n */\n/**\n * A mouse position in the canvas's own space to a SCREEN cell, or `null` if it is not on one.\n *\n * ## THE ARITHMETIC IS `hitTestAt`'s INVERSE, OFFSET AND ALL\n *\n * `paint` multiplies the scale-1 draw list by `scale` and adds the centring offset `at`, so going\n * back subtracts `at` and divides by `scale` -- exactly what `hitTestAt` does for a keypad button.\n * The plan for this feature omitted the offset and divided only by the scale, which is correct ONLY\n * when the drawing exactly fills the viewport: `center` clamps at 0, so in a window wider than the\n * screen every selected cell would be shifted left by the margin, and at scale 1 in a tight window\n * it would look perfect -- the same trap `browser-shot.mjs` runs at scale 1 to expose.\n *\n * `offsetX`/`offsetY` for the reason the `mousedown` handler below documents at length: they and\n * the draw list's `at` are measured from the SAME origin, the canvas's own box, so the expression\n * has no scroll term at all to get wrong.\n *\n * ## THE BOUNDS COME FROM THE DRAW LIST'S REGIONS, NOT FROM A ROW COUNT\n *\n * A selection must cover the 3270 SCREEN and not the OIA or the keypad -- copying the operator\n * status line as if it were host data would be a lie about what is on the screen. The screen's\n * bottom edge is therefore whichever region starts first below it (`oia.y`, else `keypad.y`, else\n * the drawing's full height), which is how `drawlist.ts` computes them in the first place; deriving\n * it from `list.height` alone would include both. Columns come from `list.width`, which IS\n * `cols * cellWidth` by construction.\n */\nfunction cellAt(offsetX: number, offsetY: number, list: DrawList): CellAddr | null {\n if (atlas === undefined) return null;\n const g = atlas.geometry;\n const within = { width: window.innerWidth, height: window.innerHeight };\n const scale = bestScale(list, within);\n const at = center(list, within, scale);\n const x = (offsetX - at.x) / scale;\n const y = (offsetY - at.y) / scale;\n if (x < 0 || y < 0) return null;\n // THE OIA IS STILL EXCLUDED, which is why this is not simply `list.height`: copying the\n // operator status line as if it were host data would be a lie about what is on the screen.\n // The keypad was a THIRD term here until 2026-10-06, so a selection drag could not run onto\n // it; it left the canvas, and a `?? list.keypad?.y` that can never fire is a reader trap.\n // `select.mjs` is what proves this simplification did not move the bound.\n const screenBottom = list.oia?.y ?? list.height;\n const cols = Math.floor(list.width / g.cellWidth);\n const rows = Math.floor(screenBottom / g.cellHeight);\n const col = Math.floor(x / g.cellWidth);\n const row = Math.floor(y / g.cellHeight);\n if (col >= cols || row >= rows) return null;\n return { row, col };\n}\n\ncanvas.addEventListener('mousedown', (e) => {\n if (e.button !== 0) return;\n // Read the module state ONCE: everything below must agree about which frame was clicked.\n const list = last;\n // A COPY NOTICE DOES NOT BLOCK A PRESS, A REAL ERROR DOES: the notice changed nothing on the\n // screen, so a press on it dismisses it and starts a drag as normal (`paint(list)` below, or the\n // returns, which leave the notice up only for a press off the screen region); a real error\n // still refuses, so the click cannot act on a session that is failing or gone.\n if ((errored && !noticeOnly) || list === undefined) return;\n // A SELECTION DRAG STARTS WHEN THE PRESS IS ON THE SCREEN. The keypad gets first refusal below,\n // so a press on a button is a button press and not the start of a selection -- and this cannot\n // be a separate listener for exactly that reason: two listeners would both act on one press.\n //\n // A PRESS OFF THE SCREEN REGION NOW DOES NOTHING, where it used to fall through to a keypad\n // hit test. `cellAt` returns null for the OIA row and for anything past the last column, and\n // there is no longer a second claimant for those pixels: the keypad left the canvas on\n // 2026-10-06. Nothing to send, so nothing to do.\n const cell = cellAt(e.offsetX, e.offsetY, list);\n if (cell === null) return;\n // A NEW PRESS ON THE SCREEN REPLACES THE SELECTION, so its prefetched text dies with it. After\n // the `cell === null` return, matching the selection: a press on the OIA leaves both alone.\n copyCache.invalidate();\n anchor = cell;\n focus = cell;\n dragging = true;\n // CLIENT COORDINATES FOR THE THRESHOLD, NOT `offsetX`, and the asymmetry with `cellAt` above is\n // deliberate. `offsetX` is relative to the EVENT'S TARGET, and the `mousemove` that compares\n // against this point is on the WINDOW: over the canvas its target is the canvas, but over an\n // overlay or the page margin it is that element, and the two `offsetX`s would be from different\n // origins. `clientX`/`clientY` are viewport-relative whatever the target, so the press and every\n // move are measured from one origin. (A scroll mid-drag would shift them by the scroll; a drag of\n // a few pixels does not scroll the page, and a drag long enough to is past the threshold anyway.)\n pressAt = { x: e.clientX, y: e.clientY };\n moved = false;\n errored = false;\n noticeOnly = false;\n paint(list);\n});\n\n/**\n * End a drag.\n *\n * `mouseup` ON THE WINDOW covers a release outside the canvas, and outside the window too:\n * Chromium takes native mouse capture on `mousedown`, so the release is still delivered here.\n * `blur` covers what capture cannot -- focus lost mid-drag by Alt+Tab, an X grab, a lock screen or\n * a native context menu. Without both, `dragging` would latch on and every later `mousemove` would\n * keep extending a selection with no button held.\n *\n * THE SELECTION ITSELF SURVIVES, deliberately: the operator presses Copy after releasing the\n * mouse, so clearing it here would make every copy impossible. Only the drag ends.\n *\n * IT USED TO CLEAR A KEYPAD PRESS HIGHLIGHT TOO, which is why it was called `release` and why it\n * returned early when no button was held -- a shape that once made `dragging = false` dead code\n * when it was appended after that guard. The keypad left the canvas on 2026-10-06 and the guard\n * went with it, leaving the one assignment this function now exists for.\n */\nfunction release(): void {\n dragging = false;\n}\n\n/**\n * A drag that ENDED ON A REAL RECTANGLE asks the gateway for its text, so a later copy event can\n * set it synchronously. 2026-11-02. Only on `mouseup`: `blur` also releases, but nobody selected\n * anything by switching windows, and a drag cut short that way just leaves the cache empty -- every\n * gesture then falls back. A plain click (a press that never passed the drag threshold) asks for\n * nothing; a drag within one cell asks for that one character.\n *\n * RETURNS AT ONCE WHERE THE BRIDGE HAS NO `fetchSelection`, which is the Electron GUI: its preload\n * supplies only the four required functions, so there nothing is ever asked for or cached.\n *\n * THE `catch` IS LOAD-BEARING. `fetchSelection` rejects when the socket closes and when a newer\n * request supersedes it, and an unhandled rejection is this repo's most-recorded silent failure.\n * A failed prefetch leaves the cache empty, and every gesture then falls back -- so nothing is\n * painted here.\n */\nfunction prefetchSelection(): void {\n const fetchSelection = window.tn3270.fetchSelection;\n // NOT WHILE A REAL ERROR IS UP: `onError` cleared the selection, and nothing on screen to copy.\n // A copy NOTICE does not block it -- it keeps the selection, and the mousedown that began this\n // drag has already dismissed it.\n if ((errored && !noticeOnly) || fetchSelection === undefined) return;\n const rect = selectedRect();\n if (rect === null) return;\n const gen = copyCache.begin(rect);\n fetchSelection.call(window.tn3270, rect)\n .then((text) => { copyCache.accept(gen, text); })\n .catch(() => { /* the cache stays empty; every gesture falls back */ });\n}\n\n// `dragging` IS READ BEFORE `release()` CLEARS IT, so only a release that ends a drag THIS PAGE\n// started prefetches. A press off the screen region, on an overlay or outside the window never set\n// it, and a stray `mouseup` after a `blur` already ended the drag finds it false.\nwindow.addEventListener('mouseup', () => {\n const ended = dragging;\n release();\n if (ended) prefetchSelection();\n});\nwindow.addEventListener('blur', release);\n\n/**\n * Extend the selection while the button is held.\n *\n * ON THE WINDOW AND NOT THE CANVAS, matching `mouseup` above: Chromium takes native mouse capture\n * on `mousedown`, so a drag that leaves the canvas still reports here, and a selection that stopped\n * growing at the canvas edge would be a surprise. `cellAt` returns null off the screen region, and\n * the null is IGNORED rather than ending the drag -- dragging out over the keypad and back must\n * keep the selection the operator was building.\n */\nwindow.addEventListener('mousemove', (e) => {\n if (!dragging) return;\n const list = last;\n if (list === undefined) return;\n // THE THRESHOLD FIRST, BEFORE EITHER RETURN BELOW, because both would skip it: the same-cell\n // return is exactly the case a one-character drag lives in, and the off-screen one is a drag\n // that has plainly moved. Latched -- moving back to the press point does not undo a drag, as it\n // does not in a browser's own text selection. `clientX`/`clientY`, matching `pressAt`.\n let crossed = false;\n if (!moved && pressAt !== null && movedPastThreshold(pressAt, { x: e.clientX, y: e.clientY })) {\n moved = true;\n crossed = true;\n }\n const cell = cellAt(e.offsetX, e.offsetY, list);\n if (cell === null) { if (crossed) paint(list); return; }\n // Repaint ONLY when the cell actually changed -- or when this move is the one that turned the\n // press into a drag, which is what makes a one-cell selection's highlight appear. A mousemove\n // fires per pixel, and repainting the whole canvas 14 times per cell crossed is work the\n // highlight cannot show.\n if (!crossed && focus !== null && focus.row === cell.row && focus.col === cell.col) return;\n focus = cell;\n paint(list);\n});\n\nwindow.addEventListener('resize', () => { if (last !== undefined) paint(last); });\n\n/**\n * Copy the current selection, or do nothing and say so.\n *\n * THE ONLY PLACE IN THIS FILE THAT BUILDS A COPY ACTION'S RECTANGLE, and it must stay that way.\n * There are now THREE callers -- the `window.__tn3270Copy` global below, `copyByChord` (the keydown\n * listener's `requestCopy` arm, as its fallback) and `forwardCopy`'s `copy` listener (also as its\n * fallback) -- and the empty-selection rule, the normalization and the action's shape are one\n * decision each. (`prefetchSelection` and `cachedSelection` normalize too, but to key the cache,\n * not to send a copy.) A second copy of this body is a second place for them to drift.\n *\n * NAMED AND HOISTED rather than assigned to a `const`: the keydown listener's arm sits ~200 lines\n * ABOVE this declaration, which a `const` arrow would put in its temporal dead zone at module\n * load. Nothing calls it at load today, so the arrow would also work -- it is declared this way so\n * that it keeps working, matching `clearSelection` above.\n *\n * IT SENDS THE RECTANGLE AND NOT THE TEXT, which is forced rather than chosen: this file only ever\n * sees a `DrawList`, whose cells carry a CG-order atlas glyph and NO character. Main holds\n * `resolve(snapshot)`, where `text` and `hidden` both live, so main extracts.\n *\n * RETURNS A BOOLEAN so the harness and the menu can tell \"nothing was selected\" from \"copied\",\n * rather than both looking like silence. Main's Copy handler calls this through\n * `executeJavaScript`; a false means the operator pressed Copy with no selection, which is not an\n * error worth a message.\n *\n * IT DOES NOT CLEAR THE SELECTION, which is the contract the keydown arm depends on -- see that\n * arm's comment. A copy leaves the screen untouched, so the highlight it just read is still an\n * honest description of what is on it, and an operator who copies twice gets the same text twice.\n *\n * THAT LAST SENTENCE WAS MACHINE-CHECKED FROM 2026-10-27 UNTIL THE PREFETCH LANDED, 2026-11-02:\n * `web/scripts/browser-clicks.mjs`'s `webCopy` presses the real chord TWICE over two different\n * clipboard sentinels with no second drag. MEASURED then on the mutant that adds a\n * `clearSelection()` AFTER this call inside the arm: all 2614 unit tests passed and `webCopy` was\n * the only failure. IT NO LONGER EXERCISES THIS FUNCTION: with the text prefetched, both presses\n * take `copyByChord`'s synchronous path and never call `copySelection()`, so what `webCopy` now\n * checks is that the CACHED path keeps the selection. `webCopyFallback` covers THIS function: it\n * holds the prefetch so the cache is empty and presses the chord twice with no re-drag. MEASURED\n * 2026-11-03 on a `clearSelection()` added after the `sendAction` below: `webCopyFallback` fails\n * on its second press, and it is the only check that does. NO UNIT TEST CAN REACH IT -- this file touches `window` at module load, so nothing\n * imports it -- and `test/renderer-imports.test.ts`'s order pin does not cover it either.\n */\nfunction copySelection(): boolean {\n const rect = selectedRect();\n if (rect === null) return false;\n window.tn3270.sendAction({ kind: 'copy', rect });\n return true;\n}\n\n/** The prefetched text for the CURRENT selection, or `undefined`. */\nfunction cachedSelection(): string | undefined {\n const rect = selectedRect();\n return rect === null ? undefined : copyCache.take(rect);\n}\n\n/**\n * `Ctrl-Shift-C` / `Cmd-Shift-C`. 2026-11-02.\n *\n * WITH TEXT CACHED, raise the browser's own `copy` event with `execCommand('copy')`, inside this\n * keystroke's activation, so `forwardCopy` can set the text synchronously -- which is what makes\n * the chord work at a plain-`http` LAN address. MEASURED in a non-secure Chromium page (Electron\n * 44): the event fires `isTrusted` and its data lands on the OS clipboard; without a user gesture\n * `execCommand('copy')` returns false and nothing happens. `execCommand` is deprecated but has no\n * replacement for raising a copy event from script.\n *\n * FALLS BACK to `copySelection()` -- the async path -- when nothing is cached, when `execCommand`\n * throws or is ignored, or when the event never reached `forwardCopy`'s listener. So a browser\n * that ignores the call still copies, exactly as it did before this. THE SIGNAL IS `copyHandled`,\n * NOT `execCommand`'s RETURN VALUE: that says whether a copy command ran, not whether our listener\n * took it.\n *\n * ONLY WITH TEXT CACHED, and that is not an optimization. The cache is filled only where the bridge\n * has `fetchSelection` (the web gateway), so in the Electron GUI this never calls `execCommand` at\n * all, and the page's default copy of an empty DOM selection never runs there.\n *\n * THE RESIDUAL RISK, NOT YET MEASURED. `copyHandled` says the `copy` EVENT reached our listener,\n * not that the browser then honoured the data it set. A browser that dispatches the event but\n * refuses the clipboard write -- Firefox and Safari are unmeasured -- leaves `copyHandled` true, so\n * this returns without falling back and that copy is lost SILENTLY. The live pass is what will\n * tell; if it happens, the fix belongs here, not in `forwardCopy`.\n */\nfunction copyByChord(): void {\n if (cachedSelection() !== undefined) {\n copyHandled = false;\n try { document.execCommand('copy'); } catch { /* fall through to the fallback */ }\n if (copyHandled) return;\n }\n copySelection();\n}\n\n/**\n * The global, unchanged in NAME, SIGNATURE and BEHAVIOR.\n *\n * A `window` GLOBAL, NOT A FIFTH BRIDGE FUNCTION -- `bridgecore.ts` records that a fifth function\n * means the renderer has stopped being shared between Electron and the browser, and both hosts get\n * a global for free because both load this file. Same mechanism as `__tn3270ButtonCenter` below.\n *\n * TWO CALL SITES DEPEND ON IT, BOTH IN ELECTRON, and it is a one-line delegation precisely so\n * that the extraction above cannot change what either observes. CITED BY NAME AND NEVER BY LINE,\n * for the reason `test/keys.test.ts` gives where it does the same (\"Cited by its words and not by\n * a line number, which the new branch above would have shifted anyway\") -- and the reason is not\n * hypothetical here, it is this file's own history: these two were written as `gui/src/main.ts:1020`\n * and `:1747` in `9a6e53e`, and `4d56edb` -- the VERY NEXT COMMIT in the same series -- inserted 41\n * lines above both and silently made them `:1038` and `:1861`. A citation that cannot survive its\n * own feature branch should not be written as a number.\n *\n * - `gui/src/main.ts`'s menu Copy handler, the `onCopy:` arm of its `buildMenuTemplate` call,\n * which invokes this through `executeJavaScript('window.__tn3270Copy && window.__tn3270Copy()')`.\n * - `gui/src/main.ts`'s `TN3270_GUI_SELECT` test seam, the `const copied = await\n * win.webContents.executeJavaScript('window.__tn3270Copy()')` line -- the route\n * `gui/scripts/select.mjs` drives, which is NOT the menu.\n *\n * IT WAS THREE UNTIL 2026-10-27, and the third leaving is the point rather than a tidy-up.\n * `web/scripts/browser-clicks.mjs` used to evaluate this global, because the browser had no copy\n * gesture to drive; its `webCopy` check now presses the real `Ctrl-Shift-C` instead, so it covers\n * the keydown arm above and `actionForKey`'s branch as well as this body.\n *\n * SO THIS GLOBAL'S ONLY HARNESS IS NOW `select.mjs`, which is adequate but is a narrower claim\n * than it used to be: nothing in a BROWSER exercises it any more. That is correct -- a browser has\n * no Edit menu to reach it from -- but it means a change to this line is covered by the Electron\n * harness alone.\n */\n(window as unknown as { __tn3270Copy: () => boolean }).__tn3270Copy = (): boolean => copySelection();\n\n/**\n * TEST SEAM: the viewport-pixel center of a SCREEN CELL, or `null`.\n *\n * The twin of `__tn3270ButtonCenter` below and it exists for the same reason: `select.mjs` drives a\n * real drag through Chromium's input pipeline, and it must name CELLS rather than pixels, because a\n * pixel list in the harness would be a second copy of the geometry that would pass while the\n * geometry was wrong. Returning coordinates rather than setting `anchor`/`focus` directly is what\n * keeps `mousedown`, `mousemove`, `cellAt` and the highlight all under test.\n *\n * THE INVERSE OF `cellAt`, so the two must agree: this adds the offset and multiplies by the\n * scale where `cellAt` subtracts and divides. A harness built on a different expression would be\n * testing its own arithmetic.\n */\n(window as unknown as {\n __tn3270CellCenter: (row: number, col: number) => { x: number; y: number } | null;\n}).__tn3270CellCenter = (row, col) => {\n const list = last;\n if (list === undefined || atlas === undefined) return null;\n const g = atlas.geometry;\n const within = { width: window.innerWidth, height: window.innerHeight };\n const scale = bestScale(list, within);\n const at = center(list, within, scale);\n // THE OIA IS STILL EXCLUDED, which is why this is not simply `list.height`: copying the\n // operator status line as if it were host data would be a lie about what is on the screen.\n // The keypad was a THIRD term here until 2026-10-06, so a selection drag could not run onto\n // it; it left the canvas, and a `?? list.keypad?.y` that can never fire is a reader trap.\n // `select.mjs` is what proves this simplification did not move the bound.\n const screenBottom = list.oia?.y ?? list.height;\n if (row < 0 || col < 0) return null;\n if (col >= Math.floor(list.width / g.cellWidth)) return null;\n if (row >= Math.floor(screenBottom / g.cellHeight)) return null;\n return {\n x: at.x + (col + 0.5) * g.cellWidth * scale,\n y: at.y + (row + 0.5) * g.cellHeight * scale,\n };\n};\n\n/*\n `__tn3270ButtonCenter` WAS HERE AND IS GONE, 2026-10-06.\n\n It returned the viewport-pixel centre of a named keypad button so `clicks.mjs` could click it\n without knowing the layout, the scale or the offset -- and it was the model for\n `__tn3270CellCenter` above, which survives it. Both front ends' keypads are real HTML controls\n now, so `clicks.mjs` queries `button[data-label=...]` and calls the element's own `click()`:\n the browser does the hit testing, and there are no coordinates to compute or get wrong.\n\n WORTH KEEPING FROM ITS DOCSTRING, because the properties outlived the function: a test seam\n should return COORDINATES rather than fire the action, so the real input pipeline stays under\n test; a `window` global is NOT a fifth bridge function, which is what let both hosts have it for\n free; and viewport pixels only work because both pages put the canvas box at the viewport origin\n (`margin:0`, `display:block`) -- the constraint `ui.css` had to be scoped around when the web\n overlay landed, having shifted the whole screen 15px by styling `body`.\n*/\n\n/** Break a message at word boundaries so an error is readable rather than clipped. */\nfunction wrap(text: string, cols: number): string[] {\n const out: string[] = [];\n let line = '';\n for (const word of text.split(/\\s+/)) {\n if (line !== '' && `${line} ${word}`.length > cols) { out.push(line); line = word; }\n else line = line === '' ? word : `${line} ${word}`;\n }\n if (line !== '') out.push(line);\n return out;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["ctx", "atlas", "anchor", "focus"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<!-- Generated by packages/gui/scripts/bundle.mjs from packages/gui/transfer.html; edit that. -->
|
|
3
|
+
<html><head><meta charset="utf-8"><title>File Transfer</title>
|
|
4
|
+
<link rel="stylesheet" href="./ui.css">
|
|
5
|
+
</head>
|
|
6
|
+
<body class="ui-surface">
|
|
7
|
+
<h1>File Transfer (IND$FILE)</h1>
|
|
8
|
+
<div id="fields"></div>
|
|
9
|
+
<div id="status"></div>
|
|
10
|
+
<div id="buttons">
|
|
11
|
+
<button id="cancel" disabled>Cancel</button>
|
|
12
|
+
<button id="start">Start</button>
|
|
13
|
+
</div>
|
|
14
|
+
<script type="module" src="./transferBoot.js"></script>
|
|
15
|
+
</body></html>
|