@cplieger/web-terminal-ui 7.2.2 → 7.3.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.
@@ -1,51 +1,55 @@
1
- // contextMenu feature: the Copy / Select All / Paste menu for the terminal
2
- // surface (design section 22.4), rendered into the overlay region. Copy and Paste
3
- // need the clipboard feature (ctx.use), and Paste routes through the kernel's
4
- // sanitizing funnel. Escape-to-close goes through the kernel keydown intercept;
5
- // outside-click is a document gesture.
6
- //
7
- // WHY THIS MENU EXISTS
8
- // The terminal output is real DOM text, so the browser already does the hard
9
- // part: drag-select with a mouse, long-press word-select plus the OS copy callout
10
- // on touch. What no platform can offer is PASTE — the keyboard target is a 1x1
11
- // pointer-events:none textarea, so there is no editable surface under the pointer
12
- // for a native paste item to attach to. This menu is the paste path. It carries
13
- // Copy and Select All because a menu appearing where the platform's would have
14
- // appeared should not be missing them.
15
- //
16
- // THE MODEL: one owner per gesture, and nothing decided mid-gesture.
17
- //
18
- // Mouse / pen — `contextmenu` is an already-classified request for a menu:
19
- // preventDefault and open at the pointer. No timers, no heuristics, no
20
- // platform branches.
21
- //
22
- // Touch — the platform owns the press while the finger is down. We run no hold
23
- // timer against it, open nothing before it finishes, and never cancel its
24
- // gesture on WebKit. We classify ONCE, on `touchend`, when every fact is
25
- // settled: a single-finger, stationary press held past the tap ceiling
26
- // (kernel/gesture.ts, shared with the kernel's tap-to-focus so the two cannot
27
- // both claim one press) that selected nothing and did not start on a link is a
28
- // press the platform declined — so it is ours, and it is the paste path. A
29
- // press that produced a selection belongs to the OS callout; a press on a link
30
- // belongs to the platform's link preview; a shorter press is the kernel's tap.
31
- //
32
- // WHY AT RELEASE RATHER THAN DURING THE HOLD
33
- // Opening from a ~550ms hold timer put this feature in a race it cannot win: the
34
- // timer has to beat the platform's own long-press threshold and then GUESS
35
- // whether a selection is still coming (iOS 26 registers a word selection well
36
- // after 550ms). Four mechanisms existed only to referee that guess — a hit test
37
- // for glyphs under the finger, a selectionchange watch to retract a menu that
38
- // opened too early, a device sniff, and per-platform contextmenu branching,
39
- // because WebKit reads preventDefault on a touch contextmenu as "cancel every
40
- // remaining default of this gesture", the not-yet-registered selection included.
41
- // Deciding at release deletes all four: the outcome is observed, not predicted.
42
- //
43
- // It also fixes the symptom that prompted the rewrite. A touch long-press emits a
44
- // trailing click on release, and the swallow window that covers that click was
45
- // armed when the menu OPENED — ~550ms into the press, expiring 350ms later — so
46
- // holding a beat longer meant the release click landed as an outside click and
47
- // dismissed the menu the instant the finger lifted. Opened BY the release, the
48
- // window can only ever start at the release edge.
1
+ /**
2
+ * contextMenu feature: the Copy / Select All / Paste menu for the terminal
3
+ * surface, rendered into the overlay region. Copy and Paste
4
+ * need the clipboard feature (ctx.use), and Paste routes through the kernel's
5
+ * sanitizing funnel. Escape-to-close goes through the kernel keydown intercept;
6
+ * outside-click is a document gesture.
7
+ *
8
+ * WHY THIS MENU EXISTS
9
+ * The terminal output is real DOM text, so the browser already does the hard
10
+ * part: drag-select with a mouse, long-press word-select plus the OS copy callout
11
+ * on touch. What no platform can offer is PASTE — the keyboard target is a 1x1
12
+ * pointer-events:none textarea, so there is no editable surface under the pointer
13
+ * for a native paste item to attach to. This menu is the paste path. It carries
14
+ * Copy and Select All because a menu appearing where the platform's would have
15
+ * appeared should not be missing them.
16
+ *
17
+ * THE MODEL: one owner per gesture, and nothing decided mid-gesture.
18
+ *
19
+ * Mouse / pen — `contextmenu` is an already-classified request for a menu:
20
+ * preventDefault and open at the pointer. No timers, no heuristics, no
21
+ * platform branches.
22
+ *
23
+ * Touch — the platform owns the press while the finger is down. We run no hold
24
+ * timer against it, open nothing before it finishes, and never cancel its
25
+ * gesture on WebKit. We classify ONCE, on `touchend`, when every fact is
26
+ * settled: a single-finger, stationary press held past the tap ceiling
27
+ * (kernel/gesture.ts, shared with the kernel's tap-to-focus so the two cannot
28
+ * both claim one press) that selected nothing and did not start on a link is a
29
+ * press the platform declined — so it is ours, and it is the paste path. A
30
+ * press that produced a selection belongs to the OS callout; a press on a link
31
+ * belongs to the platform's link preview; a shorter press is the kernel's tap.
32
+ *
33
+ * WHY AT RELEASE RATHER THAN DURING THE HOLD
34
+ * Opening from a ~550ms hold timer put this feature in a race it cannot win: the
35
+ * timer has to beat the platform's own long-press threshold and then GUESS
36
+ * whether a selection is still coming (iOS 26 registers a word selection well
37
+ * after 550ms). Four mechanisms existed only to referee that guess — a hit test
38
+ * for glyphs under the finger, a selectionchange watch to retract a menu that
39
+ * opened too early, a device sniff, and per-platform contextmenu branching,
40
+ * because WebKit reads preventDefault on a touch contextmenu as "cancel every
41
+ * remaining default of this gesture", the not-yet-registered selection included.
42
+ * Deciding at release deletes all four: the outcome is observed, not predicted.
43
+ *
44
+ * It also fixes the symptom that prompted the rewrite. A touch long-press emits a
45
+ * trailing click on release, and the swallow window that covers that click was
46
+ * armed when the menu OPENED — ~550ms into the press, expiring 350ms later — so
47
+ * holding a beat longer meant the release click landed as an outside click and
48
+ * dismissed the menu the instant the finger lifted. Opened BY the release, the
49
+ * window can only ever start at the release edge.
50
+ *
51
+ * @module
52
+ */
49
53
 
50
54
  import type { TerminalFeature } from "../kernel/types.js";
51
55
  import { TAP_MAX_MS, TAP_MOVEMENT_PX, isLinkTarget } from "../kernel/gesture.js";
@@ -56,6 +60,12 @@ import { createClickSwallow, placeMenuAt } from "./menu-position.js";
56
60
  // swallow all live in the shared point-anchored menu module (menu-position.ts),
57
61
  // shared with the tab menu.
58
62
 
63
+ /** Options for the contextMenu feature.
64
+ *
65
+ * Pass the SAME clipboard feature value the composition includes, not a second
66
+ * `clipboard()` call: `ctx.use` resolves a value to the instance the kernel set
67
+ * up, so a value that is not in the feature list resolves to nothing and the
68
+ * menu silently loses Copy and Paste. `presetSingle` shows the shape. */
59
69
  export interface ContextMenuOptions {
60
70
  /** The clipboard feature value, so the menu can offer Copy/Paste through its
61
71
  * API (ctx.use). Omitted: the menu shows only Select All. */
@@ -109,6 +119,16 @@ function selectionText(): string {
109
119
  * so the selection test alone would not keep us out of its way. */
110
120
  const onLink = isLinkTarget;
111
121
 
122
+ /** Build the contextMenu feature. Exposes no API — the menu is opened by the
123
+ * gestures described above, never programmatically by a peer.
124
+ *
125
+ * Hand it the clipboard value the composition also includes (see
126
+ * ContextMenuOptions) or it degrades to a Select All-only menu; its own position in
127
+ * the feature list is otherwise free, since nothing reads it through `ctx.use`. Its
128
+ * items act on the browser selection inside the terminal surface and on the
129
+ * kernel's paste funnel, so it holds no session state and survives a tab switch
130
+ * unchanged. Teardown removes the menu element and releases the keydown intercept
131
+ * along with every surface and document listener. */
112
132
  export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
113
133
  return {
114
134
  name: "contextMenu",
@@ -1,10 +1,14 @@
1
- // mobileToolbar feature: the on-screen key toolbar (Tab/Esc/arrows/Enter + a
2
- // collapse toggle and sticky-Ctrl) in the thumb-zone region (design section
3
- // 22.4). The engine's toolbar.bindMobileToolbar wires the buttons (DECCKM-aware
4
- // arrows, sticky-Ctrl state; engine >= v3, where the widget moved out of the
5
- // keyboard module); this feature builds the toolbar chrome, routes its
6
- // output through the kernel funnel, and registers sticky-Ctrl as an input
7
- // transform so a typed character is rewritten to its Ctrl byte when armed.
1
+ /**
2
+ * mobileToolbar feature: the on-screen key toolbar (Tab/Esc/arrows/Enter + a
3
+ * collapse toggle and sticky-Ctrl) in the thumb-zone region.
4
+ * The engine's toolbar.bindMobileToolbar wires the buttons (DECCKM-aware
5
+ * arrows, sticky-Ctrl state; engine >= v3, where the widget moved out of the
6
+ * keyboard module); this feature builds the toolbar chrome, routes its
7
+ * output through the kernel funnel, and registers sticky-Ctrl as an input
8
+ * transform so a typed character is rewritten to its Ctrl byte when armed.
9
+ *
10
+ * @module
11
+ */
8
12
 
9
13
  import { toolbar } from "@cplieger/web-terminal-engine";
10
14
  import type { TerminalFeature } from "../kernel/types.js";
@@ -52,6 +56,19 @@ const TOOLBAR_HTML = `
52
56
  <button type="button" id="kb-right" class="kb-key kb-r2c4" aria-label="Right"><svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg></button>
53
57
  </div>`;
54
58
 
59
+ /** Build the mobileToolbar feature.
60
+ *
61
+ * The chrome is always built; whether it is VISIBLE is a CSS decision — the keys
62
+ * and the toggle are `display: none` and only a coarse pointer restores them
63
+ * (`css/23-toolbar.css`) — so a desktop consumer including this feature pays the
64
+ * DOM and sees nothing. Order it before `tabs` when `externalToggle` is set, since
65
+ * tabs reads the returned API through `ctx.use` to drive the grid from the tab
66
+ * bar's keyboard button.
67
+ *
68
+ * Sticky-Ctrl is registered as a kernel INPUT TRANSFORM rather than handled in
69
+ * the toolbar's own click path, so it rewrites a character typed on the soft
70
+ * keyboard as well as one produced by these buttons. Teardown removes the
71
+ * toolbar, drops the transform, and clears every subscriber. */
55
72
  export function mobileToolbar(opts: MobileToolbarOptions = {}): TerminalFeature<MobileToolbarApi> {
56
73
  return {
57
74
  name: "mobileToolbar",
@@ -1,13 +1,32 @@
1
- // predictiveEcho feature: Mosh-style local echo (design section 22.4). Wires the
2
- // predict.ts mini-VT to the kernel input funnel: it advances a predicted cursor
3
- // on accepted input (an input observer) and owns the col-0 backspace brake (an
4
- // input transform that drops a lone DEL at the true origin, since the predicted
5
- // cursor is the authoritative position for that decision). It re-pushes the
6
- // predicted-cursor overlay on prediction changes and after each render flush.
1
+ /**
2
+ * predictiveEcho feature: Mosh-style local echo. Wires the
3
+ * predict.ts mini-VT to the kernel input funnel: it advances a predicted cursor
4
+ * on accepted input (an input observer) and owns the col-0 backspace brake (an
5
+ * input transform that drops a lone DEL at the true origin, since the predicted
6
+ * cursor is the authoritative position for that decision). It re-pushes the
7
+ * predicted-cursor overlay on prediction changes and after each render flush.
8
+ *
9
+ * @module
10
+ */
7
11
 
8
12
  import * as predict from "../predict.js";
9
13
  import type { TerminalFeature } from "../kernel/types.js";
10
14
 
15
+ /** Build the predictiveEcho feature. Exposes no API — its output is the engine
16
+ * renderer's predicted-cursor overlay.
17
+ *
18
+ * The prediction state is MODULE-scoped (`../predict.js`), not per instance, so two
19
+ * terminals in one page that both include this feature would drive one predicted
20
+ * cursor between them. That is within the kernel's existing "call createTerminal
21
+ * exactly once" contract, but it is the reason this feature cannot be made
22
+ * per-instance by composing it twice.
23
+ *
24
+ * Predictions are advisory: `predict` suspends itself on any byte it cannot
25
+ * model, because a wrong prediction is worse than a missing one. The feature
26
+ * resets it on a server restart and on a tab switch (`onDetach`), so the outgoing
27
+ * session's ghost cursor never paints over the incoming session's screen; the
28
+ * next server frame re-arms it. Teardown drops every listener, hides the overlay,
29
+ * and clears the prediction. */
11
30
  export function predictiveEcho(): TerminalFeature {
12
31
  return {
13
32
  name: "predictiveEcho",
@@ -1,8 +1,12 @@
1
- // scrollToBottom feature: a scroll-to-bottom control in the thumb-zone region,
2
- // shown only while the user has scrolled up (design section 22.4). The
3
- // scroll-state signal comes from the kernel's scroll:state event (the kernel
4
- // owns scroll.init); this feature just renders the affordance and drives
5
- // scroll.scrollToBottom.
1
+ /**
2
+ * scrollToBottom feature: a scroll-to-bottom control in the thumb-zone region,
3
+ * shown only while the user has scrolled up. The
4
+ * scroll-state signal comes from the kernel's scroll:state event (the kernel
5
+ * owns scroll.init); this feature just renders the affordance and drives
6
+ * scroll.scrollToBottom.
7
+ *
8
+ * @module
9
+ */
6
10
 
7
11
  import type { TerminalFeature } from "../kernel/types.js";
8
12
  import { fromHTML, holdFocusOnPress } from "./dom.js";
@@ -12,6 +16,16 @@ const BUTTON_HTML = `
12
16
  <svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg>
13
17
  </button>`;
14
18
 
19
+ /** Build the scrollToBottom feature. Exposes no API — the button is the whole
20
+ * surface, and a peer that wants the same effect calls `ctx.scroll.scrollToBottom`
21
+ * directly.
22
+ *
23
+ * Its job is resuming FOLLOW, not just moving the viewport, which is why it also
24
+ * acts when the reader is already at the bottom but holding (an ED3 clamp leaves
25
+ * exactly that state). The jump is smooth unless the user asked for reduced
26
+ * motion, and it is idempotent, so binding both `pointerdown` (touch presses feel
27
+ * immediate) and `click` (keyboard activation) is safe. Teardown unsubscribes and
28
+ * removes the button. */
15
29
  export function scrollToBottom(): TerminalFeature {
16
30
  return {
17
31
  name: "scrollToBottom",
@@ -151,9 +151,9 @@ export function createAttention(env: AttentionEnv): AttentionSurfaces {
151
151
  };
152
152
  }
153
153
 
154
- /** iconVariantHref rewrites an icon URL to its variant, by the convention the
155
- * asset generator writes (the fleet's .kiro/scripts/gen-attention-icons.py, which
156
- * serves every consuming app rather than living in this repo): the `favicon` token
154
+ /** iconVariantHref rewrites an icon URL to its variant, by the naming convention
155
+ * the variant assets must follow (they are the consuming app's own files, served
156
+ * alongside its icons rather than shipped by this library): the `favicon` token
157
157
  * of the filename gains `-<variant>`, so `/favicon.svg` becomes
158
158
  * `/favicon-input.svg` and `/favicon-32x32.png` becomes
159
159
  * `/favicon-input-32x32.png`. The extension is preserved, so each link keeps
@@ -161,7 +161,7 @@ export function createAttention(env: AttentionEnv): AttentionSurfaces {
161
161
  *
162
162
  * A URL whose filename does not start with `favicon` is returned unchanged,
163
163
  * which leaves that link alone rather than pointing it at a 404. Exported for
164
- * the test that pins this against the generator's own naming. */
164
+ * the test that pins this against that naming convention. */
165
165
  export function iconVariantHref(href: string, variant: string): string | null {
166
166
  const match = /(^|\/)favicon(?=[-.])/.exec(href);
167
167
  if (!match) {
@@ -1,12 +1,16 @@
1
- // tabs feature: multiple independent terminals over the one kernel (design
2
- // sections 5, 6, 12, 22.5). It owns the session set (GET/POST/DELETE
3
- // /api/sessions), a per-tab LineStore switching cache, the reconnect-on-switch
4
- // swap, and the tab chrome on both form factors: the desktop top-bar strip and
5
- // the mobile bottom-switcher + modal overview sheet. The kernel drives one
6
- // active session; switching re-points the renderer at the next tab's cached
7
- // store (ctx.render.bind) and asks the kernel to reconnect the terminal WS to it
8
- // (ctx.notifySwitch), so the last-known screen paints instantly and the
9
- // background delta arrives after.
1
+ /**
2
+ * tabs feature: multiple independent terminals over the one kernel.
3
+ * It owns the session set (GET/POST/DELETE
4
+ * /api/sessions), a per-tab LineStore switching cache, the reconnect-on-switch
5
+ * swap, and the tab chrome on both form factors: the desktop top-bar strip and
6
+ * the mobile bottom-switcher + modal overview sheet. The kernel drives one
7
+ * active session; switching re-points the renderer at the next tab's cached
8
+ * store (ctx.render.bind) and asks the kernel to reconnect the terminal WS to it
9
+ * (ctx.notifySwitch), so the last-known screen paints instantly and the
10
+ * background delta arrives after.
11
+ *
12
+ * @module
13
+ */
10
14
 
11
15
  import { modes } from "@cplieger/web-terminal-engine";
12
16
  import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/types.js";
@@ -102,6 +106,20 @@ const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
102
106
  // the shared point-anchored positioner (menu-position.ts), shared with the
103
107
  // terminal context menu (formerly two hand-synced copies of the same math).
104
108
 
109
+ /** The value a peer feature or a host reads through `ctx.use(tabs(...))`: the
110
+ * session set as commands, plus a snapshot of the strip.
111
+ *
112
+ * `create` and `close` are server round trips, and NEITHER rejects: a refusal is
113
+ * reported to the user as a toast and the promise resolves anyway, so awaiting one
114
+ * tells you the attempt finished and not that it succeeded (read `list` for that).
115
+ * Both are safe to call more than once per gesture — `create` shares an in-flight
116
+ * create, so the duplicate activation an iPad trackpad delivers opens one terminal
117
+ * rather than two. `switchTo` is local and synchronous, and ignores an unknown id.
118
+ *
119
+ * `list` is a snapshot, not a live view — it does not update, so re-read it rather
120
+ * than holding onto the array, and treat it as display data: the tab a user is
121
+ * looking at is this feature's own state and mutating it goes through the members
122
+ * above. */
105
123
  export interface TabsApi {
106
124
  /** Spawn a fresh session and switch to it. Calls made while a create is in
107
125
  * flight share that create, so one gesture opens exactly one terminal. */
@@ -114,6 +132,14 @@ export interface TabsApi {
114
132
  list(): readonly { id: string; title: string; active: boolean }[];
115
133
  }
116
134
 
135
+ /** Options for the tabs feature.
136
+ *
137
+ * The two feature-valued members (`activityMonitor`, `keyboardToggle`) must be the
138
+ * SAME values the composition includes and must be ordered before tabs in the
139
+ * feature list, since `ctx.use` only resolves a peer the kernel has already set
140
+ * up. Both are optional and their absence degrades rather than fails: no monitor
141
+ * means polling instead of the status SSE, no toolbar means no keyboard button in
142
+ * the mobile bar. `buildTabbed` in presets/tabbed.ts is the worked example. */
117
143
  export interface TabsOptions {
118
144
  /** REST base for the session API (default "/api/sessions"). */
119
145
  apiBase?: string;
@@ -149,10 +175,10 @@ export interface TabsOptions {
149
175
  * `link[rel=icon]` whose filename starts with `favicon`, three variants must
150
176
  * be served alongside it with `-input`, `-done` and `-alert` inserted after
151
177
  * that token (`/favicon.svg` needs `/favicon-input.svg`; `/favicon-32x32.png`
152
- * needs `/favicon-input-32x32.png`). Generate them with
153
- * `.kiro/scripts/gen-attention-icons.py`, which writes exactly those names, and
154
- * assert their presence in the APP's own tests — the library cannot check a
155
- * file it does not ship, and a missing variant is a blank tab icon.
178
+ * needs `/favicon-input-32x32.png`). Generate them with whatever tooling you
179
+ * like as long as it writes exactly those names, and assert their presence in
180
+ * the APP's own tests — the library cannot check a file it does not ship, and
181
+ * a missing variant is a blank tab icon.
156
182
  *
157
183
  * Not every platform honours it: Safari caches the first icon it fetched and
158
184
  * ignores later changes, and an installed app has no tab icon at all. The
@@ -321,6 +347,20 @@ function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
321
347
  }
322
348
  }
323
349
 
350
+ /** Build the tabs feature.
351
+ *
352
+ * Requires a server that speaks the session API — `GET`/`POST`/`DELETE` on
353
+ * `/api/sessions`, `?session=<id>` on the WebSocket, and ideally the status SSE.
354
+ * It registers itself as the kernel's `sessionOwner`, so it and not the kernel
355
+ * resolves the session the first connect attaches to. At most one feature may claim
356
+ * that role, and `createTerminal` throws before any DOM work when two do.
357
+ *
358
+ * The arrangement of the strip is SERVER state (the `order` field), while the
359
+ * active tab and the dismissed notification cues are per-viewer and live in
360
+ * `localStorage` — so a phone reordering tabs moves them on the desktop, and
361
+ * switching tabs on one does not move the other. Teardown removes all chrome,
362
+ * releases the per-tab caches, and leaves the sessions themselves running on the
363
+ * server; closing a tab is what kills a process. */
324
364
  export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
325
365
  const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
326
366
  const presumeReports = opts.presumeReports ?? false;
@@ -3544,9 +3584,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3544
3584
  }
3545
3585
 
3546
3586
  // --- Event wiring ---
3547
- // No input observer. The ENGINE derives a session's name from the input
3548
- // stream when its host asked for that (terminal.WithInputTitle), so no preset
3549
- // does per-keystroke title work in the browser, and the name is identical for
3587
+ // No input observer. The ENGINE owns a session's name, so no preset does
3588
+ // per-keystroke title work in the browser, and the name is identical for
3550
3589
  // every client attached to the session — including one that attaches later.
3551
3590
  // Observe (never consume) keydowns to detect a physical keyboard: a
3552
3591
  // hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
@@ -309,7 +309,8 @@ export function worseCue(a: CueStatus | "", b: CueStatus | ""): CueStatus | "" {
309
309
 
310
310
  /** The icon variant a cue paints, which is NOT one per status: `crashed` and
311
311
  * `failed` both render --status-failed, so they share one asset. An app ships
312
- * one icon per name returned here (see .kiro/scripts/gen-attention-icons.py). */
312
+ * one icon per name returned here (see TabsOptions.attentionIcons for the
313
+ * naming contract those files must satisfy). */
313
314
  export function cueIconName(status: CueStatus): "input" | "done" | "alert" {
314
315
  if (status === "input" || status === "done") {
315
316
  return status;
@@ -532,8 +533,8 @@ export interface Tab {
532
533
  * session (the boot double-create bug). */
533
534
  born: number;
534
535
  /** The title the SERVER resolved for this session: its pinned name, else the
535
- * input-derived name, else the program's OSC 0/2 window title, else a
536
- * client-pushed label, else the engine's own foreground-process/cwd inference.
536
+ * program's OSC 0/2 window title, else a title the host pushed through
537
+ * SetSessionTitle, else the engine's own foreground-process/cwd inference.
537
538
  * Possibly empty only against an engine that has none of those. The displayed
538
539
  * label adds a numbered fallback and de-duplication (see relabelAll in
539
540
  * index.ts). */
@@ -603,9 +604,9 @@ export interface Tab {
603
604
  * there is one, otherwise the title the SERVER resolved.
604
605
  *
605
606
  * Only two rungs, because the engine now owns every automatic source and folds
606
- * them into `title` in precedence order (pinned, input-derived, the program's OSC
607
- * window title, a client-pushed label, then its own foreground-process/cwd
608
- * inference). A client that re-implemented that ladder could only disagree with
607
+ * them into `title` in precedence order (pinned, the program's OSC window title,
608
+ * a title the host pushed through SetSessionTitle, then its own
609
+ * foreground-process/cwd inference). A client that re-implemented that ladder could only disagree with
609
610
  * the server and with every other client.
610
611
  *
611
612
  * The pin is still checked here, even though the server also folds it into
@@ -54,7 +54,23 @@ import type {
54
54
  const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
55
55
 
56
56
  const DEFAULT_WS_PATH = "/ws";
57
+ // The family that carries the CELL METRICS, alone: deliberately not --font-mono's
58
+ // list, which leads with the tiling overlay. WebKit resolves load() over a list as
59
+ // soon as the FIRST family whose descriptor covers the sample has loaded
60
+ // (CSSFontFaceSet::matchingFacesExcludingPreinstalledFonts, `if (found) break;`),
61
+ // so naming the overlay would settle the gate on its 11 KB while the companion is
62
+ // still loading — and the overlay copies the companion's metrics and carries no
63
+ // "M", the glyph render.ts measures, so waiting on it defends no number anyway.
57
64
  const DEFAULT_FONT_READY = '14px "Monaspace Neon NF"';
65
+ // The deadline on the WHOLE gate below, because neither document.fonts.load nor
66
+ // document.fonts.ready carries one: a response held open leaves both pending for
67
+ // as long as that request lives, and a gate that never settles reports no size at
68
+ // all. The budget is the block period the faces themselves buy with font-display:
69
+ // block (css/page.css declares it on every one; 3s is what CSS Fonts 4 recommends
70
+ // for `block`) — how long an engine withholds glyphs before it paints the
71
+ // fallback. Once it has, the cell on screen IS the fallback cell, so a longer wait
72
+ // defends metrics nobody is looking at.
73
+ const FONT_READY_TIMEOUT_MS = 3000;
58
74
 
59
75
  const TOAST_MS = 3000;
60
76
  // A touch that focuses the input (opens the soft keyboard) must be a genuine
@@ -1383,13 +1399,49 @@ function buildTerminal(
1383
1399
  });
1384
1400
  };
1385
1401
  try {
1386
- void document.fonts
1387
- .load(fontReady)
1388
- .then(onFontSettled)
1389
- .catch((err: unknown) => {
1402
+ // false once the fonts have landed, true when the deadline opened the gate
1403
+ // without them. The race covers the whole wait, not just the rejection arm: a
1404
+ // request that stalls with nothing rejecting leaves load() ITSELF pending, so a
1405
+ // deadline armed on the rejection would bound one of the ways a font can hang.
1406
+ const landed: Promise<boolean> = document.fonts.load(fontReady).then(
1407
+ () => false,
1408
+ (err: unknown) => {
1390
1409
  console.warn(`web-terminal-ui: web font ${fontReady} failed to load`, err);
1391
- onFontSettled();
1410
+ // One load() over a comma-separated stack rejects as a UNIT, so settling
1411
+ // here would let an unavailable OPTIONAL family abandon the gate for the
1412
+ // REQUIRED one and announce a size measured on fallback metrics. `ready`
1413
+ // settles the initial load INCLUDING a failed one (the same property
1414
+ // features/tabs/ink-centre.ts relies on), so the families that ARE served
1415
+ // still land their bytes before the first resize is measured.
1416
+ return document.fonts.ready.then(() => false);
1417
+ },
1418
+ );
1419
+ const deadline = new Promise<boolean>((resolve) => {
1420
+ window.setTimeout(() => {
1421
+ resolve(true);
1422
+ }, FONT_READY_TIMEOUT_MS);
1423
+ });
1424
+ // Whichever settles first opens the gate exactly once; the loser is ignored.
1425
+ void Promise.race([landed, deadline]).then((timedOut) => {
1426
+ onFontSettled();
1427
+ if (!timedOut) {
1428
+ return;
1429
+ }
1430
+ // The gate opened on fallback metrics, and the swap period is infinite: bytes
1431
+ // that land after this change the cell width with fontsLoaded already true,
1432
+ // and nothing re-measures until the next viewport transition. So one
1433
+ // corrective announce, through the path the viewport settle uses.
1434
+ // sendResize deduplicates, so a font that never lands costs nothing.
1435
+ void document.fonts.ready.then(() => {
1436
+ // The bytes can arrive arbitrarily late, so this is the one arm of the gate
1437
+ // that outlives its terminal; destroy() wins, as it does for a feature's
1438
+ // un-cancelled async.
1439
+ if (isDestroyed() || measurableSize() === null) {
1440
+ return;
1441
+ }
1442
+ connection.sendResize();
1392
1443
  });
1444
+ }, onFontSettled);
1393
1445
  } catch (err) {
1394
1446
  console.warn(`web-terminal-ui: invalid fontReady ${fontReady}`, err);
1395
1447
  onFontSettled();
@@ -54,6 +54,18 @@ const DEFAULT_PREFIX = "wt.scrollback.";
54
54
  */
55
55
  const DEFAULT_MAX_BYTES = 512 * 1024;
56
56
 
57
+ /** Options for `localScrollbackStorage`.
58
+ *
59
+ * Four of the five are the kernel's policy rather than this store's: `maxAgeMs`,
60
+ * `lines` and `saveIntervalMs` are passed straight through on the returned
61
+ * `ScrollbackPersistence`, so the store's own sweep and the kernel's load-time age
62
+ * check cannot disagree about a number a caller set once. `maxBytes` is this
63
+ * store's alone, since only it can enumerate what it owns.
64
+ *
65
+ * Every value is validated as a positive integer and an invalid one falls back to
66
+ * the default rather than being clamped. Sizing `maxBytes` is a decision ABOUT THE
67
+ * HOST APPLICATION, not about the terminal: this competes for the origin's
68
+ * `localStorage` quota with everything else the page stores. */
57
69
  export interface LocalScrollbackStorageOptions {
58
70
  /** Key prefix (default `"wt.scrollback."`). */
59
71
  prefix?: string;
@@ -1,19 +1,21 @@
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.
1
+ /**
2
+ * The copy of the "terminal did not start" recovery surface, as DATA.
3
+ *
4
+ * Why this is a module of its own rather than string literals inside the
5
+ * renderer: a full-page consumer needs the same words in a place no module can
6
+ * reach. Its HTML carries an inline bootstrap watchdog whose entire job is to
7
+ * report "the JS bundle never ran" -- a rung below `import`, by definition, or
8
+ * it could not detect its own failure case. That watchdog cannot import these
9
+ * strings at runtime, so a consumer's BUILD substitutes them into its HTML
10
+ * instead, and the words survive as one declaration rather than three
11
+ * hand-agreed copies.
12
+ *
13
+ * This file therefore imports nothing and touches no DOM, so a Node build script
14
+ * can read it without loading the kernel. It is exported at the package root and
15
+ * at the "./startup-copy" subpath for exactly that use.
16
+ *
17
+ * @module
18
+ */
17
19
 
18
20
  /** The words the recovery surface shows, whichever startup rung failed.
19
21
  *