@cplieger/web-terminal-ui 7.0.6 → 7.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.
package/README.md CHANGED
@@ -186,7 +186,27 @@ want one, for example Ctrl+L); `reattach()` attaches again to whatever the serve
186
186
  serves now, for a session that has ended (see "When a session ends"); and
187
187
  `destroy()` tears every feature down and releases the kernel.
188
188
 
189
- ### Persisting scrollback across a discard
189
+ ### Mouse and focus
190
+
191
+ A terminal application that turns mouse tracking on receives the clicks, drags
192
+ and wheel gestures a user makes in the browser, as SGR reports. The pointer says
193
+ so while that is happening: at rest the grid carries a text cursor, and while an
194
+ application holds the mouse the grid and any links in it carry an arrow instead,
195
+ because a click is reported to that application rather than selecting text or
196
+ opening the link. Shift-drag still selects and shift-click still opens a link,
197
+ which is the convention xterm set, so nothing becomes unreachable.
198
+
199
+ Mouse input is best-effort, as it is in every terminal: a report says where the
200
+ pointer is at that moment and carries nothing a receiver could use to notice it
201
+ had gone stale, so a report made while the socket is down is dropped rather than
202
+ delivered late against a screen that has since been repainted. Typed input keeps
203
+ its delivery guarantee and is unaffected.
204
+
205
+ Focus reporting is the server's answer rather than this UI's. The kernel tells
206
+ the server whether its own terminal widget holds focus and the server decides
207
+ what the application is told, so several devices attached to one session cannot
208
+ contradict each other. A blur that a press causes and that the same gesture's
209
+ click undoes is not reported, because the terminal did not lose focus.
190
210
 
191
211
  `persistScrollback` takes the storage, not a switch, because storage is a
192
212
  decision this library should not make for you. Chrome's page-lifecycle guidance
@@ -358,8 +378,19 @@ completed feature and core listener, clears the broken subtree, and shows the
358
378
  panel. If `createTerminal` itself throws (`phase: "kernel-init"`: an
359
379
  unresolvable mount selector, a preset that throws, an invalid feature list, a DOM
360
380
  invariant), it shows the same panel and then rethrows, so a caller with its own
361
- error handling still sees the error. Either way the panel is modal when the
362
- terminal owns the viewport and non-modal when it fills an embedded container.
381
+ error handling still sees the error.
382
+
383
+ The panel is a native `<dialog>`. In `viewport` layout it opens with
384
+ `showModal()`, so the rest of your document is inert while it is up; in
385
+ `container` layout it opens non-modally, so the application around an embedded
386
+ terminal keeps working and keeps its focusables. Escape does not dismiss it in
387
+ either mode: the terminal is already gone, so there would be nothing behind it
388
+ and no way back. Reload is the recovery.
389
+
390
+ One visible consequence in `viewport` layout: a modal dialog is in the browser's
391
+ top layer, above every `z-index` in the document, so the panel now paints over
392
+ your loading overlay instead of under it. It appears at once and the overlay's
393
+ fade-out finishes behind it.
363
394
 
364
395
  **This is why `target` takes a selector and `features` takes a function.** Both
365
396
  are resolved INSIDE that boundary. Written the other way round
@@ -149,6 +149,11 @@
149
149
  kernel focus handling + contextMenu feature). */
150
150
  user-select: text;
151
151
  -webkit-touch-callout: default;
152
+
153
+ /* Stated rather than inherited: the UA default over text is already an I-beam,
154
+ but the tracking rule below has to have something deterministic to override
155
+ (and to hand back to). */
156
+ cursor: text;
152
157
  }
153
158
 
154
159
  /* IME composition view: floating overlay positioned at the cursor
@@ -327,6 +332,18 @@
327
332
  --bg: var(--text-default);
328
333
  }
329
334
 
335
+ /* An application holds the mouse (DEC 1000/1002/1003) and the kernel toggles
336
+ .wt-mouse-app on the scroll container. A plain click is reported to that
337
+ application, so neither the I-beam nor the link's pointer is true any longer:
338
+ both promise something the click no longer does. Deliberately no
339
+ `user-select: none` here, because the engine hands a shifted gesture to the
340
+ browser, so shift+drag selecting and shift+click opening a link are the only
341
+ ways left to reach either and blanketing selection off would remove them. */
342
+ :where(.wt-root) .term.wt-mouse-app .term-output,
343
+ :where(.wt-root) .term.wt-mouse-app .term-link {
344
+ cursor: default;
345
+ }
346
+
330
347
  :where(.wt-root) .term-blink {
331
348
  animation: wt-blink-anim 1s step-end infinite;
332
349
  }
@@ -133,13 +133,26 @@
133
133
  Feature setup is part of the kernel composition contract. If it fails, the
134
134
  kernel removes the half-live terminal and fills only its own layout boundary
135
135
  with this recovery surface. In container mode that leaves the host app
136
- outside the terminal fully visible and interactive. */
136
+ outside the terminal fully visible and interactive.
137
+
138
+ The surface is a native <dialog>: showModal() in wt-viewport, the bare `open`
139
+ attribute in wt-container. Modality is the only difference between them. */
137
140
  :where(.wt-root) .wt-fatal {
138
141
  position: absolute;
139
142
  inset: 0;
140
- z-index: 100;
141
- display: grid;
142
- place-items: center;
143
+ /* The size resets are what let the insets stretch the panel: a <dialog> is
144
+ fit-content with the UA sheet's own `margin: auto`, and :modal additionally
145
+ caps both axes at calc(100% - 38px). margin, border-radius and box-shadow
146
+ answer an EMBEDDER's own `dialog { ... }` base rule rather than the UA sheet —
147
+ margin is the member that moves the box, the other two are cosmetic. */
148
+ margin: 0;
149
+ border: none;
150
+ border-radius: 0;
151
+ box-shadow: none;
152
+ inline-size: auto;
153
+ block-size: auto;
154
+ max-inline-size: none;
155
+ max-block-size: none;
143
156
  overflow: auto;
144
157
  padding: calc(var(--safe-top) + var(--sp-3)) var(--sp-3) calc(var(--safe-bottom) + var(--sp-3));
145
158
  background: var(--bg);
@@ -148,6 +161,39 @@
148
161
  text-align: center;
149
162
  }
150
163
 
164
+ /* Scoped to [open]: an author `display` beats the UA's `dialog { display: none }`
165
+ at any specificity, so declared unconditionally a CLOSED panel would still
166
+ paint its full-surface background over the root. */
167
+ :where(.wt-root) .wt-fatal[open] {
168
+ display: grid;
169
+ place-items: center;
170
+ }
171
+
172
+ /* The base rule's `position: absolute` serves the container half (it resolves
173
+ against .wt-root.wt-container, the embedder's box); this gives the viewport back
174
+ to the modal half. A top-layer absolute resolves against the initial containing
175
+ block — viewport-SIZED but canvas-anchored, so equivalent to fixed only at
176
+ scrollY 0 — and Blink does not lock document scrolling under a modal dialog.
177
+ Pinned by fatal-panel.test.ts's scrolled-document test. */
178
+ :where(.wt-root) .wt-fatal:modal {
179
+ position: fixed;
180
+ }
181
+
182
+ /* The stacking order the top layer supplies for free, so it is declared only for
183
+ the half that is not in it. .wt-root.wt-container is the EMBEDDER's element and
184
+ forms no stacking context, so the panel competes in the host's. */
185
+ :where(.wt-root) .wt-fatal:not(:modal) {
186
+ z-index: 100;
187
+ }
188
+
189
+ /* The panel IS the dim — it fills its whole surface in --bg — so the backdrop
190
+ agrees with it instead of adding a second, differently coloured layer. The UA's
191
+ rgba(0, 0, 0, 0.1) would show as a lighter seam if the panel were ever smaller
192
+ than the viewport. */
193
+ :where(.wt-root) .wt-fatal::backdrop {
194
+ background: var(--bg);
195
+ }
196
+
151
197
  :where(.wt-root) .wt-fatal-card {
152
198
  inline-size: min(100%, 28rem);
153
199
  padding: clamp(1.25rem, 5vi, 2rem);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "7.0.6",
3
+ "version": "7.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": "MPL-2.0",
@@ -40,7 +40,7 @@
40
40
  "verify": "bash scripts/verify.sh"
41
41
  },
42
42
  "peerDependencies": {
43
- "@cplieger/web-terminal-engine": "^5.0.3"
43
+ "@cplieger/web-terminal-engine": "^5.1.0"
44
44
  },
45
45
  "overrides": {
46
46
  "qs": "^6.15.2"
@@ -50,18 +50,18 @@
50
50
  "@stryker-mutator/core": "10.0.0",
51
51
  "@stryker-mutator/typescript-checker": "10.0.0",
52
52
  "@stryker-mutator/vitest-runner": "10.0.0",
53
- "@types/node": "26.4.1",
53
+ "@types/node": "26.5.0",
54
54
  "@typescript/native": "npm:typescript@7.0.2",
55
55
  "@vitest/browser": "5.0.0",
56
56
  "@vitest/browser-playwright": "5.0.0",
57
57
  "@vitest/coverage-v8": "5.0.0",
58
- "eslint": "10.9.1",
58
+ "eslint": "10.10.0",
59
59
  "fast-check": "4.9.0",
60
- "html-validate": "11.14.0",
61
- "knip": "6.34.0",
62
- "playwright": "1.62.1",
60
+ "html-validate": "11.15.0",
61
+ "knip": "6.35.1",
62
+ "playwright": "1.63.0",
63
63
  "prettier": "3.9.6",
64
- "stylelint": "17.14.1",
64
+ "stylelint": "17.15.0",
65
65
  "stylelint-config-standard": "40.0.0",
66
66
  "typescript": "npm:@typescript/typescript6@6.0.2",
67
67
  "typescript-eslint": "8.69.0",
@@ -13,12 +13,11 @@
13
13
  // still single-instance, so createTerminal is called once per page (tabs
14
14
  // multiplex sessions over the one kernel, section 22.5).
15
15
  //
16
- // Mouse tracking (engine `mouse` module) is intentionally NOT wired here yet:
17
- // the design adds it (section 22.3), but wiring it also brings DEC 1004 focus
18
- // emission, which must be suppressed under the keep-unfocused model (section
19
- // 7.2). Not wiring it keeps the client emitting no focus bytes (the safe
20
- // default the previous UI already had); adding mouse tracking with focus
21
- // suppression is a tracked follow-up.
16
+ // The kernel owns the terminal widget's DOM, so it owns the widget's focus: it
17
+ // REPORTS focus and blur to the transport (`connection.setClientFocus`) and
18
+ // never writes DEC 1004 bytes itself. The server derives the answer for the
19
+ // session from every attached client's report plus its own hold, which is the
20
+ // only place the question can be answered once a second device attaches.
22
21
 
23
22
  import {
24
23
  render,
@@ -26,6 +25,7 @@ import {
26
25
  connection,
27
26
  keyboard,
28
27
  modes,
28
+ mouse,
29
29
  LineStore,
30
30
  } from "@cplieger/web-terminal-engine";
31
31
  import * as composition from "../composition.js";
@@ -168,17 +168,18 @@ function renderFatalStartupInto(
168
168
  layoutMode: "viewport" | "container",
169
169
  message: string = STARTUP_FAILURE_COPY.message,
170
170
  ): void {
171
- const surface = document.createElement("section");
171
+ const surface = document.createElement("dialog");
172
172
  surface.className = "wt-fatal";
173
+ // alertdialog over the implicit `dialog` role: an urgent message to act on. No
174
+ // `aria-modal` — showModal() conveys modality; an authored value can contradict the UA.
173
175
  surface.setAttribute("role", "alertdialog");
174
176
  surface.setAttribute("aria-labelledby", "wt-fatal-title");
175
177
  surface.setAttribute("aria-describedby", "wt-fatal-message");
176
- if (layoutMode === "viewport") {
177
- // A full-page terminal has no usable host UI behind it, so this is the
178
- // page's modal recovery state. An embedded terminal is only one panel in
179
- // a larger app and must not claim that the rest of the app is inert.
180
- surface.setAttribute("aria-modal", "true");
181
- }
178
+ // showModal() makes Escape a close request, and the kernel has already cleared
179
+ // the root: nothing is behind this panel, so reload is the only recovery.
180
+ surface.addEventListener("cancel", (ev) => {
181
+ ev.preventDefault();
182
+ });
182
183
 
183
184
  const card = document.createElement("div");
184
185
  card.className = "wt-fatal-card";
@@ -201,9 +202,19 @@ function renderFatalStartupInto(
201
202
  surface.appendChild(card);
202
203
  root.replaceChildren(surface);
203
204
 
204
- // The terminal input held focus before setup failed. Move that focus to the
205
- // only recovery action; container mode remains non-modal because no trap or
206
- // aria-modal claim prevents the user from leaving the terminal panel.
205
+ // Viewport: the terminal IS the page, so the top layer and an inert document
206
+ // are both accurate; container gets the bare `open` attribute, which is neither
207
+ // and is what an embedded panel wants. `isConnected` is showModal()'s own
208
+ // precondition and a detached root arrives here from a typed call, where a
209
+ // throw would bury the real startup cause under a second error out of the catch.
210
+ if (layoutMode === "viewport" && surface.isConnected) {
211
+ surface.showModal();
212
+ } else {
213
+ surface.open = true;
214
+ }
215
+
216
+ // The terminal input held focus before setup failed; move it to the only
217
+ // recovery action. showModal() lands there itself, the non-modal half does not.
207
218
  reloadButton.focus();
208
219
  }
209
220
 
@@ -389,9 +400,9 @@ function buildTerminal(
389
400
  // output never does (the scroll controller's follow/hold job is unchanged),
390
401
  // so scrolling up to read while output streams is preserved. A no-op on the
391
402
  // alt screen (no scrollback) and when already following. NOTE: every caller
392
- // of sendBytes today is genuine user input (typed keys, paste, mobile
393
- // toolbar); if mouse tracking is ever wired through ctx.send, its motion
394
- // bytes must NOT come through here or the view would snap on every move.
403
+ // of sendBytes is genuine user input (typed keys, paste, mobile toolbar);
404
+ // mouse reports deliberately bypass this funnel and go out as ephemeral
405
+ // input, or the view would snap on every motion report.
395
406
  scroll.scrollToBottom();
396
407
  for (const obs of inputObservers) {
397
408
  obs(out);
@@ -564,6 +575,100 @@ function buildTerminal(
564
575
  });
565
576
  render.updateFontMetrics();
566
577
 
578
+ // A mouse report is BEST-EFFORT and never enters the reliable outbox: it
579
+ // carries no sequence number, so one retransmitted after a resume describes a
580
+ // screen since repainted, and `sendEphemeral` refuses rather than queues. Not
581
+ // sendBytes either: that funnel snaps the view to the bottom on every accepted
582
+ // byte, so a motion report would jump the viewport. The listeners belong on the
583
+ // scroll container (a click in its padding or over the reserved scrollbar
584
+ // gutter never reaches a row, and the wheel has to be cancellable there); the
585
+ // coordinate frame is the row container, whose box IS the grid.
586
+ const disposeMouse = mouse.init({
587
+ sendReport: (data) => connection.sendEphemeral(data),
588
+ cellSize: render.cellSize,
589
+ termElement: () => termWrap,
590
+ gridElement: () => outputEl,
591
+ // The RENDERED grid, not computeSize(): the row hit test anchors on its
592
+ // height, and this client's own measurement disagrees with the negotiated
593
+ // screen whenever a second device is attached or a local resize has not been
594
+ // answered yet, which would shift every reported row.
595
+ gridSize: render.gridSize,
596
+ });
597
+
598
+ // A press over the display-only output takes focus off the hidden textarea and
599
+ // the click handler below puts it back, so that blur/focus pair is an artifact
600
+ // of this input model, not a focus change the application should be told about.
601
+ // xterm.js never emits one because its textarea keeps focus through a click on
602
+ // the terminal. So a blur raised inside a press is withheld and the gesture's
603
+ // REAL end state is reported once it resolves: nothing when focus came back,
604
+ // one report when it did not (a live selection, a link and a bare touch tap all
605
+ // decline to restore it). Measured before this: 3600 focus reports in 90s of
606
+ // clicking, which reaches an application as FocusLost/FocusGained per click.
607
+ let pressHoldsFocus = false;
608
+ let focusResolve: ReturnType<typeof setTimeout> | null = null;
609
+
610
+ function reportFocusNow(): void {
611
+ connection.setClientFocus(
612
+ document.activeElement !== null && termWrap.contains(document.activeElement),
613
+ );
614
+ }
615
+
616
+ // Scheduled rather than read at pointerup: the focus restore happens in the
617
+ // `click` handler, which the browser dispatches after pointerup within the same
618
+ // task, so a task boundary is the first point where the end state is settled.
619
+ function endPressFocusGesture(): void {
620
+ if (focusResolve !== null) {
621
+ clearTimeout(focusResolve);
622
+ }
623
+ focusResolve = setTimeout(() => {
624
+ focusResolve = null;
625
+ pressHoldsFocus = false;
626
+ reportFocusNow();
627
+ }, 0);
628
+ }
629
+
630
+ // The keyboard target is the hidden textarea INSIDE termWrap, so the widget's
631
+ // own focus events bubble to here. What goes out is the widget's STATE, not
632
+ // DEC 1004 bytes: the server derives the answer from this report plus every
633
+ // other attached client's plus its own hold.
634
+ termWrap.addEventListener(
635
+ "focusin",
636
+ () => {
637
+ connection.setClientFocus(true);
638
+ },
639
+ { signal },
640
+ );
641
+ termWrap.addEventListener(
642
+ "focusout",
643
+ (ev) => {
644
+ if (ev.relatedTarget instanceof Node && termWrap.contains(ev.relatedTarget)) {
645
+ return; // focus moving WITHIN the terminal is not a blur
646
+ }
647
+ if (pressHoldsFocus) {
648
+ return; // this gesture's own doing; endPressFocusGesture reports the truth
649
+ }
650
+ connection.setClientFocus(false);
651
+ },
652
+ { signal },
653
+ );
654
+ // Seeded once, so a terminal mounted with its textarea already focused does not
655
+ // report blurred until the first real focusin.
656
+ reportFocusNow();
657
+
658
+ // The kernel is the toggler, and the class goes on the scroll container rather
659
+ // than on the rows: `.term-output` is replaced wholesale by the renderer, and
660
+ // `.term` is the element that survives every rebuild. What the shape MEANS is
661
+ // at the CSS rule it drives (css/02-terminal.css).
662
+ function updateMousePointer(): void {
663
+ termWrap.classList.toggle("wt-mouse-app", modes.getMouseMode() !== 0);
664
+ }
665
+
666
+ // Derived once here as well as from the two triggers below: the modes module is
667
+ // a singleton that outlives a terminal, so a re-created terminal on a page
668
+ // whose mirror already holds a tracking mode would otherwise show the resting
669
+ // pointer until the next frame.
670
+ updateMousePointer();
671
+
567
672
  composition.init({
568
673
  textarea: input,
569
674
  compositionView: compositionViewEl,
@@ -687,6 +792,7 @@ function buildTerminal(
687
792
  bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
688
793
  } else if (msg.type === "modes") {
689
794
  render.updateReverseVideo();
795
+ updateMousePointer();
690
796
  bus.emit("wire:modes", msg);
691
797
  } else if (msg.type === "clipboard") {
692
798
  // Inbound OSC 52. With no clipboard feature subscribed this is a no-op
@@ -958,9 +1064,13 @@ function buildTerminal(
958
1064
  pointerDownX = e.clientX;
959
1065
  pointerDownY = e.clientY;
960
1066
  pointerDownTime = e.timeStamp;
1067
+ pressHoldsFocus = true;
961
1068
  },
962
1069
  { passive: true, signal },
963
1070
  );
1071
+ for (const end of ["pointerup", "pointercancel", "lostpointercapture"] as const) {
1072
+ termWrap.addEventListener(end, endPressFocusGesture, { passive: true, signal });
1073
+ }
964
1074
  termWrap.addEventListener(
965
1075
  "pointerup",
966
1076
  (e) => {
@@ -1465,6 +1575,11 @@ function buildTerminal(
1465
1575
  // detach here and the onSwitch attach below.
1466
1576
  composition.cancelComposition();
1467
1577
  resetToPlaceholder(input);
1578
+ // A held mouse gesture is latched input too, and the mouse module is per
1579
+ // TERMINAL while sessions multiplex over it: a record kept across the switch
1580
+ // would let the incoming session's first open synthesize a release for a
1581
+ // press its application never saw.
1582
+ mouse.disarmGesture();
1468
1583
  for (const { instance } of instances) {
1469
1584
  instance.onDetach?.();
1470
1585
  }
@@ -1472,6 +1587,9 @@ function buildTerminal(
1472
1587
  // Reconnect the terminal WS to this session using its per-tab resume
1473
1588
  // state; the renderer was already pointed at its store by the owner.
1474
1589
  connection.setSession(session.id);
1590
+ // setSession restores the incoming session's mode mirror synchronously and
1591
+ // delivers no modes frame, so nothing else re-derives the pointer here.
1592
+ updateMousePointer();
1475
1593
  // The owned first connect has happened (session id is now on the WS URL);
1476
1594
  // wake-reconnect handlers may fire from here on.
1477
1595
  connectionInitiated = true;
@@ -1660,12 +1778,17 @@ function buildTerminal(
1660
1778
  // Reset the composition singleton: a failure during an IME cycle must not
1661
1779
  // leave module state or a deferred send alive for a later terminal mount.
1662
1780
  composition.teardown();
1781
+ disposeMouse();
1663
1782
  connection.disconnect();
1664
1783
  connState.destroy();
1665
1784
  if (toastTimer !== null) {
1666
1785
  clearTimeout(toastTimer);
1667
1786
  toastTimer = null;
1668
1787
  }
1788
+ if (focusResolve !== null) {
1789
+ clearTimeout(focusResolve);
1790
+ focusResolve = null;
1791
+ }
1669
1792
  for (const off of subscriptions) {
1670
1793
  off();
1671
1794
  }
@@ -1888,21 +2011,6 @@ function buildTerminal(
1888
2011
  };
1889
2012
  }
1890
2013
 
1891
- /** Build the terminal UI inside `root`. Call exactly once.
1892
- *
1893
- * Wraps buildTerminal so that a SYNCHRONOUS startup failure gets the same
1894
- * treatment an asynchronous one already got. Before this, the two halves of
1895
- * "the terminal did not start" were handled inconsistently: an async
1896
- * feature-setup rejection ran enterFatalStartup (overlay dismissed,
1897
- * onFatalError delivered, recovery surface rendered), while a synchronous throw
1898
- * propagated bare, leaving the consumer's loading overlay spinning forever over
1899
- * a page with no terminal and no explanation. Consumers were left hand-building
1900
- * their own surface to cover the gap, diverging from this one in shape and copy.
1901
- *
1902
- * The error still propagates: a consumer with its own handling sees it exactly
1903
- * as before, only now against a page that is no longer stuck on a spinner. A
1904
- * handler returning `true` suppresses the built-in surface, same contract as the
1905
- * async phase. */
1906
2014
  /** Resolve the mount target. A selector is looked up here, INSIDE
1907
2015
  * createTerminal's try, which is the whole point: a consumer that passed
1908
2016
  * `document.getElementById("terminal")` had to null-check it first (tsc forces
@@ -1959,6 +2067,10 @@ function createFallbackHost(): HTMLElement {
1959
2067
  return host;
1960
2068
  }
1961
2069
 
2070
+ /** Build the terminal UI inside `target`. Call exactly once. Wraps buildTerminal so
2071
+ * a SYNCHRONOUS startup failure gets the same recovery surface an async one does
2072
+ * and still propagates; that surface is modal in viewport layout, so
2073
+ * `onFatalError` returning `true` is the path that leaves the page interactive. */
1962
2074
  export function createTerminal(
1963
2075
  target: HTMLElement | string,
1964
2076
  opts: CreateTerminalOptions = {},