@cplieger/web-terminal-ui 3.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ /* ===== Animations (animations feature) =====
2
+ Every bit of motion in the UI lives here, gated on the root .wt-animate class
3
+ that the animations feature sets only when the OS is not in reduced-motion.
4
+ Without the feature (or under prefers-reduced-motion) the UI is instant: base
5
+ rules in 03-06 carry the resting state, and these rules add the transitions
6
+ and keyframes on top. Keeping motion in one file is the design-system seam for
7
+ "does this UI animate, and how". */
8
+
9
+ /* Fade transient surfaces in/out (display allow-discrete plays the fade before
10
+ the discrete display:none on hide). */
11
+ .wt-animate .wt-toast,
12
+ .wt-animate .wt-conn-banner,
13
+ .wt-animate .wt-scroll-bottom,
14
+ .wt-animate .wt-ctx-menu,
15
+ .wt-animate .wt-tab-menu {
16
+ transition:
17
+ opacity var(--dur-standard) var(--ease-standard),
18
+ display var(--dur-standard) allow-discrete;
19
+ }
20
+
21
+ /* Tab chrome micro-transitions (hover/active color shifts). */
22
+ .wt-animate .wt-tab,
23
+ .wt-animate .wt-tab-close,
24
+ .wt-animate .wt-tab-new {
25
+ transition:
26
+ background-color var(--dur-micro) var(--ease-standard),
27
+ color var(--dur-micro) var(--ease-standard);
28
+ }
29
+
30
+ /* New tab enters with a fade + scale-in. Only runtime-added tabs get
31
+ .wt-tab-enter (the tabs feature's `started` gate). A closing tab is removed
32
+ immediately with no exit animation, so the strip reflows in one frame (a
33
+ delayed exit made the "+" teleport and put a last-tab replacement in the wrong
34
+ slot). scale() is a transform, so the enter never shifts the "+". */
35
+ .wt-animate .wt-tab-enter {
36
+ animation: wt-tab-in var(--dur-enter) var(--ease-enter);
37
+ }
38
+
39
+ @keyframes wt-tab-in {
40
+ from {
41
+ opacity: 0;
42
+ transform: scale(0.82);
43
+ }
44
+ }
45
+
46
+ /* Tab list expand/collapse: the mobile bar grows upward to reveal the other
47
+ tabs and shrinks back. max-height and the vertical padding animate together (0
48
+ to the expanded values and back), so the whole tray opens/closes as one motion
49
+ — animating only max-height let the padding snap away instantly at the start
50
+ of a collapse (a choppy, two-step close). The tabs feature clears the rows
51
+ after the collapse. */
52
+ .wt-animate .wt-switcher-list {
53
+ transition:
54
+ max-height var(--dur-enter) var(--ease-standard),
55
+ padding var(--dur-enter) var(--ease-standard);
56
+ }
57
+
58
+ /* When the tab list expands, the keyboard button collapses to zero width and
59
+ fades (the key grid is closed in JS); the active row flex-grows into the freed
60
+ space in lockstep as the button's width animates. */
61
+ .wt-animate .wt-switcher-kb {
62
+ transition:
63
+ width var(--dur-enter) var(--ease-standard),
64
+ margin var(--dur-enter) var(--ease-standard),
65
+ opacity var(--dur-standard) var(--ease-standard);
66
+ }
67
+
68
+ /* Switch motion: a subtle content fade on switch, so the cache-first repaint
69
+ reads as a change rather than a flicker. A swipe additionally slides the
70
+ content in from the swipe's direction (next from the right, prev from the
71
+ left, WS5). */
72
+ .wt-animate .term.wt-switching .term-output {
73
+ animation: wt-switch-in var(--dur-standard) var(--ease-standard);
74
+ }
75
+
76
+ .wt-animate .term.wt-switching-next .term-output {
77
+ animation: wt-switch-next var(--dur-standard) var(--ease-standard);
78
+ }
79
+
80
+ .wt-animate .term.wt-switching-prev .term-output {
81
+ animation: wt-switch-prev var(--dur-standard) var(--ease-standard);
82
+ }
83
+
84
+ @keyframes wt-switch-in {
85
+ from {
86
+ opacity: 0.55;
87
+ }
88
+ }
89
+
90
+ @keyframes wt-switch-next {
91
+ from {
92
+ opacity: 0.3;
93
+ transform: translateX(18%);
94
+ }
95
+ }
96
+
97
+ @keyframes wt-switch-prev {
98
+ from {
99
+ opacity: 0.3;
100
+ transform: translateX(-18%);
101
+ }
102
+ }
package/css/MANIFEST CHANGED
@@ -1,6 +1,12 @@
1
1
  # web-terminal-ui CSS bundle manifest
2
2
  # Files concatenated in declared order to produce the served style.css.
3
+ # The order IS the cascade: tokens + reset + terminal core, then the v3 design
4
+ # system (shared primitives, then per-feature chrome), then all motion last.
3
5
  00-tokens.css
4
6
  01-reset.css
5
7
  02-app.css
6
- 03-v3.css
8
+ 03-primitives.css
9
+ 04-feedback.css
10
+ 05-tabs.css
11
+ 06-mobile.css
12
+ 07-animations.css
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "3.0.3",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
6
6
  "license": "GPL-3.0-or-later",
@@ -34,7 +34,7 @@
34
34
  "eslint": "10.6.0",
35
35
  "fast-check": "4.8.0",
36
36
  "happy-dom": "20.10.6",
37
- "html-validate": "11.5.3",
37
+ "html-validate": "11.5.5",
38
38
  "knip": "6.23.0",
39
39
  "prettier": "3.9.4",
40
40
  "stylelint": "17.14.0",
@@ -54,6 +54,16 @@ export function clipboard(): TerminalFeature<ClipboardApi> {
54
54
  // Desktop clipboard shortcuts, handled before the kernel's key mapping so
55
55
  // they take the browser selection/clipboard rather than server-bound bytes.
56
56
  const offKey = ctx.registerKeydown((ev) => {
57
+ // Plain Ctrl+V: let the browser's NATIVE paste event flow into the
58
+ // hidden textarea (the kernel's insertFromPaste path sends it through the
59
+ // sanitizing funnel). Consuming the key WITHOUT preventDefault stops the
60
+ // kernel mapping Ctrl+V to \x16 while leaving the native paste intact — so
61
+ // no navigator.clipboard.readText(), hence no Firefox clipboard-read
62
+ // popup. (Cmd+V on macOS already pastes natively and never reached the
63
+ // \x16 mapping.)
64
+ if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.code === "KeyV") {
65
+ return true; // do NOT preventDefault — the browser pastes natively
66
+ }
57
67
  if (ev.ctrlKey && ev.shiftKey && !ev.altKey && !ev.metaKey) {
58
68
  if (ev.code === "KeyC") {
59
69
  const sel = window.getSelection()?.toString();
@@ -10,7 +10,7 @@ const TEXT: Partial<Record<ConnState, string>> = {
10
10
  connecting: "Reconnecting\u2026",
11
11
  reconnecting: "Reconnecting\u2026",
12
12
  offline: "Offline",
13
- restarted: "Server restarted \u2014 recent input may have been lost",
13
+ restarted: "Server restarted; recent input may be lost",
14
14
  };
15
15
 
16
16
  export function connectionBanner(): TerminalFeature {
@@ -2,26 +2,50 @@
2
2
  // Paste) rendered into the overlay region (design section 22.4). Copy/Paste are
3
3
  // offered only when the clipboard feature is present (ctx.use), routing Paste
4
4
  // through the kernel's sanitizing funnel. Escape-to-close goes through the
5
- // kernel keydown intercept; outside-click and long-press are surface gestures.
5
+ // kernel keydown intercept; outside-click is a document gesture.
6
6
  //
7
- // Touch long-press opens this menu on every touch platform, iOS included. The
8
- // terminal's keyboard target is a 1x1, pointer-events:none textarea, so iOS's
9
- // own long-press callout never lands on an editable field and no native "Paste"
10
- // ever appears (the reason tap-and-hold-to-paste felt broken). This menu is
11
- // therefore the paste path on touch; Paste reads the system clipboard via the
12
- // Clipboard API, which needs a secure context, so serve vibecli over HTTPS for
13
- // touch paste.
7
+ // TOUCH MODEL (best-practice, cross-platform iOS + Android):
8
+ // The terminal output is the browser's native text-selection surface, so a
9
+ // touch long-press ON TEXT is left to the platform: it selects a word and shows
10
+ // the OS copy callout. We do NOT stack a custom menu on that (it fought native
11
+ // selection and, since the long-press is immediately followed by a touchend,
12
+ // closed the instant the finger lifted). The custom menu opens on touch ONLY
13
+ // when the long-press selected nothing (empty space) — its job there is Paste,
14
+ // which the native callout cannot offer because our keyboard target is a 1x1,
15
+ // pointer-events:none textarea (no editable surface under the finger for the OS
16
+ // paste item). Paste reads the clipboard via the Clipboard API (secure context
17
+ // required, so serve over HTTPS for touch paste). Desktop is the ordinary
18
+ // right-click menu (Copy / Select All / Paste).
19
+ //
20
+ // Deciding "did the long-press select text?" is the crux — a naive
21
+ // getSelection() read at a fixed delay races the native selection and caused the
22
+ // reported flakiness (sometimes native, sometimes custom, sometimes both). So we
23
+ // TRACK whether a selection is made during THIS press via a selectionchange
24
+ // listener armed only while a touch is down: the moment one appears we cancel
25
+ // the pending menu and, if it already opened, close it — native always wins over
26
+ // text. The platforms deliver the long-press differently: iOS fires NO
27
+ // contextmenu (a hold timer detects it), Android DOES (we handle it and
28
+ // preventDefault it when showing our own, so Android's native menu doesn't also
29
+ // appear — the "both menus" case). Both paths are gated by the same selection
30
+ // check, so the outcome is consistent across platforms.
14
31
 
15
32
  import type { TerminalFeature } from "../kernel/types.js";
16
33
  import type { ClipboardApi } from "./clipboard.js";
17
34
 
18
- const LONG_PRESS_MS = 500;
35
+ // Slightly longer than the browser's ~500ms native long-press so a native word
36
+ // selection (which we defer to) has registered — and fired selectionchange —
37
+ // before this fires.
38
+ const LONG_PRESS_MS = 550;
19
39
  const LONG_PRESS_MOVE_PX = 10;
20
40
  const EDGE_MARGIN = 8;
21
41
  // When the menu can't fit below the anchor (a long-press near the bottom edge or
22
42
  // above the keyboard), it opens above the point. This gap lifts it clear of the
23
43
  // fingertip so the touch does not cover the menu items.
24
44
  const MENU_GAP = 16;
45
+ // After a touch long-press opens the menu, swallow the trailing click/tap that
46
+ // the same gesture emits on finger-release for this window, so the menu does not
47
+ // immediately dismiss itself (the classic contextmenu-then-touchend race).
48
+ const SWALLOW_MS = 350;
25
49
 
26
50
  export interface ContextMenuOptions {
27
51
  /** The clipboard feature value, so the menu can offer Copy/Paste through its
@@ -40,6 +64,18 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
40
64
 
41
65
  let longPressTimer = 0;
42
66
  let longPressOrigin = { x: 0, y: 0 };
67
+ // The device that started the current interaction: contextmenu fires on
68
+ // desktop right-click AND on Android long-press, so its handler branches
69
+ // on this to tell them apart.
70
+ let lastPointerType = "mouse";
71
+ // Timestamp until which a document click is swallowed (see SWALLOW_MS).
72
+ let swallowUntil = 0;
73
+ // A native selection was made during the current touch press (tracked via
74
+ // selectionchange). Gates the touch menu: a selected long-press belongs to
75
+ // the native callout, an empty one to us (Paste).
76
+ let pressSelectedText = false;
77
+ // A touch press is in progress, so selectionchange is relevant (armed).
78
+ let touchActive = false;
43
79
 
44
80
  const clip = (): ClipboardApi | undefined =>
45
81
  opts.clipboard ? ctx.use(opts.clipboard) : undefined;
@@ -48,45 +84,74 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
48
84
  surface.querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
49
85
  }
50
86
 
51
- function hide(): void {
52
- const refocus = menu.contains(document.activeElement);
87
+ const clearLongPress = (): void => {
88
+ if (longPressTimer) {
89
+ clearTimeout(longPressTimer);
90
+ longPressTimer = 0;
91
+ }
92
+ };
93
+
94
+ // True when text is (or just got) selected, so the OS callout owns Copy and
95
+ // our menu stays out of the way. pressSelectedText catches a native
96
+ // selection the instant it happens (selectionchange), avoiding a race with
97
+ // a fixed-delay read; the live check is the fallback for when selectionchange
98
+ // did not arm (a desktop right-click on an existing selection).
99
+ const hasNativeSelection = (): boolean => {
100
+ if (pressSelectedText) {
101
+ return true;
102
+ }
103
+ const s = window.getSelection();
104
+ return s !== null && !s.isCollapsed && s.toString().length > 0;
105
+ };
106
+
107
+ function hide(refocus = true): void {
108
+ const shouldRefocus = refocus && menu.contains(document.activeElement);
53
109
  menu.classList.remove("visible");
54
110
  menu.replaceChildren();
55
- if (refocus) {
111
+ if (shouldRefocus) {
56
112
  focusInput();
57
113
  }
58
114
  }
59
115
 
60
- function addButton(label: string, onClick: () => void): void {
116
+ // refocus=false suppresses the return-focus-to-input step: Select All must
117
+ // NOT refocus the textarea, or Firefox collapses the just-made selection
118
+ // when focus leaves the output.
119
+ function addButton(label: string, onClick: () => void, refocus = true): void {
61
120
  const b = document.createElement("button");
62
121
  b.type = "button";
63
122
  b.textContent = label;
64
123
  b.addEventListener("click", () => {
65
124
  onClick();
66
- hide();
125
+ hide(refocus);
67
126
  });
68
127
  menu.appendChild(b);
69
128
  }
70
129
 
71
- function show(x: number, y: number): void {
130
+ function show(x: number, y: number, touch: boolean): void {
72
131
  hide();
73
132
  const clipboard = clip();
74
- const sel = window.getSelection()?.toString();
75
- if (sel) {
76
- addButton("Copy", () => {
77
- clipboard?.copy(sel);
78
- });
79
- }
80
- addButton("Select All", () => {
81
- const s = window.getSelection();
82
- const output = surface.querySelector(".term-output");
83
- if (s && output) {
84
- s.selectAllChildren(output);
133
+ // On touch we only reach here over empty space (no selection), so Copy
134
+ // has nothing to act on; the native callout owns Copy over selected
135
+ // text. On desktop, offer Copy when there is a selection.
136
+ if (!touch) {
137
+ const sel = window.getSelection()?.toString();
138
+ if (sel) {
139
+ addButton("Copy", () => {
140
+ clipboard?.copy(sel);
141
+ });
85
142
  }
86
- });
87
- // Paste needs the clipboard feature. Offered on touch too (see the header
88
- // note): the hidden textarea means iOS's native paste callout never
89
- // appears, so this is the paste path there.
143
+ }
144
+ addButton(
145
+ "Select All",
146
+ () => {
147
+ const s = window.getSelection();
148
+ const output = surface.querySelector(".term-output");
149
+ if (s && output) {
150
+ s.selectAllChildren(output);
151
+ }
152
+ },
153
+ false,
154
+ );
90
155
  if (clipboard) {
91
156
  addButton("Paste", () => {
92
157
  clipboard.paste();
@@ -125,14 +190,61 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
125
190
  );
126
191
  menu.style.left = `${String(left)}px`;
127
192
  menu.style.top = `${String(top)}px`;
193
+ // A touch long-press is immediately followed by a synthetic click on
194
+ // release; swallow it so the menu stays open (see SWALLOW_MS).
195
+ if (touch) {
196
+ swallowUntil = performance.now() + SWALLOW_MS;
197
+ }
128
198
  }
129
199
 
200
+ const onPointerDown = (e: PointerEvent): void => {
201
+ lastPointerType = e.pointerType;
202
+ };
203
+ surface.addEventListener("pointerdown", onPointerDown, { passive: true });
204
+
205
+ // contextmenu fires on desktop right-click AND on Android long-press (iOS
206
+ // fires none). Desktop: always our menu. Touch (Android): our menu only
207
+ // when nothing is selected — and preventDefault so Android's native menu
208
+ // does not ALSO appear (the "both menus" bug); with a selection, defer to
209
+ // the native selection toolbar (don't preventDefault).
130
210
  const onContextMenu = (e: MouseEvent): void => {
211
+ if (lastPointerType !== "touch") {
212
+ e.preventDefault();
213
+ show(e.clientX, e.clientY, false);
214
+ return;
215
+ }
216
+ if (menu.classList.contains("visible")) {
217
+ e.preventDefault(); // the hold timer already opened it; just suppress native
218
+ return;
219
+ }
220
+ if (hasNativeSelection()) {
221
+ return; // native selection toolbar owns Copy over text
222
+ }
131
223
  e.preventDefault();
132
- show(e.clientX, e.clientY);
224
+ clearLongPress();
225
+ show(e.clientX, e.clientY, true);
133
226
  };
134
227
  surface.addEventListener("contextmenu", onContextMenu);
135
228
 
229
+ // Track a native selection made during the current touch press, so the
230
+ // long-press decision never races a fixed-delay read. Armed only while a
231
+ // touch is down (touchActive) so unrelated selection changes (Select All,
232
+ // typing) don't trip it.
233
+ const onSelectionChange = (): void => {
234
+ if (!touchActive) {
235
+ return;
236
+ }
237
+ const s = window.getSelection();
238
+ if (s && !s.isCollapsed && s.toString().length > 0) {
239
+ pressSelectedText = true;
240
+ clearLongPress(); // native selection is happening; don't open our menu
241
+ if (menu.classList.contains("visible")) {
242
+ hide(false); // a native selection appeared after we opened; native wins
243
+ }
244
+ }
245
+ };
246
+ document.addEventListener("selectionchange", onSelectionChange);
247
+
136
248
  // Escape closes the menu without also sending ESC to the PTY.
137
249
  const offKey = ctx.registerKeydown((ev) => {
138
250
  if (ev.key === "Escape" && menu.classList.contains("visible")) {
@@ -144,17 +256,26 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
144
256
  });
145
257
 
146
258
  const onDocClick = (): void => {
259
+ // Swallow the release click of the long-press that just opened the menu.
260
+ if (performance.now() < swallowUntil) {
261
+ return;
262
+ }
147
263
  hide();
148
264
  };
149
265
  document.addEventListener("click", onDocClick);
150
-
151
- // Long-press opens the menu on touch, all platforms (see the header note).
152
- const clearLongPress = (): void => {
153
- if (longPressTimer) {
154
- clearTimeout(longPressTimer);
155
- longPressTimer = 0;
266
+ // A right-click outside the terminal surface (a tab, its menu, elsewhere,
267
+ // or a native browser menu) dismisses this menu. A right-click on the
268
+ // surface is handled by onContextMenu (which reopens it) and fires first.
269
+ const onDocContextMenu = (e: MouseEvent): void => {
270
+ if (!surface.contains(e.target as Node)) {
271
+ hide();
156
272
  }
157
273
  };
274
+ document.addEventListener("contextmenu", onDocContextMenu);
275
+
276
+ // Touch long-press — the iOS path (iOS fires no contextmenu). Open the menu
277
+ // only when nothing got natively selected during the hold; if a word/line
278
+ // was selected, the OS callout owns it and we stay out.
158
279
  const onTouchStart = (e: TouchEvent): void => {
159
280
  if (e.touches.length !== 1) {
160
281
  clearLongPress();
@@ -164,10 +285,14 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
164
285
  if (!t) {
165
286
  return;
166
287
  }
288
+ touchActive = true;
289
+ pressSelectedText = false;
167
290
  longPressOrigin = { x: t.clientX, y: t.clientY };
168
291
  longPressTimer = window.setTimeout(() => {
169
292
  longPressTimer = 0;
170
- show(longPressOrigin.x, longPressOrigin.y);
293
+ if (!hasNativeSelection()) {
294
+ show(longPressOrigin.x, longPressOrigin.y, true);
295
+ }
171
296
  }, LONG_PRESS_MS);
172
297
  };
173
298
  const onTouchMove = (e: TouchEvent): void => {
@@ -184,21 +309,28 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
184
309
  clearLongPress();
185
310
  }
186
311
  };
312
+ const onTouchEnd = (): void => {
313
+ clearLongPress();
314
+ touchActive = false;
315
+ };
187
316
  surface.addEventListener("touchstart", onTouchStart, { passive: true });
188
317
  surface.addEventListener("touchmove", onTouchMove, { passive: true });
189
- surface.addEventListener("touchend", clearLongPress, { passive: true });
190
- surface.addEventListener("touchcancel", clearLongPress, { passive: true });
318
+ surface.addEventListener("touchend", onTouchEnd, { passive: true });
319
+ surface.addEventListener("touchcancel", onTouchEnd, { passive: true });
191
320
 
192
321
  return {
193
322
  teardown() {
194
323
  clearLongPress();
195
324
  offKey();
325
+ surface.removeEventListener("pointerdown", onPointerDown);
196
326
  surface.removeEventListener("contextmenu", onContextMenu);
327
+ document.removeEventListener("selectionchange", onSelectionChange);
197
328
  document.removeEventListener("click", onDocClick);
329
+ document.removeEventListener("contextmenu", onDocContextMenu);
198
330
  surface.removeEventListener("touchstart", onTouchStart);
199
331
  surface.removeEventListener("touchmove", onTouchMove);
200
- surface.removeEventListener("touchend", clearLongPress);
201
- surface.removeEventListener("touchcancel", clearLongPress);
332
+ surface.removeEventListener("touchend", onTouchEnd);
333
+ surface.removeEventListener("touchcancel", onTouchEnd);
202
334
  menu.remove();
203
335
  },
204
336
  };
@@ -11,6 +11,31 @@ import { fromHTML } from "./dom.js";
11
11
 
12
12
  const { bindMobileToolbar } = keyboard;
13
13
 
14
+ /** mobileToolbar's public API, so a peer feature (tabs) can drive the key grid
15
+ * from a button it owns instead of the toolbar's own toggle. */
16
+ export interface MobileToolbarApi {
17
+ /** Show/hide the key grid (flips the toolbar's collapsed state). */
18
+ toggle(): void;
19
+ /** Whether the key grid is currently open. */
20
+ isOpen(): boolean;
21
+ /** Whether sticky-Ctrl is currently armed (a pending Ctrl press). */
22
+ isCtrlArmed(): boolean;
23
+ /** Subscribe to sticky-Ctrl arm/disarm; returns an unsubscribe. Lets a peer
24
+ * (the tab bar's keyboard button) surface the pending modifier even while the
25
+ * key grid — and its own Ctrl button — is closed. */
26
+ onCtrlArmedChange(fn: (armed: boolean) => void): () => void;
27
+ }
28
+
29
+ /** Options for the mobileToolbar feature. */
30
+ export interface MobileToolbarOptions {
31
+ /** When true, the toolbar hides its own top-right toggle button and positions
32
+ * the key grid to open above the mobile tab bar. The grid is then driven
33
+ * externally through the returned API (tabs renders a keyboard button in the
34
+ * switcher bar and calls toggle()). The tabbed presets set this; presetTouch
35
+ * leaves it off and keeps the self-contained top-right toggle. */
36
+ externalToggle?: boolean;
37
+ }
38
+
14
39
  // The key grid, without the scroll-to-bottom button (that is the scrollToBottom
15
40
  // feature, composed into the same region's "scroll" slot).
16
41
  const TOOLBAR_HTML = `
@@ -26,20 +51,34 @@ const TOOLBAR_HTML = `
26
51
  <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>
27
52
  </div>`;
28
53
 
29
- export function mobileToolbar(): TerminalFeature {
54
+ export function mobileToolbar(opts: MobileToolbarOptions = {}): TerminalFeature<MobileToolbarApi> {
30
55
  return {
31
56
  name: "mobileToolbar",
32
57
  setup(ctx) {
33
58
  const encoder = new TextEncoder();
34
59
  const decoder = new TextDecoder();
35
60
  const toolbar = fromHTML(TOOLBAR_HTML);
61
+ // Externally-driven mode (the tabbed presets): the toolbar hides its own
62
+ // toggle and opens the grid above the mobile tab bar; tabs drives it via
63
+ // the returned API. The class gates both behaviors in CSS.
64
+ if (opts.externalToggle) {
65
+ toolbar.classList.add("wt-toolbar-external");
66
+ }
36
67
  ctx.region("bottom-inset-end", "keys").appendChild(toolbar);
37
68
 
69
+ // Fan sticky-Ctrl arm/disarm out to subscribers (the tab bar's keyboard
70
+ // button mirrors it, so a pending Ctrl is visible with the grid closed).
71
+ const armedListeners = new Set<(armed: boolean) => void>();
38
72
  const ctrl = bindMobileToolbar({
39
73
  toolbar,
40
74
  send: (bytes) => {
41
75
  ctx.send(encoder.encode(bytes));
42
76
  },
77
+ onCtrlChange: (armed) => {
78
+ for (const fn of [...armedListeners]) {
79
+ fn(armed);
80
+ }
81
+ },
43
82
  });
44
83
 
45
84
  // Sticky-Ctrl as an outbound-byte transform: when armed, a single typed
@@ -63,6 +102,24 @@ export function mobileToolbar(): TerminalFeature {
63
102
  });
64
103
 
65
104
  return {
105
+ // Expose grid open/close so a peer (tabs) can drive it from the tab bar.
106
+ // Toggling the same `.collapsed` class the engine's own kb-toggle flips
107
+ // keeps a single source of truth for the grid's visibility.
108
+ api: {
109
+ toggle() {
110
+ toolbar.classList.toggle("collapsed");
111
+ },
112
+ isOpen() {
113
+ return !toolbar.classList.contains("collapsed");
114
+ },
115
+ isCtrlArmed() {
116
+ return ctrl.isCtrlArmed();
117
+ },
118
+ onCtrlArmedChange(fn) {
119
+ armedListeners.add(fn);
120
+ return () => armedListeners.delete(fn);
121
+ },
122
+ },
66
123
  onDetach() {
67
124
  // A tab switch disarms a latched sticky-Ctrl so a pending Ctrl does
68
125
  // not fire against the incoming session (e.g. an accidental Ctrl+C to
@@ -73,6 +130,7 @@ export function mobileToolbar(): TerminalFeature {
73
130
  },
74
131
  teardown() {
75
132
  offTransform();
133
+ armedListeners.clear();
76
134
  ctrl.dispose();
77
135
  toolbar.remove();
78
136
  },
@@ -20,18 +20,34 @@ export function scrollToBottom(): TerminalFeature {
20
20
  const btn = fromHTML(BUTTON_HTML);
21
21
  slot.appendChild(btn);
22
22
 
23
+ // Jump to the bottom. With motion allowed, smooth-scroll the surface so
24
+ // the jump is animated rather than an instant teleport; the engine's
25
+ // scroll controller re-derives its follow state from the scroll position
26
+ // (its listener sees the animation land at the bottom), so following
27
+ // re-engages with no programmatic flag, consistent with the engine's
28
+ // position-only model. Under prefers-reduced-motion, fall back to the
29
+ // engine's instant scrollToBottom (which re-engages following synchronously).
30
+ const jump = (): void => {
31
+ const reduce =
32
+ typeof window.matchMedia === "function" &&
33
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
34
+ if (reduce) {
35
+ ctx.scroll.scrollToBottom();
36
+ return;
37
+ }
38
+ const surface = ctx.surface();
39
+ surface.scrollTo({ top: surface.scrollHeight, behavior: "smooth" });
40
+ };
41
+
23
42
  // pointerdown (like the toolbar keys) so touch devices show press
24
43
  // feedback; preventDefault keeps focus on the terminal. click is kept for
25
- // keyboard activation. scrollToBottom is idempotent, so the pair is safe.
44
+ // keyboard activation. jump is idempotent, so the pair is safe.
26
45
  const onDown = (e: PointerEvent): void => {
27
46
  e.preventDefault();
28
- ctx.scroll.scrollToBottom();
29
- };
30
- const onClick = (): void => {
31
- ctx.scroll.scrollToBottom();
47
+ jump();
32
48
  };
33
49
  btn.addEventListener("pointerdown", onDown);
34
- btn.addEventListener("click", onClick);
50
+ btn.addEventListener("click", jump);
35
51
 
36
52
  // Visible only while scrolled up; the region reflects it via a class.
37
53
  const off = ctx.on("scroll:state", ({ scrolledUp }) => {