@cplieger/web-terminal-ui 3.5.0 → 4.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.
Files changed (41) hide show
  1. package/README.md +60 -27
  2. package/css/00-tokens.css +18 -2
  3. package/css/01-scope.css +76 -0
  4. package/css/{02-app.css → 02-terminal.css} +113 -239
  5. package/css/10-primitives.css +98 -0
  6. package/css/20-connection-banner.css +42 -0
  7. package/css/21-scroll-to-bottom.css +65 -0
  8. package/css/22-context-menu.css +45 -0
  9. package/css/23-toolbar.css +181 -0
  10. package/css/{05-tabs.css → 30-tabs.css} +127 -66
  11. package/css/{06-mobile.css → 31-switcher.css} +85 -80
  12. package/css/{07-animations.css → 40-animations.css} +15 -15
  13. package/css/MANIFEST +19 -11
  14. package/css/MANIFEST.single +12 -0
  15. package/css/MANIFEST.tabbed +16 -0
  16. package/css/MANIFEST.touch +12 -0
  17. package/css/{01-reset.css → page.css} +18 -19
  18. package/package.json +19 -6
  19. package/scaffold/index.html +5 -0
  20. package/src/features/activity-monitor.ts +32 -2
  21. package/src/features/connection-banner.ts +1 -0
  22. package/src/features/context-menu.ts +6 -33
  23. package/src/features/menu-position.ts +50 -0
  24. package/src/features/mobile-toolbar.ts +5 -4
  25. package/src/features/{tabs.ts → tabs/index.ts} +310 -721
  26. package/src/features/tabs/model.ts +342 -0
  27. package/src/features/tabs/strip.ts +101 -0
  28. package/src/features/tabs/switcher.ts +129 -0
  29. package/src/index.ts +1 -0
  30. package/src/kernel/a11y.ts +30 -5
  31. package/src/kernel/conn-state.ts +16 -6
  32. package/src/kernel/kernel.ts +170 -59
  33. package/src/kernel/types.ts +68 -15
  34. package/src/presets/agent-tabbed.ts +15 -0
  35. package/src/presets/single.ts +27 -0
  36. package/src/presets/tabbed.ts +42 -0
  37. package/src/presets/touch.ts +13 -0
  38. package/src/presets.ts +12 -76
  39. package/src/viewport.ts +28 -15
  40. package/css/03-primitives.css +0 -83
  41. package/css/04-feedback.css +0 -180
package/package.json CHANGED
@@ -1,12 +1,25 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "3.5.0",
3
+ "version": "4.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",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
- "./presets": "./src/presets.ts"
9
+ "./presets": "./src/presets.ts",
10
+ "./presets/single": "./src/presets/single.ts",
11
+ "./presets/touch": "./src/presets/touch.ts",
12
+ "./presets/tabbed": "./src/presets/tabbed.ts",
13
+ "./presets/agent-tabbed": "./src/presets/agent-tabbed.ts",
14
+ "./features/clipboard": "./src/features/clipboard.ts",
15
+ "./features/context-menu": "./src/features/context-menu.ts",
16
+ "./features/scroll-to-bottom": "./src/features/scroll-to-bottom.ts",
17
+ "./features/predictive-echo": "./src/features/predictive-echo.ts",
18
+ "./features/connection-banner": "./src/features/connection-banner.ts",
19
+ "./features/mobile-toolbar": "./src/features/mobile-toolbar.ts",
20
+ "./features/tabs": "./src/features/tabs/index.ts",
21
+ "./features/activity-monitor": "./src/features/activity-monitor.ts",
22
+ "./features/animations": "./src/features/animations.ts"
10
23
  },
11
24
  "sideEffects": [
12
25
  "**/*.css"
@@ -25,7 +38,7 @@
25
38
  "verify": "bash scripts/verify.sh"
26
39
  },
27
40
  "peerDependencies": {
28
- "@cplieger/web-terminal-engine": ">=2.4.0"
41
+ "@cplieger/web-terminal-engine": "^3.0.0"
29
42
  },
30
43
  "devDependencies": {
31
44
  "@eslint/js": "10.0.1",
@@ -38,13 +51,13 @@
38
51
  "eslint": "10.7.0",
39
52
  "fast-check": "4.9.0",
40
53
  "happy-dom": "20.10.6",
41
- "html-validate": "11.5.5",
42
- "knip": "6.26.0",
54
+ "html-validate": "11.5.6",
55
+ "knip": "6.27.0",
43
56
  "prettier": "3.9.5",
44
57
  "stylelint": "17.14.0",
45
58
  "stylelint-config-standard": "40.0.0",
46
59
  "typescript": "npm:@typescript/typescript6@6.0.2",
47
- "typescript-eslint": "8.63.0",
60
+ "typescript-eslint": "8.64.0",
48
61
  "vitest": "4.1.10"
49
62
  },
50
63
  "publishConfig": {
@@ -6,6 +6,11 @@
6
6
  <meta name="color-scheme" content="dark">
7
7
  <title>Web Terminal</title>
8
8
  <!-- App icons and web-app manifest are deployment-specific; add your own. -->
9
+ <!-- /style.css = the package's css/MANIFEST concatenated: the reference
10
+ FULL-PAGE bundle (css/page.css — html/body reset + @font-face expecting
11
+ the font files at /vendor/fonts/ — plus the complete tabbed component
12
+ set). An embedder serves a per-preset manifest (css/MANIFEST.single/
13
+ .touch/.tabbed) instead and passes layout: "container". -->
9
14
  <link rel="stylesheet" href="/style.css">
10
15
  <!-- [html-validate-disable-next no-style-tag -- critical CSS for instant loading indicator] -->
11
16
  <style>
@@ -3,8 +3,11 @@
3
3
  // session's live status through its API (design sections 7, 22.4, 22.5). It is a
4
4
  // pure data source with no chrome of its own; the tabs feature consumes it via
5
5
  // ctx.use to render per-tab activity dots and drop exited/removed tabs. The
6
- // server pushes an initial snapshot on every (re)open, so no explicit resync is
7
- // needed here.
6
+ // server pushes an initial snapshot on every (re)open — but the snapshot
7
+ // carries no tombstones for sessions a REPLACEMENT server never knew (manager
8
+ // restart), so the stream-open signal is surfaced via onStreamOpen and the
9
+ // consumer (tabs) runs a one-shot GET /api/sessions reconcile there to drop
10
+ // zombie tabs.
8
11
 
9
12
  import { connectStatusStream } from "@cplieger/web-terminal-engine";
10
13
  import type { SessionStatus } from "@cplieger/web-terminal-engine";
@@ -18,6 +21,13 @@ export interface ActivityMonitorApi {
18
21
  onStatus(cb: (s: SessionStatus) => void): Unsubscribe;
19
22
  /** The last known status for a session, or undefined. */
20
23
  current(id: string): SessionStatus | undefined;
24
+ /** Subscribe to stream (re)open events — fired on the initial SSE connect
25
+ * and every reconnect (including the reopen against a restarted server,
26
+ * whose fresh snapshot cannot tombstone sessions it never knew). A
27
+ * subscriber attaching after an open has already happened is called
28
+ * immediately (catch-up), so a late consumer never misses the initial
29
+ * open. Optional so alternative monitor implementations remain valid. */
30
+ onStreamOpen?(cb: () => void): Unsubscribe;
21
31
  }
22
32
 
23
33
  export function activityMonitor(
@@ -29,8 +39,20 @@ export function activityMonitor(
29
39
  const path = opts.eventsPath ?? DEFAULT_EVENTS_PATH;
30
40
  const statuses = new Map<string, SessionStatus>();
31
41
  const subs = new Set<(s: SessionStatus) => void>();
42
+ const openSubs = new Set<() => void>();
43
+ let opens = 0;
32
44
 
33
45
  const stream = connectStatusStream(path, {
46
+ onOpen() {
47
+ opens++;
48
+ for (const cb of [...openSubs]) {
49
+ try {
50
+ cb();
51
+ } catch (err) {
52
+ console.error("web-terminal-ui: activityMonitor open subscriber threw", err);
53
+ }
54
+ }
55
+ },
34
56
  onStatus(s) {
35
57
  if (s.removed) {
36
58
  statuses.delete(s.id);
@@ -59,10 +81,18 @@ export function activityMonitor(
59
81
  current(id) {
60
82
  return statuses.get(id);
61
83
  },
84
+ onStreamOpen(cb) {
85
+ openSubs.add(cb);
86
+ if (opens > 0) {
87
+ cb(); // catch-up: the stream opened before this subscriber attached
88
+ }
89
+ return () => openSubs.delete(cb);
90
+ },
62
91
  },
63
92
  teardown() {
64
93
  stream.close();
65
94
  subs.clear();
95
+ openSubs.clear();
66
96
  statuses.clear();
67
97
  },
68
98
  };
@@ -14,6 +14,7 @@ const TEXT: Partial<Record<ConnState, string>> = {
14
14
  // The session's process exited (definitive; nothing is retrying). The final
15
15
  // screen stays rendered behind the banner; open a new tab to keep working.
16
16
  ended: "Session ended",
17
+ incompatible: "Terminal protocol mismatch; update the server or reload this page",
17
18
  };
18
19
 
19
20
  export function connectionBanner(): TerminalFeature {
@@ -38,17 +38,15 @@
38
38
 
39
39
  import type { TerminalFeature } from "../kernel/types.js";
40
40
  import type { ClipboardApi } from "./clipboard.js";
41
+ import { placeMenuAt } from "./menu-position.js";
41
42
 
42
43
  // Slightly longer than the browser's ~500ms native long-press so a native word
43
44
  // selection (which we defer to) has registered — and fired selectionchange —
44
45
  // before this fires.
45
46
  const LONG_PRESS_MS = 550;
46
47
  const LONG_PRESS_MOVE_PX = 10;
47
- const EDGE_MARGIN = 8;
48
- // When the menu can't fit below the anchor (a long-press near the bottom edge or
49
- // above the keyboard), it opens above the point. This gap lifts it clear of the
50
- // fingertip so the touch does not cover the menu items.
51
- const MENU_GAP = 16;
48
+ // Viewport clamping + the flip-above-the-fingertip gap live in the shared
49
+ // point-anchored positioner (menu-position.ts), shared with the tab menu.
52
50
  // After a touch long-press opens the menu, swallow the trailing click/tap that
53
51
  // the same gesture emits on finger-release for this window, so the menu does not
54
52
  // immediately dismiss itself (the classic contextmenu-then-touchend race).
@@ -209,35 +207,10 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
209
207
  return; // nothing to offer
210
208
  }
211
209
  // Make visible (so it has measurable size), then place it within the
212
- // VISIBLE viewport. position:fixed => x/y are viewport coordinates; the
213
- // visual viewport (when present) is the area above the on-screen
214
- // keyboard, so clamping to it keeps the menu off the keyboard too.
210
+ // visible viewport via the shared point-anchored positioner (clamp to
211
+ // the visual viewport; flip above the finger near the bottom edge).
215
212
  menu.classList.add("visible");
216
- const vv = window.visualViewport;
217
- const viewLeft = vv ? vv.offsetLeft : 0;
218
- const viewTop = vv ? vv.offsetTop : 0;
219
- const viewWidth = vv ? vv.width : window.innerWidth;
220
- const viewHeight = vv ? vv.height : window.innerHeight;
221
- const menuW = menu.offsetWidth;
222
- const menuH = menu.offsetHeight;
223
- const left = Math.max(
224
- viewLeft + EDGE_MARGIN,
225
- Math.min(x, viewLeft + viewWidth - menuW - EDGE_MARGIN),
226
- );
227
- // Open just below the point; but if that would overflow the visible
228
- // bottom (a long-press near the bottom edge, where the user naturally
229
- // taps), flip above the finger so the menu is neither clipped off-screen
230
- // nor hidden under the fingertip.
231
- let top = y;
232
- if (y + menuH + EDGE_MARGIN > viewTop + viewHeight) {
233
- top = y - menuH - MENU_GAP;
234
- }
235
- top = Math.max(
236
- viewTop + EDGE_MARGIN,
237
- Math.min(top, viewTop + viewHeight - menuH - EDGE_MARGIN),
238
- );
239
- menu.style.left = `${String(left)}px`;
240
- menu.style.top = `${String(top)}px`;
213
+ placeMenuAt(menu, x, y);
241
214
  // A touch long-press is immediately followed by a synthetic click on
242
215
  // release; swallow it so the menu stays open (see SWALLOW_MS).
243
216
  if (touch) {
@@ -0,0 +1,50 @@
1
+ // menu-position.ts — shared point-anchored placement for the fixed-position
2
+ // menus (the terminal context menu and the tab-strip menu), which previously
3
+ // carried two hand-synced copies of this exact clamp/flip math.
4
+ //
5
+ // position:fixed means x/y are viewport coordinates; the VISUAL viewport
6
+ // (when present) is the area above the on-screen keyboard, so clamping to it
7
+ // keeps a menu off the keyboard too.
8
+
9
+ /** Clamp margin from the visible viewport edges (px). */
10
+ const EDGE_MARGIN = 8;
11
+
12
+ /** Gap between the pointer and a flipped-above menu (px), so the menu is not
13
+ * hidden under the fingertip that long-pressed near the bottom edge. */
14
+ const FLIP_GAP = 16;
15
+
16
+ /** Place an already-visible `menu` at viewport point (x, y): open just below
17
+ * the point, flip above it when that would overflow the visible bottom, and
18
+ * clamp into the visual viewport on both axes. The menu is absolute-positioned
19
+ * against the terminal root (.wt-root), so the viewport-space result is
20
+ * rebased onto its offsetParent's box — placement is identical whether the
21
+ * root fills the viewport (wt-viewport) or an embedder's panel
22
+ * (wt-container). The menu must be measurable (rendered, not display:none)
23
+ * when called. */
24
+ export function placeMenuAt(menu: HTMLElement, x: number, y: number): void {
25
+ const vv = window.visualViewport;
26
+ const viewLeft = vv ? vv.offsetLeft : 0;
27
+ const viewTop = vv ? vv.offsetTop : 0;
28
+ const viewWidth = vv ? vv.width : window.innerWidth;
29
+ const viewHeight = vv ? vv.height : window.innerHeight;
30
+ const menuW = menu.offsetWidth;
31
+ const menuH = menu.offsetHeight;
32
+ const left = Math.max(
33
+ viewLeft + EDGE_MARGIN,
34
+ Math.min(x, viewLeft + viewWidth - menuW - EDGE_MARGIN),
35
+ );
36
+ let top = y;
37
+ if (y + menuH + EDGE_MARGIN > viewTop + viewHeight) {
38
+ top = y - menuH - FLIP_GAP;
39
+ }
40
+ top = Math.max(viewTop + EDGE_MARGIN, Math.min(top, viewTop + viewHeight - menuH - EDGE_MARGIN));
41
+ // Rebase from viewport coordinates onto the positioned ancestor's box: left/
42
+ // top on an absolute element are offsets from the offsetParent's padding box,
43
+ // which for all terminal chrome is .wt-root (getBoundingClientRect is its
44
+ // viewport-space position; the root has no border, so border/padding offsets
45
+ // do not enter). A missing offsetParent (display:none ancestor — the menu is
46
+ // visible when called, so not in practice) falls back to (0,0) viewport base.
47
+ const base = menu.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 };
48
+ menu.style.left = `${String(Math.round(left - base.left))}px`;
49
+ menu.style.top = `${String(Math.round(top - base.top))}px`;
50
+ }
@@ -1,15 +1,16 @@
1
1
  // mobileToolbar feature: the on-screen key toolbar (Tab/Esc/arrows/Enter + a
2
2
  // collapse toggle and sticky-Ctrl) in the thumb-zone region (design section
3
- // 22.4). The engine's keyboard.bindMobileToolbar wires the buttons (DECCKM-aware
4
- // arrows, sticky-Ctrl state); this feature builds the toolbar chrome, routes its
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
5
6
  // output through the kernel funnel, and registers sticky-Ctrl as an input
6
7
  // transform so a typed character is rewritten to its Ctrl byte when armed.
7
8
 
8
- import { keyboard } from "@cplieger/web-terminal-engine";
9
+ import { toolbar } from "@cplieger/web-terminal-engine";
9
10
  import type { TerminalFeature } from "../kernel/types.js";
10
11
  import { fromHTML } from "./dom.js";
11
12
 
12
- const { bindMobileToolbar } = keyboard;
13
+ const { bindMobileToolbar } = toolbar;
13
14
 
14
15
  /** mobileToolbar's public API, so a peer feature (tabs) can drive the key grid
15
16
  * from a button it owns instead of the toolbar's own toggle. */