@cplieger/web-terminal-ui 3.3.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/css/06-mobile.css CHANGED
@@ -1,17 +1,20 @@
1
1
  /* ===== Mobile tab chrome (tabs feature, section 12) =====
2
2
  The bottom bar (switcher) is the mobile tab surface, the externally-driven key
3
3
  grid docks above it, and the overview sheet lists every tab. The desktop strip
4
- is hidden on a coarse pointer and these appear instead; all motion (bar number
5
- fade, sheet slide) lives in 07-animations.css. */
6
-
7
- /* The switcher is hidden on a fine pointer, where the top strip is used instead;
8
- the top strip is correspondingly hidden on a coarse pointer so tabs live in
9
- the thumb zone. */
4
+ is hidden only on a narrow coarse-pointer device (a phone) and these appear
5
+ instead; all motion (bar number fade, sheet slide) lives in 07-animations.css. */
6
+
7
+ /* The switcher is used only on a NARROW coarse-pointer device (a phone), where
8
+ the top strip is hidden so tabs live in the thumb zone. A wide touchscreen (an
9
+ iPad) and any fine-pointer device keep the multi-tab top strip — a single
10
+ giant tab wastes a big screen. This media query MUST stay in lockstep with
11
+ SWITCHER_MEDIA in src/features/tabs.ts and the complementary strip clearance
12
+ in css/05-tabs.css. */
10
13
  .wt-switcher {
11
14
  display: none;
12
15
  }
13
16
 
14
- @media (pointer: coarse) {
17
+ @media (pointer: coarse) and (width <= 600px) {
15
18
  .wt-tab-bar,
16
19
  .wt-tab-new {
17
20
  display: none;
@@ -57,7 +60,11 @@
57
60
  padding: 0 var(--sp-2);
58
61
  display: flex;
59
62
  flex-direction: column;
60
- gap: 2px;
63
+ /* A touch more room than the desktop strip's --sp-1: vertical touch rows read
64
+ better with a bit more separation, and this equals the bar's --sp-2 bottom
65
+ padding so the active-tab -> first-row seam matches the inter-row gap (see
66
+ the expanded-list padding-top: 0 below). */
67
+ gap: var(--sp-2);
61
68
  max-height: 0;
62
69
  overflow: hidden;
63
70
  /* max-height caps the whole box (incl. the expanded padding) so an interactive
@@ -67,9 +74,14 @@
67
74
  }
68
75
 
69
76
  .wt-switcher-expanded .wt-switcher-list {
70
- max-height: 50vh;
77
+ /* Cap against the DYNAMIC viewport so a shown mobile browser toolbar can't push the list
78
+ (and the bar above it) off the top; the switcher's own bottom: kb-inset already lifts it
79
+ clear of the soft keyboard. */
80
+ max-height: 50dvh;
71
81
  overflow-y: auto;
72
- padding-top: var(--sp-1);
82
+ /* No top padding: the bar's --sp-2 bottom padding already sits between the
83
+ active tab and the first row, so the seam equals the --sp-2 inter-row gap. */
84
+ padding-top: 0;
73
85
  padding-bottom: var(--sp-1);
74
86
  }
75
87
 
@@ -102,7 +114,7 @@
102
114
  fill/foreground the desktop strip uses (themeable; purple in vibecli). */
103
115
  background: var(--tab-active-bg);
104
116
  color: var(--tab-active-fg);
105
- border: none;
117
+ border: 1px solid var(--tab-active-border);
106
118
  border-radius: var(--radius-inner);
107
119
  font-family: var(--font-ui);
108
120
  font-size: 0.875rem;
@@ -167,6 +179,13 @@
167
179
  button collapses to zero width and fades out (the key grid is closed too, in
168
180
  JS), and the active row flex-grows to fill the freed space. */
169
181
  .wt-switcher-kb {
182
+ /* Rigid 44px box like the "+" and switch buttons (flex: none), so a long
183
+ active-tab title that overflows the bar is absorbed entirely by the
184
+ ellipsizing active-tab field, not shared with this button. It is otherwise
185
+ the only bar control with flex-shrink, so it squished (≈28px, non-square) on
186
+ long-title tabs while staying square on short ones. The `width` below still
187
+ drives the expand-collapse animation; flex: none honours it. */
188
+ flex: none;
170
189
  width: var(--touch-target);
171
190
  /* Override .wt-btn's min-width (the 44px tap floor). min-width is NOT
172
191
  transitioned, so on collapse it would revert to 44px instantly and clamp the
@@ -206,6 +225,41 @@
206
225
  pointer-events: none;
207
226
  }
208
227
 
228
+ /* Tab-switcher button: toggles the list open/closed, sitting between the
229
+ keyboard and "+" buttons. position: relative anchors its notification dot,
230
+ absolutely placed in the top-right corner. Unlike the keyboard button it
231
+ stays visible while the list is expanded — it is the close control. */
232
+ .wt-switcher-switch {
233
+ position: relative;
234
+ flex: none;
235
+ }
236
+
237
+ @media (hover: hover) {
238
+ .wt-switcher-switch:hover {
239
+ background: var(--tab-hover-bg);
240
+ }
241
+ }
242
+
243
+ /* Latest-wins background-tab notification dot on the switch button — the moved
244
+ attention cue. Amber = a background terminal needs input, green = a background
245
+ turn finished. Reuses the exact .wt-status-dot colours (05-tabs.css) so it
246
+ matches the per-tab dots. Hidden until the tabs feature sets data-status;
247
+ cleared when the list opens (expandSwitcher). */
248
+ .wt-switcher-switch-dot {
249
+ position: absolute;
250
+ top: 6px;
251
+ right: 6px;
252
+ }
253
+
254
+ /* The dot carries the .wt-status-dot class, so its size, shape, and colour come
255
+ straight from the shared .wt-status-dot[data-status="input"|"done"] rules
256
+ (05-tabs.css) — the SAME dot colours every tab already uses, defined once.
257
+ .wt-status-dot is display:none by default; force it visible only while a
258
+ notification is set (data-status present). */
259
+ .wt-switcher-switch-dot[data-status] {
260
+ display: block;
261
+ }
262
+
209
263
  /* Mobile "+": the primary action, carried in the accent colour so it reads as
210
264
  permanent and prominent. Hover fill is gated to real hover pointers: on touch,
211
265
  :hover sticks after a tap (no pointer-leave), so the purple would linger — the
@@ -225,31 +279,12 @@
225
279
  background: var(--tab-hover-bg);
226
280
  }
227
281
 
228
- /* Aggregate needs-input attention now rides the active surface (the separate
229
- overview button is gone): a background tab is blocked on you, and a tap /
230
- swipe-up here opens the overview to resolve it. Shape + color (an inset ring
231
- plus a corner glyph), never hue alone (WCAG 1.4.1). */
232
- .wt-switcher-current[data-attention="input"] {
233
- box-shadow: inset 0 0 0 2px oklch(70% 0.18 25deg / 45%);
234
- }
235
-
236
- .wt-switcher-current[data-attention="input"]::after {
237
- content: "";
238
- position: absolute;
239
- top: 4px;
240
- right: 4px;
241
- width: 8px;
242
- height: 8px;
243
- border-radius: 50%;
244
- background: oklch(70% 0.18 25deg);
245
- }
246
-
247
282
  /* ===== Externally-driven key grid (tabbed presets, WS3) =====
248
283
  In the tabbed presets the mobile toolbar hides its own toggle (the tab bar's
249
284
  keyboard button drives it) and the grid docks above the bar on the right, to
250
285
  the left of where scroll-to-bottom sits. It collapses to fully hidden, since
251
286
  there is no in-grid toggle to keep visible. */
252
- @media (pointer: coarse) {
287
+ @media (pointer: coarse) and (width <= 600px) {
253
288
  .wt-toolbar-external .kb-toggle {
254
289
  display: none;
255
290
  }
@@ -268,6 +303,36 @@
268
303
  }
269
304
  }
270
305
 
306
+ /* Desktop tab strip on a WIDE touchscreen (an iPad): the mobile switcher is
307
+ gone, so the desktop strip carries its OWN keyboard button (.wt-tab-kb, built
308
+ by the same factory as the switcher's — see css/05-tabs.css + tabs.ts) to open
309
+ the key grid. So, exactly like switcher mode, the toolbar's own toggle is
310
+ hidden and the collapsed grid fully hidden — the tab-strip keyboard button is
311
+ the sole control (this replaces the old CSS-only workaround that un-hid the
312
+ toolbar's own toggle here). The grid still docks bottom-right ABOVE the strip:
313
+ above the tabs (never overlapping them, even when many are open and the strip
314
+ scrolls) and to the left of the scroll-to-bottom FAB (same right offset as
315
+ switcher mode). Complements the strip-shown breakpoint in css/05-tabs.css;
316
+ keep in lockstep with SWITCHER_MEDIA in src/features/tabs.ts. */
317
+ @media (pointer: coarse) and (width >= 601px) {
318
+ .wt-toolbar-external .kb-toggle {
319
+ display: none;
320
+ }
321
+
322
+ .key-toolbar.wt-toolbar-external {
323
+ top: auto;
324
+ right: calc(var(--sp-3) + var(--touch-target) + var(--sp-2));
325
+ bottom: calc(
326
+ var(--wt-tabbar-h, 44px) + var(--safe-bottom, 0px) + var(--kb-inset, 0px) + var(--sp-2)
327
+ );
328
+ grid-template-columns: repeat(4, var(--touch-target));
329
+ }
330
+
331
+ .key-toolbar.wt-toolbar-external.collapsed {
332
+ display: none;
333
+ }
334
+ }
335
+
271
336
  /* ===== Expanded tab-list rows (tabs feature) =====
272
337
  One row per OTHER tab in the expanded list: a stretched select target (dot +
273
338
  label) with the close (x) laid inside at the right, mirroring the desktop
@@ -285,9 +350,9 @@
285
350
  min-height: var(--touch-target);
286
351
  /* Right padding reserves room for the close (x) overlaid inside the box. */
287
352
  padding: 0 var(--touch-target) 0 var(--sp-2);
288
- background: transparent;
353
+ background: var(--tab-bg);
289
354
  color: var(--text);
290
- border: none;
355
+ border: 1px solid var(--border);
291
356
  border-radius: var(--radius-inner);
292
357
  font-family: var(--font-ui);
293
358
  font-size: 0.875rem;
@@ -298,15 +363,16 @@
298
363
 
299
364
  @media (hover: hover) {
300
365
  .wt-switcher-row-select:hover {
301
- background: var(--tab-hover-bg);
366
+ background: color-mix(in oklch, var(--tab-bg), var(--text) 15%);
302
367
  }
303
368
  }
304
369
 
305
- /* Press / touch-hold feedback on a listed (inactive) tab: the same subtle fill
306
- the "+" button uses (--tab-hover-bg), NOT the stronger active-tab fill, so an
307
- inactive row never looks like the active one. */
370
+ /* Press / touch-hold feedback on a listed (inactive) tab: a solid lift over the
371
+ chip (the same 15% --text lift the desktop tabs use), not the translucent
372
+ --tab-hover-bg wash that would erase the chip fill. The active tab is set apart
373
+ by its accent border, so a lit inactive row is never mistaken for it. */
308
374
  .wt-switcher-row-select:active {
309
- background: var(--tab-hover-bg);
375
+ background: color-mix(in oklch, var(--tab-bg), var(--text) 15%);
310
376
  }
311
377
 
312
378
  .wt-switcher-row-label {
@@ -1,18 +1,27 @@
1
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". */
2
+ Chrome-surface motion is centralized here, gated on the root .wt-animate class
3
+ that the animations feature sets only when the OS is not in reduced-motion:
4
+ base rules in 03-06 carry the resting state, and these rules add the
5
+ transitions and keyframes on top. A few pieces of motion are intentionally NOT
6
+ gated on .wt-animate and animate unconditionally: terminal-core motion (cursor
7
+ and SGR blink) and the mobile key toolbar open/close in 02-app.css, plus the
8
+ working-status-dot pulse in 05-tabs.css (motion is its only non-hue cue, per
9
+ WCAG 1.4.1). prefers-reduced-motion still defeats all of it via the reset in
10
+ 01-reset.css. Keeping the chrome-surface motion in one file is the
11
+ design-system seam for "does this chrome animate, and how". */
8
12
 
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 {
13
+ /* Fade the toast OUT on hide (display allow-discrete holds display:block for
14
+ the fade, then flips to display:none). It appears INSTANTLY on show: a
15
+ transition out of display:none does not animate without a matching
16
+ @starting-style rule (none is defined in css/), so opacity snaps 0->1 on
17
+ show. Only the toast sets opacity in its base and
18
+ .visible rules, so only it can interpolate. The other transient surfaces
19
+ (connection banner, scroll-to-bottom, context menu, tab menu) toggle `display`
20
+ only and never set opacity, so they are intentionally not listed here: a
21
+ shared opacity transition would be inert while the `display allow-discrete`
22
+ half held them fully visible for --dur-standard before popping out on hide.
23
+ They show and hide instantly. */
24
+ .wt-animate .wt-toast {
16
25
  transition:
17
26
  opacity var(--dur-standard) var(--ease-standard),
18
27
  display var(--dur-standard) allow-discrete;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "3.3.1",
3
+ "version": "3.4.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",
@@ -26,14 +26,13 @@
26
26
  // uses the textarea as its anchor; we move the textarea to the cursor
27
27
  // for the same reason.
28
28
 
29
- import { keyboard } from "@cplieger/web-terminal-engine";
30
29
  import { resetToPlaceholder } from "./input-placeholder.js";
31
- const { bracketTextForPaste, prepareTextForTerminal } = keyboard;
32
30
 
33
31
  let textarea: HTMLTextAreaElement;
34
32
  let compositionView: HTMLElement;
35
33
  let getCursorPx: () => { left: number; top: number; cellH: number };
36
34
  let send: (bytes: string) => void;
35
+ let paste: (text: string) => void;
37
36
 
38
37
  let composing = false;
39
38
  let sendingComposition = false;
@@ -45,11 +44,15 @@ export function init(opts: {
45
44
  compositionView: HTMLElement;
46
45
  getCursorPx: () => { left: number; top: number; cellH: number };
47
46
  send: (bytes: string) => void;
47
+ /** The kernel's single bracketed-paste+normalize funnel; the native iOS-callout
48
+ * paste routes through it rather than re-composing the funnel here. */
49
+ paste: (text: string) => void;
48
50
  }): void {
49
51
  textarea = opts.textarea;
50
52
  compositionView = opts.compositionView;
51
53
  getCursorPx = opts.getCursorPx;
52
54
  send = opts.send;
55
+ paste = opts.paste;
53
56
 
54
57
  textarea.addEventListener("compositionstart", onStart);
55
58
  textarea.addEventListener("compositionupdate", onUpdate);
@@ -79,6 +82,26 @@ export function cancelComposition(): void {
79
82
  resetToPlaceholder(textarea);
80
83
  }
81
84
 
85
+ /** Release init()-attached listeners and drop the captured kernel closures on kernel
86
+ * destroy(), so a destroy without a remount does not retain the old textarea + the
87
+ * kernel send/paste closure graph. Mirrors viewport.teardown(). */
88
+ export function teardown(): void {
89
+ cancelComposition(); // neutralize a pending finalizer + reset composing state
90
+ textarea.removeEventListener("compositionstart", onStart);
91
+ textarea.removeEventListener("compositionupdate", onUpdate);
92
+ textarea.removeEventListener("compositionend", onEnd);
93
+ textarea.removeEventListener("paste", onPaste);
94
+ // Drop references to the old kernel instance so its closure graph is GC-eligible
95
+ // (the detached textarea/compositionView are overwritten on the next init()).
96
+ send = () => {
97
+ /* no-op after teardown */
98
+ };
99
+ paste = () => {
100
+ /* no-op after teardown */
101
+ };
102
+ getCursorPx = () => ({ left: 0, top: 0, cellH: 0 });
103
+ }
104
+
82
105
  function onStart(): void {
83
106
  composing = true;
84
107
  const start = textarea.selectionStart;
@@ -118,7 +141,9 @@ function onEnd(): void {
118
141
  suffixSnapshot.length > 0 && value.endsWith(suffixSnapshot)
119
142
  ? value.length - suffixSnapshot.length
120
143
  : value.length;
121
- const composed = value.substring(startSnapshot, Math.max(startSnapshot, valueEnd));
144
+ const composed = value
145
+ .substring(startSnapshot, Math.max(startSnapshot, valueEnd))
146
+ .replace(/\u00A0/g, " ");
122
147
  if (composed.length > 0) {
123
148
  send(composed);
124
149
  }
@@ -139,7 +164,7 @@ function onPaste(ev: ClipboardEvent): void {
139
164
  }
140
165
  ev.preventDefault();
141
166
  ev.stopPropagation();
142
- send(bracketTextForPaste(prepareTextForTerminal(raw)));
167
+ paste(raw);
143
168
  // Restore the placeholder so the subsequent `input` event (some
144
169
  // browsers fire it after paste) and held-Backspace auto-repeat keep
145
170
  // working.
@@ -38,7 +38,14 @@ export function activityMonitor(
38
38
  statuses.set(s.id, s);
39
39
  }
40
40
  for (const cb of [...subs]) {
41
- cb(s);
41
+ try {
42
+ cb(s);
43
+ } catch (err) {
44
+ // Isolate a throwing subscriber so it neither skips the
45
+ // remaining subscribers nor propagates into the engine's
46
+ // SSE reader (mirrors the kernel bus's ctx.on wrapping).
47
+ console.error("web-terminal-ui: activityMonitor subscriber threw", err);
48
+ }
42
49
  }
43
50
  },
44
51
  });
@@ -1,8 +1,13 @@
1
- // animations feature: opt-in motion (design sections 13, 22.4). Without it the
2
- // UI is instant (no transitions); with it, and when the user has not asked for
3
- // reduced motion, it enables the shared animation vocabulary by marking the
4
- // terminal root, which the feature stylesheets key their transitions off. It
5
- // tracks prefers-reduced-motion live so toggling the OS setting takes effect.
1
+ // animations feature: opt-in motion for the chrome surfaces (design sections
2
+ // 13, 22.4). Without it the chrome surfaces are instant (no transitions); with
3
+ // it, and when the user has not asked for reduced motion, it enables the shared
4
+ // animation vocabulary by marking the terminal root, which the feature
5
+ // stylesheets key their transitions off. A few pieces of motion animate
6
+ // unconditionally regardless of this feature (terminal-core cursor/SGR blink and
7
+ // the mobile key toolbar open/close in 02-app.css, plus the working-status-dot
8
+ // pulse in 05-tabs.css, its WCAG non-hue cue), though prefers-reduced-motion
9
+ // still defeats all of it. It tracks prefers-reduced-motion live so toggling the
10
+ // OS setting takes effect.
6
11
 
7
12
  import type { TerminalFeature } from "../kernel/types.js";
8
13
 
@@ -89,9 +89,15 @@ export function clipboard(): TerminalFeature<ClipboardApi> {
89
89
  copy(text);
90
90
  });
91
91
 
92
- // Native copy (e.g. Cmd/Ctrl+C on a selection) feedback toast.
92
+ // Native copy (e.g. Cmd/Ctrl+C on a selection) feedback toast, scoped to a
93
+ // selection inside the terminal surface so an embedding host's copies of
94
+ // its own (non-terminal) content do not raise a spurious "Copied".
95
+ const surface = ctx.surface();
93
96
  const onCopy = (): void => {
94
- ctx.toast("Copied");
97
+ const node = window.getSelection()?.anchorNode ?? null;
98
+ if (node && surface.contains(node)) {
99
+ ctx.toast("Copied");
100
+ }
95
101
  };
96
102
  document.addEventListener("copy", onCopy);
97
103
 
@@ -20,7 +20,6 @@ export function connectionBanner(): TerminalFeature {
20
20
  const slot = ctx.region("banner", "status");
21
21
  const banner = document.createElement("div");
22
22
  banner.className = "wt-conn-banner";
23
- banner.setAttribute("role", "status");
24
23
  slot.appendChild(banner);
25
24
 
26
25
  let last: ConnState | null = null;
@@ -53,6 +53,28 @@ export interface ContextMenuOptions {
53
53
  clipboard?: TerminalFeature<ClipboardApi>;
54
54
  }
55
55
 
56
+ /** iPhone/iPad/iPod, including iPadOS Safari's default "desktop mode" (which
57
+ * reports platform MacIntel but with a touch screen). On these, a long-press on
58
+ * text fires `contextmenu` slightly BEFORE the native word-selection registers,
59
+ * so hasNativeSelection() is still false at that instant and the Android
60
+ * empty-space branch would wrongly open our menu and preventDefault the native
61
+ * selection. Deferring on any Apple touch device lets iOS/iPadOS run its own
62
+ * text selection + drag handles (bug: an iPad long-press opened the custom menu
63
+ * instead of native selection, leaving Select All the only way to select). */
64
+ function isAppleTouchDevice(): boolean {
65
+ if (typeof navigator === "undefined") {
66
+ return false;
67
+ }
68
+ const ua = navigator.userAgent || "";
69
+ const platform = navigator.platform || "";
70
+ if (/iP(hone|ad|od)/.test(ua) || /iP(hone|ad|od)/.test(platform)) {
71
+ return true;
72
+ }
73
+ // iPadOS 13+ Safari defaults to desktop mode: platform "MacIntel" but a touch
74
+ // screen (maxTouchPoints > 1). A real trackpad Mac reports maxTouchPoints 0.
75
+ return platform === "MacIntel" && navigator.maxTouchPoints > 1;
76
+ }
77
+
56
78
  export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
57
79
  return {
58
80
  name: "contextMenu",
@@ -68,6 +90,9 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
68
90
  // desktop right-click AND on Android long-press, so its handler branches
69
91
  // on this to tell them apart.
70
92
  let lastPointerType = "mouse";
93
+ // Computed once: on an Apple touch device, defer a touch contextmenu to
94
+ // native text selection (see isAppleTouchDevice).
95
+ const appleTouch = isAppleTouchDevice();
71
96
  // Timestamp until which a document click is swallowed (see SWALLOW_MS).
72
97
  let swallowUntil = 0;
73
98
  // A native selection was made during the current touch press (tracked via
@@ -220,6 +245,13 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
220
245
  if (hasNativeSelection()) {
221
246
  return; // native selection toolbar owns Copy over text
222
247
  }
248
+ if (appleTouch) {
249
+ // iOS/iPadOS fires contextmenu a beat before the native word-select
250
+ // registers, so hasNativeSelection() can still be false here. Defer
251
+ // anyway so the platform's own selection wins instead of our
252
+ // empty-space menu (which would preventDefault and suppress it).
253
+ return;
254
+ }
223
255
  e.preventDefault();
224
256
  clearLongPress();
225
257
  show(e.clientX, e.clientY, true);
@@ -47,6 +47,16 @@ export function predictiveEcho(): TerminalFeature {
47
47
  });
48
48
 
49
49
  return {
50
+ // A tab switch (kernel notifySwitch -> onDetach) must drop the predicted
51
+ // cursor, so the outgoing session's ghost cursor does not paint on the
52
+ // incoming session's freshly-bound screen until its first server frame
53
+ // re-arms prediction. Mirrors composition.cancelComposition on the same
54
+ // detach: both local-echo paths reset on switch. predict.reset() hides the
55
+ // overlay (its onChange -> push -> setPredictedCursor(0,0,false)); the next
56
+ // wire:screen re-arms via onScreenFrame.
57
+ onDetach() {
58
+ predict.reset();
59
+ },
50
60
  teardown() {
51
61
  offTransform();
52
62
  offObserver();