@cplieger/web-terminal-ui 5.2.3 → 5.3.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.
@@ -8,7 +8,7 @@
8
8
  // (ctx.notifySwitch), so the last-known screen paints instantly and the
9
9
  // background delta arrives after.
10
10
 
11
- import { LineStore, modes } from "@cplieger/web-terminal-engine";
11
+ import { modes } from "@cplieger/web-terminal-engine";
12
12
  import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/types.js";
13
13
  import type { ActivityMonitorApi } from "../activity-monitor.js";
14
14
  import type { MobileToolbarApi } from "../mobile-toolbar.js";
@@ -167,6 +167,22 @@ const CREATE_RETRY_MAX_TOTAL_MS = 1200000;
167
167
  const CREATE_RETRY_FALLBACK_MS = 5000;
168
168
  const CREATE_RETRY_REANNOUNCE_MS = 60000;
169
169
 
170
+ /** How long the catching-up cue may wait for the render backlog to drain before
171
+ * retiring itself. A backlog that never drains (the server stops mid-replay, the
172
+ * socket drops) must not leave a "Catching up" badge on screen forever, and the
173
+ * reconnect that follows will arm a fresh one. */
174
+ const CATCHUP_MAX_MS = 30000;
175
+ /** How long the render queue must stay empty before the restore counts as
176
+ * finished. The queue empties BETWEEN the server's replay chunks, so a bare
177
+ * "queue is empty" test declares victory several times per restore; this
178
+ * hysteresis is what turns it into one honest completion signal. */
179
+ const CATCHUP_SETTLE_MS = 250;
180
+ /** Backlog that arms the cue, in queued rows. The renderer builds at most 300
181
+ * rows per frame, so a backlog above this needs multiple frames and is worth
182
+ * telling the user about; ordinary streaming queues a handful of rows per frame
183
+ * and must never arm it (nor pay for the completion poll). */
184
+ const CATCHUP_MIN_BACKLOG = 400;
185
+
170
186
  /** orderSignature collapses an arrangement into one comparable string, so the
171
187
  * persist path can decide "did the order change?" with a string compare instead
172
188
  * of re-serializing and re-writing on every status tick. NUL is the separator
@@ -646,6 +662,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
646
662
  catchup.textContent = "Catching up\u2026";
647
663
  ctx.region("banner", "catchup").appendChild(catchup);
648
664
  let catchupTimer: ReturnType<typeof setTimeout> | null = null;
665
+ // Completion-poll state for the catching-up cue (see armCatchup).
666
+ let catchupPoll: number | null = null;
667
+ let catchupEmptySince = 0;
668
+ let catchupDeadline = 0;
649
669
 
650
670
  // --- Desktop right-click tab context menu (overlay region) ---
651
671
  // Replaces the old bar "Close all" button with a richer per-tab menu. Built
@@ -1283,7 +1303,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1283
1303
  nameSeq: 0,
1284
1304
  display: "",
1285
1305
  createdAt: info.createdAt,
1286
- store: new LineStore(),
1306
+ // Through the kernel factory, never `new LineStore()`: the factory
1307
+ // applies the consumer's scrollbackLines cap, so per-tab caches and
1308
+ // the kernel's implicit store share one retained-line budget. The id
1309
+ // is what lets it come back HYDRATED when the consumer enabled
1310
+ // persistScrollback, and registers it to be saved.
1311
+ store: ctx.newLineStore(info.id),
1287
1312
  el,
1288
1313
  label,
1289
1314
  dot,
@@ -1292,8 +1317,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1292
1317
  // the REST list starts with none; the first status event fills it in.
1293
1318
  progress: normalizeProgress(info.progressValue),
1294
1319
  aria,
1295
- scrollTop: 0,
1296
- following: true,
1320
+ view: null,
1297
1321
  reports: reportsOf(info.reportsActivity),
1298
1322
  };
1299
1323
  paintProgress(progressEl, renderedProgress(info.status, tab.progress));
@@ -1474,13 +1498,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1474
1498
  slide = to > from ? "next" : "prev";
1475
1499
  }
1476
1500
  }
1477
- // Detach the current tab: save its scroll memory (keep its cache).
1478
- // Read through the engine's scroll seam, never surface.scrollTop —
1479
- // the controller owns the container's scroll geometry.
1501
+ // Detach the current tab: save its reading position (keep its cache).
1502
+ // Read through the engine's view seam, never surface.scrollTop — the
1503
+ // renderer owns the mapping from scroll position to absolute line, and a
1504
+ // LINE is what survives the rebuild and the background output this tab's
1505
+ // session may produce before we come back (engine
1506
+ // docs/scroll-position-fidelity.md §3.1).
1480
1507
  const cur = tabList.find((t) => t.id === activeId);
1481
1508
  if (cur) {
1482
- cur.scrollTop = ctx.scroll.currentScrollTop();
1483
- cur.following = !ctx.scroll.isUserScrolledUp();
1509
+ cur.view = ctx.render.captureViewMemory();
1484
1510
  }
1485
1511
  // Decide whether to animate the expanded list as a rotation: only a
1486
1512
  // swipe to an adjacent tab while the list is open. prepareReel snapshots
@@ -1504,6 +1530,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1504
1530
  // Attach the next tab: point the renderer at its cached store and
1505
1531
  // rebuild viewport-first, so the last-known screen paints with no
1506
1532
  // round-trip. Then let the kernel reconnect the WS to it (resume delta).
1533
+ //
1534
+ // The view goes in WITH the bind, which is what makes the swap atomic.
1535
+ // The engine's follow flag is GLOBAL (one per kernel), so the first
1536
+ // flush after a bind is gated on whatever state the tab we LEFT was in:
1537
+ // binding a following tab right after being scrolled up in another left
1538
+ // the controller holding, the post-flush stickToBottom() no-op'd, and the
1539
+ // cached screen rendered above the viewport — a black gap until a touch
1540
+ // scrolled it and re-engaged follow (the "content pops down when I
1541
+ // wiggle it" symptom). bind adopts the incoming follow state
1542
+ // synchronously, before the wipe, and re-asserts the incoming reading
1543
+ // POSITION across the rebuild's frames until the line it names has
1544
+ // actually been built. That second half is why this is no longer a
1545
+ // fire-and-forget rAF: a single deferred write landed while only ~301 of
1546
+ // up to 5000 rows existed, so the browser clamped it away and nothing
1547
+ // retried (engine docs/scroll-position-fidelity.md §1.1, §3.3, §3.4).
1507
1548
  activeId = next.id;
1508
1549
  // Arriving on the tab that raised the switch-button cue resolves it
1509
1550
  // (a swipe through the tabs must dismiss the dot, not only opening the
@@ -1517,9 +1558,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1517
1558
  } catch {
1518
1559
  /* storage unavailable (private mode / disabled) — non-fatal */
1519
1560
  }
1520
- ctx.render.bind(next.store);
1561
+ ctx.render.bind(next.store, { view: next.view });
1521
1562
  ctx.notifySwitch({ id: next.id });
1522
- armCatchup();
1563
+ // Arm on a switch only when the user is actually about to wait: either
1564
+ // the bind queued a backlog worth several frames, or the incoming tab has
1565
+ // no cached content at all (a first visit, so its whole screen is coming
1566
+ // over the network). A revisited tab with a warm store paints from cache
1567
+ // in one frame and must not flash a cue at every switch.
1568
+ if (
1569
+ ctx.render.pendingRowCount() > CATCHUP_MIN_BACKLOG ||
1570
+ ctx.render.getHighestIndex() < 0
1571
+ ) {
1572
+ armCatchup();
1573
+ }
1523
1574
  flashSwitch(slide);
1524
1575
  // Mark the reconcile as reel-driven so renderSwitcherList suppresses its
1525
1576
  // add/remove row animation (the reel owns row motion here).
@@ -1527,49 +1578,89 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1527
1578
  syncChrome(); // reconciles the expanded list into the new order
1528
1579
  reelReconcile = false;
1529
1580
  playReel?.(); // FLIP the rows so the reorder reads as a rotation, not a reload
1530
- // Restore scroll memory best-effort after the async rebuild. The engine
1531
- // scroll controller's follow flag is GLOBAL (one per kernel) and still
1532
- // reflects the tab we just left, so binding a following tab right after
1533
- // being scrolled up in another tab left the controller in "holding":
1534
- // the renderer's post-flush stickToBottom() no-op'd and the cached
1535
- // screen rendered above the viewport (a black gap until a touch scrolled
1536
- // it and re-engaged follow — the "content pops down when I wiggle it"
1537
- // symptom). Re-assert here: scrollToBottom() snaps down AND re-engages
1538
- // follow (so the resume delta then pins correctly); a scrolled-up tab
1539
- // restores its saved read position instead.
1540
- const savedTop = next.scrollTop;
1541
- const following = next.following;
1542
- requestAnimationFrame(() => {
1543
- if (following) {
1544
- ctx.scroll.scrollToBottom();
1545
- } else {
1546
- // The write half of scroll memory goes through the same seam; the
1547
- // controller re-derives hold from the resulting scroll event.
1548
- ctx.scroll.restoreScrollTop(savedTop);
1549
- }
1550
- });
1551
1581
  ctx.announce(`Switched to ${next.display}`);
1552
1582
  focusAfterSwitch();
1553
1583
  }
1554
1584
 
1555
- // armCatchup shows the "catching up" cue only if the resume delta has not
1556
- // landed shortly after a switch; clearCatchup hides it when a screen frame
1557
- // arrives (subscribed below).
1585
+ // armCatchup shows the "catching up" cue while the surface still has a
1586
+ // large backlog of rows to build, and clearCatchup hides it and stops the
1587
+ // completion poll.
1588
+ //
1589
+ // What it measures is the RENDER backlog (render.pendingRowCount): rows the
1590
+ // store already holds that have not been built into DOM yet. That is the
1591
+ // thing the user is actually waiting on, whatever produced it — a resume
1592
+ // replay after a wake, the rebuild after a tab switch, or a program that
1593
+ // printed a few thousand lines at once.
1594
+ //
1595
+ // Two earlier conditions were tried and are wrong, so do not go back to
1596
+ // them. Clearing on the first screen frame fires long before a large
1597
+ // restore has landed (the frame that carries the live window arrives
1598
+ // first). Comparing the store's highest index against the resumeAck's
1599
+ // `committed` fails for the same reason and more sharply: the window frame
1600
+ // delivers the HIGHEST indices, so highest reaches the target while every
1601
+ // history line below it is still in flight. Measured on a 4000-line phone
1602
+ // restore: the cue cleared immediately and stayed clear for the whole fill.
1603
+ // catchupWarranted: the surface still owes the user content. Either rows are
1604
+ // queued for building (a resume replay, a switch rebuild, a large burst), or
1605
+ // the tab holds nothing at all, which means its screen is still on the
1606
+ // network. The second half matters as much as the first: without it, the
1607
+ // case the cue is most needed for — switching into a tab that has never been
1608
+ // viewed — has an empty queue and would never show it.
1609
+ const catchupWarranted = (): boolean =>
1610
+ ctx.render.pendingRowCount() > 0 || ctx.render.getHighestIndex() < 0;
1611
+
1558
1612
  function armCatchup(): void {
1559
- if (catchupTimer !== null) {
1560
- clearTimeout(catchupTimer);
1561
- }
1562
- catchup.classList.remove("visible");
1563
- catchupTimer = setTimeout(() => {
1564
- catchupTimer = null;
1565
- catchup.classList.add("visible");
1566
- }, 150);
1613
+ if (catchupTimer === null && !catchup.classList.contains("visible")) {
1614
+ catchupTimer = setTimeout(() => {
1615
+ catchupTimer = null;
1616
+ // Re-check before showing. The delay is anti-flicker, so it has to
1617
+ // ask again at the end of it: a burst that arms the cue and then
1618
+ // drains inside the delay has nothing to report, and showing it
1619
+ // anyway guaranteed a visible flash on every large-but-fast burst
1620
+ // (the clear path needs CATCHUP_SETTLE_MS of quiet, so the flash
1621
+ // outlived the backlog it described).
1622
+ if (catchupWarranted()) {
1623
+ catchup.classList.add("visible");
1624
+ }
1625
+ }, 150);
1626
+ }
1627
+ catchupDeadline = Date.now() + CATCHUP_MAX_MS;
1628
+ catchupEmptySince = 0;
1629
+ if (catchupPoll === null) {
1630
+ pollCatchup();
1631
+ }
1632
+ }
1633
+ // pollCatchup runs on rAF because completion is a RENDER condition and the
1634
+ // renderer has no event for "queue drained". It stops itself, so the loop
1635
+ // only exists while the cue does.
1636
+ function pollCatchup(): void {
1637
+ catchupPoll = requestAnimationFrame(() => {
1638
+ catchupPoll = null;
1639
+ if (catchupWarranted()) {
1640
+ catchupEmptySince = 0;
1641
+ } else if (catchupEmptySince === 0) {
1642
+ catchupEmptySince = Date.now();
1643
+ } else if (Date.now() - catchupEmptySince >= CATCHUP_SETTLE_MS) {
1644
+ clearCatchup();
1645
+ return;
1646
+ }
1647
+ if (Date.now() > catchupDeadline) {
1648
+ clearCatchup();
1649
+ return;
1650
+ }
1651
+ pollCatchup();
1652
+ });
1567
1653
  }
1568
1654
  function clearCatchup(): void {
1569
1655
  if (catchupTimer !== null) {
1570
1656
  clearTimeout(catchupTimer);
1571
1657
  catchupTimer = null;
1572
1658
  }
1659
+ if (catchupPoll !== null) {
1660
+ cancelAnimationFrame(catchupPoll);
1661
+ catchupPoll = null;
1662
+ }
1663
+ catchupEmptySince = 0;
1573
1664
  catchup.classList.remove("visible");
1574
1665
  }
1575
1666
  // flashSwitch plays the switch animation the animations feature keys off
@@ -1594,9 +1685,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1594
1685
  }, 360);
1595
1686
  }
1596
1687
 
1597
- // The first screen frame after a switch is the resume delta landing.
1688
+ // Any frame can reveal a backlog worth telling the user about: a resume
1689
+ // replay after a wake or reconnect, the rebuild after a tab switch, or a
1690
+ // program that dumped thousands of lines at once. Arming off the backlog
1691
+ // itself is what makes the cue fire on a wake, which is where it used to be
1692
+ // blind (it was armed only by an explicit tab switch). The threshold keeps
1693
+ // ordinary streaming out of it entirely, so the completion poll only exists
1694
+ // while there is something to complete.
1598
1695
  ctx.on("wire:screen", () => {
1599
- clearCatchup();
1696
+ if (ctx.render.pendingRowCount() > CATCHUP_MIN_BACKLOG) {
1697
+ armCatchup();
1698
+ }
1600
1699
  });
1601
1700
 
1602
1701
  // Live window-title updates for the active session: the engine sends a
@@ -1968,6 +2067,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1968
2067
  // closed tab -- mirrors the single-close path in dropTab (h-f2).
1969
2068
  tombstones.add(t.id);
1970
2069
  acknowledgeSwitchNotify(t.id); // a cue for a closed tab is moot
2070
+ // ...and its stored acknowledgement goes with it, for the reason dropTab
2071
+ // gives: the session is gone, so the entry would only sit in storage
2072
+ // until the 200-entry cap evicted it. The bulk path drops tabs itself
2073
+ // rather than through dropTab, so every per-session store it touches has
2074
+ // to be listed here too.
2075
+ forgetCueSeen(t.id);
1971
2076
  t.aria.remove();
1972
2077
  t.el.remove();
1973
2078
  ctx.dropSession(t.id);
@@ -2260,14 +2365,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2260
2365
  //
2261
2366
  // menuSwallow covers the contextmenu-then-touchend race: iPadOS Safari
2262
2367
  // raises a context menu from a LONG-PRESS, and the same gesture emits a
2263
- // click on release which onDocClickMenu would read as a click-away. The
2264
- // terminal menu has guarded this since it shipped; this one did not, so a
2265
- // long-pressed tab menu closed itself on release — on the one platform
2266
- // where the desktop strip is the chrome you touch. menuTouch records
2267
- // whether the press that raised the menu was a finger, since only touch
2268
- // emits that trailing click.
2368
+ // click on release which onDocClickMenu would read as a click-away.
2369
+ // menuTouch records whether the press that raised the menu was a finger,
2370
+ // since only touch emits that trailing click.
2371
+ //
2372
+ // The window is a fixed 350ms, so arming it here — mid-press, when the
2373
+ // platform delivers `contextmenu` — only covers a release that comes within
2374
+ // 350ms; hold the chip a beat longer and the menu still dismissed itself on
2375
+ // release. menuOpenedInPress carries the fact that THIS press opened the
2376
+ // menu through to its pointerup, which re-arms on the release edge. The
2377
+ // flag is what keeps that re-arm off an unrelated later tap, whose click is
2378
+ // a genuine dismiss.
2269
2379
  const menuSwallow = createClickSwallow();
2270
2380
  let menuTouch = false;
2381
+ let menuOpenedInPress = false;
2271
2382
  // One listener on the bar rather than one per chip: chips are created and
2272
2383
  // destroyed as sessions come and go, and both facts recorded here (the
2273
2384
  // gesture's pointer type, and whether it took the keyboard off the
@@ -2276,11 +2387,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2276
2387
  "pointerdown",
2277
2388
  (e) => {
2278
2389
  menuTouch = e.pointerType === "touch";
2390
+ menuOpenedInPress = false;
2279
2391
  noteChromePress();
2280
2392
  notifier.gesture();
2281
2393
  },
2282
2394
  { passive: true },
2283
2395
  );
2396
+ // The release edge of the press that opened the menu. pointercancel counts:
2397
+ // iPadOS cancels the pointer when it takes the gesture over for its own
2398
+ // long-press handling, and that release still emits the trailing click.
2399
+ const onBarPointerRelease = (): void => {
2400
+ if (menuOpenedInPress) {
2401
+ menuOpenedInPress = false;
2402
+ menuSwallow.arm();
2403
+ }
2404
+ };
2405
+ bar.addEventListener("pointerup", onBarPointerRelease, { passive: true });
2406
+ bar.addEventListener("pointercancel", onBarPointerRelease, { passive: true });
2284
2407
  // The switcher's half of that snapshot (its rows and the x are buttons, so
2285
2408
  // pressing one focuses it and blurs the terminal exactly as a chip does).
2286
2409
  // It is also the mobile half of the notification-permission gesture.
@@ -2366,7 +2489,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2366
2489
  tabMenu.classList.add("visible");
2367
2490
  placeMenuAt(tabMenu, x, y);
2368
2491
  if (menuTouch) {
2492
+ // A floor for the case where no pointerup follows (the release edge is
2493
+ // where onBarPointerRelease re-arms it).
2369
2494
  menuSwallow.arm();
2495
+ menuOpenedInPress = true;
2370
2496
  }
2371
2497
  }
2372
2498
 
@@ -3,7 +3,7 @@
3
3
  // font's tables.
4
4
  //
5
5
  // Flex centring centres a label's LINE BOX. A font's metrics box reaches much
6
- // further above the baseline than below (the bundled MonaspiceNe NFM: 0.995em
6
+ // further above the baseline than below (the bundled Monaspace Neon NF: 0.995em
7
7
  // over, 0.250em under) while a mixed-case title's ink runs cap height to
8
8
  // descender (0.734em over, 0.203em under), so the visible text sits low inside
9
9
  // the box it is centred by. The correction is the gap between those two centres.
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { LineStore } from "@cplieger/web-terminal-engine";
9
9
  import type { SessionInfo } from "@cplieger/web-terminal-engine";
10
+ import type { ViewMemory } from "@cplieger/web-terminal-engine";
10
11
  import type { TabHandle } from "../../kernel/types.js";
11
12
 
12
13
  // One session's wire shape (SessionInfo) is the ENGINE's exported type — the
@@ -399,8 +400,20 @@ export interface Tab {
399
400
  * any title field. */
400
401
  progress: number;
401
402
  aria: TabHandle;
402
- scrollTop: number;
403
- following: boolean;
403
+ /** This tab's saved reading position: the absolute LINE at the viewport top
404
+ * plus its on-screen offset and the follow state, as captureViewMemory()
405
+ * returned it when the tab was last left. Null for a tab never viewed (and
406
+ * for one left on the alternate screen, which has no absolute indices worth
407
+ * remembering) — restoring null means "follow the tail", which is the right
408
+ * default for a first visit.
409
+ *
410
+ * NOT a pixel scrollTop, which is what this was until 2026-08: a rebuild has
411
+ * built only ~301 of up to 5000 rows when the restore lands, so the browser
412
+ * clamped the offset away, and it stopped meaning the same line as soon as
413
+ * the tab's session produced output while backgrounded
414
+ * (engine docs/scroll-position-fidelity.md §1.1). In memory only, like
415
+ * before: a reload starts every tab following. */
416
+ view: ViewMemory | null;
404
417
  /** Sticky: true once this session emitted a genuine activity signal (OSC 9;4).
405
418
  * Its activity dot is shown only while true; a session that never reports
406
419
  * activity (a plain shell) keeps a clean, dot-less tab. Fed from the server's
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@
12
12
  // their own DOM.
13
13
 
14
14
  export { createTerminal } from "./kernel/kernel.js";
15
+ export { localScrollbackStorage } from "./kernel/scrollback-storage.js";
16
+ export type { LocalScrollbackStorageOptions } from "./kernel/scrollback-storage.js";
15
17
  export { STARTUP_FAILURE_COPY } from "./kernel/startup-copy.js";
16
18
  export { LOADING_OVERLAY_CLASSES, PUBLIC_THEME_TOKENS } from "./kernel/style-contract.js";
17
19
  export type { PublicThemeToken } from "./kernel/style-contract.js";
@@ -21,6 +23,8 @@ export type {
21
23
  TerminalHandle,
22
24
  TerminalFeature,
23
25
  FeatureInstance,
26
+ PersistedScrollback,
27
+ ScrollbackPersistence,
24
28
  SessionOwnerRegistration,
25
29
  TerminalContext,
26
30
  TerminalEvents,
@@ -0,0 +1,32 @@
1
+ // gesture.ts — the single boundary between a TAP and a LONG-PRESS on touch.
2
+ //
3
+ // Two handlers split one gesture and must not overlap or leave a gap (a tap that
4
+ // also opens a menu, a hold that does nothing): the kernel's tap-to-focus claims
5
+ // a short, low-movement press and opens the soft keyboard or clears a selection,
6
+ // and the contextMenu feature claims anything held past the ceiling. One
7
+ // threshold pair, imported by both, is what keeps that split exact — two copies
8
+ // of the number would drift and re-open the overlap.
9
+
10
+ /** Movement ceiling for a stationary press (px, compared per axis). Beyond it
11
+ * the gesture is a scroll or a selection-extend and belongs to the browser. */
12
+ export const TAP_MOVEMENT_PX = 10;
13
+
14
+ /** Duration ceiling for a tap (ms). A stationary press at or below it is a tap
15
+ * (tap-to-focus / tap-to-deselect); strictly above it, it is a long-press and
16
+ * belongs to native text selection or the context menu. */
17
+ export const TAP_MAX_MS = 500;
18
+
19
+ /**
20
+ * Whether a press landed on a LINK, in which case neither handler claims it: the
21
+ * platform's own link affordances (preview on hold, activate on tap) win.
22
+ *
23
+ * Shared for the same reason the thresholds are, and it was NOT: the kernel
24
+ * matched `.term-link` only while the menu matched `a[href], .term-link`, so an
25
+ * ordinary anchor in the output was a link to one half of the boundary and not the
26
+ * other — the menu stood down for the platform while tap-to-focus stole the press.
27
+ * `.term-link` is the engine linkifier's class; `a[href]` covers anchors a consumer
28
+ * or an application wrote itself.
29
+ */
30
+ export function isLinkTarget(target: EventTarget | null): boolean {
31
+ return target instanceof Element && target.closest("a[href], .term-link") !== null;
32
+ }