@cplieger/web-terminal-ui 4.8.1 → 5.0.1
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/30-tabs.css +76 -16
- package/css/31-switcher.css +20 -10
- package/css/page.css +167 -0
- package/package.json +2 -1
- package/scaffold/index.html +22 -16
- package/src/features/tabs/index.ts +27 -4
- 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/30-tabs.css
CHANGED
|
@@ -75,6 +75,22 @@
|
|
|
75
75
|
width: 300px;
|
|
76
76
|
flex: 0 1 auto;
|
|
77
77
|
min-width: 100px;
|
|
78
|
+
/* ONE height for every control in the strip: the chip, the "+" and the
|
|
79
|
+
keyboard button all resolve to --touch-target, so a chip and a button can
|
|
80
|
+
never disagree about how tall the row is. Before this the chip's height was
|
|
81
|
+
whatever its tallest child happened to be, and the "+" hard-coded a COPY of
|
|
82
|
+
that arithmetic (close button + padding + border) — two sources of truth
|
|
83
|
+
for one row, which drifted the moment a child's box grew: a browser without
|
|
84
|
+
text-box support kept the label's untrimmed line box (31px, past the 24px
|
|
85
|
+
close button), so the chip rendered 49px tall next to a 42px "+" and a 44px
|
|
86
|
+
keyboard button. --touch-target is also the tap-target floor the rest of
|
|
87
|
+
the chrome already respects, including this chip's mobile counterpart
|
|
88
|
+
(.wt-switcher-current in 31-switcher.css, same declaration).
|
|
89
|
+
|
|
90
|
+
min-height, not height: the floor governs today (the tallest child is the
|
|
91
|
+
1.5rem close button, which fits with room to spare), and taller future
|
|
92
|
+
content grows the row rather than overflowing a fixed box. */
|
|
93
|
+
min-height: var(--touch-target);
|
|
78
94
|
padding: var(--sp-2);
|
|
79
95
|
/* A transparent border is reserved on every tab so toggling the inactive
|
|
80
96
|
outline (below) never shifts the strip's layout. */
|
|
@@ -112,9 +128,8 @@
|
|
|
112
128
|
|
|
113
129
|
/* Press (mousedown / touch-hold): one rung past the hover lift — same recipe,
|
|
114
130
|
deeper mix — so hover -> press reads as a single dimension. After the hover
|
|
115
|
-
rule so it wins while both apply.
|
|
116
|
-
|
|
117
|
-
it is. */
|
|
131
|
+
rule so it wins while both apply. The active chip has its own pair (above),
|
|
132
|
+
mixed against the active fill instead of the inactive one. */
|
|
118
133
|
:where(.wt-root) .wt-tab:not(.wt-tab-active):active {
|
|
119
134
|
background: color-mix(in oklch, var(--tab-bg), var(--text) 25%);
|
|
120
135
|
color: var(--tab-active-fg);
|
|
@@ -129,6 +144,34 @@
|
|
|
129
144
|
color: var(--tab-active-fg);
|
|
130
145
|
}
|
|
131
146
|
|
|
147
|
+
/* The active tab answers a press too. Clicking it selects nothing, which is why
|
|
148
|
+
it was originally left inert — but selection is not the only thing a chip does:
|
|
149
|
+
it is a drag handle for reordering, a right-click target for the tab menu, a
|
|
150
|
+
double-tap target for rename, and it carries a close button. A control with
|
|
151
|
+
four affordances and no pointer feedback reads as decoration, and it was the
|
|
152
|
+
only chip in the strip that gave none (the same gap that got the keyboard
|
|
153
|
+
button its hover wash).
|
|
154
|
+
|
|
155
|
+
The lift is mixed with --tab-active-fg rather than --text: this is a lighter
|
|
156
|
+
press over an already-filled control (the .wt-switcher-current / .wt-tab-new
|
|
157
|
+
recipe), not a wash over a transparent one, so it follows the filled-surface
|
|
158
|
+
vocabulary and stays themeable through one token. It is a rung subtler than the
|
|
159
|
+
inactive chip's 15%/25% because it starts from an already-lifted fill.
|
|
160
|
+
|
|
161
|
+
Hover is gated to real hover pointers. Tapping an INACTIVE chip makes it
|
|
162
|
+
active, so a touchscreen's sticky :hover leaves with the class; tapping the
|
|
163
|
+
ACTIVE chip changes nothing, so an ungated hover would stay lit until the next
|
|
164
|
+
tap somewhere else. Touch feedback there is :active, which is correct. */
|
|
165
|
+
@media (hover: hover) {
|
|
166
|
+
:where(.wt-root) .wt-tab-active:hover {
|
|
167
|
+
background: color-mix(in oklch, var(--tab-active-bg), var(--tab-active-fg) 12%);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
:where(.wt-root) .wt-tab-active:active {
|
|
172
|
+
background: color-mix(in oklch, var(--tab-active-bg), var(--tab-active-fg) 22%);
|
|
173
|
+
}
|
|
174
|
+
|
|
132
175
|
/* The label flexes to fill the fixed-width tab and truncates with an ellipsis
|
|
133
176
|
(min-width: 0 lets a flex item shrink below its content width). */
|
|
134
177
|
:where(.wt-root) .wt-tab-label {
|
|
@@ -137,7 +180,9 @@
|
|
|
137
180
|
overflow: hidden;
|
|
138
181
|
white-space: nowrap;
|
|
139
182
|
text-overflow: ellipsis;
|
|
140
|
-
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* Center the INK, not the line box: UI fonts carry far more ascent than
|
|
141
186
|
descent (the -apple-system/Noto Sans stack at 13px: 14px over / 4px under
|
|
142
187
|
the baseline), so the flex-centered line box leaves the visible text ~1.5px
|
|
143
188
|
low — reading as baseline-aligned with the activity dot's bottom instead of
|
|
@@ -159,15 +204,26 @@
|
|
|
159
204
|
measured: 3.3px of p/y/g/j tail and 2.3px of an acute accent, i.e. "pygmy
|
|
160
205
|
jaguar Ápple" rendered as "pyqmy iaquar Apple"). 0.5em restores the ink
|
|
161
206
|
overhang the trim removes from the box; being symmetric it does not move
|
|
162
|
-
the centered band, and being em-relative it tracks the font size.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
box
|
|
207
|
+
the centered band, and being em-relative it tracks the font size.
|
|
208
|
+
|
|
209
|
+
The pair is @supports-GATED because the padding is the trim's allowance and
|
|
210
|
+
nothing else: an untrimmed line box already contains that overhang, so
|
|
211
|
+
adding the padding there is not "harmless slack" (this comment used to
|
|
212
|
+
claim it was) — it grew the label's box to 31px, past the 24px close button,
|
|
213
|
+
and a browser without text-box support rendered 49px tab chips beside a
|
|
214
|
+
42px "+" and a 44px keyboard button. The chip's own --touch-target floor
|
|
215
|
+
pins the row height either way; the gate is what keeps the label from
|
|
216
|
+
pushing PAST it. Gating the shorthand (not the longhands) is deliberate: an
|
|
217
|
+
engine that ships text-box-trim without the shorthand does not apply the
|
|
218
|
+
declaration we actually write, so it must take the untrimmed branch too.
|
|
166
219
|
|
|
167
220
|
Shared by all three chip labels (this one + the two switcher labels in
|
|
168
221
|
31-switcher.css). */
|
|
169
|
-
|
|
170
|
-
|
|
222
|
+
@supports (text-box: trim-both ex alphabetic) {
|
|
223
|
+
:where(.wt-root) .wt-tab-label {
|
|
224
|
+
text-box: trim-both ex alphabetic;
|
|
225
|
+
padding-block: 0.5em;
|
|
226
|
+
}
|
|
171
227
|
}
|
|
172
228
|
|
|
173
229
|
/* A little breathing room after the activity dot, before the label. */
|
|
@@ -241,12 +297,16 @@
|
|
|
241
297
|
align-items: center;
|
|
242
298
|
justify-content: center;
|
|
243
299
|
flex: none;
|
|
244
|
-
/*
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
300
|
+
/* A SQUARE box of exactly the strip's control height, so it lines up with the
|
|
301
|
+
tab chips beside it and with the keyboard button after it (the .wt-btn
|
|
302
|
+
primitive's 44px box, same token). Sized explicitly on both axes rather
|
|
303
|
+
than by aspect-ratio: a stretched flex item ignores aspect-ratio against
|
|
304
|
+
its SVG's intrinsic width. It used to restate the chip's height arithmetic
|
|
305
|
+
(close button + padding + border) as a calc(), which is how it ended up
|
|
306
|
+
7px short of a chip whose content grew — see the min-height note on
|
|
307
|
+
.wt-tab. */
|
|
308
|
+
width: var(--touch-target);
|
|
309
|
+
height: var(--touch-target);
|
|
250
310
|
padding: 0;
|
|
251
311
|
background: transparent;
|
|
252
312
|
border: none;
|
package/css/31-switcher.css
CHANGED
|
@@ -178,11 +178,16 @@
|
|
|
178
178
|
text-overflow: ellipsis;
|
|
179
179
|
white-space: nowrap;
|
|
180
180
|
text-align: left;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Ink-centering trim + its mandatory descender allowance, same as .wt-tab-label
|
|
184
|
+
(see 30-tabs.css for why `ex`, why the padding is not optional, and why the
|
|
185
|
+
pair is @supports-gated rather than left to fall back). */
|
|
186
|
+
@supports (text-box: trim-both ex alphabetic) {
|
|
187
|
+
:where(.wt-root) .wt-switcher-label {
|
|
188
|
+
text-box: trim-both ex alphabetic;
|
|
189
|
+
padding-block: 0.5em;
|
|
190
|
+
}
|
|
186
191
|
}
|
|
187
192
|
|
|
188
193
|
/* Keyboard button: hidden until a keyboardToggle is wired; filled while the key
|
|
@@ -432,11 +437,16 @@
|
|
|
432
437
|
overflow: hidden;
|
|
433
438
|
text-overflow: ellipsis;
|
|
434
439
|
white-space: nowrap;
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* Ink-centering trim + its mandatory descender allowance, same as .wt-tab-label
|
|
443
|
+
(see 30-tabs.css for why `ex`, why the padding is not optional, and why the
|
|
444
|
+
pair is @supports-gated rather than left to fall back). */
|
|
445
|
+
@supports (text-box: trim-both ex alphabetic) {
|
|
446
|
+
:where(.wt-root) .wt-switcher-row-label {
|
|
447
|
+
text-box: trim-both ex alphabetic;
|
|
448
|
+
padding-block: 0.5em;
|
|
449
|
+
}
|
|
440
450
|
}
|
|
441
451
|
|
|
442
452
|
/* The close sits flush at the row's right edge (no gap), vertically centered. */
|
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.1",
|
|
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>
|
|
@@ -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
|
});
|
|
@@ -2437,8 +2445,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2437
2445
|
void close_(activeId);
|
|
2438
2446
|
}
|
|
2439
2447
|
});
|
|
2440
|
-
// Dismiss the tab context menu on an outside click, on Escape, and on
|
|
2441
|
-
// scroll
|
|
2448
|
+
// Dismiss the tab context menu on an outside click, on Escape, and on a
|
|
2449
|
+
// scroll of the strip itself (which moves the chip the menu is anchored to).
|
|
2442
2450
|
const onDocClickMenu = (): void => {
|
|
2443
2451
|
// The trailing click of the long-press that just opened the menu is that
|
|
2444
2452
|
// gesture's own release, not a click-away.
|
|
@@ -2458,8 +2466,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2458
2466
|
}
|
|
2459
2467
|
};
|
|
2460
2468
|
document.addEventListener("contextmenu", onDocContextMenu);
|
|
2461
|
-
|
|
2462
|
-
|
|
2469
|
+
// Only a scroll that moves the menu's ANCHOR dismisses it, which is the
|
|
2470
|
+
// strip's own horizontal scroller and nothing else: the menu is placed
|
|
2471
|
+
// root-relative (menu-position.ts rebases onto the offsetParent), so any
|
|
2472
|
+
// scroll that moves .wt-root moves the menu with it, and the terminal's
|
|
2473
|
+
// scroller does not move the chip at all.
|
|
2474
|
+
//
|
|
2475
|
+
// The target check is load-bearing, not a micro-optimization. This listener
|
|
2476
|
+
// is capture-phase on window, so it sees EVERY scroll in the document —
|
|
2477
|
+
// including the terminal surface auto-scrolling to the bottom on each chunk
|
|
2478
|
+
// of output. Without the check, an agent printing into the TUI closed the
|
|
2479
|
+
// tab menu within a frame of it opening, which is unusable on exactly the
|
|
2480
|
+
// screen the menu exists for (a busy multi-agent strip).
|
|
2481
|
+
const onScrollMenu = (e: Event): void => {
|
|
2482
|
+
const target = e.target;
|
|
2483
|
+
if (target instanceof Element && target.closest(".wt-tab-scroll")) {
|
|
2484
|
+
hideTabMenu();
|
|
2485
|
+
}
|
|
2463
2486
|
};
|
|
2464
2487
|
window.addEventListener("scroll", onScrollMenu, true);
|
|
2465
2488
|
const offMenuKey = ctx.registerKeydown((ev) => {
|
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
|