@cplieger/web-terminal-ui 4.4.0 → 4.5.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
@@ -140,22 +140,30 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
140
140
 
141
141
  ### Options
142
142
 
143
- | Option | Default | Purpose |
144
- | ----------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
145
- | `features` | _(none — bare kernel)_ | The feature list. Omitted or empty builds only the terminal (no chrome). Use a preset from `./presets` or a hand-picked array. |
146
- | `layout` | `"viewport"` | How the terminal claims space. `"viewport"`: the root becomes a fixed full-viewport box (the full-page product). `"container"`: the root fills your container element, which becomes the styling and positioning boundary (the embedded case). |
147
- | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
148
- | `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. |
149
- | `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
150
- | `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-bg`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`, `--tab-active-border`, plus the activity-dot palette `--status-working`, `--status-done`, `--status-input` (the working and input rings derive from their own tokens). The library ships neutral defaults. |
143
+ | Option | Default | Purpose |
144
+ | -------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
145
+ | `features` | _(none — bare kernel)_ | The feature list. Omitted or empty builds only the terminal (no chrome). Use a preset from `./presets` or a hand-picked array. |
146
+ | `layout` | `"viewport"` | How the terminal claims space. `"viewport"`: the root becomes a fixed full-viewport box (the full-page product). `"container"`: the root fills your container element, which becomes the styling and positioning boundary (the embedded case). |
147
+ | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
148
+ | `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. |
149
+ | `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
150
+ | `onFatalError` | _(built-in recovery)_ | Called after a fatal feature setup failure has stopped the connection, released the terminal runtime, and cleared the root. It receives `{ phase, feature, cause }`. Return `true` only after rendering replacement recovery UI into the root; otherwise the kernel shows its Reload page surface. |
151
+ | `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-bg`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`, `--tab-active-border`, plus the activity-dot palette `--status-working`, `--status-done`, `--status-input` (the working and input rings derive from their own tokens). The library ships neutral defaults. |
151
152
 
152
153
  `createTerminal()` returns a handle: `focus()` re-focuses the terminal input
153
154
  (and opens the soft keyboard on touch); `send(bytes)` sends bytes to the active
154
155
  session through the kernel's sanitizing input funnel (the supported host path
155
156
  for a "type this command" affordance); `reset()` drops the local scrollback and
156
157
  screen without injecting keystrokes (send a redraw keystroke yourself if you
157
- want one — e.g. Ctrl+L); and `destroy()` tears every feature down and releases
158
- the kernel.
158
+ want one, for example Ctrl+L); and `destroy()` tears every feature down and
159
+ releases the kernel.
160
+
161
+ If a feature's setup throws or rejects, the kernel stops the connection, tears
162
+ down every completed feature and core listener, clears the broken subtree, and
163
+ shows a reload surface. The surface is modal when the terminal owns the
164
+ viewport and non-modal when it fills an embedded container. `onFatalError`
165
+ receives the failure after cleanup; return `true` only when the host has rendered
166
+ replacement recovery UI into the terminal root.
159
167
 
160
168
  ## What ships
161
169
 
@@ -186,7 +194,7 @@ Consumers that ship this UI:
186
194
 
187
195
  This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
188
196
 
189
- This project was built with AI-assisted tooling using [Claude Opus](https://www.anthropic.com/claude) and [Kiro](https://kiro.dev). The human maintainer defines architecture, supervises implementation, and makes all final decisions.
197
+ This project was built with AI-assisted tooling using [Claude](https://claude.com), [GPT](https://openai.com), and [Kiro](https://kiro.dev). The human maintainer defines architecture, supervises implementation, and makes all final decisions.
190
198
 
191
199
  ## License
192
200
 
@@ -13,7 +13,8 @@
13
13
  motion is opt-in from one place). Cascade order is the manifest file order.
14
14
 
15
15
  Primitives are the building blocks every feature reuses: the transparent
16
- layout regions, the 44px touch button, and the toast surface. */
16
+ layout regions, the 44px touch button, the toast surface, and the kernel's
17
+ fatal-startup recovery surface. */
17
18
 
18
19
  :where(.wt-root) .wt-region,
19
20
  :where(.wt-root) .wt-slot {
@@ -96,3 +97,63 @@
96
97
  display: block;
97
98
  opacity: 1;
98
99
  }
100
+
101
+ /* ===== Fatal startup recovery =====
102
+ Feature setup is part of the kernel composition contract. If it fails, the
103
+ kernel removes the half-live terminal and fills only its own layout boundary
104
+ with this recovery surface. In container mode that leaves the host app
105
+ outside the terminal fully visible and interactive. */
106
+ :where(.wt-root) .wt-fatal {
107
+ position: absolute;
108
+ inset: 0;
109
+ z-index: 100;
110
+ display: grid;
111
+ place-items: center;
112
+ overflow: auto;
113
+ padding: calc(var(--safe-top) + var(--sp-3)) var(--sp-3) calc(var(--safe-bottom) + var(--sp-3));
114
+ background: var(--bg);
115
+ color: var(--text);
116
+ font-family: var(--font-ui);
117
+ text-align: center;
118
+ }
119
+
120
+ :where(.wt-root) .wt-fatal-card {
121
+ inline-size: min(100%, 28rem);
122
+ padding: clamp(1.25rem, 5vi, 2rem);
123
+ background: var(--surface);
124
+ border: 1px solid var(--border);
125
+ border-radius: var(--radius);
126
+ }
127
+
128
+ :where(.wt-root) .wt-fatal-title {
129
+ margin: 0;
130
+ color: var(--text);
131
+ font-size: clamp(1.25rem, 5vi, 1.5rem);
132
+ line-height: 1.2;
133
+ }
134
+
135
+ :where(.wt-root) .wt-fatal-message {
136
+ margin: var(--sp-3) 0 1.25rem;
137
+ color: color-mix(in oklch, var(--text), transparent 20%);
138
+ font-size: 0.9375rem;
139
+ line-height: 1.5;
140
+ }
141
+
142
+ :where(.wt-root) .wt-fatal-reload {
143
+ inline-size: fit-content;
144
+ padding-inline: 1rem;
145
+ background: var(--accent);
146
+ color: var(--bg);
147
+ font-weight: 600;
148
+ }
149
+
150
+ @media (hover: hover) {
151
+ :where(.wt-root) .wt-fatal-reload:hover {
152
+ background: color-mix(in oklch, var(--accent), var(--text) 12%);
153
+ }
154
+ }
155
+
156
+ :where(.wt-root) .wt-fatal-reload:active {
157
+ background: color-mix(in oklch, var(--accent), var(--bg) 12%);
158
+ transform: translateY(1px);
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
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": "GPL-3.0-or-later",
@@ -54,10 +54,10 @@
54
54
  "html-validate": "11.5.6",
55
55
  "knip": "6.27.0",
56
56
  "prettier": "3.9.5",
57
- "stylelint": "17.14.0",
57
+ "stylelint": "17.14.1",
58
58
  "stylelint-config-standard": "40.0.0",
59
59
  "typescript": "npm:@typescript/typescript6@6.0.2",
60
- "typescript-eslint": "8.64.0",
60
+ "typescript-eslint": "8.65.0",
61
61
  "vitest": "4.1.10"
62
62
  },
63
63
  "publishConfig": {
@@ -19,7 +19,11 @@
19
19
  #loading .bar{position:relative;inline-size:min(220px,62vw);block-size:5px;overflow:hidden;border-radius:999px;background:color-mix(in oklch,currentColor 16%,transparent)}
20
20
  #loading .bar::before{content:"";position:absolute;inset-block:0;inline-size:42%;border-radius:inherit;background:linear-gradient(90deg,transparent,currentColor 28%,color-mix(in oklch,currentColor,#fff 45%) 50%,currentColor 72%,transparent);filter:drop-shadow(0 0 4px color-mix(in oklch,currentColor 55%,transparent));animation:wt-loading-slide 1.5s cubic-bezier(.65,0,.35,1) infinite}
21
21
  @keyframes wt-loading-slide{0%{transform:translateX(-110%)}100%{transform:translateX(250%)}}
22
- @media (prefers-reduced-motion:reduce){#loading .bar::before{animation-duration:3s}}
22
+ @keyframes wt-loading-pulse{0%,100%{opacity:1}50%{opacity:.55}}
23
+ /* Reduced motion: no translation sweep (the vestibular-trigger class). The bar
24
+ becomes a full-width solid fill whose only animation is a gentle opacity
25
+ pulse — liveness stays signaled, nothing moves. */
26
+ @media (prefers-reduced-motion:reduce){#loading .bar::before{inline-size:100%;background:currentColor;filter:none;animation:wt-loading-pulse 2s ease-in-out infinite}}
23
27
  </style>
24
28
  </head>
25
29
  <body>
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  export { createTerminal } from "./kernel/kernel.js";
15
15
  export type {
16
16
  CreateTerminalOptions,
17
+ TerminalStartupFailure,
17
18
  TerminalHandle,
18
19
  TerminalFeature,
19
20
  FeatureInstance,
@@ -186,7 +186,13 @@ export function createTerminal(
186
186
  return;
187
187
  }
188
188
  for (const fn of [...errorHandlers]) {
189
- fn(feature, err);
189
+ try {
190
+ fn(feature, err);
191
+ } catch (handlerErr) {
192
+ // Error reporting must never turn the original feature failure into an
193
+ // unhandled rejection or prevent fatal-startup cleanup from running.
194
+ console.error("web-terminal-ui: feature error handler failed", handlerErr);
195
+ }
190
196
  }
191
197
  }
192
198
 
@@ -508,6 +514,11 @@ export function createTerminal(
508
514
  let pointerDownY = 0;
509
515
  let pointerDownTime = 0;
510
516
  function focusTerminal(): void {
517
+ // The public handle can outlive a fatal startup or explicit destroy. Never
518
+ // focus the detached textarea after the runtime has been released.
519
+ if (destroyed) {
520
+ return;
521
+ }
511
522
  input.focus({ preventScroll: true });
512
523
  }
513
524
  termWrap.addEventListener(
@@ -794,13 +805,19 @@ export function createTerminal(
794
805
  }
795
806
 
796
807
  // --- Feature lifecycle ---
808
+ // `destroyed` means the terminal runtime is no longer usable, whether that
809
+ // happened through the public destroy() handle or a fatal startup rollback.
810
+ // `rootReleased` is narrower: destroy() has also removed the recovery surface
811
+ // and boundary classes. Keeping the two facts separate lets a fatal terminal
812
+ // remain visible and still be explicitly destroyed later.
797
813
  let destroyed = false;
798
- // Live read of `destroyed` for the post-await re-check in setupFeatures():
799
- // a plain `if (destroyed)` there is narrowed to always-false by TS CFA
800
- // (it cannot model destroy() firing during the await), tripping
801
- // @typescript-eslint/no-unnecessary-condition. A call defeats the stale
802
- // narrowing with identical runtime behavior.
814
+ let rootReleased = false;
815
+ let runtimeCleaned = false;
816
+ // Live read of `destroyed` for post-await re-checks: a plain variable read is
817
+ // narrowed to always-false by TS CFA because it cannot model destroy() firing
818
+ // while feature setup or session resolution is awaiting.
803
819
  const isDestroyed = (): boolean => destroyed;
820
+
804
821
  function teardownFeatures(): void {
805
822
  for (let i = instances.length - 1; i >= 0; i--) {
806
823
  const entry = instances[i];
@@ -816,49 +833,157 @@ export function createTerminal(
816
833
  instances.length = 0;
817
834
  }
818
835
 
819
- // Resolves true when every feature set up; false when setup was aborted (a
820
- // feature threw and everything rolled back, or destroy() ran mid-setup). The
821
- // session-owner first connect below runs only on true — a resolver must never
822
- // run against torn-down chrome.
823
- async function setupFeatures(): Promise<boolean> {
836
+ // Release the complete live runtime while leaving the root boundary classes
837
+ // intact. Fatal startup uses the root for its recovery surface; destroy()
838
+ // follows this cleanup by removing the classes and any recovery UI.
839
+ function cleanupRuntime(): void {
840
+ if (runtimeCleaned) {
841
+ return;
842
+ }
843
+ runtimeCleaned = true;
844
+ teardownFeatures();
845
+ kernelAbort.abort();
846
+ narrowObserver?.disconnect();
847
+ narrowObserver = null;
848
+ viewport.teardown();
849
+ // Reset the composition singleton: a failure during an IME cycle must not
850
+ // leave module state or a deferred send alive for a later terminal mount.
851
+ composition.teardown();
852
+ connection.disconnect();
853
+ connState.destroy();
854
+ if (toastTimer !== null) {
855
+ clearTimeout(toastTimer);
856
+ toastTimer = null;
857
+ }
858
+ for (const off of subscriptions) {
859
+ off();
860
+ }
861
+ subscriptions.length = 0;
862
+ inputTransforms.length = 0;
863
+ inputObservers.length = 0;
864
+ keydownHandlers.length = 0;
865
+ errorHandlers.clear();
866
+ apiMap.clear();
867
+ activeSession = null;
868
+ bus.clear();
869
+ announcer.destroy();
870
+ regions.destroy();
871
+ root.classList.remove("wt-narrow");
872
+ root.replaceChildren();
873
+ }
874
+
875
+ function renderFatalStartup(): void {
876
+ const surface = document.createElement("section");
877
+ surface.className = "wt-fatal";
878
+ surface.setAttribute("role", "alertdialog");
879
+ surface.setAttribute("aria-labelledby", "wt-fatal-title");
880
+ surface.setAttribute("aria-describedby", "wt-fatal-message");
881
+ if (layoutMode === "viewport") {
882
+ // A full-page terminal has no usable host UI behind it, so this is the
883
+ // page's modal recovery state. An embedded terminal is only one panel in
884
+ // a larger app and must not claim that the rest of the app is inert.
885
+ surface.setAttribute("aria-modal", "true");
886
+ }
887
+
888
+ const card = document.createElement("div");
889
+ card.className = "wt-fatal-card";
890
+ const title = document.createElement("h2");
891
+ title.id = "wt-fatal-title";
892
+ title.className = "wt-fatal-title";
893
+ title.textContent = "Terminal failed to start";
894
+ const message = document.createElement("p");
895
+ message.id = "wt-fatal-message";
896
+ message.className = "wt-fatal-message";
897
+ message.textContent = "A required interface could not be loaded. Reload the page to try again.";
898
+ const reloadButton = document.createElement("button");
899
+ reloadButton.className = "wt-btn wt-fatal-reload";
900
+ reloadButton.type = "button";
901
+ reloadButton.textContent = "Reload page";
902
+ reloadButton.addEventListener("click", () => {
903
+ window.location.reload();
904
+ });
905
+ card.append(title, message, reloadButton);
906
+ surface.appendChild(card);
907
+ root.replaceChildren(surface);
908
+
909
+ // The terminal input held focus before setup failed. Move that focus to the
910
+ // only recovery action; container mode remains non-modal because no trap or
911
+ // aria-modal claim prevents the user from leaving the terminal panel.
912
+ reloadButton.focus();
913
+ }
914
+
915
+ function enterFatalStartup(feature: string, cause: unknown): void {
916
+ if (destroyed) {
917
+ return;
918
+ }
919
+ destroyed = true;
920
+ cleanupRuntime();
921
+ dismissLoadingOverlay();
922
+
923
+ let handled = false;
924
+ try {
925
+ handled = opts.onFatalError?.({ phase: "feature-setup", feature, cause }) === true;
926
+ } catch (handlerErr) {
927
+ console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
928
+ }
929
+ // A handler may call destroy() while taking over, so do not recreate the
930
+ // default surface after the root has explicitly been released.
931
+ if (!handled && !rootReleased) {
932
+ renderFatalStartup();
933
+ }
934
+ }
935
+
936
+ type FeatureSetupOutcome =
937
+ | { readonly status: "ready" }
938
+ | { readonly status: "aborted" }
939
+ | {
940
+ readonly status: "failed";
941
+ readonly feature: string;
942
+ readonly cause: unknown;
943
+ };
944
+
945
+ // Distinguish intentional cancellation from a fatal feature failure. A
946
+ // boolean collapsed both into the same "not ready" outcome, which is why a
947
+ // failed setup previously left a half-live terminal and only wrote a log.
948
+ async function setupFeatures(): Promise<FeatureSetupOutcome> {
824
949
  for (const feature of featureList) {
825
950
  if (destroyed) {
826
- return false;
951
+ return { status: "aborted" };
827
952
  }
828
953
  try {
829
954
  const instance = await feature.setup(makeContext(feature.name));
830
- // destroy() may have run during the await above; teardownFeatures() has already
831
- // swept `instances`, and destroy() is one-shot, so a straight push here would
832
- // leave this instance's listeners/timers/observers (tabs' SSE + poll + window/
833
- // document listeners + ResizeObservers) alive forever. Tear it down instead of
834
- // registering it.
955
+ // destroy() may have run during the await above; cleanupRuntime() has
956
+ // already swept `instances`, and destroy() is one-shot, so a straight
957
+ // push here would leave this instance's listeners/timers/observers alive
958
+ // forever. Tear it down instead of registering it.
835
959
  if (isDestroyed()) {
836
960
  try {
837
961
  instance.teardown();
838
962
  } catch (err) {
839
963
  reportError(feature.name, err);
840
964
  }
841
- return false;
965
+ return { status: "aborted" };
842
966
  }
843
967
  instances.push({ feature, instance });
844
968
  // Populate the feature value's readonly api (consumer pattern:
845
969
  // tabs.api?.create()) via a narrow cast, and the ctx.use lookup map.
846
970
  (feature as { api?: unknown }).api = instance.api;
847
971
  apiMap.set(feature, instance.api);
848
- } catch (err) {
849
- // Fail fast: roll back the already-set-up features in reverse and
850
- // surface a composed, named error (section 22.4).
851
- reportError(feature.name, err);
852
- teardownFeatures();
853
- console.error(
854
- `web-terminal-ui: feature "${feature.name}" setup failed; terminal has no chrome`,
855
- err,
856
- );
857
- return false;
972
+ } catch (cause) {
973
+ if (isDestroyed()) {
974
+ return { status: "aborted" };
975
+ }
976
+ reportError(feature.name, cause);
977
+ console.error(`web-terminal-ui: feature "${feature.name}" setup failed`, cause);
978
+ if (isDestroyed()) {
979
+ return { status: "aborted" };
980
+ }
981
+ return { status: "failed", feature: feature.name, cause };
858
982
  }
859
983
  }
860
- return true;
984
+ return { status: "ready" };
861
985
  }
986
+
862
987
  // Features set up in the background; first paint is never gated on them.
863
988
  // Under a session owner the kernel then drives the first connect itself:
864
989
  // await the owner's resolveInitialSession() and switch to the session it
@@ -870,14 +995,12 @@ export function createTerminal(
870
995
  // alive, and the kernel — which now SEES the failure instead of inferring it
871
996
  // from a missing side effect — dismisses the loading overlay so that chrome
872
997
  // is visible instead of an eternal spinner.
873
- void setupFeatures().then(async (ok) => {
874
- if (!ok) {
875
- // Setup aborted (rolled back or destroyed). Under a session owner no
876
- // connect will ever be attempted, so nothing else can lower the loading
877
- // overlay — dismiss it so the page shows its real state.
878
- if (!connectionInitiated && !destroyed) {
879
- dismissLoadingOverlay();
880
- }
998
+ void setupFeatures().then(async (outcome) => {
999
+ if (outcome.status === "failed") {
1000
+ enterFatalStartup(outcome.feature, outcome.cause);
1001
+ return;
1002
+ }
1003
+ if (outcome.status === "aborted") {
881
1004
  return;
882
1005
  }
883
1006
  if (!sessionOwner || isDestroyed()) {
@@ -924,32 +1047,15 @@ export function createTerminal(
924
1047
  render.resetScreen();
925
1048
  },
926
1049
  destroy() {
927
- if (destroyed) {
1050
+ if (rootReleased) {
928
1051
  return;
929
1052
  }
1053
+ rootReleased = true;
930
1054
  destroyed = true;
931
- teardownFeatures();
932
- kernelAbort.abort();
933
- narrowObserver?.disconnect();
934
- viewport.teardown();
935
- // Reset the composition singleton (kernel-driven, no feature teardown): a
936
- // destroy() mid-IME-composition otherwise leaves module-level `composing`
937
- // stuck true, so a remounted terminal swallows every keydown until an IME
938
- // cycle resets it; also neutralizes a pending compositionend setTimeout.
939
- composition.teardown();
940
- connection.disconnect();
941
- connState.destroy();
942
- if (toastTimer !== null) {
943
- clearTimeout(toastTimer);
944
- }
945
- for (const off of subscriptions) {
946
- off();
947
- }
948
- subscriptions.length = 0;
949
- bus.clear();
950
- announcer.destroy();
951
- regions.destroy();
1055
+ cleanupRuntime();
952
1056
  root.classList.remove("wt-root", "wt-viewport", "wt-container", "wt-narrow");
1057
+ // cleanupRuntime() removed the live subtree. A fatal handler may have
1058
+ // rendered replacement UI afterward, so destroy() clears once more.
953
1059
  root.replaceChildren();
954
1060
  },
955
1061
  };
@@ -303,6 +303,18 @@ export interface FeatureInstance<Api = void> {
303
303
 
304
304
  // --- Entry point ---
305
305
 
306
+ /** A fatal failure while createTerminal is starting its feature composition.
307
+ * The kernel has already stopped the connection, released every listener and
308
+ * singleton, torn down completed features, and cleared the terminal root when
309
+ * it delivers this value to `onFatalError`. */
310
+ export interface TerminalStartupFailure {
311
+ readonly phase: "feature-setup";
312
+ /** Name of the feature whose setup threw or rejected. */
313
+ readonly feature: string;
314
+ /** The original thrown or rejected value. */
315
+ readonly cause: unknown;
316
+ }
317
+
306
318
  /** Options for createTerminal. */
307
319
  export interface CreateTerminalOptions {
308
320
  /** The feature list; omitted or empty means the bare kernel (no chrome).
@@ -323,6 +335,14 @@ export interface CreateTerminalOptions {
323
335
  fontReady?: string;
324
336
  /** Optional pre-JS loading overlay the kernel fades out on first paint. */
325
337
  loading?: HTMLElement;
338
+ /** Observe or replace the kernel's fatal feature-setup surface. Called only
339
+ * after the live terminal has been completely torn down and its root
340
+ * cleared. Return true after rendering a replacement recovery surface into
341
+ * that root; false or undefined keeps the built-in Reload page surface. If
342
+ * this callback throws, the kernel logs that error and shows the built-in
343
+ * surface, so a reporting failure cannot leave the page blank. */
344
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- an observing handler returns nothing; only a literal `true` claims the recovery surface, so forcing `return undefined` on every observer is worse than the union
345
+ onFatalError?: (failure: TerminalStartupFailure) => boolean | void;
326
346
  /** Theme overrides: CSS custom properties set on the terminal root so a
327
347
  * consumer recolors the UI (accent, tab hover/active) without shipping CSS.
328
348
  * Keys must be CSS custom-property names (start with "--"); values are any