@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Thornton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # @tn3270/gui — the Electron client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An Electron TN3270 emulator with a canvas renderer that blits glyphs from an atlas baked
4
+ out of x3270's own 3270 bitmap font, at integer scale with antialiasing off.
5
+
6
+ ```sh
7
+ npm install -g @tn3270/gui
8
+ ```
9
+
10
+ **This needs Electron's binary, roughly 100 MB.** Electron 44 fetches it the first time
11
+ it is used rather than during `npm install`, so your first `tn3270-gui` prints "Downloading
12
+ Electron binary..." and takes a while: that is the download, not a hang. If it fails (no
13
+ network, a proxy, a wrong `ELECTRON_MIRROR`), `tn3270-gui` says so; fix the network and run
14
+ it again. There is no slimmer, native-only build yet; that is planned as a follow-up.
15
+
16
+ ```sh
17
+ tn3270-gui -insecure -model 3278-4-E HOST:PORT
18
+ ```
19
+
20
+ It takes the TUI's flags unchanged (`-model`, `--terminal-type`, `-oversize`, `-tn3270e
21
+ on|off`, the TLS set) because all three interactive front ends parse them with the same
22
+ code. See the root README's *Using the GUI* section
23
+ (https://github.com/athornton/tn3270#readme) for keys, the keypad, the keyboard window
24
+ and copy/paste.
25
+
26
+ Electron's own switches (`--no-sandbox`, `--disable-gpu`, ...) can be given on the same
27
+ command line; they go to Chromium, not to the TN3270 flag parser.
28
+
29
+ ### Linux: the sandbox
30
+
31
+ `tn3270-gui` leaves Chromium's sandbox ON. npm cannot install Electron's `chrome-sandbox`
32
+ helper setuid root, but Chromium does not need it where unprivileged user namespaces are
33
+ allowed (most distributions), and runs sandboxed. Where they are blocked — Ubuntu 23.10 and
34
+ later, through AppArmor, and some hardened kernels — Electron aborts with *"The SUID sandbox
35
+ helper binary was found, but is not configured correctly"*, and `tn3270-gui` prints two
36
+ ways out:
37
+
38
+ - as root, make the helper what Chromium expects (keeps the sandbox):
39
+ `chown root:root <path>/chrome-sandbox && chmod 4755 <path>/chrome-sandbox` — the path is
40
+ in the message, under the global `node_modules/@tn3270/gui/node_modules/electron/dist/`;
41
+ - or run without the sandbox: `tn3270-gui --no-sandbox ...`. In normal use the app loads
42
+ only its own bundled pages, but a sandbox is defence in depth against a
43
+ renderer bug, and this switches it off. The launcher never does this for you.
44
+
45
+ ### Displays and signals
46
+
47
+ It needs a display: on a headless Linux box, run it under `xvfb-run`. Without a GPU, add
48
+ `--disable-gpu`. SIGINT, SIGTERM and SIGHUP sent to `tn3270-gui` are forwarded to Electron,
49
+ and Electron is killed if the launcher exits first; a SIGKILL cannot be forwarded by
50
+ anything, so kill Electron's own PID if you need that. On Windows (not yet tested), stopping
51
+ the launcher terminates Electron outright.
package/app/BUNDLED ADDED
@@ -0,0 +1 @@
1
+ Marker read by src/appRoot.ts: this directory is the flat esbuild layout. Contents unused.
package/app/atlas.bin ADDED
Binary file
package/app/atlas.json ADDED
@@ -0,0 +1 @@
1
+ {"cellWidth":9,"cellHeight":14,"cols":431,"index":{"0":0,"1":1,"2":2,"3":3,"4":4,"5":5,"6":6,"7":7,"8":8,"9":9,"10":10,"11":11,"12":12,"13":13,"14":14,"15":15,"16":16,"17":17,"18":18,"19":19,"20":20,"21":21,"22":22,"23":23,"24":24,"25":25,"26":26,"27":27,"28":28,"29":29,"30":30,"31":31,"32":32,"33":33,"34":34,"35":35,"36":36,"37":37,"38":38,"39":39,"40":40,"41":41,"42":42,"43":43,"44":44,"45":45,"46":46,"47":47,"48":48,"49":49,"50":50,"51":51,"52":52,"53":53,"54":54,"55":55,"56":56,"57":57,"58":58,"59":59,"60":60,"61":61,"62":62,"63":63,"64":64,"65":65,"66":66,"67":67,"68":68,"69":69,"70":70,"71":71,"72":72,"73":73,"74":74,"75":75,"76":76,"77":77,"78":78,"79":79,"80":80,"81":81,"82":82,"83":83,"84":84,"85":85,"86":86,"87":87,"88":88,"89":89,"90":90,"91":91,"92":92,"93":93,"94":94,"95":95,"96":96,"97":97,"98":98,"99":99,"100":100,"101":101,"102":102,"103":103,"104":104,"105":105,"106":106,"107":107,"108":108,"109":109,"110":110,"111":111,"112":112,"113":113,"114":114,"115":115,"116":116,"117":117,"118":118,"119":119,"120":120,"121":121,"122":122,"123":123,"124":124,"125":125,"126":126,"127":127,"128":128,"129":129,"130":130,"131":131,"132":132,"133":133,"134":134,"135":135,"136":136,"137":137,"138":138,"139":139,"140":140,"141":141,"142":142,"143":143,"144":144,"145":145,"146":146,"147":147,"148":148,"149":149,"150":150,"151":151,"152":152,"153":153,"154":154,"155":155,"156":156,"157":157,"158":158,"159":159,"160":160,"161":161,"162":162,"163":163,"164":164,"165":165,"166":166,"167":167,"168":168,"169":169,"170":170,"171":171,"172":172,"173":173,"174":174,"175":175,"176":176,"177":177,"178":178,"179":179,"180":180,"181":181,"182":182,"183":183,"184":184,"185":185,"186":186,"187":187,"188":188,"189":189,"190":190,"191":191,"192":192,"193":193,"194":194,"195":195,"196":196,"197":197,"198":198,"199":199,"200":200,"201":201,"202":202,"203":203,"204":204,"205":205,"206":206,"207":207,"208":208,"209":209,"210":210,"211":211,"212":212,"213":213,"214":214,"215":215,"216":216,"217":217,"218":218,"219":219,"220":220,"221":221,"222":222,"223":223,"224":224,"225":225,"226":226,"227":227,"228":228,"229":229,"230":230,"231":231,"232":232,"233":233,"234":234,"235":235,"236":236,"237":237,"238":238,"239":239,"240":240,"241":241,"242":242,"243":243,"244":244,"245":245,"246":246,"247":247,"248":248,"249":249,"250":250,"251":251,"252":252,"253":253,"254":254,"255":255,"257":256,"263":257,"266":258,"267":259,"270":260,"271":261,"272":262,"277":263,"284":264,"285":265,"286":266,"287":267,"288":268,"289":269,"290":270,"291":271,"292":272,"293":273,"294":274,"295":275,"296":276,"297":277,"298":278,"311":279,"312":280,"313":281,"314":282,"315":283,"316":284,"318":285,"319":286,"320":287,"321":288,"322":289,"323":290,"324":291,"325":292,"326":293,"327":294,"328":295,"329":296,"330":297,"331":298,"332":299,"334":300,"335":301,"336":302,"337":303,"338":304,"339":305,"340":306,"341":307,"342":308,"343":309,"344":310,"345":311,"346":312,"347":313,"348":314,"349":315,"350":316,"351":317,"352":318,"353":319,"354":320,"355":321,"356":322,"357":323,"358":324,"359":325,"360":326,"362":327,"363":328,"364":329,"365":330,"366":331,"367":332,"368":333,"369":334,"370":335,"371":336,"372":337,"373":338,"374":339,"375":340,"376":341,"377":342,"378":343,"379":344,"380":345,"381":346,"382":347,"383":348,"384":349,"385":350,"386":351,"387":352,"388":353,"393":354,"394":355,"395":356,"396":357,"397":358,"402":359,"403":360,"404":361,"410":362,"411":363,"412":364,"413":365,"416":366,"417":367,"418":368,"419":369,"420":370,"421":371,"422":372,"423":373,"425":374,"426":375,"427":376,"428":377,"429":378,"430":379,"431":380,"432":381,"434":382,"435":383,"436":384,"437":385,"438":386,"439":387,"442":388,"443":389,"444":390,"445":391,"464":392,"465":393,"470":394,"471":395,"473":396,"503":397,"505":398,"512":399,"513":400,"514":401,"515":402,"516":403,"517":404,"518":405,"519":406,"520":407,"521":408,"522":409,"523":410,"524":411,"525":412,"526":413,"527":414,"528":415,"529":416,"530":417,"531":418,"532":419,"533":420,"534":421,"535":422,"536":423,"537":424,"538":425,"539":426,"540":427,"541":428,"542":429,"543":430}}
@@ -0,0 +1,27 @@
1
+ <!DOCTYPE html>
2
+ <!-- Generated by packages/gui/scripts/bundle.mjs from packages/gui/connect.html; edit that. -->
3
+ <html><head><meta charset="utf-8"><title>Connect</title>
4
+ <link rel="stylesheet" href="./ui.css">
5
+ </head>
6
+ <body class="ui-surface">
7
+ <form id="connect-form" autocomplete="off">
8
+ <div class="row">
9
+ <label for="host">Host</label>
10
+ <input id="host" type="text" placeholder="host:port" spellcheck="false">
11
+ </div>
12
+ <div class="row">
13
+ <label for="model">Model</label>
14
+ <select id="model"></select>
15
+ </div>
16
+ <div class="row">
17
+ <label for="tls">TLS</label>
18
+ <input id="tls" type="checkbox" checked>
19
+ </div>
20
+ <div id="connect-error" role="alert"></div>
21
+ <div id="buttons">
22
+ <button id="quit" type="button">Quit</button>
23
+ <button id="connect" type="submit" disabled>Connect</button>
24
+ </div>
25
+ </form>
26
+ <script type="module" src="./connectBoot.js"></script>
27
+ </body></html>
@@ -0,0 +1,64 @@
1
+ // packages/gui/dist/connectBoot.js
2
+ var bridge = window.tn3270connect;
3
+ var form = document.querySelector("#connect-form");
4
+ var host = document.querySelector("#host");
5
+ var model = document.querySelector("#model");
6
+ var tls = document.querySelector("#tls");
7
+ var connect = document.querySelector("#connect");
8
+ var quit = document.querySelector("#quit");
9
+ var error = document.querySelector("#connect-error");
10
+ if (form === null || host === null || model === null || tls === null || connect === null || quit === null || error === null) {
11
+ throw new Error("connect.html is missing one of its controls");
12
+ }
13
+ if (bridge === void 0) {
14
+ throw new Error("connect window: the tn3270connect bridge is absent (preload failed to load?)");
15
+ }
16
+ var busy = true;
17
+ var setBusy = (b) => {
18
+ busy = b;
19
+ connect.disabled = b;
20
+ };
21
+ setBusy(true);
22
+ void bridge.models().then(({ models, default: chosen }) => {
23
+ for (const m of models) {
24
+ const o = document.createElement("option");
25
+ o.value = m;
26
+ o.textContent = m;
27
+ if (m === chosen)
28
+ o.selected = true;
29
+ model.append(o);
30
+ }
31
+ setBusy(false);
32
+ }, (err) => {
33
+ error.textContent = `could not list the models: ${String(err)}`;
34
+ });
35
+ form.addEventListener("submit", (e) => {
36
+ e.preventDefault();
37
+ if (busy)
38
+ return;
39
+ setBusy(true);
40
+ error.textContent = "";
41
+ bridge.submit({ host: host.value, model: model.value, tls: tls.checked }).then((r) => {
42
+ if (r.ok === true)
43
+ return;
44
+ error.textContent = r.error ?? "refused, with no reason given";
45
+ setBusy(false);
46
+ host.focus();
47
+ host.select();
48
+ }, (err) => {
49
+ error.textContent = String(err);
50
+ setBusy(false);
51
+ host.focus();
52
+ });
53
+ });
54
+ quit.addEventListener("click", () => {
55
+ bridge.quit();
56
+ });
57
+ document.addEventListener("keydown", (e) => {
58
+ if (e.key === "Escape") {
59
+ e.preventDefault();
60
+ bridge.quit();
61
+ }
62
+ });
63
+ host.focus();
64
+ //# sourceMappingURL=connectBoot.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/connectBoot.ts"],
4
+ "sourcesContent": ["/**\n * The startup Connect dialog's page: real elements in, the preload's bridge out.\n *\n * DELIBERATELY THIN, the same split as `transferBoot.ts`: there is no `document` under vitest, so\n * every decision about what the form MEANS lives in main -- `submitConnect` in `connectDialog.ts`,\n * which runs the real `parseGuiArgs`. This file only reads controls, shows the answer and moves\n * focus. It imports NOTHING, so `connect.html` needs no import map and the bundle no graph.\n */\ndeclare global {\n interface Window {\n tn3270connect?: {\n models(): Promise<{ models: readonly string[]; default: string }>;\n submit(form: { host: string; model: string; tls: boolean }): Promise<{ ok?: true; error?: string }>;\n quit(): void;\n };\n }\n}\n\nconst bridge = window.tn3270connect;\nconst form = document.querySelector<HTMLFormElement>('#connect-form');\nconst host = document.querySelector<HTMLInputElement>('#host');\nconst model = document.querySelector<HTMLSelectElement>('#model');\nconst tls = document.querySelector<HTMLInputElement>('#tls');\nconst connect = document.querySelector<HTMLButtonElement>('#connect');\nconst quit = document.querySelector<HTMLButtonElement>('#quit');\nconst error = document.querySelector<HTMLDivElement>('#connect-error');\nif (form === null || host === null || model === null || tls === null || connect === null\n || quit === null || error === null) {\n throw new Error('connect.html is missing one of its controls');\n}\nif (bridge === undefined) {\n throw new Error('connect window: the tn3270connect bridge is absent (preload failed to load?)');\n}\n\n/**\n * DISABLED UNTIL THE MODELS ARRIVE, and while a submit is in flight. An empty dropdown would submit\n * `model: ''`, and two submits in flight would race main's one-time session start.\n */\nlet busy = true;\nconst setBusy = (b: boolean): void => {\n busy = b;\n connect.disabled = b;\n};\nsetBusy(true);\n\nvoid bridge.models().then(({ models, default: chosen }) => {\n for (const m of models) {\n const o = document.createElement('option');\n o.value = m;\n o.textContent = m;\n if (m === chosen) o.selected = true;\n model.append(o);\n }\n setBusy(false);\n}, (err: unknown) => {\n error.textContent = `could not list the models: ${String(err)}`;\n});\n\n/**\n * ENTER IS THE FORM'S OWN SUBMIT: Connect is the form's `type=\"submit\"` button, so Enter in the\n * Host field submits through this listener exactly as a click does -- one path, not two.\n */\nform.addEventListener('submit', (e) => {\n e.preventDefault();\n // ONE SUBMIT IN FLIGHT. Main refuses a second start too; this keeps the page from asking.\n if (busy) return;\n setBusy(true);\n error.textContent = '';\n bridge.submit({ host: host.value, model: model.value, tls: tls.checked }).then((r) => {\n if (r.ok === true) return; // main closes this window and starts the session\n error.textContent = r.error ?? 'refused, with no reason given';\n setBusy(false);\n host.focus();\n host.select();\n }, (err: unknown) => {\n error.textContent = String(err);\n setBusy(false);\n host.focus();\n });\n});\n\nquit.addEventListener('click', () => { bridge.quit(); });\ndocument.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') {\n e.preventDefault();\n bridge.quit();\n }\n});\n\nhost.focus();\n\nexport {};\n"],
5
+ "mappings": ";AAkBA,IAAM,SAAS,OAAO;AACtB,IAAM,OAAO,SAAS,cAA+B,eAAe;AACpE,IAAM,OAAO,SAAS,cAAgC,OAAO;AAC7D,IAAM,QAAQ,SAAS,cAAiC,QAAQ;AAChE,IAAM,MAAM,SAAS,cAAgC,MAAM;AAC3D,IAAM,UAAU,SAAS,cAAiC,UAAU;AACpE,IAAM,OAAO,SAAS,cAAiC,OAAO;AAC9D,IAAM,QAAQ,SAAS,cAA8B,gBAAgB;AACrE,IAAI,SAAS,QAAQ,SAAS,QAAQ,UAAU,QAAQ,QAAQ,QAAQ,YAAY,QAC/E,SAAS,QAAQ,UAAU,MAAM;AACpC,QAAM,IAAI,MAAM,6CAA6C;AAC/D;AACA,IAAI,WAAW,QAAW;AACxB,QAAM,IAAI,MAAM,8EAA8E;AAChG;AAMA,IAAI,OAAO;AACX,IAAM,UAAU,CAAC,MAAoB;AACnC,SAAO;AACP,UAAQ,WAAW;AACrB;AACA,QAAQ,IAAI;AAEZ,KAAK,OAAO,OAAM,EAAG,KAAK,CAAC,EAAE,QAAQ,SAAS,OAAM,MAAM;AACxD,aAAW,KAAK,QAAQ;AACtB,UAAM,IAAI,SAAS,cAAc,QAAQ;AACzC,MAAE,QAAQ;AACV,MAAE,cAAc;AAChB,QAAI,MAAM;AAAQ,QAAE,WAAW;AAC/B,UAAM,OAAO,CAAC;EAChB;AACA,UAAQ,KAAK;AACf,GAAG,CAAC,QAAgB;AAClB,QAAM,cAAc,8BAA8B,OAAO,GAAG,CAAC;AAC/D,CAAC;AAMD,KAAK,iBAAiB,UAAU,CAAC,MAAK;AACpC,IAAE,eAAc;AAEhB,MAAI;AAAM;AACV,UAAQ,IAAI;AACZ,QAAM,cAAc;AACpB,SAAO,OAAO,EAAE,MAAM,KAAK,OAAO,OAAO,MAAM,OAAO,KAAK,IAAI,QAAO,CAAE,EAAE,KAAK,CAAC,MAAK;AACnF,QAAI,EAAE,OAAO;AAAM;AACnB,UAAM,cAAc,EAAE,SAAS;AAC/B,YAAQ,KAAK;AACb,SAAK,MAAK;AACV,SAAK,OAAM;EACb,GAAG,CAAC,QAAgB;AAClB,UAAM,cAAc,OAAO,GAAG;AAC9B,YAAQ,KAAK;AACb,SAAK,MAAK;EACZ,CAAC;AACH,CAAC;AAED,KAAK,iBAAiB,SAAS,MAAK;AAAG,SAAO,KAAI;AAAI,CAAC;AACvD,SAAS,iBAAiB,WAAW,CAAC,MAAK;AACzC,MAAI,EAAE,QAAQ,UAAU;AACtB,MAAE,eAAc;AAChB,WAAO,KAAI;EACb;AACF,CAAC;AAED,KAAK,MAAK;",
6
+ "names": []
7
+ }
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+
3
+ // packages/gui/dist/connectPreload.cjs
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ var electron_1 = require("electron");
6
+ electron_1.contextBridge.exposeInMainWorld("tn3270connect", {
7
+ /** The dropdown's models and its default, from `KNOWN_MODELS` in main. */
8
+ models: () => electron_1.ipcRenderer.invoke("connect:models"),
9
+ /**
10
+ * `invoke`, not `send`: a refusal is the ANSWER, shown in the dialog, which stays open. On
11
+ * `{ ok: true }` main closes this window and starts the session.
12
+ */
13
+ submit: (form) => electron_1.ipcRenderer.invoke("connect:submit", form),
14
+ /** Quit the application: there is nothing to show without a host. */
15
+ quit: () => {
16
+ electron_1.ipcRenderer.send("connect:quit");
17
+ }
18
+ });
19
+ //# sourceMappingURL=connectPreload.cjs.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/connectPreload.cts"],
4
+ "sourcesContent": ["import { contextBridge, ipcRenderer } from 'electron';\n\n/**\n * The startup Connect dialog's bridge. A `.cts` FILE ON PURPOSE, like every preload here: an ESM\n * preload fails with \"Cannot use import statement outside a module\" and the bridge then silently\n * never appears -- a dialog whose Connect does nothing. See `transferPreload.cts`.\n *\n * `tn3270connect` AND NOT AN ADDITION TO `tn3270`: `exposeInMainWorld` defines a NON-WRITABLE\n * property, and two bridges sharing a name is a measured failure recorded in `main.ts`.\n *\n * THREE FUNCTIONS, all of them main's: the model list (so the page imports nothing from core), the\n * submit (whose answer is the parser's own words), and quit.\n */\ncontextBridge.exposeInMainWorld('tn3270connect', {\n /** The dropdown's models and its default, from `KNOWN_MODELS` in main. */\n models: (): Promise<{ models: readonly string[]; default: string }> =>\n ipcRenderer.invoke('connect:models') as Promise<{ models: readonly string[]; default: string }>,\n /**\n * `invoke`, not `send`: a refusal is the ANSWER, shown in the dialog, which stays open. On\n * `{ ok: true }` main closes this window and starts the session.\n */\n submit: (form: { host: string; model: string; tls: boolean }): Promise<{ ok?: true; error?: string }> =>\n ipcRenderer.invoke('connect:submit', form) as Promise<{ ok?: true; error?: string }>,\n /** Quit the application: there is nothing to show without a host. */\n quit: (): void => { ipcRenderer.send('connect:quit'); },\n});\n"],
5
+ "mappings": ";;;;AAAA,IAAA,aAAA,QAAA,UAAA;AAaA,WAAA,cAAc,kBAAkB,iBAAiB;;EAE/C,QAAQ,MACN,WAAA,YAAY,OAAO,gBAAgB;;;;;EAKrC,QAAQ,CAAC,SACP,WAAA,YAAY,OAAO,kBAAkB,IAAI;;EAE3C,MAAM,MAAW;AAAG,eAAA,YAAY,KAAK,cAAc;EAAG;CACvD;",
6
+ "names": []
7
+ }
package/app/icon.png ADDED
Binary file
package/app/index.html ADDED
@@ -0,0 +1,6 @@
1
+ <!DOCTYPE html>
2
+ <!-- Generated by packages/gui/scripts/bundle.mjs from packages/gui/index.html; edit that. -->
3
+ <html><head><meta charset="utf-8"><title>tn3270</title>
4
+ <style>html,body{margin:0;background:#000;overflow:hidden}canvas{display:block}</style>
5
+ </head><body><canvas id="screen"></canvas>
6
+ <script type="module" src="./renderer.js"></script></body></html>
@@ -0,0 +1,10 @@
1
+ <!DOCTYPE html>
2
+ <!-- Generated by packages/gui/scripts/bundle.mjs from packages/gui/keyboard.html; edit that. -->
3
+ <html><head><meta charset="utf-8"><title>Keyboard</title>
4
+ <link rel="stylesheet" href="./ui.css">
5
+ </head>
6
+ <body class="ui-surface">
7
+ <h1>Keyboard</h1>
8
+ <div id="keyboard-root" class="keyboard-surface"></div>
9
+ <script type="module" src="./keyboardBoot.js"></script>
10
+ </body></html>