@cplieger/web-terminal-ui 4.8.0 → 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 +51 -19
- package/css/00-tokens.css +21 -4
- package/css/page.css +167 -0
- package/package.json +2 -1
- package/scaffold/index.html +22 -16
- package/src/features/tabs/index.ts +104 -16
- package/src/index.ts +1 -0
- package/src/kernel/kernel.ts +128 -24
- package/src/kernel/loading-status.ts +236 -0
- package/src/kernel/startup-copy.ts +35 -0
- package/src/kernel/types.ts +63 -11
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(
|
|
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(
|
|
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(
|
|
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(
|
|
96
|
+
// createTerminal("#terminal", { features: presetTabbed, wsPath: "/api/shell/ws", fontReady: '14px "MyMono"' });
|
|
97
97
|
</script>
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
`createTerminal(
|
|
101
|
-
plus every feature's chrome) inside
|
|
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)_ |
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
170
|
-
|
|
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)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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": "
|
|
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",
|
package/scaffold/index.html
CHANGED
|
@@ -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
|
-
<!--
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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"
|
|
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
|
-
|
|
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>
|
|
@@ -79,7 +79,8 @@ const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
|
|
|
79
79
|
// terminal context menu (formerly two hand-synced copies of the same math).
|
|
80
80
|
|
|
81
81
|
export interface TabsApi {
|
|
82
|
-
/** Spawn a fresh session and switch to it.
|
|
82
|
+
/** Spawn a fresh session and switch to it. Calls made while a create is in
|
|
83
|
+
* flight share that create, so one gesture opens exactly one terminal. */
|
|
83
84
|
create(): Promise<void>;
|
|
84
85
|
/** Close a session (kills its process) and drop its tab + cache. */
|
|
85
86
|
close(id: string): Promise<void>;
|
|
@@ -213,6 +214,14 @@ async function createSessionHonouringRetry(
|
|
|
213
214
|
ctx.toast(`${waiting}; retrying`, 8000);
|
|
214
215
|
ctx.announce(`${waiting}; retrying`);
|
|
215
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…`);
|
|
216
225
|
await new Promise((resolve) => {
|
|
217
226
|
window.setTimeout(resolve, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS);
|
|
218
227
|
});
|
|
@@ -297,7 +306,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
297
306
|
// the FIRST tap to blur the input (so "+" only fired create on the second
|
|
298
307
|
// tap — the reported double-tap). preventDefault on pointerdown fires the
|
|
299
308
|
// action on the first tap; switchTo then re-focuses the input for the new
|
|
300
|
-
// tab
|
|
309
|
+
// tab (see focusAfterSwitch). It does NOT make one press one activation —
|
|
310
|
+
// that is create()'s in-flight coalescing, for the opposite failure.
|
|
301
311
|
btn.addEventListener("pointerdown", (e) => {
|
|
302
312
|
e.preventDefault();
|
|
303
313
|
});
|
|
@@ -657,8 +667,58 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
657
667
|
}
|
|
658
668
|
}
|
|
659
669
|
|
|
670
|
+
const termInput = (): HTMLElement | null =>
|
|
671
|
+
ctx.surface().querySelector<HTMLElement>(".term-input");
|
|
660
672
|
function focusInput(): void {
|
|
661
|
-
|
|
673
|
+
termInput()?.focus({ preventScroll: true });
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Did the terminal input own the keyboard when the pointer press now being
|
|
677
|
+
// handled began? A press on tab chrome focuses the control it landed on
|
|
678
|
+
// (the browser's mousedown default action), so by the time the click
|
|
679
|
+
// handler runs the input is already blurred and only a pointerdown-time
|
|
680
|
+
// snapshot still knows where the keyboard came from. Recorded for the whole
|
|
681
|
+
// chrome by the two pointerdown listeners below (the strip and the
|
|
682
|
+
// switcher), not per control: chips come and go with sessions, and the fact
|
|
683
|
+
// belongs to the gesture rather than to what it hit.
|
|
684
|
+
let inputFocusedAtPress = false;
|
|
685
|
+
function noteChromePress(): void {
|
|
686
|
+
const el = termInput();
|
|
687
|
+
inputFocusedAtPress = el !== null && document.activeElement === el;
|
|
688
|
+
}
|
|
689
|
+
function keyboardParkedOnChrome(): boolean {
|
|
690
|
+
const active = document.activeElement;
|
|
691
|
+
return active !== null && (bar.contains(active) || switcher.contains(active));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// focusAfterSwitch applies the tab-switch focus rule. Two independent
|
|
695
|
+
// reasons to hand the keyboard to the terminal input:
|
|
696
|
+
//
|
|
697
|
+
// - A physical keyboard is (likely) present, so a switch should leave you
|
|
698
|
+
// able to type at once, with no extra tap (the iPad + Magic Keyboard
|
|
699
|
+
// ask). Skipped on a keyboard-less touchscreen, or every switch would
|
|
700
|
+
// pop the virtual keyboard (#7).
|
|
701
|
+
// - The press that drove this switch took the keyboard OFF the terminal —
|
|
702
|
+
// it focused the chip (or the x, or a switcher row) it pressed, and the
|
|
703
|
+
// keyboard is still parked there. Handing it back is not NEW focus, so
|
|
704
|
+
// it pops no soft keyboard: it restores what the press displaced. Both
|
|
705
|
+
// halves are required for exactly that reason — a switch with no press
|
|
706
|
+
// behind it (a remote adopt, ensureActive), or a press on a device where
|
|
707
|
+
// the input was not focused to begin with, must not summon a keyboard.
|
|
708
|
+
// Leaving the keyboard parked on a chip is not neutral either: the
|
|
709
|
+
// strip's own keydown handling reads arrows as "switch tab" and Delete
|
|
710
|
+
// as "close tab", so every keystroke meant for the terminal is eaten.
|
|
711
|
+
//
|
|
712
|
+
// Never while a rename field is open: it owns the keyboard until it closes,
|
|
713
|
+
// and a switch (the leading click of a double-click, a remote-driven
|
|
714
|
+
// ensureActive) would otherwise yank the caret out of the field.
|
|
715
|
+
function focusAfterSwitch(): void {
|
|
716
|
+
if (editingId !== null) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (physicalKeyboardLikely() || (inputFocusedAtPress && keyboardParkedOnChrome())) {
|
|
720
|
+
focusInput();
|
|
721
|
+
}
|
|
662
722
|
}
|
|
663
723
|
|
|
664
724
|
// paintActive updates the desktop strip's active state. When the ACTIVE
|
|
@@ -1249,6 +1309,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1249
1309
|
|
|
1250
1310
|
function switchTo(id: string, dir?: "next" | "prev"): void {
|
|
1251
1311
|
if (id === activeId) {
|
|
1312
|
+
// Already active, so there is no switch to perform — but the FOCUS rule
|
|
1313
|
+
// still owes an answer, because the press that delivered this click has
|
|
1314
|
+
// already moved the keyboard onto the chip. Two pointer paths land here:
|
|
1315
|
+
// clicking the tab that is already active, and the SECOND activation of
|
|
1316
|
+
// one press on another tab (see create() for the evidence that this
|
|
1317
|
+
// device delivers those). The first activation switches and focuses the
|
|
1318
|
+
// terminal; the second one's mousedown re-focuses the chip and then fell
|
|
1319
|
+
// out here with the keyboard stranded on it — the reported "the
|
|
1320
|
+
// invisible input loses focus when I click another tab", and the same
|
|
1321
|
+
// hole a plain mouse click on the active tab hit on every platform.
|
|
1322
|
+
focusAfterSwitch();
|
|
1252
1323
|
return;
|
|
1253
1324
|
}
|
|
1254
1325
|
const next = tabList.find((t) => t.id === id);
|
|
@@ -1339,16 +1410,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1339
1410
|
}
|
|
1340
1411
|
});
|
|
1341
1412
|
ctx.announce(`Switched to ${next.display}`);
|
|
1342
|
-
|
|
1343
|
-
// A physical keyboard is (likely) present, so focus the input on
|
|
1344
|
-
// switch — switch and type immediately (the iPad + Magic Keyboard
|
|
1345
|
-
// ask). On a keyboard-less touchscreen this is skipped, or every
|
|
1346
|
-
// switch would pop the virtual keyboard (#7). Skipped entirely while a
|
|
1347
|
-
// rename field is open: a switch (e.g. the leading click of a
|
|
1348
|
-
// double-click, or a remote-driven ensureActive) would otherwise yank
|
|
1349
|
-
// the caret out of the field the user is typing in.
|
|
1350
|
-
focusInput();
|
|
1351
|
-
}
|
|
1413
|
+
focusAfterSwitch();
|
|
1352
1414
|
}
|
|
1353
1415
|
|
|
1354
1416
|
// armCatchup shows the "catching up" cue only if the resume delta has not
|
|
@@ -1590,7 +1652,28 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1590
1652
|
}
|
|
1591
1653
|
}
|
|
1592
1654
|
|
|
1593
|
-
|
|
1655
|
+
// create is the coalesced door: while one create is in flight, every caller
|
|
1656
|
+
// asking for another gets THAT one, so a single gesture can only ever open
|
|
1657
|
+
// one terminal. Spawning a session is expensive and non-idempotent, and one
|
|
1658
|
+
// press does not reliably arrive as one activation — the web-terminal-kiro
|
|
1659
|
+
// server logged two POST /api/sessions 0-3ms apart (three times in one
|
|
1660
|
+
// afternoon, both answered 201) for single "+" taps from an iPad + Magic
|
|
1661
|
+
// Keyboard, so the user got two terminals and had to close one. Sharing the
|
|
1662
|
+
// in-flight promise needs no threshold to make that decision, unlike a
|
|
1663
|
+
// click-level debounce: a duplicate activation lands while the POST is still
|
|
1664
|
+
// open and collapses into it, while a deliberate second tap (the POST
|
|
1665
|
+
// resolves in milliseconds) still opens a second terminal. It also stops "+"
|
|
1666
|
+
// mashing from queueing sessions across the server's 503 install window,
|
|
1667
|
+
// where createSessionHonouringRetry legitimately waits minutes.
|
|
1668
|
+
let creating: Promise<void> | null = null;
|
|
1669
|
+
function create(): Promise<void> {
|
|
1670
|
+
creating ??= openNewSession().finally(() => {
|
|
1671
|
+
creating = null;
|
|
1672
|
+
});
|
|
1673
|
+
return creating;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
async function openNewSession(): Promise<void> {
|
|
1594
1677
|
let info: SessionInfo;
|
|
1595
1678
|
try {
|
|
1596
1679
|
info = await createSessionHonouringRetry(api, ctx, () => tornDown);
|
|
@@ -2041,15 +2124,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2041
2124
|
const menuSwallow = createClickSwallow();
|
|
2042
2125
|
let menuTouch = false;
|
|
2043
2126
|
// One listener on the bar rather than one per chip: chips are created and
|
|
2044
|
-
// destroyed as sessions come and go, and
|
|
2045
|
-
// gesture,
|
|
2127
|
+
// destroyed as sessions come and go, and both facts recorded here (the
|
|
2128
|
+
// gesture's pointer type, and whether it took the keyboard off the
|
|
2129
|
+
// terminal) belong to the gesture, not to a chip.
|
|
2046
2130
|
bar.addEventListener(
|
|
2047
2131
|
"pointerdown",
|
|
2048
2132
|
(e) => {
|
|
2049
2133
|
menuTouch = e.pointerType === "touch";
|
|
2134
|
+
noteChromePress();
|
|
2050
2135
|
},
|
|
2051
2136
|
{ passive: true },
|
|
2052
2137
|
);
|
|
2138
|
+
// The switcher's half of that snapshot (its rows and the x are buttons, so
|
|
2139
|
+
// pressing one focuses it and blurs the terminal exactly as a chip does).
|
|
2140
|
+
switcher.addEventListener("pointerdown", noteChromePress, { passive: true });
|
|
2053
2141
|
function hideTabMenu(): void {
|
|
2054
2142
|
tabMenu.classList.remove("visible");
|
|
2055
2143
|
tabMenu.replaceChildren();
|
package/src/index.ts
CHANGED
package/src/kernel/kernel.ts
CHANGED
|
@@ -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(
|
|
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 =
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 =
|
|
146
|
+
reloadButton.textContent = STARTUP_FAILURE_COPY.reloadLabel;
|
|
141
147
|
reloadButton.addEventListener("click", () => {
|
|
142
148
|
window.location.reload();
|
|
143
149
|
});
|
|
144
|
-
card.append(title,
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1198
|
+
root = resolveRoot(target);
|
|
1199
|
+
return buildTerminal(root, opts, resolveFeatures(opts.features));
|
|
1108
1200
|
} catch (cause) {
|
|
1109
|
-
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
|
|
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(
|
|
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;
|
package/src/kernel/types.ts
CHANGED
|
@@ -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
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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
|
|
351
|
-
* Heterogeneous feature APIs are held as unknown here; a
|
|
352
|
-
* specific feature's api off the feature value it holds.
|
|
353
|
-
|
|
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
|