@cplieger/web-terminal-ui 7.0.3 → 7.0.4

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/30-tabs.css CHANGED
@@ -355,27 +355,6 @@
355
355
  color: var(--text);
356
356
  }
357
357
 
358
- /* On an inactive tab the close appears only on hover, kept in the layout via
359
- visibility so the tab width never jumps; on the active tab it is always shown. */
360
- :where(.wt-root) .wt-tab:not(.wt-tab-active) .wt-tab-close {
361
- visibility: hidden;
362
- }
363
-
364
- :where(.wt-root) .wt-tab:not(.wt-tab-active):hover .wt-tab-close {
365
- visibility: visible;
366
- }
367
-
368
- /* Coarse pointer (an iPad using the strip): no hover fires, so an inactive tab's close
369
- would be unreachable without first activating the tab. Reveal every tab's close there,
370
- matching the always-visible mobile switcher closes. No narrow gate needed: on the
371
- narrow-coarse phone the whole strip is display:none (31-switcher.css), so this rule
372
- is moot there. */
373
- @media (pointer: coarse) {
374
- :where(.wt-root) .wt-tab:not(.wt-tab-active) .wt-tab-close {
375
- visibility: visible;
376
- }
377
- }
378
-
379
358
  /* The add-tab (+) button: a fixed bar item OUTSIDE the tab scroller (it never
380
359
  scrolls away with an overflowing list; the shrink-wrapped scroller keeps it
381
360
  visually trailing the last tab while the tabs fit — see .wt-tab-scroll). Its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "7.0.3",
3
+ "version": "7.0.4",
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",
@@ -50,19 +50,21 @@
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.2.0",
53
+ "@types/node": "26.4.0",
54
54
  "@typescript/native": "npm:typescript@7.0.2",
55
+ "@vitest/browser": "4.1.11",
56
+ "@vitest/browser-playwright": "4.1.11",
55
57
  "@vitest/coverage-v8": "4.1.11",
56
- "eslint": "10.8.1",
58
+ "eslint": "10.9.1",
57
59
  "fast-check": "4.9.0",
58
- "happy-dom": "20.11.6",
59
- "html-validate": "11.8.0",
60
- "knip": "6.32.2",
60
+ "html-validate": "11.10.0",
61
+ "knip": "6.33.0",
62
+ "playwright": "1.62.1",
61
63
  "prettier": "3.9.6",
62
64
  "stylelint": "17.14.1",
63
65
  "stylelint-config-standard": "40.0.0",
64
66
  "typescript": "npm:@typescript/typescript6@6.0.2",
65
- "typescript-eslint": "8.67.0",
67
+ "typescript-eslint": "8.68.0",
66
68
  "vitest": "4.1.11"
67
69
  },
68
70
  "publishConfig": {
@@ -75,6 +75,16 @@ export interface ContextMenuOptions {
75
75
  * opens, so a wrong answer here costs at most a duplicated menu on an unusual
76
76
  * device, never a broken selection and never a lost paste. */
77
77
  function isAppleTouchDevice(): boolean {
78
+ // Dead in every runtime that reaches this, and left in place deliberately.
79
+ // Measured on this repo's Node (v24.18.0, the version ts-ci pins): `typeof
80
+ // navigator` is "object", with userAgent "Node.js/24", platform
81
+ // "Linux x86_64" and maxTouchPoints undefined — so the fall-through already
82
+ // answers false there, by the same route a browser on a non-Apple device
83
+ // takes. A Web Worker has a WorkerNavigator, so it does not reach this
84
+ // either. What the guard still buys is totality: without it the bare
85
+ // `navigator` reads below would throw a ReferenceError in a runtime that has
86
+ // no navigator at all. It answers the same `false` the fall-through would, so
87
+ // nothing depends on reaching it, and no test can reach it without stubbing.
78
88
  if (typeof navigator === "undefined") {
79
89
  return false;
80
90
  }
@@ -19,10 +19,25 @@
19
19
  // the switcher's aggregate dot about what wants the user; the fold and the raise
20
20
  // share isUnseenCue (model.ts).
21
21
  //
22
- // DOM-free and global-free by construction, like notify.ts: every capability
23
- // arrives through AttentionEnv, so the decisions are unit-testable with no
24
- // document, no navigator and no icon assets, and browserAttentionEnv() below is
25
- // the one place that touches globals.
22
+ // DOM-free by construction, like notify.ts: every capability arrives through
23
+ // AttentionEnv, so the fold, the idempotence and the degradation paths are driven
24
+ // in the tests by plain recorder functions, and browserAttentionEnv() below is the
25
+ // only function here that reads a global (globalThis.navigator, document).
26
+ //
27
+ // That last clause is a reading of the source, and nothing enforces it — on
28
+ // purpose, not by omission. An ambient-absence test would assert the RUNTIME
29
+ // rather than this module: the only importer is tabs/index.ts, the node project
30
+ // holds one file and it reads stylesheets off disk, so nothing here loads this
31
+ // module outside a browser and no test can observe an absent `document` without
32
+ // passing for a third wrong reason (.kiro/steering/testing-ts.md,
33
+ // "Capability-absence is NOT a project selection"). The half that does ship IS
34
+ // enforced, and at the site: attention.test.ts shadows the whole `navigator`
35
+ // object and drives the real browserAttentionEnv through it. The mechanism that
36
+ // could enforce the load-time half is an AST scan of this file's top-level
37
+ // statements — vibekit carries one for its ported copy, where a node-project test
38
+ // statically imports the module and a module-scope global read really does break
39
+ // something. Copy it here the day anything needs this module to load without a
40
+ // document.
26
41
 
27
42
  import { type CueStatus, cueIconName, isUnseenCue, worseCue } from "./model.js";
28
43
 
@@ -1007,8 +1007,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1007
1007
  // sync, so a user browsing a scrolled strip is never yanked back by an
1008
1008
  // unrelated repaint), the newly active chip is brought into view in the
1009
1009
  // overflowed scroller; inline: "nearest" is a no-op when it is already
1010
- // visible. The typeof guard covers happy-dom, which lacks
1011
- // scrollIntoView.
1010
+ // visible. The typeof guard covers an environment without scrollIntoView.
1012
1011
  let lastRevealedActive = "";
1013
1012
  function paintActive(): void {
1014
1013
  for (const t of tabList) {
@@ -2247,7 +2246,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2247
2246
  }
2248
2247
  }
2249
2248
  if (invert.size === 0) {
2250
- return; // nothing moved on screen (a zero-rect DOM: happy-dom)
2249
+ return; // nothing moved on screen (every box measured zero)
2251
2250
  }
2252
2251
  applyShift(invert, "none");
2253
2252
  // The read forces the reflow that COMMITS the from-state. Without it the
@@ -2537,8 +2536,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2537
2536
  // Reordering re-appends every chip, and reparenting the focused field blurs
2538
2537
  // it — so an open edit would be committed as a side effect of moving some
2539
2538
  // OTHER tab, via a blur the user never performed. Resolve it here instead,
2540
- // so the outcome is chosen and testable rather than emergent (happy-dom
2541
- // does not reproduce the reparent-blur, so no test could catch it).
2539
+ // so the outcome is chosen rather than emergent: a blur that arrives as a
2540
+ // side effect of reparenting is not a decision anyone made.
2542
2541
  resolveEdit("blur", false);
2543
2542
  const from = tabList.findIndex((t) => t.id === id);
2544
2543
  const to = from + delta;
@@ -249,9 +249,9 @@ export function centreChipLabels(
249
249
  };
250
250
 
251
251
  remeasure(false);
252
- // The DOM lib types document.fonts as always present; happy-dom ships no
253
- // FontFaceSet at all, and neither does any engine without the CSS Font
254
- // Loading API, so the honest type is the optional one.
252
+ // The DOM lib types document.fonts as always present; an engine without the CSS
253
+ // Font Loading API ships no FontFaceSet at all, so the honest type is the
254
+ // optional one.
255
255
  const fonts = document.fonts as FontFaceSet | undefined;
256
256
  // `ready` settles the initial load (including a failed one); `loadingdone`
257
257
  // covers a face the host page adds afterwards, which `ready` never reports.
@@ -48,7 +48,7 @@ export type NotificationCtorLike = new (
48
48
  * not recognise must degrade rather than be asserted. */
49
49
  export interface NotifierEnv {
50
50
  /** The Notification constructor, or undefined where the API does not exist
51
- * (iOS Safari outside an installed PWA, a non-secure context, happy-dom). */
51
+ * (iOS Safari outside an installed PWA, a non-secure context). */
52
52
  ctor: NotificationCtorLike | undefined;
53
53
  /** Current permission: "default" | "granted" | "denied" (or anything else). */
54
54
  permission: () => string;
@@ -10,7 +10,7 @@
10
10
  // on the 360 ms fallback timer instead of the animation's own end, and every test
11
11
  // still passes because the tests dispatch the JS constant.
12
12
  //
13
- // Living here rather than inside the feature is what lets `css-contract.test.ts`
13
+ // Living here rather than inside the feature is what lets `css-contract.node.test.ts`
14
14
  // import it and assert the CSS agrees. Same reason `input-placeholder.ts` and
15
15
  // `kernel/gesture.ts` exist: a constant two places must keep in lockstep gets its
16
16
  // own module. NOT part of the package's `exports` map, so it is internal and can
@@ -40,6 +40,7 @@ import { createConnState } from "./conn-state.js";
40
40
  import { createScrollbackKeeper } from "./scrollback.js";
41
41
  import { STARTUP_FAILURE_COPY } from "./startup-copy.js";
42
42
  import { attachLoadingStatus, DEFAULT_LOADING_MESSAGES } from "./loading-status.js";
43
+ import { reloadPage } from "./navigation.js";
43
44
  import type {
44
45
  CreateTerminalOptions,
45
46
  FeatureInstance,
@@ -184,7 +185,7 @@ function renderFatalStartupInto(
184
185
  reloadButton.type = "button";
185
186
  reloadButton.textContent = STARTUP_FAILURE_COPY.reloadLabel;
186
187
  reloadButton.addEventListener("click", () => {
187
- window.location.reload();
188
+ reloadPage();
188
189
  });
189
190
  card.append(title, messageEl, reloadButton);
190
191
  surface.appendChild(card);
@@ -0,0 +1,19 @@
1
+ // navigation.ts — the library's one page-navigation call, behind its own module.
2
+ //
3
+ // `window.location.reload` cannot be substituted in a real browser: `reload` is
4
+ // an own, sealed property of the location instance (`configurable: false`,
5
+ // `writable: false`), `Location.prototype` carries no `reload` to spy on, and
6
+ // `window`, `globalThis.window` and `globalThis.location` are all
7
+ // non-configurable. So a test cannot redefine it, assign over it, or stub the
8
+ // global, and letting the real call run navigates the test runner's own frame
9
+ // and takes the whole run down with it.
10
+ //
11
+ // A separate module is the seam that works: a caller's import is a live binding
12
+ // the test framework can replace, which an intra-module call is not. Keeping the
13
+ // call here also means there is exactly one place in the library that navigates,
14
+ // so the next one has to be added deliberately.
15
+
16
+ /** Reload the current page. The only navigation this library performs. */
17
+ export function reloadPage(): void {
18
+ window.location.reload();
19
+ }
@@ -25,7 +25,7 @@
25
25
  // These lists are HAND-WRITTEN on purpose. They are the public contract — a
26
26
  // decision about what this library promises to support — not a fact derived
27
27
  // from the stylesheets. What IS derived from the stylesheets is the
28
- // verification that the promise holds: `src/css-contract.test.ts` generates the
28
+ // verification that the promise holds: `src/css-contract.node.test.ts` generates the
29
29
  // declared/referenced token inventory from the CSS itself and fails if any
30
30
  // token named here is not both DECLARED and READ by a rule. A token that is
31
31
  // declared but never read is the silent-no-op case above, reached from the
@@ -10,6 +10,18 @@ import { activityMonitor } from "../features/activity-monitor.js";
10
10
  import { animations } from "../features/animations.js";
11
11
  import { presetSingle } from "./single.js";
12
12
 
13
+ /** Options shared by the two tabbed presets, passed through to the tabs feature.
14
+ * A preset that takes arguments is called as `features: () =>
15
+ * presetTabbed({...})`, which keeps the call inside createTerminal's failure
16
+ * boundary (see CreateTerminalOptions.features). */
17
+ export interface TabbedPresetOptions {
18
+ /** Swap the page's icon links to a status variant while a background session
19
+ * wants the user. Off by default, and enabling it is a promise that the
20
+ * variant assets are served — see TabsOptions.attentionIcons for the naming
21
+ * contract and .kiro/scripts/gen-attention-icons.py, which writes them. */
22
+ attentionIcons?: boolean;
23
+ }
24
+
13
25
  /** buildTabbed composes the tabbed UI: the single-pane touch features, an
14
26
  * externally-driven mobile toolbar (its grid is opened from a keyboard button
15
27
  * in the tab bar, not its own toggle), the activity monitor (the status-SSE
@@ -27,18 +39,6 @@ import { presetSingle } from "./single.js";
27
39
  * foreground-process/cwd inference — and both presets render what it reports. A
28
40
  * browser that re-derived any of that could only disagree with the server and
29
41
  * with every other client attached to the same session. */
30
- /** Options shared by the two tabbed presets, passed through to the tabs feature.
31
- * A preset that takes arguments is called as `features: () =>
32
- * presetTabbed({...})`, which keeps the call inside createTerminal's failure
33
- * boundary (see CreateTerminalOptions.features). */
34
- export interface TabbedPresetOptions {
35
- /** Swap the page's icon links to a status variant while a background session
36
- * wants the user. Off by default, and enabling it is a promise that the
37
- * variant assets are served — see TabsOptions.attentionIcons for the naming
38
- * contract and .kiro/scripts/gen-attention-icons.py, which writes them. */
39
- attentionIcons?: boolean;
40
- }
41
-
42
42
  export function buildTabbed(
43
43
  agentShell: boolean,
44
44
  opts: TabbedPresetOptions = {},
@@ -59,12 +59,8 @@ export function buildTabbed(
59
59
  ];
60
60
  }
61
61
 
62
- /** Tabbed UI: the touch features, tabs, the mobile keyboard bar, the activity
63
- * monitor, and animations. Each tab's title follows the process OSC 0/2 window
64
- * title when the program sets one, else the last submitted line; each tab's
65
- * activity dot stays hidden until its session reports activity via OSC 9;4
66
- * progress (kiro-cli, Claude Code, …), so a plain bash/sh keeps clean,
67
- * label-only tabs. */
62
+ /** Tabbed UI: buildTabbed with agent activity presumed off (see buildTabbed for
63
+ * title and activity-dot sourcing). */
68
64
  export function presetTabbed(opts: TabbedPresetOptions = {}): TerminalFeature<unknown>[] {
69
65
  return buildTabbed(false, opts);
70
66
  }