@cplieger/web-terminal-ui 1.0.0 → 2.0.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/README.md CHANGED
@@ -1,14 +1,20 @@
1
1
  # @cplieger/web-terminal-ui
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@cplieger/web-terminal-ui)](https://www.npmjs.com/package/@cplieger/web-terminal-ui)
4
+ [![JSR](https://jsr.io/badges/@cplieger/web-terminal-ui)](https://jsr.io/@cplieger/web-terminal-ui)
5
+ [![Test coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/web-terminal-ui/badges/coverage.json)](https://github.com/cplieger/web-terminal-ui/actions/workflows/coverage.yml)
6
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13431/badge)](https://www.bestpractices.dev/projects/13431)
7
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cplieger/web-terminal-ui/badge)](https://scorecard.dev/viewer/?uri=github.com/cplieger/web-terminal-ui)
8
+
3
9
  The reference touch-first browser UI for
4
10
  [`@cplieger/web-terminal-engine`](https://github.com/cplieger/web-terminal-engine) — the part
5
11
  that turns the engine's render/scroll/connection/keyboard modules into a
6
12
  usable terminal on a phone as well as a desktop.
7
13
 
8
- One `mount()` call wires the engine to a small HTML scaffold and owns
9
- everything above the raw terminal:
14
+ One `mount(root)` call builds the entire terminal UI inside a single container
15
+ element you provide and owns everything above the raw terminal:
10
16
 
11
- - a **display-only** `#term-output` (native text selection survives redraws)
17
+ - a **display-only** terminal output (native text selection survives redraws)
12
18
  - a hidden `<textarea>` that owns the keyboard, the local typing buffer, and IME
13
19
  - a **mobile key toolbar** (Tab / Esc / arrows / Enter / sticky-Ctrl) and a
14
20
  scroll-to-bottom control
@@ -20,7 +26,7 @@ everything above the raw terminal:
20
26
  - a connection-status banner and a copy toast
21
27
 
22
28
  It is published as TypeScript source (no build step) to npm and JSR, alongside
23
- the CSS bundle and an HTML scaffold. Consumers who want a different UI should
29
+ the CSS bundle and a reference HTML page. Consumers who want a different UI should
24
30
  depend on the engine directly and skip this package.
25
31
 
26
32
  ## Install
@@ -35,41 +41,73 @@ pins the engine version explicitly.
35
41
 
36
42
  ## Usage
37
43
 
38
- Serve the scaffold (`scaffold/index.html` — copy and adapt it) and the bundled
39
- CSS (`css/` concatenated per `css/MANIFEST` into the `style.css` the scaffold
40
- links), then call `mount()` from your entry module:
41
-
42
- ```ts
43
- import { mount } from "@cplieger/web-terminal-ui";
44
-
45
- mount();
46
- // or, for a server that exposes the WebSocket elsewhere / a custom font:
47
- mount({ wsPath: "/api/shell/ws", fontReady: '14px "MyMono"' });
44
+ Serve the bundled CSS (`css/` concatenated per `css/MANIFEST` into the
45
+ `style.css` your page links) plus a minimal HTML page that has one empty
46
+ container element for the terminal, then call `mount(root)` from your entry
47
+ module:
48
+
49
+ ```html
50
+ <div id="terminal"></div>
51
+ <div id="loading">Loading…</div>
52
+ <script type="importmap">
53
+ {
54
+ "imports": {
55
+ "@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js",
56
+ "@cplieger/web-terminal-ui": "/vendor/cplieger-web-terminal-ui/index.js"
57
+ }
58
+ }
59
+ </script>
60
+ <script type="module">
61
+ import { mount } from "@cplieger/web-terminal-ui";
62
+ mount(document.getElementById("terminal"), {
63
+ loading: document.getElementById("loading"),
64
+ });
65
+ // or, for a server that exposes the WebSocket elsewhere / a custom font:
66
+ // mount(root, { wsPath: "/api/shell/ws", fontReady: '14px "MyMono"' });
67
+ </script>
48
68
  ```
49
69
 
50
- `mount()` expects the scaffold's element ids to exist in the DOM (`term`,
51
- `term-output`, `term-input`, `composition-view`, `pred-cursor`, `loading`,
52
- `conn-banner`, `key-toolbar`, the `kb-*` buttons, `scroll-bottom`, `ctx-menu`)
53
- and throws a clear error if a required one is missing. Call it exactly once —
54
- the UI is single-instance per page.
70
+ `mount(root, opts?)` builds the entire terminal subtree (the display output,
71
+ the hidden keyboard textarea, the mobile key toolbar, the status banner, and
72
+ the context menu) inside `root` itself — there is no element-id contract for
73
+ the host page to reproduce. Call it exactly once; the module state is
74
+ single-instance per page. `scaffold/index.html` is a complete reference page to
75
+ copy and adapt.
55
76
 
56
77
  ### Options
57
78
 
58
- | Option | Default | Purpose |
59
- | ----------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
60
- | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
61
- | `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
79
+ | Option | Default | Purpose |
80
+ | ----------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
81
+ | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
82
+ | `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
83
+ | `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); mount fades it out and removes it once the first frame renders. |
62
84
 
63
85
  `mount()` returns a small handle: `{ focus() }` re-focuses the terminal input
64
86
  (and opens the soft keyboard on touch).
65
87
 
66
88
  ## What ships
67
89
 
68
- | Path | Purpose |
69
- | ------------------------ | ----------------------------------------------------------------- |
70
- | `src/*.ts` | The UI modules (`mount`, IME, predictive echo, viewport, status). |
71
- | `css/*.css` + `MANIFEST` | The default theme + layout; concatenate in MANIFEST order. |
72
- | `scaffold/index.html` | A reference HTML scaffold with the required element ids. |
90
+ | Path | Purpose |
91
+ | ------------------------ | --------------------------------------------------------------------- |
92
+ | `src/*.ts` | The UI modules (`mount`, IME, predictive echo, viewport, status). |
93
+ | `css/*.css` + `MANIFEST` | The default theme + layout; concatenate in MANIFEST order. |
94
+ | `scaffold/index.html` | A reference HTML page: `<head>` + one empty root element + importmap. |
95
+
96
+ ## Related projects
97
+
98
+ The web-terminal family:
99
+
100
+ - [`web-terminal-engine`](https://github.com/cplieger/web-terminal-engine) — the
101
+ Go session engine + TypeScript browser renderer this UI is built on (peer
102
+ dependency).
103
+ - [`web-terminal-server`](https://github.com/cplieger/web-terminal-server) — a
104
+ ready-to-run container that serves this UI over HTTP + WebSocket for any
105
+ command.
106
+
107
+ Consumers that ship this UI:
108
+
109
+ - [`vibekit`](https://github.com/cplieger/vibekit)
110
+ - [`vibecli`](https://github.com/cplieger/vibecli)
73
111
 
74
112
  ## License
75
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: textarea input model, mobile key toolbar, context menu, IME, predictive echo, viewport/keyboard-inset handling, and CSS. One mount() call.",
6
6
  "license": "GPL-3.0-or-later",
@@ -27,13 +27,13 @@
27
27
  "@eslint/js": "10.0.1",
28
28
  "@types/node": "25.9.4",
29
29
  "@vitest/coverage-v8": "4.1.9",
30
- "eslint": "10.5.0",
30
+ "eslint": "10.6.0",
31
31
  "fast-check": "4.8.0",
32
32
  "happy-dom": "20.10.6",
33
33
  "html-validate": "11.5.3",
34
- "knip": "6.20.0",
35
- "prettier": "3.8.4",
36
- "stylelint": "17.13.0",
34
+ "knip": "6.21.0",
35
+ "prettier": "3.8.5",
36
+ "stylelint": "17.14.0",
37
37
  "stylelint-config-standard": "40.0.0",
38
38
  "typescript": "6.0.3",
39
39
  "typescript-eslint": "8.62.0",
@@ -16,37 +16,24 @@
16
16
  </style>
17
17
  </head>
18
18
  <body>
19
- <div id="term" class="term">
20
- <div id="term-output" class="term-output" role="log" aria-live="off" aria-roledescription="Terminal" aria-label="Terminal"></div>
21
- <textarea id="term-input" class="term-input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Terminal input" tabindex="-1"></textarea>
22
- <div id="composition-view" class="composition-view" aria-hidden="true"></div>
23
- <div id="pred-cursor" class="pred-cursor" aria-hidden="true"></div>
24
- </div>
19
+ <!-- mount() builds the entire terminal UI inside this single root element.
20
+ The loading overlay is a sibling (not a child of the root) so mount never
21
+ clears it; mount fades it out once the first frame renders. -->
22
+ <div id="terminal"></div>
25
23
  <div id="loading"><div class="spin"></div>Loading…</div>
26
- <div id="conn-banner" class="conn-banner" role="status" aria-live="polite"></div>
27
- <div id="key-toolbar" class="key-toolbar collapsed no-transition" aria-label="Navigation keys" role="toolbar">
28
- <button type="button" id="kb-toggle" class="kb-toggle" aria-label="Toggle key toolbar"><svg class="icon-hamburger" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg><svg class="icon-close" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>
29
- <button type="button" id="kb-tab" class="kb-key kb-r1c1" aria-label="Tab">TAB</button>
30
- <button type="button" id="kb-esc" class="kb-key kb-r1c2" aria-label="Escape">ESC</button>
31
- <button type="button" id="kb-up" class="kb-key kb-r1c3" aria-label="Up"><svg viewBox="0 0 24 24"><polyline points="6 15 12 9 18 15"/></svg></button>
32
- <button type="button" id="kb-enter" class="kb-key kb-r1c4" aria-label="Enter"><svg viewBox="0 0 24 24"><polyline points="9 10 4 15 9 20"/><polyline points="20 4 20 15 4 15"/></svg></button>
33
- <button type="button" id="scroll-bottom" class="kb-scroll-bottom" aria-label="Scroll to bottom"><svg viewBox="0 0 24 24"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg></button>
34
- <button type="button" id="kb-ctrl" class="kb-key kb-r2c1" aria-label="Sticky Ctrl modifier" aria-pressed="false">CTRL</button>
35
- <button type="button" id="kb-left" class="kb-key kb-r2c2" aria-label="Left"><svg viewBox="0 0 24 24"><polyline points="15 6 9 12 15 18"/></svg></button>
36
- <button type="button" id="kb-down" class="kb-key kb-r2c3" aria-label="Down"><svg viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg></button>
37
- <button type="button" id="kb-right" class="kb-key kb-r2c4" aria-label="Right"><svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg></button>
38
- </div>
39
- <div id="ctx-menu" class="ctx-menu"></div>
40
- <!-- Deployment-specific: the importmap resolves the bare engine specifier to
41
- the engine's compiled JS, and /app.js is your bundle that calls
42
- `mount()` from @cplieger/web-terminal-ui. Adjust paths to your build. -->
24
+ <!-- Deployment-specific: the importmap resolves the bare package specifiers to
25
+ the compiled JS your build emits. Adjust the paths to your build layout. -->
43
26
  <script type="importmap">
44
27
  {
45
28
  "imports": {
46
- "@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js"
29
+ "@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js",
30
+ "@cplieger/web-terminal-ui": "/vendor/cplieger-web-terminal-ui/index.js"
47
31
  }
48
32
  }
49
33
  </script>
50
- <script type="module" src="/app.js"></script>
34
+ <script type="module">
35
+ import { mount } from "@cplieger/web-terminal-ui";
36
+ mount(document.getElementById("terminal"), { loading: document.getElementById("loading") });
37
+ </script>
51
38
  </body>
52
39
  </html>
package/src/mount.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  // @cplieger/web-terminal-ui — reference touch-first browser terminal UI.
2
2
  //
3
- // mount() wires the @cplieger/web-terminal-engine engine to a fixed DOM scaffold
4
- // (see scaffold/index.html for the required element ids) and owns the
5
- // touch-first input model: a display-only #term-output, a hidden <textarea>
6
- // that owns the keyboard + IME + local typing buffer, a mobile key toolbar,
7
- // a viewport-clamped context menu, predictive echo, and viewport/keyboard-
8
- // inset handling. This is the full default UI extracted from vibecli; a
9
- // consumer either mounts it as-is on the scaffold or builds its own UI on the
10
- // engine directly.
3
+ // mount(root) builds the entire terminal subtree inside a single host-provided
4
+ // container element and owns the touch-first input model: a display-only
5
+ // terminal output, a hidden <textarea> that owns the keyboard + IME + local
6
+ // typing buffer, a mobile key toolbar, a viewport-clamped context menu,
7
+ // predictive echo, and viewport/keyboard-inset handling. The consumer's HTML
8
+ // is just <head> + one empty root element (+ an optional loading overlay) +
9
+ // the importmap; mount creates everything else, so there is no shared
10
+ // element-id contract to keep in sync. A consumer that wants a different UI
11
+ // builds on the engine directly instead.
11
12
  //
12
13
  // Single-instance per page: the module holds the one terminal's DOM refs and
13
14
  // state, matching a terminal-per-page model. Call mount() exactly once.
@@ -28,6 +29,11 @@ export interface MountOptions {
28
29
  * server is sized against the real web font's cell metrics rather than a
29
30
  * fallback. Default '14px "MonaspiceNe NFM"'. */
30
31
  fontReady?: string;
32
+ /** Optional pre-JS loading overlay element (lives in the served HTML so it
33
+ * paints before this module loads). mount fades it out and removes it once
34
+ * the first screen frame renders. The consumer owns the element; mount only
35
+ * dismisses it. */
36
+ loading?: HTMLElement;
31
37
  }
32
38
 
33
39
  export interface TerminalUI {
@@ -61,10 +67,13 @@ let termWrap!: HTMLElement;
61
67
  let input!: HTMLTextAreaElement;
62
68
  let compositionViewEl!: HTMLElement;
63
69
  let ctxMenu!: HTMLElement;
64
- let ctrlBtn: HTMLElement | null = null;
65
70
 
66
71
  // --- Mutable UI state ---
67
- let ctrlArmed = false;
72
+ // Sticky-Ctrl state + the C0 mapping table + the toolbar button wiring all
73
+ // live in the engine's keyboard.bindMobileToolbar; mount() binds it against
74
+ // the required #key-toolbar and keeps the controller so the input handler
75
+ // can route typed text through its applyStickyCtrl.
76
+ let toolbarCtrl!: keyboard.MobileToolbarController;
68
77
  let fontsLoaded = false;
69
78
  let wsOpen = false;
70
79
  let lastPointerType = "mouse";
@@ -81,10 +90,43 @@ let longPressOrigin = { x: 0, y: 0 };
81
90
  // the Paste button from our custom menu on iOS to steer them there.
82
91
  let isIOS = false;
83
92
 
84
- function getRequired(id: string): HTMLElement {
85
- const el = document.getElementById(id);
93
+ // The terminal subtree mount() builds inside the host-provided root. Static,
94
+ // trusted markup (no interpolation) parsed once via a <template>. Element refs
95
+ // are then picked out by class with scoped queries — no global getElementById,
96
+ // no element-id contract the consumer must reproduce. The only ids are on the
97
+ // kb-* toolbar buttons, which the engine's bindMobileToolbar resolves with a
98
+ // scoped `toolbar.querySelector('#id')` (not a document-global lookup). The
99
+ // predicted-cursor overlay is created and owned by the engine renderer; the
100
+ // loading overlay stays in the consumer's served HTML (it must paint before
101
+ // this module loads).
102
+ const TERMINAL_TEMPLATE = `
103
+ <div class="term">
104
+ <div class="term-output" role="log" aria-live="off" aria-roledescription="Terminal" aria-label="Terminal"></div>
105
+ <textarea class="term-input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Terminal input" tabindex="-1"></textarea>
106
+ <div class="composition-view" aria-hidden="true"></div>
107
+ </div>
108
+ <div class="conn-banner" role="status" aria-live="polite"></div>
109
+ <div class="key-toolbar collapsed no-transition" aria-label="Navigation keys" role="toolbar">
110
+ <button type="button" id="kb-toggle" class="kb-toggle" aria-label="Toggle key toolbar"><svg class="icon-hamburger" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg><svg class="icon-close" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>
111
+ <button type="button" id="kb-tab" class="kb-key kb-r1c1" aria-label="Tab">TAB</button>
112
+ <button type="button" id="kb-esc" class="kb-key kb-r1c2" aria-label="Escape">ESC</button>
113
+ <button type="button" id="kb-up" class="kb-key kb-r1c3" aria-label="Up"><svg viewBox="0 0 24 24"><polyline points="6 15 12 9 18 15"/></svg></button>
114
+ <button type="button" id="kb-enter" class="kb-key kb-r1c4" aria-label="Enter"><svg viewBox="0 0 24 24"><polyline points="9 10 4 15 9 20"/><polyline points="20 4 20 15 4 15"/></svg></button>
115
+ <button type="button" class="kb-scroll-bottom" aria-label="Scroll to bottom"><svg viewBox="0 0 24 24"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg></button>
116
+ <button type="button" id="kb-ctrl" class="kb-key kb-r2c1" aria-label="Sticky Ctrl modifier" aria-pressed="false">CTRL</button>
117
+ <button type="button" id="kb-left" class="kb-key kb-r2c2" aria-label="Left"><svg viewBox="0 0 24 24"><polyline points="15 6 9 12 15 18"/></svg></button>
118
+ <button type="button" id="kb-down" class="kb-key kb-r2c3" aria-label="Down"><svg viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg></button>
119
+ <button type="button" id="kb-right" class="kb-key kb-r2c4" aria-label="Right"><svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg></button>
120
+ </div>
121
+ <div class="ctx-menu"></div>`;
122
+
123
+ // Pick a freshly-built element out of the root by class. Throws if the
124
+ // template and the selector ever drift apart (a build-time invariant, not a
125
+ // host-scaffold dependency).
126
+ function pick(root: ParentNode, selector: string): HTMLElement {
127
+ const el = root.querySelector<HTMLElement>(selector);
86
128
  if (!el) {
87
- throw new Error(`web-terminal-ui: missing required scaffold element #${id}`);
129
+ throw new Error(`web-terminal-ui: mount() failed to build element ${selector}`);
88
130
  }
89
131
  return el;
90
132
  }
@@ -103,61 +145,6 @@ function send(bytes: string): void {
103
145
  }
104
146
  }
105
147
 
106
- // --- Sticky Ctrl modifier ---
107
- // The iOS virtual keyboard has no Ctrl key, so control sequences
108
- // (Ctrl+C, Ctrl+L = clear screen, Ctrl+X, ...) are otherwise
109
- // unreachable on touch. The toolbar's Ctrl button arms a one-shot
110
- // modifier: tap it, then tap a letter on the virtual keyboard and that
111
- // keystroke is sent as its C0 control byte. Auto-disarms after one
112
- // printable character.
113
- function setCtrlArmed(on: boolean): void {
114
- ctrlArmed = on;
115
- ctrlBtn?.classList.toggle("armed", on);
116
- ctrlBtn?.setAttribute("aria-pressed", on ? "true" : "false");
117
- }
118
-
119
- // Map one printable character to its Ctrl+<char> C0 control byte,
120
- // mirroring the Ctrl handling in @cplieger/web-terminal-engine's keyboard mapper.
121
- function ctrlByteFor(ch: string): string | null {
122
- const code = ch.toLowerCase().charCodeAt(0);
123
- if (code >= 97 && code <= 122) {
124
- return String.fromCharCode(code - 96); // a–z → 0x01–0x1a
125
- }
126
- switch (ch) {
127
- case " ":
128
- case "@":
129
- return "\x00";
130
- case "[":
131
- return "\x1b";
132
- case "\\":
133
- return "\x1c";
134
- case "]":
135
- return "\x1d";
136
- case "^":
137
- return "\x1e";
138
- case "_":
139
- return "\x1f";
140
- case "?":
141
- return "\x7f";
142
- default:
143
- return null;
144
- }
145
- }
146
-
147
- // Apply a one-shot armed Ctrl to freshly-typed text: a single printable
148
- // character becomes its control byte; longer input (paste) just disarms
149
- // and passes through unchanged.
150
- function applyStickyCtrl(data: string): string {
151
- if (!ctrlArmed) {
152
- return data;
153
- }
154
- setCtrlArmed(false);
155
- if (data.length === 1) {
156
- return ctrlByteFor(data) ?? data;
157
- }
158
- return data;
159
- }
160
-
161
148
  function resetInputPlaceholder(): void {
162
149
  input.value = INPUT_PLACEHOLDER;
163
150
  // Cursor at the end so the next typed char appends after the
@@ -317,24 +304,50 @@ function showCtxMenu(x: number, y: number): void {
317
304
  ctxMenu.style.top = `${top}px`;
318
305
  }
319
306
 
320
- export function mount(opts: MountOptions = {}): TerminalUI {
307
+ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
321
308
  const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
322
309
  const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
323
310
 
324
- // --- DOM refs ---
325
- outputEl = getRequired("term-output");
326
- termWrap = getRequired("term");
327
- input = getRequired("term-input") as HTMLTextAreaElement;
328
- compositionViewEl = getRequired("composition-view");
329
- ctxMenu = getRequired("ctx-menu");
330
- ctrlBtn = document.getElementById("kb-ctrl");
311
+ // --- Build the terminal subtree inside the host-provided root ---
312
+ const tpl = document.createElement("template");
313
+ tpl.innerHTML = TERMINAL_TEMPLATE;
314
+ root.replaceChildren(tpl.content);
315
+
316
+ // --- DOM refs (picked from the just-built subtree by class) ---
317
+ termWrap = pick(root, ".term");
318
+ outputEl = pick(root, ".term-output");
319
+ input = pick(root, ".term-input") as HTMLTextAreaElement;
320
+ compositionViewEl = pick(root, ".composition-view");
321
+ ctxMenu = pick(root, ".ctx-menu");
322
+ const banner = pick(root, ".conn-banner");
323
+ const toolbar = pick(root, ".key-toolbar");
331
324
 
332
325
  isIOS =
333
326
  /iPad|iPhone|iPod/.test(navigator.userAgent) ||
334
327
  (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
335
328
 
329
+ // markReady fires once: when the first screen frame has rendered AND the web
330
+ // font is loaded. It tells the status banner the initial load is over (so it
331
+ // may show reconnect state from here on) and fades out the consumer's
332
+ // loading overlay if one was passed.
333
+ let ready = false;
334
+ function markReady(): void {
335
+ if (ready) {
336
+ return;
337
+ }
338
+ ready = true;
339
+ status.setLoaded();
340
+ const ld = opts.loading;
341
+ if (ld) {
342
+ ld.classList.add("fade");
343
+ ld.addEventListener("transitionend", () => {
344
+ ld.remove();
345
+ });
346
+ }
347
+ }
348
+
336
349
  // --- Initialize layers ---
337
- status.init();
350
+ status.init({ banner });
338
351
 
339
352
  render.init({
340
353
  output: outputEl,
@@ -370,10 +383,7 @@ export function mount(opts: MountOptions = {}): TerminalUI {
370
383
  onUserScrollChange(scrolledUp) {
371
384
  // Toggle .scrolled-up on the toolbar so CSS can show/hide the
372
385
  // scroll-bottom button and grow the pill vertically.
373
- const toolbar = document.getElementById("key-toolbar");
374
- if (toolbar) {
375
- toolbar.classList.toggle("scrolled-up", scrolledUp);
376
- }
386
+ toolbar.classList.toggle("scrolled-up", scrolledUp);
377
387
  },
378
388
  });
379
389
 
@@ -386,13 +396,7 @@ export function mount(opts: MountOptions = {}): TerminalUI {
386
396
  if (msg.type === "screen") {
387
397
  render.handleScreen(msg);
388
398
  if (fontsLoaded) {
389
- const ld = document.getElementById("loading");
390
- if (ld) {
391
- ld.classList.add("fade");
392
- ld.addEventListener("transitionend", () => {
393
- ld.remove();
394
- });
395
- }
399
+ markReady();
396
400
  }
397
401
  predict.onScreenFrame(msg.cursor[0], msg.cursor[1], msg.cursorHidden);
398
402
  } else if (msg.type === "scroll") {
@@ -482,16 +486,18 @@ export function mount(opts: MountOptions = {}): TerminalUI {
482
486
  // space byte. (Native paste is bracketed in composition.ts's paste
483
487
  // handler before this fires; the insertFromPaste data here is a
484
488
  // fallback for browsers that don't raise a separate paste event.)
485
- send(applyStickyCtrl(ev.data.replace(/\u00A0/g, " ")));
489
+ send(toolbarCtrl.applyStickyCtrl(ev.data.replace(/\u00A0/g, " ")));
486
490
  } else {
487
491
  // Fallback: anything in the textarea past the placeholder is new
488
492
  // content. Covers browsers that don't populate inputType / data
489
493
  // (older WebKit).
490
494
  const v = input.value;
491
495
  if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
492
- send(applyStickyCtrl(v.slice(INPUT_PLACEHOLDER.length).replace(/\u00A0/g, " ")));
496
+ send(
497
+ toolbarCtrl.applyStickyCtrl(v.slice(INPUT_PLACEHOLDER.length).replace(/\u00A0/g, " ")),
498
+ );
493
499
  } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
494
- send(applyStickyCtrl(v.replace(/\u00A0/g, " ")));
500
+ send(toolbarCtrl.applyStickyCtrl(v.replace(/\u00A0/g, " ")));
495
501
  }
496
502
  }
497
503
  resetInputPlaceholder();
@@ -651,21 +657,19 @@ export function mount(opts: MountOptions = {}): TerminalUI {
651
657
  });
652
658
 
653
659
  // --- Scroll-to-bottom (inside toolbar grid) ---
654
- const scrollBtn = document.getElementById("scroll-bottom");
655
- if (scrollBtn) {
656
- // pointerdown (like the other toolbar keys) so touch devices enter
657
- // :active and show press feedback; preventDefault keeps focus on the
658
- // terminal. click is kept for keyboard activation of the desktop FAB.
659
- // scrollToBottom() is idempotent, so the pointerdown+click pair on a
660
- // mouse press is harmless.
661
- scrollBtn.addEventListener("pointerdown", (e) => {
662
- e.preventDefault();
663
- scroll.scrollToBottom();
664
- });
665
- scrollBtn.addEventListener("click", () => {
666
- scroll.scrollToBottom();
667
- });
668
- }
660
+ const scrollBtn = pick(root, ".kb-scroll-bottom");
661
+ // pointerdown (like the other toolbar keys) so touch devices enter
662
+ // :active and show press feedback; preventDefault keeps focus on the
663
+ // terminal. click is kept for keyboard activation of the desktop FAB.
664
+ // scrollToBottom() is idempotent, so the pointerdown+click pair on a
665
+ // mouse press is harmless.
666
+ scrollBtn.addEventListener("pointerdown", (e) => {
667
+ e.preventDefault();
668
+ scroll.scrollToBottom();
669
+ });
670
+ scrollBtn.addEventListener("click", () => {
671
+ scroll.scrollToBottom();
672
+ });
669
673
 
670
674
  // --- Context menu ---
671
675
  termWrap.addEventListener("contextmenu", (e: MouseEvent) => {
@@ -750,44 +754,18 @@ export function mount(opts: MountOptions = {}): TerminalUI {
750
754
  });
751
755
 
752
756
  // --- Mobile key toolbar ---
753
- const keyToolbar = document.getElementById("key-toolbar");
754
- if (keyToolbar) {
755
- requestAnimationFrame(() =>
756
- requestAnimationFrame(() => {
757
- keyToolbar.classList.remove("no-transition");
758
- }),
759
- );
760
- const toggleBtn = document.getElementById("kb-toggle");
761
- toggleBtn?.addEventListener("pointerdown", (e) => {
762
- e.preventDefault();
763
- keyToolbar.classList.toggle("collapsed");
764
- });
765
-
766
- const keyMap: Record<string, string> = {
767
- "kb-up": "\x1b[A",
768
- "kb-down": "\x1b[B",
769
- "kb-left": "\x1b[D",
770
- "kb-right": "\x1b[C",
771
- "kb-esc": "\x1b",
772
- "kb-tab": "\t",
773
- "kb-enter": "\r",
774
- };
775
-
776
- for (const [id, seq] of Object.entries(keyMap)) {
777
- document.getElementById(id)?.addEventListener("pointerdown", (e) => {
778
- e.preventDefault();
779
- setCtrlArmed(false);
780
- send(seq);
781
- });
782
- }
783
-
784
- // Sticky Ctrl: tap to arm/disarm. preventDefault keeps focus on the
785
- // terminal so the iOS virtual keyboard stays up for the next tap.
786
- ctrlBtn?.addEventListener("pointerdown", (e) => {
787
- e.preventDefault();
788
- setCtrlArmed(!ctrlArmed);
789
- });
790
- }
757
+ // The engine's keyboard.bindMobileToolbar wires the collapse toggle, the
758
+ // arrows (DECCKM-aware — SS3 under application-cursor mode, matching
759
+ // mapKeyboardEvent), Tab / Enter / Esc, and the sticky-Ctrl button on the
760
+ // built toolbar. It returns the controller whose applyStickyCtrl the input
761
+ // handler above applies to typed text. The no-transition priming below
762
+ // stays local (a UI-specific concern).
763
+ toolbarCtrl = keyboard.bindMobileToolbar({ toolbar, send });
764
+ requestAnimationFrame(() =>
765
+ requestAnimationFrame(() => {
766
+ toolbar.classList.remove("no-transition");
767
+ }),
768
+ );
791
769
 
792
770
  // --- Copy feedback toast ---
793
771
  document.addEventListener("copy", () => {
package/src/status.ts CHANGED
@@ -21,6 +21,11 @@ let banner: HTMLElement | null = null;
21
21
  let state: State = "connecting";
22
22
  let stableTimer: ReturnType<typeof setTimeout> | null = null;
23
23
  let consecutiveFailures = 0;
24
+ // True once the first screen frame has rendered (mount calls setLoaded()).
25
+ // Until then the initial loading overlay is up and we suppress the
26
+ // reconnect/offline banner so the two don't stack — this replaces the old
27
+ // `document.getElementById("loading")` probe.
28
+ let loaded = false;
24
29
 
25
30
  /** Mark the connection as open. Hides the banner. */
26
31
  export function open(): void {
@@ -28,9 +33,16 @@ export function open(): void {
28
33
  setState("open", 0);
29
34
  }
30
35
 
36
+ /** Mark the terminal as past its initial load (first screen frame rendered).
37
+ * Until this is called the connection banner stays suppressed so it never
38
+ * stacks on top of the consumer's loading overlay. */
39
+ export function setLoaded(): void {
40
+ loaded = true;
41
+ }
42
+
31
43
  /** Mark a reconnect attempt in progress (after a close, before re-open). */
32
44
  export function reconnecting(): void {
33
- if (document.getElementById("loading")) {
45
+ if (!loaded) {
34
46
  return;
35
47
  }
36
48
  setState("reconnecting", CONNECTING_GRACE_MS);
@@ -39,7 +51,7 @@ export function reconnecting(): void {
39
51
  /** Mark the connection as closed; shows banner after grace period. */
40
52
  export function closed(): void {
41
53
  consecutiveFailures++;
42
- if (document.getElementById("loading")) {
54
+ if (!loaded) {
43
55
  return;
44
56
  }
45
57
  setState(consecutiveFailures > 3 ? "offline" : "reconnecting", CONNECTING_GRACE_MS);
@@ -111,19 +123,19 @@ function dismissToast(): void {
111
123
  }
112
124
  }
113
125
 
114
- export function init(): void {
115
- banner = document.getElementById("conn-banner");
126
+ export function init(opts: { banner: HTMLElement }): void {
127
+ banner = opts.banner;
116
128
  applyState();
117
129
 
118
130
  // Hover-pause: stop the auto-dismiss timer while the user hovers
119
131
  // (gives them time to read longer messages or click actions).
120
- banner?.addEventListener("mouseenter", () => {
132
+ banner.addEventListener("mouseenter", () => {
121
133
  if (toastDismissTimer !== null) {
122
134
  clearTimeout(toastDismissTimer);
123
135
  toastDismissTimer = null;
124
136
  }
125
137
  });
126
- banner?.addEventListener("mouseleave", () => {
138
+ banner.addEventListener("mouseleave", () => {
127
139
  if (banner?.dataset["state"] === "toast" && banner.classList.contains("visible")) {
128
140
  scheduleToastDismiss(toastDismissMs);
129
141
  }