@cplieger/web-terminal-ui 4.8.1 → 5.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
@@ -12,7 +12,7 @@ The reference touch-first browser UI for
12
12
  It turns the engine's render/scroll/connection/keyboard modules into a usable
13
13
  terminal on a phone as well as a desktop.
14
14
 
15
- One `createTerminal(root, { features })` call builds the entire terminal UI
15
+ One `createTerminal(target, { features })` call builds the entire terminal UI
16
16
  inside a single container element you provide. A small always-present kernel
17
17
  composes with opt-in feature modules that own everything above the raw
18
18
  terminal:
@@ -54,7 +54,7 @@ through its connection banner.
54
54
 
55
55
  Serve a CSS bundle matching how you embed the terminal, plus a minimal HTML
56
56
  page that has one empty container element, then call
57
- `createTerminal(root, { features })` from your entry module.
57
+ `createTerminal(target, { features })` from your entry module.
58
58
 
59
59
  **Full-page host** (the terminal IS the page, as in `web-terminal-server` and
60
60
  `web-terminal-kiro`): concatenate `css/MANIFEST` into the `style.css` your page
@@ -88,17 +88,20 @@ entry modules beside it:
88
88
  <script type="module">
89
89
  import { createTerminal } from "@cplieger/web-terminal-ui";
90
90
  import { presetTabbed } from "@cplieger/web-terminal-ui/presets";
91
- createTerminal(document.getElementById("terminal"), {
92
- features: presetTabbed(),
91
+ createTerminal("#terminal", {
92
+ features: presetTabbed,
93
93
  loading: document.getElementById("loading"),
94
94
  });
95
95
  // or, for a server that exposes the WebSocket elsewhere / a custom font:
96
- // createTerminal(root, { features: presetTabbed(), wsPath: "/api/shell/ws", fontReady: '14px "MyMono"' });
96
+ // createTerminal("#terminal", { features: presetTabbed, wsPath: "/api/shell/ws", fontReady: '14px "MyMono"' });
97
97
  </script>
98
98
  ```
99
99
 
100
- `createTerminal(root, opts?)` builds the entire terminal subtree (the kernel
101
- plus every feature's chrome) inside `root` itself. There is no element-id
100
+ `createTerminal(target, opts?)` builds the entire terminal subtree (the kernel
101
+ plus every feature's chrome) inside the target element itself. `target` is a CSS
102
+ selector or an element: pass a **selector** from a page (`"#terminal"`) and pass
103
+ an **element** only when you already hold one you created yourself. The
104
+ difference matters — see "Startup failures" below. There is no element-id
102
105
  contract for the host page to reproduce, and every style and CSS custom
103
106
  property is scoped to the `wt-root` class it stamps on your element (removed
104
107
  again by `destroy()`). Call it exactly once; the engine's
@@ -138,7 +141,7 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
138
141
 
139
142
  | Option | Default | Purpose |
140
143
  | -------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
141
- | `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. |
144
+ | `features` | _(none; bare kernel)_ | A FUNCTION returning the feature list. Omitted builds only the terminal (no chrome). Pass a preset by name (`features: presetTabbed`) or a factory of your own; it is called inside the startup-failure boundary, so a preset that throws is handled rather than escaping. |
142
145
  | `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). |
143
146
  | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
144
147
  | `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. |
@@ -154,20 +157,49 @@ screen without injecting keystrokes (send a redraw keystroke yourself if you
154
157
  want one, for example Ctrl+L); and `destroy()` tears every feature down and
155
158
  releases the kernel.
156
159
 
157
- Startup can fail in two phases, and both end at the same recovery surface. If a
158
- feature's setup throws or rejects (`phase: "feature-setup"`), the kernel stops the
159
- connection, tears down every completed feature and core listener, clears the
160
- broken subtree, and shows a reload surface. If `createTerminal` itself throws
161
- (`phase: "kernel-init"` — an invalid feature list, a DOM invariant), it shows the
162
- same surface, lowers your loading overlay so the surface is visible, and then
163
- rethrows, so a caller with its own error handling still sees the error. Either
164
- way the surface is modal when the terminal owns the viewport and non-modal when
165
- it fills an embedded container.
160
+ ### Startup failures
161
+
162
+ You do not need your own startup-failure UI. Every way starting up can fail ends
163
+ at one recovery surface this package owns — a "Terminal failed to start" panel
164
+ with a Reload button — and your loading overlay is lowered so that panel is
165
+ visible.
166
+
167
+ Two phases can fail. If a feature's setup throws or rejects
168
+ (`phase: "feature-setup"`), the kernel stops the connection, tears down every
169
+ completed feature and core listener, clears the broken subtree, and shows the
170
+ panel. If `createTerminal` itself throws (`phase: "kernel-init"` — an
171
+ unresolvable mount selector, a preset that throws, an invalid feature list, a DOM
172
+ invariant), it shows the same panel and then rethrows, so a caller with its own
173
+ error handling still sees the error. Either way the panel is modal when the
174
+ terminal owns the viewport and non-modal when it fills an embedded container.
175
+
176
+ **This is why `target` takes a selector and `features` takes a function.** Both
177
+ are resolved INSIDE that boundary. Written the other way round —
178
+ `createTerminal(document.getElementById("terminal"), { features: presetTabbed() })`
179
+ — the lookup and the preset call both happen at your call site, before
180
+ `createTerminal` is entered, so a missing element or a throwing preset escapes
181
+ the library entirely and leaves the page spinning under your overlay with nothing
182
+ but a console error. Those were the two failures every consumer used to
183
+ hand-build its own dialog for.
184
+
185
+ One case has no panel by design: an embedded terminal (`layout: "container"`)
186
+ whose mount target does not exist. It is one panel inside a host application that
187
+ is otherwise working, so claiming the viewport to report its own failure would
188
+ break a healthy page. The failure is still delivered to `onFatalError` and still
189
+ rethrown, with `surface: undefined` to say there is nowhere to render.
166
190
 
167
191
  `onFatalError` receives the failure after cleanup. Discriminate on `phase`:
168
192
  `feature-setup` names the offending `feature`, `kernel-init` does not, because
169
- feature composition never began. Return `true` only when the host has rendered
170
- replacement recovery UI into the terminal root.
193
+ feature composition never began. `surface` names the element the built-in panel
194
+ would fill, and is the element to render into if you claim it. Return `true` only
195
+ when you have rendered replacement recovery UI there.
196
+
197
+ If your page also carries an inline bootstrap watchdog — a script that reports
198
+ "the JS bundle never loaded at all", a rung below `import` — it cannot import
199
+ anything by definition. Take its wording from `STARTUP_FAILURE_COPY` (exported at
200
+ the package root and at `@cplieger/web-terminal-ui/startup-copy`, which imports
201
+ nothing and touches no DOM so a build script can read it) and substitute the
202
+ strings into your HTML at build time, rather than restating them by hand.
171
203
 
172
204
  ## What ships
173
205
 
package/css/00-tokens.css CHANGED
@@ -58,10 +58,27 @@
58
58
  coincidence of taste, not a dependency.
59
59
 
60
60
  Working shares the accent's hue family, so on the ACTIVE tab it sits on an
61
- accent-derived fill (--tab-active-bg): measured 5.2:1 against it, and 8.0:1
62
- against the bar, both clear of the 3:1 non-text minimum. Hue still never
63
- carries state on its own (the wave's motion, and the donut it becomes under
64
- prefers-reduced-motion, are the non-hue cues per WCAG 1.4.1). */
61
+ accent-derived fill (--tab-active-bg) rather than on the bar. Measured
62
+ (sRGB, WCAG 1.4.3 formula, each layer composited over the page's #000):
63
+ 8.0:1 against the bar, but only 2.6:1 against the active-tab fill -- under
64
+ the 3:1 non-text minimum. This comment previously claimed 5.2:1 for the
65
+ active-tab case, which was wrong by ~2x; a consumer's code review quoted the
66
+ figure back as its baseline and built a finding on it, so treat the number
67
+ here as load-bearing and re-measure rather than adjust it by eye.
68
+
69
+ Why it is nevertheless not a 1.4.1 failure, and why the fill is not simply
70
+ darkened: hue never carries state on its own here -- working is a travelling
71
+ wave, input a hard 2px ring, done a bare disc, and under
72
+ prefers-reduced-motion working becomes a donut -- so every state stays
73
+ distinguishable by shape and motion at any of these ratios. Reaching 3:1 on
74
+ the fill means dropping the accent fraction below ~25%, which visibly
75
+ de-themes the active tab; that is a design call for a future release, not a
76
+ silent token tweak. The dot's contrast against the fill does NOT vary with
77
+ terminal output: the tabs feature publishes --wt-reserve-bottom and
78
+ viewport.ts folds it into the terminal's bottom inset, so content stops
79
+ above the bar and #000 is what composites behind it. The one exception is
80
+ the mobile switcher tray while EXPANDED, which overlays the terminal by
81
+ design. */
65
82
  --status-working: #52a9fe; /* agent working (waves): the same blue as the default --accent */
66
83
  --status-done: #22c55e; /* turn finished: solid green */
67
84
  --status-input: #fb923c; /* action required: solid orange + ring */
package/css/page.css CHANGED
@@ -39,6 +39,173 @@ body {
39
39
  touch-action: pan-x pan-y;
40
40
  }
41
41
 
42
+ /* ===== The pre-JS loading overlay =====
43
+ ONE definition of the loading screen, owned here. Before this, the library
44
+ only PRESCRIBED it in scaffold/index.html and every full-page consumer carried
45
+ its own copy in an inline <style> — web-terminal-server's was byte-identical to
46
+ the scaffold's, web-terminal-kiro's had drifted (renamed keyframe, extra
47
+ states) — so the "one" loading screen existed three times and none of them
48
+ could be improved without editing three files.
49
+
50
+ Why it can live in a stylesheet at all, since the overlay's whole job is to be
51
+ on screen before the app is: a full-page host loads this bundle through a plain
52
+ render-blocking <link rel="stylesheet"> in <head>, so NOTHING paints until it
53
+ arrives. An inline copy buys no earlier paint. What an inline copy does buy is
54
+ the case where this stylesheet FAILS to load, which a consumer's own bootstrap
55
+ watchdog reports — so a consumer keeps a deliberately minimal inline fallback
56
+ for that failure surface only, not a second copy of everything below.
57
+
58
+ Consumer overrides work the same way the component tokens do, except simpler:
59
+ the overlay is the consumer's OWN element, so it sets the tokens on #loading in
60
+ its own CSS and needs no JS. web-terminal-kiro already did exactly this with
61
+ --accent before any of this was tokenised.
62
+
63
+ Selected by CLASS, never by the consumer's `id`: the library owns .wt-loading*
64
+ and must not reach into a host document's own identifiers (the same
65
+ no-element-id-contract rule the kernel follows for the terminal subtree), and
66
+ an unnamespaced `.bar` in a host page would collide with anything. */
67
+ .wt-loading {
68
+ /* The overlay's accent: the bar and the status text derive from it, so one
69
+ override recolors the whole screen. Defaults to the component --accent
70
+ (00-tokens.css), restated as a literal because the component tokens are
71
+ scoped to .wt-root and the overlay is deliberately OUTSIDE it. */
72
+ --wt-loading-accent: oklch(72% 0.15 250deg);
73
+ --wt-loading-bg: #000;
74
+ --wt-loading-bar-inline-size: min(220px, 62vw);
75
+ --wt-loading-bar-block-size: 5px;
76
+ /* Symmetric ease-in-out-cubic, deliberately NOT one of the component easing
77
+ tokens: those are all asymmetric (they settle at one end), which reads as a
78
+ stutter on a looping indeterminate sweep. */
79
+ --wt-loading-sweep-easing: cubic-bezier(0.65, 0, 0.35, 1);
80
+ --wt-loading-sweep-duration: 1.5s;
81
+
82
+ position: fixed;
83
+ inset: 0;
84
+ z-index: 200;
85
+ display: flex;
86
+ /* Column, so the status line the kernel writes after a few seconds sits under
87
+ the bar instead of beside it. It is the only child that can appear, and it
88
+ is absent on a fast boot, so the column costs nothing when unused. */
89
+ flex-direction: column;
90
+ align-items: center;
91
+ justify-content: center;
92
+ gap: 1rem;
93
+ padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))
94
+ max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
95
+ background: var(--wt-loading-bg);
96
+ color: var(--wt-loading-accent);
97
+ font-family:
98
+ system-ui,
99
+ -apple-system,
100
+ sans-serif;
101
+ text-align: center;
102
+ transition: opacity 0.3s cubic-bezier(0.4, 0, 1, 1);
103
+ }
104
+
105
+ /* `fade` (not a wt- name) is the state class the kernel's fadeOutOverlay has always
106
+ added; it stays as-is so a consumer's own watchdog guards that read it keep
107
+ working. Compounding it with .wt-loading is what keeps a generic name safe. */
108
+ .wt-loading.fade {
109
+ opacity: 0;
110
+ /* The kernel removes the node on transitionend (with a 1.5s fallback); until
111
+ then this invisible full-viewport box must not swallow the first
112
+ tap-to-focus on the freshly rendered terminal. */
113
+ pointer-events: none;
114
+ }
115
+
116
+ .wt-loading-bar {
117
+ position: relative;
118
+ inline-size: var(--wt-loading-bar-inline-size);
119
+ block-size: var(--wt-loading-bar-block-size);
120
+ overflow: hidden;
121
+ border-radius: 999px;
122
+ background: color-mix(in oklch, currentcolor 16%, transparent);
123
+ /* The bar is the fixed element of the screen; a status line appearing below it
124
+ must not shove it off centre, so it never shrinks. */
125
+ flex: none;
126
+ }
127
+
128
+ .wt-loading-bar::before {
129
+ content: "";
130
+ position: absolute;
131
+ inset-block: 0;
132
+ inline-size: 42%;
133
+ border-radius: inherit;
134
+ background: linear-gradient(
135
+ 90deg,
136
+ transparent,
137
+ currentcolor 28%,
138
+ color-mix(in oklch, currentcolor, #fff 45%) 50%,
139
+ currentcolor 72%,
140
+ transparent
141
+ );
142
+ filter: drop-shadow(0 0 4px color-mix(in oklch, currentcolor 55%, transparent));
143
+ animation: wt-loading-slide var(--wt-loading-sweep-duration) var(--wt-loading-sweep-easing)
144
+ infinite;
145
+ }
146
+
147
+ /* The progressive status line (kernel/loading-status.ts writes it; see that
148
+ module for WHY it is delayed and why the rotating half is aria-hidden).
149
+ Hidden until it has something to say, so a fast boot shows the bar alone. */
150
+ .wt-loading-text:empty {
151
+ display: none;
152
+ }
153
+
154
+ .wt-loading-text {
155
+ margin: 0;
156
+ max-inline-size: 34rem;
157
+ color: color-mix(in oklch, currentcolor, #fff 25%);
158
+ font-size: 0.9375rem;
159
+ line-height: 1.4;
160
+ /* Fade each message in rather than snapping it: the whole point of the delayed
161
+ and rotating text is to read as a system that is alive, and a hard swap reads
162
+ as a glitch. */
163
+ transition: opacity 0.4s cubic-bezier(0.4, 0, 1, 1);
164
+ }
165
+
166
+ .wt-loading-text.wt-loading-text-out {
167
+ opacity: 0;
168
+ }
169
+
170
+ @keyframes wt-loading-slide {
171
+ 0% {
172
+ transform: translateX(-110%);
173
+ }
174
+ 100% {
175
+ transform: translateX(250%);
176
+ }
177
+ }
178
+
179
+ @media (prefers-reduced-motion: reduce) {
180
+ .wt-loading {
181
+ transition-duration: 0.01s;
182
+ }
183
+
184
+ /* A travelling sweep is the motion a reduced-motion user asked not to see, but
185
+ the screen still has to read as busy — so the bar becomes a slow symmetric
186
+ pulse of the whole track instead of a moving highlight. */
187
+ .wt-loading-bar::before {
188
+ inline-size: 100%;
189
+ background: currentcolor;
190
+ filter: none;
191
+ animation: wt-loading-pulse 2s ease-in-out infinite;
192
+ }
193
+
194
+ .wt-loading-text {
195
+ transition-duration: 0.01s;
196
+ }
197
+ }
198
+
199
+ @keyframes wt-loading-pulse {
200
+ 0%,
201
+ 100% {
202
+ opacity: 0.35;
203
+ }
204
+ 50% {
205
+ opacity: 1;
206
+ }
207
+ }
208
+
42
209
  /* ===== Fonts =====
43
210
  The terminal web font, served by the full-page host at /vendor/fonts (both
44
211
  web-terminal-server and web-terminal-kiro ship these files there; see their
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "4.8.1",
3
+ "version": "5.0.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",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
+ "./startup-copy": "./src/kernel/startup-copy.ts",
9
10
  "./presets": "./src/presets.ts",
10
11
  "./presets/single": "./src/presets/single.ts",
11
12
  "./presets/touch": "./src/presets/touch.ts",
@@ -12,26 +12,25 @@
12
12
  set). An embedder serves a per-preset manifest (css/MANIFEST.single/
13
13
  .touch/.tabbed) instead and passes layout: "container". -->
14
14
  <link rel="stylesheet" href="/style.css">
15
- <!-- [html-validate-disable-next no-style-tag -- critical CSS for instant loading indicator] -->
16
- <style>
17
- #loading{position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;background:#000;color:var(--accent,#6ea8ff);transition:opacity .3s ease}
18
- #loading.fade{opacity:0}
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
- #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
- @keyframes wt-loading-slide{0%{transform:translateX(-110%)}100%{transform:translateX(250%)}}
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}}
27
- </style>
15
+ <!-- No critical CSS for the loading overlay: css/page.css owns .wt-loading and
16
+ arrives with /style.css above, which is render-blocking, so nothing paints
17
+ before it either way. A consumer that also wants a failure surface for the
18
+ case where /style.css itself fails to load adds its own minimal inline block
19
+ for that (web-terminal-kiro does); a bare host does not need one. -->
28
20
  </head>
29
21
  <body>
30
22
  <!-- createTerminal() builds the entire terminal UI inside this single root
31
23
  element. The loading overlay is a sibling (not a child of the root) so it is
32
- never cleared; it is faded out once the first frame renders. -->
24
+ never cleared; it is faded out once the first frame renders.
25
+ The overlay is styled by css/page.css through the wt-loading* CLASSES, not
26
+ by its id — recolor it by setting the tokens (--wt-loading-accent,
27
+ --wt-loading-bg, ...) on this element in your own CSS. role="status" is
28
+ load-bearing: the kernel writes a progressive status line into it if startup
29
+ drags on, and that is the channel a screen reader reads. -->
33
30
  <div id="terminal"></div>
34
- <div id="loading" role="status" aria-label="Loading"><div class="bar" aria-hidden="true"></div></div>
31
+ <div id="loading" class="wt-loading" role="status" aria-label="Loading">
32
+ <div class="wt-loading-bar" aria-hidden="true"></div>
33
+ </div>
35
34
  <!-- Deployment-specific: the importmap resolves the bare package specifiers to
36
35
  the compiled JS your build emits. Adjust the paths to your build layout. -->
37
36
  <script type="importmap">
@@ -46,7 +45,14 @@
46
45
  <script type="module">
47
46
  import { createTerminal } from "@cplieger/web-terminal-ui";
48
47
  import { presetTabbed } from "@cplieger/web-terminal-ui/presets";
49
- createTerminal(document.getElementById("terminal"), { features: presetTabbed(), loading: document.getElementById("loading") });
48
+ // The root is passed as a SELECTOR and the preset as a FUNCTION, and both
49
+ // matter: createTerminal resolves the selector and calls the preset inside its
50
+ // own failure boundary, so a missing root element or a preset that throws
51
+ // renders the library's "Terminal failed to start" surface and lowers the
52
+ // overlay. Resolving the element here instead (and calling presetTabbed() as
53
+ // an argument) moves both failures OUTSIDE that boundary, where the only
54
+ // outcome is a page stuck on the spinner and an error in the console.
55
+ createTerminal("#terminal", { features: presetTabbed, loading: document.getElementById("loading") });
50
56
  </script>
51
57
  </body>
52
58
  </html>
@@ -214,6 +214,14 @@ async function createSessionHonouringRetry(
214
214
  ctx.toast(`${waiting}; retrying`, 8000);
215
215
  ctx.announce(`${waiting}; retrying`);
216
216
  }
217
+ // Separate from the throttled pair above, and unthrottled: this writes the
218
+ // reason onto the loading OVERLAY, which is the only surface a user can
219
+ // actually see before the first frame -- the toast and the banner both live
220
+ // inside .wt-root and paint under it. It replaces text in place rather than
221
+ // stacking notifications, so there is no storm to throttle, and repeating
222
+ // the same string is idempotent. This is what turns the black screen of a
223
+ // twenty-minute tools install into a screen that says why it is waiting.
224
+ ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"} — retrying…`);
217
225
  await new Promise((resolve) => {
218
226
  window.setTimeout(resolve, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS);
219
227
  });
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  // their own DOM.
13
13
 
14
14
  export { createTerminal } from "./kernel/kernel.js";
15
+ export { STARTUP_FAILURE_COPY } from "./kernel/startup-copy.js";
15
16
  export type {
16
17
  CreateTerminalOptions,
17
18
  TerminalStartupFailure,
@@ -28,6 +28,8 @@ import { createBus } from "./bus.js";
28
28
  import { createRegions } from "./regions.js";
29
29
  import { createAnnouncer, createTablist } from "./a11y.js";
30
30
  import { createConnState } from "./conn-state.js";
31
+ import { STARTUP_FAILURE_COPY } from "./startup-copy.js";
32
+ import { attachLoadingStatus, DEFAULT_LOADING_MESSAGES } from "./loading-status.js";
31
33
  import type {
32
34
  CreateTerminalOptions,
33
35
  FeatureInstance,
@@ -111,7 +113,11 @@ function fadeOutOverlay(ld: HTMLElement | undefined): void {
111
113
  * implementation of this surface, shared by the async `feature-setup` phase and
112
114
  * the synchronous `kernel-init` phase, so the two can never drift apart. It
113
115
  * depends on nothing but its two arguments. */
114
- function renderFatalStartupInto(root: HTMLElement, layoutMode: "viewport" | "container"): void {
116
+ function renderFatalStartupInto(
117
+ root: HTMLElement,
118
+ layoutMode: "viewport" | "container",
119
+ message: string = STARTUP_FAILURE_COPY.message,
120
+ ): void {
115
121
  const surface = document.createElement("section");
116
122
  surface.className = "wt-fatal";
117
123
  surface.setAttribute("role", "alertdialog");
@@ -129,19 +135,19 @@ function renderFatalStartupInto(root: HTMLElement, layoutMode: "viewport" | "con
129
135
  const title = document.createElement("h2");
130
136
  title.id = "wt-fatal-title";
131
137
  title.className = "wt-fatal-title";
132
- title.textContent = "Terminal failed to start";
133
- const message = document.createElement("p");
134
- message.id = "wt-fatal-message";
135
- message.className = "wt-fatal-message";
136
- message.textContent = "A required interface could not be loaded. Reload the page to try again.";
138
+ title.textContent = STARTUP_FAILURE_COPY.title;
139
+ const messageEl = document.createElement("p");
140
+ messageEl.id = "wt-fatal-message";
141
+ messageEl.className = "wt-fatal-message";
142
+ messageEl.textContent = message;
137
143
  const reloadButton = document.createElement("button");
138
144
  reloadButton.className = "wt-btn wt-fatal-reload";
139
145
  reloadButton.type = "button";
140
- reloadButton.textContent = "Reload page";
146
+ reloadButton.textContent = STARTUP_FAILURE_COPY.reloadLabel;
141
147
  reloadButton.addEventListener("click", () => {
142
148
  window.location.reload();
143
149
  });
144
- card.append(title, message, reloadButton);
150
+ card.append(title, messageEl, reloadButton);
145
151
  surface.appendChild(card);
146
152
  root.replaceChildren(surface);
147
153
 
@@ -153,10 +159,13 @@ function renderFatalStartupInto(root: HTMLElement, layoutMode: "viewport" | "con
153
159
 
154
160
  /** Build the terminal. See createTerminal, which wraps this to give a
155
161
  * SYNCHRONOUS failure the same recovery surface an async one already gets. */
156
- function buildTerminal(root: HTMLElement, opts: CreateTerminalOptions = {}): TerminalHandle {
162
+ function buildTerminal(
163
+ root: HTMLElement,
164
+ opts: CreateTerminalOptions,
165
+ featureList: readonly TerminalFeature<unknown>[],
166
+ ): TerminalHandle {
157
167
  const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
158
168
  const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
159
- const featureList = opts.features ?? [];
160
169
  // At most one feature owns session selection (fail fast, before any DOM
161
170
  // work): the kernel drives the first connect through its registration.
162
171
  const owners = featureList.filter((f) => f.sessionOwner !== undefined);
@@ -325,7 +334,16 @@ function buildTerminal(root: HTMLElement, opts: CreateTerminalOptions = {}): Ter
325
334
  let fontsLoaded = false;
326
335
  let wsOpen = false;
327
336
  let overlayDismissed = false;
337
+ // Progressive status text on the consumer's overlay. Attached unconditionally
338
+ // (inert when no overlay was supplied) and torn down by the same function that
339
+ // lowers the overlay, so there is exactly one place where the loading screen's
340
+ // life ends and no timer can outlive the thing it was writing into.
341
+ const loadingStatus = attachLoadingStatus(opts.loading, {
342
+ ...DEFAULT_LOADING_MESSAGES,
343
+ ...opts.loadingMessages,
344
+ });
328
345
  function dismissLoadingOverlay(): void {
346
+ loadingStatus.stop();
329
347
  const ld = opts.loading;
330
348
  if (!ld || overlayDismissed) {
331
349
  return;
@@ -844,6 +862,13 @@ function buildTerminal(root: HTMLElement, opts: CreateTerminalOptions = {}): Ter
844
862
  announce: (message, politeness) => {
845
863
  announcer.announce(message, politeness);
846
864
  },
865
+ loadingReason: (message) => {
866
+ // No overlayDismissed guard needed: loadingStatus.stop() runs in
867
+ // dismissLoadingOverlay and every method is inert afterwards, so the
868
+ // "a late retry cannot resurrect the overlay" contract holds in ONE
869
+ // place rather than at each caller.
870
+ loadingStatus.reason(message);
871
+ },
847
872
  tablist: () => tablistController,
848
873
  layout: () => ({
849
874
  narrow: isNarrow(),
@@ -945,7 +970,8 @@ function buildTerminal(root: HTMLElement, opts: CreateTerminalOptions = {}): Ter
945
970
 
946
971
  let handled = false;
947
972
  try {
948
- handled = opts.onFatalError?.({ phase: "feature-setup", feature, cause }) === true;
973
+ handled =
974
+ opts.onFatalError?.({ phase: "feature-setup", feature, cause, surface: root }) === true;
949
975
  } catch (handlerErr) {
950
976
  console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
951
977
  }
@@ -1099,35 +1125,113 @@ function buildTerminal(root: HTMLElement, opts: CreateTerminalOptions = {}): Ter
1099
1125
  * as before, only now against a page that is no longer stuck on a spinner. A
1100
1126
  * handler returning `true` suppresses the built-in surface, same contract as the
1101
1127
  * async phase. */
1128
+ /** Resolve the mount target. A selector is looked up here, INSIDE
1129
+ * createTerminal's try, which is the whole point: a consumer that passed
1130
+ * `document.getElementById("terminal")` had to null-check it first (tsc forces
1131
+ * that), and the only thing it could do in the null branch was hand-build a
1132
+ * startup-failure surface this library already owns. Taking the selector moves
1133
+ * that failure inside the boundary and deletes the consumer's branch.
1134
+ *
1135
+ * An element is still accepted, because an embedder that CREATED the element
1136
+ * already holds a non-null reference and asking it to invent a selector for its
1137
+ * own div would be worse. The trap was never "passing an element", it was
1138
+ * "passing the result of a lookup". */
1139
+ function resolveRoot(target: HTMLElement | string): HTMLElement {
1140
+ if (typeof target !== "string") {
1141
+ return target;
1142
+ }
1143
+ const found = document.querySelector(target);
1144
+ if (found === null) {
1145
+ throw new Error(`web-terminal-ui: no element matches the mount selector ${target}`);
1146
+ }
1147
+ if (!(found instanceof HTMLElement)) {
1148
+ // An SVG or MathML element can match a selector but cannot host the terminal
1149
+ // (no style/classList contract this kernel relies on). Fail with the reason
1150
+ // rather than crashing later on a missing property.
1151
+ throw new Error(`web-terminal-ui: the mount selector ${target} matched a non-HTML element`);
1152
+ }
1153
+ return found;
1154
+ }
1155
+
1156
+ /** Build the feature list. `features` is a FUNCTION so a preset that throws does
1157
+ * so inside createTerminal's try. As an eagerly-evaluated array argument
1158
+ * (`features: presetTabbed()`) the throw happened at the CALL SITE, before this
1159
+ * function's boundary existed, which is why a consumer had to wrap the preset
1160
+ * call in its own try/catch and render its own surface. One character of
1161
+ * laziness moves that failure inside. */
1162
+ function resolveFeatures(
1163
+ features: CreateTerminalOptions["features"],
1164
+ ): readonly TerminalFeature<unknown>[] {
1165
+ return features === undefined ? [] : features();
1166
+ }
1167
+
1168
+ /** Create the host element the recovery surface uses when the mount target could
1169
+ * not be resolved at all.
1170
+ *
1171
+ * It is a NEW element appended to the body, never the body itself: .wt-root
1172
+ * carries the token defaults, box-sizing, color-scheme and scrollbar treatment,
1173
+ * and .wt-viewport carries `position: fixed; inset: 0` — stamping those on a
1174
+ * host application's own body would reformat a page that is otherwise perfectly
1175
+ * healthy. A dedicated element gets the styling boundary the surface needs
1176
+ * without touching anything the consumer owns. */
1177
+ function createFallbackHost(): HTMLElement {
1178
+ const host = document.createElement("div");
1179
+ host.className = "wt-root wt-viewport";
1180
+ document.body.appendChild(host);
1181
+ return host;
1182
+ }
1183
+
1102
1184
  export function createTerminal(
1103
- root: HTMLElement,
1185
+ target: HTMLElement | string,
1104
1186
  opts: CreateTerminalOptions = {},
1105
1187
  ): TerminalHandle {
1188
+ const layoutMode = opts.layout ?? "viewport";
1189
+ // Declared outside the try so the catch can tell "we never resolved a root"
1190
+ // from "we had one and the build failed". The old code read the root straight
1191
+ // from its parameter, so a caller who passed a null root (reachable from any
1192
+ // untyped call site — an inline <script type="module"> never sees tsc) threw a
1193
+ // SECOND error out of the catch itself, losing the real cause and rendering no
1194
+ // surface at all. There is no null root to pass now, and the unresolved case
1195
+ // has an explicit branch below.
1196
+ let root: HTMLElement | undefined;
1106
1197
  try {
1107
- return buildTerminal(root, opts);
1198
+ root = resolveRoot(target);
1199
+ return buildTerminal(root, opts, resolveFeatures(opts.features));
1108
1200
  } catch (cause) {
1109
- const layoutMode = opts.layout ?? "viewport";
1110
- // Stamp the boundary classes before rendering. buildTerminal normally does
1111
- // this early, but a throw can precede it (the multiple-session-owner guard
1112
- // fires before any DOM work by design), and every .wt-fatal rule is scoped
1113
- // :where(.wt-root) -- so without this the recovery surface would render
1114
- // completely unstyled exactly when it matters most. Idempotent when
1115
- // buildTerminal already stamped them.
1116
- root.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
1201
+ // Container mode declines the fallback on purpose. An embedded terminal is
1202
+ // one panel inside someone else's working application; if its mount target
1203
+ // is missing, claiming the viewport to say so would break a page that is
1204
+ // otherwise fine — strictly worse than the host app reporting the failure in
1205
+ // the space it owns. The failure is still delivered and still rethrown, so an
1206
+ // embedder is informed, just not overruled. A full-page consumer is the
1207
+ // opposite case: the page IS the terminal, so a blank page with nothing but a
1208
+ // console error is the worst outcome available.
1209
+ const surface = root ?? (layoutMode === "viewport" ? createFallbackHost() : undefined);
1210
+ if (surface !== undefined) {
1211
+ // Stamp the boundary classes before rendering. buildTerminal normally does
1212
+ // this early, but a throw can precede it (the multiple-session-owner guard
1213
+ // fires before any DOM work by design), and every .wt-fatal rule is scoped
1214
+ // :where(.wt-root) -- so without this the recovery surface would render
1215
+ // completely unstyled exactly when it matters most. Idempotent when
1216
+ // buildTerminal already stamped them, and when createFallbackHost did.
1217
+ surface.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
1218
+ }
1117
1219
  // The overlay must come down even though nothing was built: it is the
1118
1220
  // consumer's pre-JS spinner, and leaving it up hides the surface below it.
1221
+ // This runs even when no surface is rendered — a spinner over an embedder's
1222
+ // broken panel is a lie either way.
1119
1223
  fadeOutOverlay(opts.loading);
1120
1224
 
1121
1225
  let handled = false;
1122
1226
  try {
1123
- handled = opts.onFatalError?.({ phase: "kernel-init", cause }) === true;
1227
+ handled = opts.onFatalError?.({ phase: "kernel-init", cause, surface }) === true;
1124
1228
  } catch (handlerErr) {
1125
1229
  // Same posture as the async phase: a reporting failure must not leave the
1126
1230
  // page blank, so fall through to the built-in surface.
1127
1231
  console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
1128
1232
  }
1129
- if (!handled) {
1130
- renderFatalStartupInto(root, layoutMode);
1233
+ if (!handled && surface !== undefined) {
1234
+ renderFatalStartupInto(surface, layoutMode);
1131
1235
  }
1132
1236
  throw cause;
1133
1237
  }
@@ -0,0 +1,236 @@
1
+ // Progressive status text for the consumer's pre-JS loading overlay.
2
+ //
3
+ // The problem this solves: the overlay is an opaque full-viewport box the
4
+ // consumer paints before any JS runs, and the kernel only lowers it once the
5
+ // first terminal frame arrives. Every piece of terminal chrome the library could
6
+ // speak through -- the toast layer, the connection banner -- lives inside
7
+ // .wt-root and paints UNDER that overlay, so for as long as startup takes, the
8
+ // library can say nothing a sighted user can see. On a fast boot that does not
9
+ // matter; the overlay is gone in well under a second. On web-terminal-kiro's
10
+ // first boot it matters a lot: the server answers session creation with 503
11
+ // "tools installing" while its tool engine installs, the session owner retries
12
+ // for up to twenty minutes, and the user watched a black screen with an
13
+ // unlabelled sweep bar the whole time, unable to tell a server that is
14
+ // deliberately waiting from an app that has hung.
15
+ //
16
+ // The design, in order of what a user experiences:
17
+ //
18
+ // 1. SILENCE FIRST. Nothing is written for the first few seconds. A message
19
+ // that flashes up and vanishes on a normal boot is worse than no message,
20
+ // and most boots never reach the first threshold at all.
21
+ // 2. ONE calm line once the wait is real ("Loading terminal...").
22
+ // 3. After a minute, ROTATING reassurance. A single sentence frozen on screen
23
+ // for nineteen more minutes reads exactly as hung as no sentence -- the
24
+ // whole point is to show the system is still alive, so the line changes.
25
+ // 4. A LIVE REASON supersedes all of the above the moment one is known. The
26
+ // truthful wording for "tools installing" lives on the server, arrives in
27
+ // the 503 body, and is pushed in by the session owner; it is strictly more
28
+ // useful than anything this module could guess.
29
+ //
30
+ // Accessibility, and the trap that shapes the DOM here: the overlay is a
31
+ // role="status" live region, so anything written into it is ANNOUNCED. Rotating
32
+ // four messages every twenty seconds for twenty minutes would interrupt a screen
33
+ // reader roughly sixty times to say nothing new. So the visible line is
34
+ // aria-hidden and a separate off-screen live line carries only MEANINGFUL
35
+ // changes -- the first threshold, and each new live reason. Sighted users get
36
+ // motion; assistive tech gets information; neither gets the other's noise.
37
+
38
+ /** Wording for the progressive loading status. Every field has a library
39
+ * default; a consumer overrides only what it wants to reword. */
40
+ export interface LoadingMessages {
41
+ /** Shown once the wait passes the first threshold. */
42
+ readonly initial: string;
43
+ /** Rotated, in order, once the wait passes the second threshold. Keep them
44
+ * interchangeable: a user joins the rotation at an arbitrary point. */
45
+ readonly waiting: readonly string[];
46
+ }
47
+
48
+ export const DEFAULT_LOADING_MESSAGES: LoadingMessages = {
49
+ initial: "Loading terminal…",
50
+ waiting: [
51
+ "Still working — this can take a while on first start.",
52
+ "Still working — preparing the environment.",
53
+ "Still working — almost there.",
54
+ "Still working — thanks for your patience.",
55
+ ],
56
+ };
57
+
58
+ /** Delay before the first line appears. Long enough that a normal boot finishes
59
+ * in silence (the overlay is typically gone inside a second), short enough that
60
+ * a user who is about to start wondering gets an answer first. */
61
+ const INITIAL_DELAY_MS = 5000;
62
+ /** When the rotation takes over from the single initial line. */
63
+ const WAITING_AFTER_MS = 60000;
64
+ /** How often the rotation advances. Slow enough not to read as nervous chatter,
65
+ * brisk enough that a glance a few seconds later shows something changed. */
66
+ const ROTATE_EVERY_MS = 20000;
67
+ /** The fade-out half of a message swap; must stay under ROTATE_EVERY_MS and
68
+ * match the .wt-loading-text transition in css/page.css. */
69
+ const SWAP_FADE_MS = 400;
70
+
71
+ /** Controls the status line on one overlay. Every method is safe to call after
72
+ * stop(), so the kernel never has to sequence teardown against a pending
73
+ * timer. */
74
+ export interface LoadingStatus {
75
+ /** Replace the wording with a known, specific reason and keep it until the
76
+ * reason CHANGES. Cancels the rotation: a real reason outranks reassurance,
77
+ * and alternating between them would be incoherent. Safe to call on every
78
+ * tick of a retry loop -- an identical string is a no-op, so the announced
79
+ * line is not re-read to a screen reader every few seconds. */
80
+ reason(text: string): void;
81
+ /** Cancel every timer and remove the text nodes. Idempotent. */
82
+ stop(): void;
83
+ }
84
+
85
+ /** Attach a progressive status line to `overlay`.
86
+ *
87
+ * `overlay` is the consumer's element, so this only ever APPENDS two children
88
+ * and removes them again on stop(); it never touches the overlay's own markup,
89
+ * attributes or classes. A consumer that supplies no overlay gets an inert
90
+ * controller, so callers need no null handling. */
91
+ export function attachLoadingStatus(
92
+ overlay: HTMLElement | undefined,
93
+ messages: LoadingMessages = DEFAULT_LOADING_MESSAGES,
94
+ doc: Document = document,
95
+ ): LoadingStatus {
96
+ if (!overlay) {
97
+ return { reason: () => undefined, stop: () => undefined };
98
+ }
99
+
100
+ // The visible line. aria-hidden because the overlay is a live region and this
101
+ // element's whole job is to CHANGE often; the live line below is what speaks.
102
+ const visible = doc.createElement("p");
103
+ visible.className = "wt-loading-text";
104
+ visible.setAttribute("aria-hidden", "true");
105
+
106
+ // The announced line. Off-screen by the same clip technique the kernel
107
+ // announcer uses, and written only on meaningful transitions.
108
+ const live = doc.createElement("p");
109
+ live.className = "wt-loading-live";
110
+ Object.assign(live.style, {
111
+ position: "absolute",
112
+ inlineSize: "1px",
113
+ blockSize: "1px",
114
+ margin: "-1px",
115
+ padding: "0",
116
+ overflow: "hidden",
117
+ clipPath: "inset(50%)",
118
+ whiteSpace: "nowrap",
119
+ border: "0",
120
+ });
121
+
122
+ overlay.append(visible, live);
123
+
124
+ const timers: number[] = [];
125
+ let rotation: number | undefined;
126
+ let rotateIndex = 0;
127
+ let stopped = false;
128
+ let pinned = false; // a live reason has superseded the scripted wording
129
+ let current = ""; // last reason shown, so a repeat call is a no-op
130
+
131
+ const later = (fn: () => void, ms: number): void => {
132
+ timers.push(window.setTimeout(fn, ms));
133
+ };
134
+
135
+ // Swap the visible text through a fade so a change reads as deliberate rather
136
+ // than as a glitch. The first write skips the fade (there is nothing to fade
137
+ // out) so the line does not arrive half-transparent.
138
+ const show = (text: string): void => {
139
+ if (stopped) {
140
+ return;
141
+ }
142
+ // First write has nothing to fade out, so it must not arrive half-transparent.
143
+ if (visible.textContent === "") {
144
+ visible.textContent = text;
145
+ return;
146
+ }
147
+ visible.classList.add("wt-loading-text-out");
148
+ later(() => {
149
+ if (stopped) {
150
+ return;
151
+ }
152
+ visible.textContent = text;
153
+ visible.classList.remove("wt-loading-text-out");
154
+ }, SWAP_FADE_MS);
155
+ };
156
+
157
+ const announce = (text: string): void => {
158
+ if (!stopped) {
159
+ live.textContent = text;
160
+ }
161
+ };
162
+
163
+ later(() => {
164
+ if (pinned) {
165
+ return;
166
+ }
167
+ show(messages.initial);
168
+ announce(messages.initial);
169
+ }, INITIAL_DELAY_MS);
170
+
171
+ later(() => {
172
+ const waiting = messages.waiting;
173
+ if (pinned || stopped || waiting.length === 0) {
174
+ return;
175
+ }
176
+ // showAt reads through an undefined check rather than an index assertion:
177
+ // under noUncheckedIndexedAccess every element read is `string | undefined`,
178
+ // and the check is the only form both the assertion-style and
179
+ // no-non-null-assertion lint rules accept.
180
+ const showAt = (i: number): void => {
181
+ const text = waiting[i];
182
+ if (text !== undefined) {
183
+ show(text);
184
+ }
185
+ };
186
+ // Deliberately NOT announced, now or on any rotation: these carry no
187
+ // information a screen-reader user has not already been told once.
188
+ showAt(0);
189
+ rotation = window.setInterval(() => {
190
+ // No pinned/stopped guard here: both reason() and stop() clear this
191
+ // interval, so a tick cannot reach this line after either. The guard that
192
+ // matters is the `pinned` check on the ENCLOSING timer above, because the
193
+ // real production sequence puts reason() FIRST -- the server refuses
194
+ // session creation within a second or two, long before this 60s threshold
195
+ // -- and without it the rotation would start late and overwrite the
196
+ // server's own explanation with generic reassurance.
197
+ rotateIndex = (rotateIndex + 1) % waiting.length;
198
+ showAt(rotateIndex);
199
+ }, ROTATE_EVERY_MS);
200
+ }, WAITING_AFTER_MS);
201
+
202
+ return {
203
+ reason(text: string): void {
204
+ // Idempotent by text, and that is load-bearing rather than an
205
+ // optimisation: the natural caller is a retry loop that knows the reason
206
+ // on every tick, and re-writing the announced line would re-announce the
207
+ // same sentence to a screen reader every few seconds for the whole wait.
208
+ // Guarding here means a caller may call this as often as it likes.
209
+ if (stopped || text === "" || text === current) {
210
+ return;
211
+ }
212
+ current = text;
213
+ pinned = true;
214
+ if (rotation !== undefined) {
215
+ window.clearInterval(rotation);
216
+ rotation = undefined;
217
+ }
218
+ show(text);
219
+ announce(text);
220
+ },
221
+ stop(): void {
222
+ if (stopped) {
223
+ return;
224
+ }
225
+ stopped = true;
226
+ for (const t of timers) {
227
+ window.clearTimeout(t);
228
+ }
229
+ if (rotation !== undefined) {
230
+ window.clearInterval(rotation);
231
+ }
232
+ visible.remove();
233
+ live.remove();
234
+ },
235
+ };
236
+ }
@@ -0,0 +1,35 @@
1
+ // The copy of the "terminal did not start" recovery surface, as DATA.
2
+ //
3
+ // Why this is a module of its own rather than string literals inside the
4
+ // renderer: a full-page consumer needs the same words in a place no module can
5
+ // reach. Its HTML carries an inline bootstrap watchdog whose entire job is to
6
+ // report "the JS bundle never ran" -- a rung below `import`, by definition, or
7
+ // it could not detect its own failure case. That watchdog cannot import these
8
+ // strings at runtime, so a consumer's BUILD substitutes them into its HTML
9
+ // instead, and the words survive as one declaration rather than three
10
+ // hand-agreed copies (the pattern this replaced had web-terminal-kiro's app
11
+ // code, its inline watchdog, and this library all restating them, kept in step
12
+ // by comments asking the next reader to remember).
13
+ //
14
+ // This file therefore imports nothing and touches no DOM, so a Node build script
15
+ // can read it without loading the kernel. It is exported at the package root and
16
+ // at the "./startup-copy" subpath for exactly that use.
17
+
18
+ /** The words the recovery surface shows, whichever startup rung failed.
19
+ *
20
+ * A user cannot tell these failures apart and does not need to: the bundle not
21
+ * loading, the mount target not existing, and a feature constructor throwing
22
+ * all look like "I opened this and there is no terminal", and reloading is what
23
+ * they will try. So the title and the action label are deliberately identical
24
+ * across every rung; only the diagnostic detail differs, and that belongs in a
25
+ * log or a consumer-supplied message, not in the headline.
26
+ *
27
+ * `message` is the generic body the library uses when it has nothing more
28
+ * specific. A consumer with a precise cause (its own bootstrap watchdog knows a
29
+ * stylesheet failed) should say so instead -- it is strictly more useful than
30
+ * this text -- while still taking `title` and `reloadLabel` from here. */
31
+ export const STARTUP_FAILURE_COPY = {
32
+ title: "Terminal failed to start",
33
+ message: "A required interface could not be loaded. Reload the page to try again.",
34
+ reloadLabel: "Reload page",
35
+ } as const;
@@ -7,6 +7,7 @@
7
7
  // capability lookup) so a feature's public API is held by reference and a peer
8
8
  // reads it through a typed token.
9
9
 
10
+ import type { LoadingMessages } from "./loading-status.js";
10
11
  import type { ScreenMessage, ModesMessage, LineStore } from "@cplieger/web-terminal-engine";
11
12
 
12
13
  /** Cancels a subscription or registration. Idempotent by convention. */
@@ -220,6 +221,19 @@ export interface TerminalContext {
220
221
  /** Announce a message on the single kernel-owned polite (or assertive) live
221
222
  * region, so features do not spawn competing aria-live regions. */
222
223
  announce(message: string, politeness?: "polite" | "assertive"): void;
224
+ /** Report WHY startup is still waiting, onto the consumer's loading overlay.
225
+ *
226
+ * For the window before the first frame, when the overlay covers every
227
+ * surface a feature could otherwise speak through: the toast layer and the
228
+ * connection banner both live inside .wt-root and paint UNDER it, so a
229
+ * feature that retries for minutes has no visible channel without this.
230
+ * Supersedes the kernel's own scripted wording (and stops its rotation),
231
+ * because a reason the SERVER gave is always better than a guess -- pass the
232
+ * server's own message where there is one. Safe to call on every tick of a
233
+ * retry loop: an unchanged message is a no-op, so a screen reader is not
234
+ * re-read the same sentence every few seconds. Also a no-op once the overlay
235
+ * has been dismissed, so a late retry cannot resurrect it. */
236
+ loadingReason(message: string): void;
223
237
  /** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
224
238
  tablist(): TablistController;
225
239
 
@@ -318,19 +332,26 @@ export interface FeatureInstance<Api = void> {
318
332
  /** A fatal failure while createTerminal is starting up.
319
333
  *
320
334
  * Two phases can fail, and both are delivered here so a consumer never has to
321
- * hand-build its own startup-failure surface:
335
+ * hand-build its own startup-failure surface. That sentence used to be
336
+ * aspirational: resolving the mount target and constructing the feature list
337
+ * both happened at the CALL SITE, outside this boundary, so a consumer really
338
+ * did need its own surface for those two — and every consumer that built one
339
+ * restated this library's copy and re-decided its focus and ARIA behavior.
340
+ * createTerminal now takes a selector and a feature THUNK, which pulls both
341
+ * failures inside, and the promise holds literally.
322
342
  *
323
- * - `kernel-init`: a SYNCHRONOUS throw out of createTerminal (an invalid
324
- * feature list, a DOM invariant, a feature constructor). The kernel renders
325
- * the recovery surface and then RETHROWS, so a caller with its own handling
326
- * still sees the error.
343
+ * - `kernel-init`: a SYNCHRONOUS throw out of createTerminal (an unresolvable
344
+ * mount selector, a throwing preset or feature constructor, an invalid
345
+ * feature list, a DOM invariant). The kernel renders the recovery surface and
346
+ * then RETHROWS, so a caller with its own handling still sees the error.
327
347
  * - `feature-setup`: an async feature-composition failure. The kernel has
328
348
  * already stopped the connection, released every listener and singleton,
329
349
  * torn down completed features, and cleared the terminal root when it
330
350
  * delivers this value. Nothing is rethrown; the failure is asynchronous.
331
351
  *
332
352
  * Discriminate on `phase`: `feature-setup` names the offending feature,
333
- * `kernel-init` has no feature to name because composition never began. */
353
+ * `kernel-init` has no feature to name because composition never began, and
354
+ * only `kernel-init` can carry an undefined `surface`. */
334
355
  export type TerminalStartupFailure =
335
356
  | {
336
357
  readonly phase: "feature-setup";
@@ -338,19 +359,41 @@ export type TerminalStartupFailure =
338
359
  readonly feature: string;
339
360
  /** The original thrown or rejected value. */
340
361
  readonly cause: unknown;
362
+ /** The element the built-in surface would fill — always the terminal root
363
+ * in this phase, since the terminal had already mounted. */
364
+ readonly surface: HTMLElement;
341
365
  }
342
366
  | {
343
367
  readonly phase: "kernel-init";
344
368
  /** The original thrown value, rethrown to the caller after this returns. */
345
369
  readonly cause: unknown;
370
+ /** The element the built-in surface would fill, and the element a handler
371
+ * claiming the surface (returning true) must render into. Usually the
372
+ * resolved root — but when the MOUNT TARGET itself could not be resolved
373
+ * there is no root, so in `viewport` layout this is a fresh full-viewport
374
+ * element the kernel appended to the body, and in `container` layout it is
375
+ * `undefined`: an embedded terminal is one panel in a host application, so
376
+ * a missing mount target is reported and rethrown but never answered by
377
+ * seizing the host's page. A handler that renders its own UI must
378
+ * therefore check for undefined. */
379
+ readonly surface: HTMLElement | undefined;
346
380
  };
347
381
 
348
382
  /** Options for createTerminal. */
349
383
  export interface CreateTerminalOptions {
350
- /** The feature list; omitted or empty means the bare kernel (no chrome).
351
- * Heterogeneous feature APIs are held as unknown here; a consumer reads a
352
- * specific feature's api off the feature value it holds. */
353
- features?: readonly TerminalFeature<unknown>[];
384
+ /** The feature list, as a FUNCTION that produces it; omitted means the bare
385
+ * kernel (no chrome). Heterogeneous feature APIs are held as unknown here; a
386
+ * consumer reads a specific feature's api off the feature value it holds.
387
+ *
388
+ * It is a function, not an array, so that a preset or feature constructor
389
+ * that throws does so INSIDE createTerminal's failure boundary. Passed as an
390
+ * array (`features: presetTabbed()`) the call was evaluated as an argument —
391
+ * before createTerminal was entered — so the throw escaped the library
392
+ * entirely and every consumer had to wrap the preset call in its own
393
+ * try/catch and hand-build a recovery surface. Write `features:
394
+ * presetTabbed` when the preset takes no arguments, or `features: () =>
395
+ * presetTabbed({...})` when it does. */
396
+ features?: () => readonly TerminalFeature<unknown>[];
354
397
  /** How the terminal claims space (default "viewport").
355
398
  * "viewport": the root becomes a fixed full-viewport box — the full-page
356
399
  * product (web-terminal-server, web-terminal-kiro, the scaffold page).
@@ -363,8 +406,17 @@ export interface CreateTerminalOptions {
363
406
  wsPath?: string;
364
407
  /** CSS font shorthand awaited before the first resize. */
365
408
  fontReady?: string;
366
- /** Optional pre-JS loading overlay the kernel fades out on first paint. */
409
+ /** Optional pre-JS loading overlay the kernel fades out on first paint. Give
410
+ * it the `wt-loading` class and a `wt-loading-bar` child and css/page.css
411
+ * styles it; the kernel also writes a progressive status line into it while
412
+ * startup drags on (see kernel/loading-status.ts). */
367
413
  loading?: HTMLElement;
414
+ /** Reword the loading overlay's progressive status text. Partial: anything
415
+ * omitted keeps the library default. The wording only ever appears on a SLOW
416
+ * start (nothing is written for the first few seconds), so this is about what
417
+ * a waiting user reads, not about branding the normal case. A live reason
418
+ * pushed by a feature via `ctx.loadingReason` supersedes all of it. */
419
+ loadingMessages?: Partial<LoadingMessages>;
368
420
  /** Observe or replace the kernel's fatal feature-setup surface. Called only
369
421
  * after the live terminal has been completely torn down and its root
370
422
  * cleared. Return true after rendering a replacement recovery surface into