@cplieger/web-terminal-ui 7.0.5 → 7.0.7

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
@@ -358,8 +358,19 @@ completed feature and core listener, clears the broken subtree, and shows the
358
358
  panel. If `createTerminal` itself throws (`phase: "kernel-init"`: an
359
359
  unresolvable mount selector, a preset that throws, an invalid feature list, a DOM
360
360
  invariant), it shows the same panel and then rethrows, so a caller with its own
361
- error handling still sees the error. Either way the panel is modal when the
362
- terminal owns the viewport and non-modal when it fills an embedded container.
361
+ error handling still sees the error.
362
+
363
+ The panel is a native `<dialog>`. In `viewport` layout it opens with
364
+ `showModal()`, so the rest of your document is inert while it is up; in
365
+ `container` layout it opens non-modally, so the application around an embedded
366
+ terminal keeps working and keeps its focusables. Escape does not dismiss it in
367
+ either mode: the terminal is already gone, so there would be nothing behind it
368
+ and no way back. Reload is the recovery.
369
+
370
+ One visible consequence in `viewport` layout: a modal dialog is in the browser's
371
+ top layer, above every `z-index` in the document, so the panel now paints over
372
+ your loading overlay instead of under it. It appears at once and the overlay's
373
+ fade-out finishes behind it.
363
374
 
364
375
  **This is why `target` takes a selector and `features` takes a function.** Both
365
376
  are resolved INSIDE that boundary. Written the other way round
@@ -133,13 +133,26 @@
133
133
  Feature setup is part of the kernel composition contract. If it fails, the
134
134
  kernel removes the half-live terminal and fills only its own layout boundary
135
135
  with this recovery surface. In container mode that leaves the host app
136
- outside the terminal fully visible and interactive. */
136
+ outside the terminal fully visible and interactive.
137
+
138
+ The surface is a native <dialog>: showModal() in wt-viewport, the bare `open`
139
+ attribute in wt-container. Modality is the only difference between them. */
137
140
  :where(.wt-root) .wt-fatal {
138
141
  position: absolute;
139
142
  inset: 0;
140
- z-index: 100;
141
- display: grid;
142
- place-items: center;
143
+ /* The size resets are what let the insets stretch the panel: a <dialog> is
144
+ fit-content with the UA sheet's own `margin: auto`, and :modal additionally
145
+ caps both axes at calc(100% - 38px). margin, border-radius and box-shadow
146
+ answer an EMBEDDER's own `dialog { ... }` base rule rather than the UA sheet —
147
+ margin is the member that moves the box, the other two are cosmetic. */
148
+ margin: 0;
149
+ border: none;
150
+ border-radius: 0;
151
+ box-shadow: none;
152
+ inline-size: auto;
153
+ block-size: auto;
154
+ max-inline-size: none;
155
+ max-block-size: none;
143
156
  overflow: auto;
144
157
  padding: calc(var(--safe-top) + var(--sp-3)) var(--sp-3) calc(var(--safe-bottom) + var(--sp-3));
145
158
  background: var(--bg);
@@ -148,6 +161,39 @@
148
161
  text-align: center;
149
162
  }
150
163
 
164
+ /* Scoped to [open]: an author `display` beats the UA's `dialog { display: none }`
165
+ at any specificity, so declared unconditionally a CLOSED panel would still
166
+ paint its full-surface background over the root. */
167
+ :where(.wt-root) .wt-fatal[open] {
168
+ display: grid;
169
+ place-items: center;
170
+ }
171
+
172
+ /* The base rule's `position: absolute` serves the container half (it resolves
173
+ against .wt-root.wt-container, the embedder's box); this gives the viewport back
174
+ to the modal half. A top-layer absolute resolves against the initial containing
175
+ block — viewport-SIZED but canvas-anchored, so equivalent to fixed only at
176
+ scrollY 0 — and Blink does not lock document scrolling under a modal dialog.
177
+ Pinned by fatal-panel.test.ts's scrolled-document test. */
178
+ :where(.wt-root) .wt-fatal:modal {
179
+ position: fixed;
180
+ }
181
+
182
+ /* The stacking order the top layer supplies for free, so it is declared only for
183
+ the half that is not in it. .wt-root.wt-container is the EMBEDDER's element and
184
+ forms no stacking context, so the panel competes in the host's. */
185
+ :where(.wt-root) .wt-fatal:not(:modal) {
186
+ z-index: 100;
187
+ }
188
+
189
+ /* The panel IS the dim — it fills its whole surface in --bg — so the backdrop
190
+ agrees with it instead of adding a second, differently coloured layer. The UA's
191
+ rgba(0, 0, 0, 0.1) would show as a lighter seam if the panel were ever smaller
192
+ than the viewport. */
193
+ :where(.wt-root) .wt-fatal::backdrop {
194
+ background: var(--bg);
195
+ }
196
+
151
197
  :where(.wt-root) .wt-fatal-card {
152
198
  inline-size: min(100%, 28rem);
153
199
  padding: clamp(1.25rem, 5vi, 2rem);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "7.0.5",
3
+ "version": "7.0.7",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
6
6
  "license": "MPL-2.0",
@@ -52,20 +52,20 @@
52
52
  "@stryker-mutator/vitest-runner": "10.0.0",
53
53
  "@types/node": "26.4.1",
54
54
  "@typescript/native": "npm:typescript@7.0.2",
55
- "@vitest/browser": "4.1.11",
56
- "@vitest/browser-playwright": "4.1.11",
57
- "@vitest/coverage-v8": "4.1.11",
58
- "eslint": "10.9.1",
55
+ "@vitest/browser": "5.0.0",
56
+ "@vitest/browser-playwright": "5.0.0",
57
+ "@vitest/coverage-v8": "5.0.0",
58
+ "eslint": "10.10.0",
59
59
  "fast-check": "4.9.0",
60
- "html-validate": "11.12.0",
60
+ "html-validate": "11.14.0",
61
61
  "knip": "6.34.0",
62
- "playwright": "1.62.1",
62
+ "playwright": "1.63.0",
63
63
  "prettier": "3.9.6",
64
- "stylelint": "17.14.1",
64
+ "stylelint": "17.15.0",
65
65
  "stylelint-config-standard": "40.0.0",
66
66
  "typescript": "npm:@typescript/typescript6@6.0.2",
67
67
  "typescript-eslint": "8.69.0",
68
- "vitest": "4.1.11"
68
+ "vitest": "5.0.0"
69
69
  },
70
70
  "publishConfig": {
71
71
  "access": "public"
@@ -39,30 +39,38 @@ let sendingComposition = false;
39
39
  let compositionStart = 0;
40
40
  let compositionSuffix = "";
41
41
 
42
- // A soft keyboard may announce its composition only at COMMIT time: the
43
- // per-character `input` events arrive with no composition in progress, the
44
- // kernel sends each keystroke as typed, and then the keyboard fires
45
- // compositionstart + compositionend back-to-back on the committing key while
46
- // writing the WHOLE accumulated word into the textarea. The deferred read below
47
- // then sends that word a second time — reported on Android Chrome with
48
- // SwiftKey, where typing "hello" and pressing space produced "hellohello ".
42
+ // A composition that never ENDS used to stop the terminal accepting input at
43
+ // all. `composing` gates both the kernel's `input` and `keydown` listeners, and
44
+ // nothing cleared it: no blur handler, no timer. So a keyboard that opened a
45
+ // composition and never closed it left every subsequent keystroke, Enter
46
+ // included, dropped on the floor — reported on Android Chrome with SwiftKey as
47
+ // "typing does not work", recoverable only by tapping a word suggestion, which
48
+ // is what finally fired compositionend.
49
49
  //
50
- // The terminal has already been given the literal keystrokes and cannot be
51
- // asked to retract them, so a commit that only repeats them is a no-op.
52
- // `noteDirectSend` records what the kernel sent with no composition running;
53
- // the finaliser drops a commit that matches it.
50
+ // Not a SwiftKey quirk to special-case. Chromium has a filed defect for
51
+ // compositionend going missing (crbug 446714223, in EditContext rather than a
52
+ // textarea, so it is adjacent evidence and not proof of this exact path), and
53
+ // CodeMirror synthesises the event outright for Safari dead keys, which is a
54
+ // working implementation treating the same absence as real.
54
55
  //
55
- // Unreachable on a keyboard that announces its composition BEFORE the
56
- // keystrokes (every desktop IME, iOS, Android's own keyboard): there the
57
- // kernel's `input` listener returns early while composing, so nothing is
58
- // recorded and there is nothing for a commit to match.
59
- const ECHO_TAIL_MAX = 64;
60
- const ECHO_WINDOW_MS = 1500;
61
- const ECHO_MAX_COMPOSE_MS = 60;
56
+ // The fix is a STALENESS READ rather than a timer. A composition the user is
57
+ // typing into keeps firing compositionupdate, so its clock keeps refreshing; an
58
+ // abandoned one stops and ages out, releasing the gate. UI Events promises one
59
+ // or more compositionupdate events and one whenever the DOM is updated as part
60
+ // of the composition, not strictly one per key, so this is a heuristic about
61
+ // activity rather than a guaranteed per-keystroke signal. Deliberately NOT
62
+ // refreshed by `input` events: in this failure the user IS typing and those
63
+ // events ARE arriving, so refreshing there would defeat the mechanism.
64
+ //
65
+ // Five seconds is the number Slate's android-input-manager uses for its own
66
+ // composition-gone-quiet bound (a DOM flush decision, not an input gate, so the
67
+ // value is borrowed and the job is not the same). It is generous because the
68
+ // residual risk runs the other way: a composition paused longer than this and
69
+ // then resumed can duplicate its first character. That trade is deliberate —
70
+ // the failure being replaced loses ALL input until an unrelated event fires.
71
+ const COMPOSITION_IDLE_MS = 5000;
62
72
 
63
- let directTail = "";
64
- let directTailAt = 0;
65
- let compositionStartedAt: number | null = null;
73
+ let lastCompositionActivity = 0;
66
74
 
67
75
  export function init(opts: {
68
76
  textarea: HTMLTextAreaElement;
@@ -85,61 +93,61 @@ export function init(opts: {
85
93
  textarea.addEventListener("paste", onPaste);
86
94
  }
87
95
 
88
- /** True while an IME composition is in progress. Caller's keydown
89
- * listener must early-return on true to avoid sending raw key bytes
90
- * during composition. */
96
+ /** True while an IME composition is in progress, RECONCILED first: a composition
97
+ * whose clock has not been refreshed for COMPOSITION_IDLE_MS is expired here,
98
+ * clearing the latch, the clock and the overlay, so a keyboard that never fires
99
+ * compositionend cannot strand the terminal or leave a stale panel on screen.
100
+ * A late compositionend still delivers its text — `onEnd` snapshots what it
101
+ * needs and does not require `composing` to have stayed true.
102
+ *
103
+ * `sendingComposition` is exempt: that window is bounded by a setTimeout(0)
104
+ * this module owns. */
91
105
  export function isComposing(): boolean {
92
- return composing || sendingComposition;
93
- }
94
-
95
- /** Record text the kernel put on the wire with no composition running, so a
96
- * keyboard that announces its composition only at commit time cannot have the
97
- * same keystrokes delivered twice (see the ECHO_* note above). Typed text
98
- * only — a paste is never re-committed by a keyboard. */
99
- export function noteDirectSend(text: string): void {
100
- if (text === "") {
101
- return;
106
+ if (sendingComposition) {
107
+ return true;
102
108
  }
103
- const now = Date.now();
104
- if (now - directTailAt > ECHO_WINDOW_MS) {
105
- directTail = "";
106
- }
107
- directTail = (directTail + text).slice(-ECHO_TAIL_MAX);
108
- directTailAt = now;
109
- }
110
-
111
- /** Whether this commit merely repeats keystrokes already delivered literally. */
112
- function repeatsDirectSends(composed: string, startedAt: number | null, endedAt: number): boolean {
113
- if (directTail === "") {
109
+ if (!composing) {
114
110
  return false;
115
111
  }
116
- // A composition the user typed INTO spans human time; a commit-time
117
- // announcement starts and ends inside one keystroke.
118
- if (startedAt !== null && endedAt - startedAt > ECHO_MAX_COMPOSE_MS) {
119
- return false;
112
+ if (Date.now() - lastCompositionActivity <= COMPOSITION_IDLE_MS) {
113
+ return true;
120
114
  }
121
- // Only the burst still being typed can be a re-commit of itself.
122
- if (endedAt - directTailAt > ECHO_WINDOW_MS) {
123
- return false;
124
- }
125
- // The committing key (space, tab) is delivered as its own `input` event, so
126
- // what has to match is the WORD the keyboard is re-committing.
127
- const word = composed.replace(/[ \t]+$/u, "");
128
- return word !== "" && directTail.endsWith(word);
115
+ expireComposition();
116
+ return false;
117
+ }
118
+
119
+ /** The RAW latch: is a composition open at all, regardless of whether it has
120
+ * gone quiet. Read before the reconciling `isComposing()` by the keydown guard,
121
+ * because the keyCode 229 signal only means "an IME claimed this key" while a
122
+ * composition is genuinely open — on Android it is the value for nearly every
123
+ * soft-keyboard key. */
124
+ export function isCompositionOpen(): boolean {
125
+ return composing || sendingComposition;
126
+ }
127
+
128
+ /** Drop a composition that has gone quiet: the latch, the clock and the visible
129
+ * overlay together. Without the overlay half the gate would say no composition
130
+ * while `.composition-view.active` still said there was one, leaving a stale
131
+ * panel over the terminal for as long as the missing compositionend never
132
+ * arrived. Does NOT touch the textarea: a late compositionend still reads it. */
133
+ function expireComposition(): void {
134
+ composing = false;
135
+ lastCompositionActivity = 0;
136
+ compositionView.textContent = "";
137
+ compositionView.classList.remove("active");
129
138
  }
130
139
 
131
140
  /** Abort any in-flight composition without sending, and clear the textarea.
132
- * The kernel calls this on a tab switch (detach): committing half-composed IME
133
- * text to either the outgoing or the incoming session is wrong, so we discard
134
- * it (design 5.1, "end any composition ... or cancel"). Clearing
135
- * sendingComposition also neutralizes a just-fired compositionend whose
136
- * deferred send is still pending on the microtask/timeout queue, so it cannot
137
- * land on whoever is active after the switch. */
141
+ * The kernel calls this on a tab switch (detach) and on blur: committing
142
+ * half-composed IME text to either the outgoing or the incoming session is
143
+ * wrong, so we discard it (design 5.1, "end any composition ... or cancel").
144
+ * Clearing sendingComposition also neutralizes a just-fired compositionend
145
+ * whose deferred send is still pending on the microtask/timeout queue, so it
146
+ * cannot land on whoever is active after the switch. */
138
147
  export function cancelComposition(): void {
139
148
  composing = false;
140
149
  sendingComposition = false;
141
- compositionStartedAt = null;
142
- directTail = "";
150
+ lastCompositionActivity = 0;
143
151
  compositionView.textContent = "";
144
152
  compositionView.classList.remove("active");
145
153
  resetToPlaceholder(textarea);
@@ -167,7 +175,7 @@ export function teardown(): void {
167
175
 
168
176
  function onStart(): void {
169
177
  composing = true;
170
- compositionStartedAt = Date.now();
178
+ lastCompositionActivity = Date.now();
171
179
  const start = textarea.selectionStart;
172
180
  const end = textarea.selectionEnd;
173
181
  compositionStart = Math.min(start, end);
@@ -179,6 +187,10 @@ function onStart(): void {
179
187
  }
180
188
 
181
189
  function onUpdate(ev: CompositionEvent): void {
190
+ // Every keystroke of a live composition lands here, which is what keeps its
191
+ // staleness clock fresh (see COMPOSITION_IDLE_MS). An abandoned composition
192
+ // stops firing this and ages out of the gate.
193
+ lastCompositionActivity = Date.now();
182
194
  // \u200E (LTR mark) wrappers + CSS direction:rtl on the view make
183
195
  // long compositions show their trailing edge instead of being
184
196
  // clipped at the start. Pattern from xterm.js.
@@ -189,15 +201,13 @@ function onUpdate(ev: CompositionEvent): void {
189
201
  function onEnd(): void {
190
202
  compositionView.classList.remove("active");
191
203
  composing = false;
204
+ lastCompositionActivity = 0;
192
205
  // The compositionend event fires before the textarea reflects the
193
206
  // final value on most browsers (Chromium especially). Defer one tick
194
207
  // and read the textarea then; xterm.js's pattern.
195
208
  sendingComposition = true;
196
209
  const startSnapshot = compositionStart;
197
210
  const suffixSnapshot = compositionSuffix;
198
- const startedAt = compositionStartedAt;
199
- const endedAt = Date.now();
200
- compositionStartedAt = null;
201
211
  setTimeout(() => {
202
212
  if (!sendingComposition) {
203
213
  return;
@@ -211,10 +221,9 @@ function onEnd(): void {
211
221
  const composed = value
212
222
  .substring(startSnapshot, Math.max(startSnapshot, valueEnd))
213
223
  .replace(/\u00A0/g, " ");
214
- if (composed.length > 0 && !repeatsDirectSends(composed, startedAt, endedAt)) {
224
+ if (composed.length > 0) {
215
225
  send(composed);
216
226
  }
217
- directTail = "";
218
227
  resetToPlaceholder(textarea);
219
228
  }, 0);
220
229
  }
@@ -120,6 +120,16 @@ function normalizeTypedText(text: string): string {
120
120
  return text.replace(/\u00A0/g, " ");
121
121
  }
122
122
 
123
+ /** `KeyboardEvent.keyCode` for a key an IME has claimed. Windows' VK_PROCESS,
124
+ * reported by Blink and WebKit for the key that commits a composition, and by
125
+ * Android for essentially every soft-keyboard key — which is why every read of
126
+ * it is gated on a composition being open.
127
+ *
128
+ * Deprecated and nonetheless the only carrier of this fact: for that commit key
129
+ * both engines report `key` as the ordinary `"Enter"`, and Safari reports
130
+ * `isComposing` false, so neither the modern flag nor `key` distinguishes it. */
131
+ const IME_COMMIT_KEYCODE = 229;
132
+
123
133
  function pick(root: ParentNode, selector: string): HTMLElement {
124
134
  const el = root.querySelector<HTMLElement>(selector);
125
135
  if (!el) {
@@ -158,17 +168,18 @@ function renderFatalStartupInto(
158
168
  layoutMode: "viewport" | "container",
159
169
  message: string = STARTUP_FAILURE_COPY.message,
160
170
  ): void {
161
- const surface = document.createElement("section");
171
+ const surface = document.createElement("dialog");
162
172
  surface.className = "wt-fatal";
173
+ // alertdialog over the implicit `dialog` role: an urgent message to act on. No
174
+ // `aria-modal` — showModal() conveys modality; an authored value can contradict the UA.
163
175
  surface.setAttribute("role", "alertdialog");
164
176
  surface.setAttribute("aria-labelledby", "wt-fatal-title");
165
177
  surface.setAttribute("aria-describedby", "wt-fatal-message");
166
- if (layoutMode === "viewport") {
167
- // A full-page terminal has no usable host UI behind it, so this is the
168
- // page's modal recovery state. An embedded terminal is only one panel in
169
- // a larger app and must not claim that the rest of the app is inert.
170
- surface.setAttribute("aria-modal", "true");
171
- }
178
+ // showModal() makes Escape a close request, and the kernel has already cleared
179
+ // the root: nothing is behind this panel, so reload is the only recovery.
180
+ surface.addEventListener("cancel", (ev) => {
181
+ ev.preventDefault();
182
+ });
172
183
 
173
184
  const card = document.createElement("div");
174
185
  card.className = "wt-fatal-card";
@@ -191,9 +202,19 @@ function renderFatalStartupInto(
191
202
  surface.appendChild(card);
192
203
  root.replaceChildren(surface);
193
204
 
194
- // The terminal input held focus before setup failed. Move that focus to the
195
- // only recovery action; container mode remains non-modal because no trap or
196
- // aria-modal claim prevents the user from leaving the terminal panel.
205
+ // Viewport: the terminal IS the page, so the top layer and an inert document
206
+ // are both accurate; container gets the bare `open` attribute, which is neither
207
+ // and is what an embedded panel wants. `isConnected` is showModal()'s own
208
+ // precondition and a detached root arrives here from a typed call, where a
209
+ // throw would bury the real startup cause under a second error out of the catch.
210
+ if (layoutMode === "viewport" && surface.isConnected) {
211
+ surface.showModal();
212
+ } else {
213
+ surface.open = true;
214
+ }
215
+
216
+ // The terminal input held focus before setup failed; move it to the only
217
+ // recovery action. showModal() lands there itself, the non-modal half does not.
197
218
  reloadButton.focus();
198
219
  }
199
220
 
@@ -390,14 +411,6 @@ function buildTerminal(
390
411
  function sendText(text: string): void {
391
412
  sendBytes(encoder.encode(text));
392
413
  }
393
- /** A keystroke the user typed with no composition running. Recorded so a
394
- * keyboard that announces its composition only at commit time cannot have
395
- * the same characters delivered twice (composition.ts, the ECHO_* note). */
396
- function sendTyped(text: string): void {
397
- const out = normalizeTypedText(text);
398
- sendText(out);
399
- composition.noteDirectSend(out);
400
- }
401
414
  function paste(text: string): void {
402
415
  // The one bracketed-paste + newline-normalize funnel; every feature and the
403
416
  // kernel's own paste paths route through here (paste-jacking defense).
@@ -766,19 +779,43 @@ function buildTerminal(
766
779
  ) {
767
780
  resetToPlaceholder(input);
768
781
  return;
782
+ } else if (inputType === "insertReplacementText") {
783
+ // A spellcheck / autocorrect / autofill REWRITE of text already typed.
784
+ // The bytes it is correcting have already gone to the pty and a terminal
785
+ // cannot retract them: 0x7f means whatever the remote application decides
786
+ // it means, so a pager, vim, a y/n prompt and a no-echo password prompt
787
+ // each read it as their own input. Applying the rewrite forward instead
788
+ // duplicates — measured on xterm.js's value-diff, where `helo` corrected
789
+ // to `hello` puts `helohello` on the wire (its issue #3600). So drop it
790
+ // and leave the typed text standing, which is also what a shell wants:
791
+ // `cd /usr` silently becoming `cd /use` is worse than no autocorrect.
792
+ //
793
+ // The spec's own signal for this, so no length heuristic is needed: a
794
+ // max-one-character rule would break glide typing (a whole word in one
795
+ // event, and SwiftKey's flagship input method) while still admitting
796
+ // CJK's one-for-one in-place replacement. Cancelling is not an option
797
+ // either — preventDefault on beforeinput is a no-op in Chrome, per
798
+ // CodeMirror's own note — but declining to SEND is entirely ours.
799
+ //
800
+ // Reachable on more than Android: WebKit fires this type for a spelling
801
+ // suggestion and for a macOS system text substitution whatever
802
+ // autocorrect="off" says. Dropping is right wherever it fires, because
803
+ // what it carries is always a replacement for bytes the pty already has.
804
+ resetToPlaceholder(input);
805
+ return;
769
806
  } else if (typeof ev.data === "string" && ev.data.length > 0) {
770
807
  if (inputType === "insertFromPaste") {
771
808
  paste(ev.data);
772
809
  } else {
773
810
  // Normalize iOS's NBSP-for-space quirk, then send through the funnel.
774
- sendTyped(ev.data);
811
+ sendText(normalizeTypedText(ev.data));
775
812
  }
776
813
  } else {
777
814
  const v = input.value;
778
815
  if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
779
- sendTyped(v.slice(INPUT_PLACEHOLDER.length));
816
+ sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
780
817
  } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
781
- sendTyped(v);
818
+ sendText(normalizeTypedText(v));
782
819
  }
783
820
  }
784
821
  resetToPlaceholder(input);
@@ -796,7 +833,12 @@ function buildTerminal(
796
833
  input.addEventListener(
797
834
  "blur",
798
835
  () => {
799
- resetToPlaceholder(input);
836
+ // cancelComposition, not just resetToPlaceholder: `composing` gates both
837
+ // input listeners, so a composition abandoned by losing focus would
838
+ // otherwise keep the gate shut and the terminal would take no input when
839
+ // focus came back. The staleness read covers a keyboard that strands us
840
+ // while still focused; this covers the case with a definite signal.
841
+ composition.cancelComposition();
800
842
  termWrap.classList.remove("focus");
801
843
  },
802
844
  { signal },
@@ -846,6 +888,33 @@ function buildTerminal(
846
888
  input.addEventListener(
847
889
  "keydown",
848
890
  (ev: KeyboardEvent) => {
891
+ // Three gates, and the order is the design. The spec signal first: UI
892
+ // Events requires a key event inside a composition session to carry
893
+ // isComposing, which covers every engine except one case.
894
+ //
895
+ // That case is the key which COMMITS a composition: Chrome and Safari both
896
+ // report keyCode 229 for it and Safari reports isComposing false (measured
897
+ // in quill PR #3445), so a second signal is needed. It is read against the
898
+ // RAW latch, before the reconciling isComposing() below, and both halves of
899
+ // that matter. Gated, because on Android 229 is the value for nearly every
900
+ // soft-keyboard key, so an unconditional check swallows ordinary Backspace
901
+ // and Enter — found in review after exactly that shipped here. Before the
902
+ // reconcile, because reconciling clears the latch, and a CJK user who left
903
+ // a candidate window open past the idle bound is still committing a real
904
+ // composition: taking this key as an ordinary Enter would send a carriage
905
+ // return before the composed phrase and execute a half-typed line.
906
+ //
907
+ // The consequence to know: in a stranded composition on Android, Enter and
908
+ // Backspace stay blocked until a character arrives (which reconciles the
909
+ // latch and releases them) or focus is lost. That is the deliberate side
910
+ // of the trade, because the alternative costs desktop and iOS correctness.
911
+ if (ev.isComposing) {
912
+ return;
913
+ }
914
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- see above
915
+ if (composition.isCompositionOpen() && ev.keyCode === IME_COMMIT_KEYCODE) {
916
+ return;
917
+ }
849
918
  if (composition.isComposing()) {
850
919
  return;
851
920
  }
@@ -1179,7 +1248,7 @@ function buildTerminal(
1179
1248
  // an uncanceled keydown's insertion target after a listener moved focus
1180
1249
  // mid-dispatch. Chromium does; no specification requires it.
1181
1250
  ev.preventDefault();
1182
- sendTyped(char);
1251
+ sendText(normalizeTypedText(char));
1183
1252
  return;
1184
1253
  }
1185
1254
  applyMappedKey(ev, result);
@@ -1830,21 +1899,6 @@ function buildTerminal(
1830
1899
  };
1831
1900
  }
1832
1901
 
1833
- /** Build the terminal UI inside `root`. Call exactly once.
1834
- *
1835
- * Wraps buildTerminal so that a SYNCHRONOUS startup failure gets the same
1836
- * treatment an asynchronous one already got. Before this, the two halves of
1837
- * "the terminal did not start" were handled inconsistently: an async
1838
- * feature-setup rejection ran enterFatalStartup (overlay dismissed,
1839
- * onFatalError delivered, recovery surface rendered), while a synchronous throw
1840
- * propagated bare, leaving the consumer's loading overlay spinning forever over
1841
- * a page with no terminal and no explanation. Consumers were left hand-building
1842
- * their own surface to cover the gap, diverging from this one in shape and copy.
1843
- *
1844
- * The error still propagates: a consumer with its own handling sees it exactly
1845
- * as before, only now against a page that is no longer stuck on a spinner. A
1846
- * handler returning `true` suppresses the built-in surface, same contract as the
1847
- * async phase. */
1848
1902
  /** Resolve the mount target. A selector is looked up here, INSIDE
1849
1903
  * createTerminal's try, which is the whole point: a consumer that passed
1850
1904
  * `document.getElementById("terminal")` had to null-check it first (tsc forces
@@ -1901,6 +1955,10 @@ function createFallbackHost(): HTMLElement {
1901
1955
  return host;
1902
1956
  }
1903
1957
 
1958
+ /** Build the terminal UI inside `target`. Call exactly once. Wraps buildTerminal so
1959
+ * a SYNCHRONOUS startup failure gets the same recovery surface an async one does
1960
+ * and still propagates; that surface is modal in viewport layout, so
1961
+ * `onFatalError` returning `true` is the path that leaves the page interactive. */
1904
1962
  export function createTerminal(
1905
1963
  target: HTMLElement | string,
1906
1964
  opts: CreateTerminalOptions = {},