@cplieger/web-terminal-ui 4.7.0 → 4.8.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/00-tokens.css CHANGED
@@ -45,10 +45,24 @@
45
45
  ); /* subtle edge: the fill lightened + slightly desaturated, matching the inactive chip's faint border rather than a vivid accent outline */
46
46
 
47
47
  /* Status-dot palette (the activity vocabulary painted in 30-tabs.css; the
48
- switcher notification dot reuses the same rules). The working and input
49
- rings each derive from their own token in 30-tabs.css, so one override
50
- recolors dot + ring together. */
51
- --status-working: #d9f99d; /* agent working (pulses): pale lime */
48
+ switcher notification dot reuses the same rules). The working wave and the
49
+ input ring each derive from their own token in 30-tabs.css, so one override
50
+ recolors dot + surround together.
51
+
52
+ The trio is the conventional progress scheme — blue in flight, green
53
+ finished, orange waiting on you — which is why working is a LITERAL copy of
54
+ the default --accent blue rather than var(--accent): a consumer that
55
+ rethemes its brand accent to (say) a red should not have its "working" dot
56
+ follow, and a status vocabulary that shifts hue per consumer stops being a
57
+ vocabulary. The value being equal to the default accent is a deliberate
58
+ coincidence of taste, not a dependency.
59
+
60
+ Working shares the accent's hue family, so on the ACTIVE tab it sits on an
61
+ accent-derived fill (--tab-active-bg): measured 5.2:1 against it, and 8.0:1
62
+ against the bar, both clear of the 3:1 non-text minimum. Hue still never
63
+ carries state on its own (the wave's motion, and the donut it becomes under
64
+ prefers-reduced-motion, are the non-hue cues per WCAG 1.4.1). */
65
+ --status-working: #52a9fe; /* agent working (waves): the same blue as the default --accent */
52
66
  --status-done: #22c55e; /* turn finished: solid green */
53
67
  --status-input: #fb923c; /* action required: solid orange + ring */
54
68
  --radius: 0.75rem;
package/css/30-tabs.css CHANGED
@@ -137,17 +137,37 @@
137
137
  overflow: hidden;
138
138
  white-space: nowrap;
139
139
  text-overflow: ellipsis;
140
- /* Center the INK, not the line box: UI fonts carry more ascent than descent
141
- (Noto Sans at 13px: 14px over / 4px under the baseline), so the flex-
142
- centered line box leaves the visible text ~1.5px low — reading as
143
- baseline-aligned with the activity dot's bottom instead of its center.
144
- Trimming the box to the cap-height/alphabetic band makes flexbox center
145
- the glyphs themselves, on every platform font. Descender ink still paints
146
- (verified: ink overflow survives the overflow: hidden ellipsis clip).
147
- Non-supporting browsers simply keep the untrimmed box. Shared by all
148
- three chip labels (this one + the two switcher labels in
140
+ /* Center the INK, not the line box: UI fonts carry far more ascent than
141
+ descent (the -apple-system/Noto Sans stack at 13px: 14px over / 4px under
142
+ the baseline), so the flex-centered line box leaves the visible text ~1.5px
143
+ low — reading as baseline-aligned with the activity dot's bottom instead of
144
+ its center. Trimming the box makes flexbox center the glyph band instead of
145
+ the font's metrics box.
146
+
147
+ `ex`, not `cap`, is the over edge, because a tab title is sentence case and
148
+ lowercase-dominant ("workspace", "refactor the auth module"). Centering the
149
+ x-height/baseline band centers the total ink of such a string, since a
150
+ Latin font's ascender overshoot above x-height is about its descender depth
151
+ below the baseline (measured on this stack at 13px: 3.0px over vs 3.0px
152
+ under, ink center 0.02px off the chip's center). `cap alphabetic` — the
153
+ right choice for an ALL-CAPS label — leaves lowercase text 1.14px low,
154
+ which is the original complaint barely moved.
155
+
156
+ padding-block is LOAD-BEARING, not spacing: trimming puts the box's under
157
+ edge ON the baseline, and `overflow: hidden` clips at the padding edge, so
158
+ with no padding every descender and every tall diacritic is cut off (
159
+ measured: 3.3px of p/y/g/j tail and 2.3px of an acute accent, i.e. "pygmy
160
+ jaguar Ápple" rendered as "pyqmy iaquar Apple"). 0.5em restores the ink
161
+ overhang the trim removes from the box; being symmetric it does not move
162
+ the centered band, and being em-relative it tracks the font size. The
163
+ grown box (~20px) still sits inside the close button's 1.5rem, so the tab
164
+ height is unchanged. A browser without text-box support keeps the untrimmed
165
+ box, where the padding is harmless slack.
166
+
167
+ Shared by all three chip labels (this one + the two switcher labels in
149
168
  31-switcher.css). */
150
- text-box: trim-both cap alphabetic;
169
+ text-box: trim-both ex alphabetic;
170
+ padding-block: 0.5em;
151
171
  }
152
172
 
153
173
  /* A little breathing room after the activity dot, before the label. */
@@ -535,17 +555,18 @@
535
555
  /* Agent working: a solid disc with two animated cues (the status-badge
536
556
  "processing" idiom — Ant Design's processing badge, the classic CSS
537
557
  live-indicator ripple): the disc keeps full opacity and gently GLOWS (its
538
- colors brighten mid-beat), while a ::after ring RIPPLES outward off the
558
+ colors brighten mid-beat), while a ::after WAVE travels outward off the
539
559
  disc's edge and dissolves, re-emitted each cycle. No static ring at rest —
540
- the disc + live ripple ARE the working read; input freezes that exact ping
541
- at its birth frame (see input below), so motion separates working from
542
- input, and the ring separates both from done's bare disc. The old
543
- single-layer pulse faded disc + ring together (opacity on the element),
544
- which read as a blink and coupled the layers — an element's opacity always
545
- multiplies onto its pseudos, so true independence requires animating disc
546
- COLORS, not element opacity. Server-side this is driven by the OSC 9;4
547
- progress signal, so it tracks agent activity, not the user typing at the
548
- prompt. */
560
+ the disc + live wave ARE the working read. The wave's soft feathered edge is
561
+ the second axis that separates working from input: input is a HARD-edged 2px
562
+ ring, held still (see input below), so the pair differs by both motion and
563
+ edge quality, and the presence of any surround separates both from done's
564
+ bare disc. The old single-layer pulse faded disc + ring together (opacity on
565
+ the element), which read as a blink and coupled the layers — an element's
566
+ opacity always multiplies onto its pseudos, so true independence requires
567
+ animating disc COLORS, not element opacity. Server-side this is driven by the
568
+ OSC 9;4 progress signal, so it tracks agent activity, not the user typing at
569
+ the prompt. */
549
570
  :where(.wt-root) .wt-status-dot[data-status="working"] {
550
571
  position: relative;
551
572
  background: var(--status-working);
@@ -553,40 +574,58 @@
553
574
  animation: wt-working-glow 1.2s ease-in-out infinite;
554
575
  }
555
576
 
556
- /* The ripple layer: at scale(1) its 2px border hugs the disc's edge (inset
557
- -2px, the 9->13px band), then the wave detaches, expanding and fading.
558
- transform + opacity only, so it composites without repaints. It exists only
559
- in the working state — no stale layer to pin when a dot changes status.
560
- (The switcher notification dot never shows working — input/done only — so
561
- its position: absolute placement never meets this rule's
562
- position: relative.) */
577
+ /* The ripple layer: a feathered radial BAND (not a hairline border) that starts
578
+ hugging the disc's edge (inset -2px, the 9->13px box) and then travels
579
+ outward, dissolving. The band is painted as a radial-gradient — soft on both
580
+ edges, so it reads as a wave of light leaving the disc rather than a ring
581
+ outline shrinking in opacity; scaling the layer scales the feather with it,
582
+ which is what makes the wave visibly dissipate as it grows. transform +
583
+ opacity only, so it composites without repaints (a gradient background is
584
+ rasterized once, unlike an animated box-shadow spread, which repaints every
585
+ frame). It exists only in the working state — no stale layer to pin when a
586
+ dot changes status. (The switcher notification dot never shows working —
587
+ input/done only — so its position: absolute placement never meets this
588
+ rule's position: relative.)
589
+
590
+ The final scale is deliberately capped at today's 27px footprint: the mobile
591
+ overview row (.wt-switcher-row-select) clips at overflow: hidden 12.5px from
592
+ the dot's center, and a wave wider than ~25px picks up a visible straight
593
+ edge there. Visibility comes from the band's weight and timing, not size. */
563
594
  :where(.wt-root) .wt-status-dot[data-status="working"]::after {
564
595
  content: "";
565
596
  position: absolute;
566
597
  inset: -2px;
567
- border: 2px solid var(--status-working);
568
598
  border-radius: 50%;
569
- animation: wt-working-ripple 1.2s ease-in-out infinite;
599
+ background: radial-gradient(
600
+ closest-side,
601
+ transparent 44%,
602
+ var(--status-working) 76%,
603
+ transparent 100%
604
+ );
605
+ animation: wt-working-ripple 1.2s linear infinite;
570
606
  }
571
607
 
572
608
  /* Agent done / ready: solid disc (--status-done), no motion. The default green
573
- matches Kiro IDE's "Ready" (#22c55e), darker and more saturated than
574
- working's default pale lime so the two states differ by lightness as well as
575
- motion. */
609
+ matches Kiro IDE's "Ready" (#22c55e). It sits at almost exactly working's
610
+ default lightness now that working is the accent blue (both ~72% OKLab L), so
611
+ the pair separates by hue plus the wave's motion rather than by lightness —
612
+ and under prefers-reduced-motion by shape, since working becomes a donut. */
576
613
  :where(.wt-root) .wt-status-dot[data-status="done"] {
577
614
  background: var(--status-done);
578
615
  border-color: var(--status-done);
579
616
  }
580
617
 
581
- /* User action required (permission / approval): the working ping frozen at
582
- its birth frame — solid dot + the same 2px ring at the ripple's 30% birth
583
- opacity, motionless (--status-input; the default orange is shifted away
584
- from the old amber so it separates from both default-lime working and green
585
- done). The stillness is the message: the agent's turn is mid-flight but
586
- blocked on you. The ring keeps it distinct from done's bare disc without
587
- leaning on hue; it derives from the same token, so a theme override
588
- recolors dot + ring together. It persists until you act on or switch into
589
- the tab. */
618
+ /* User action required (permission / approval): a solid dot inside a static,
619
+ HARD-edged 2px ring, motionless (--status-input; the default orange is
620
+ shifted away from the old amber so it separates from both blue working and
621
+ green done). The stillness plus the hard edge is the message: the
622
+ agent's turn is mid-flight but blocked on you, and the surround is a fixed
623
+ outline rather than working's soft travelling wave. (Before the wave landed,
624
+ working's ripple was a 2px ring too and this state was literally that ping
625
+ frozen at its birth frame; the two now differ by edge quality as well as
626
+ motion, which reads more distinctly at 9px.) The ring derives from the same
627
+ token as the dot, so a theme override recolors both together. It persists
628
+ until you act on or switch into the tab. */
590
629
  :where(.wt-root) .wt-status-dot[data-status="input"] {
591
630
  background: var(--status-input);
592
631
  border-color: var(--status-input);
@@ -621,13 +660,33 @@
621
660
  }
622
661
 
623
662
  /* The ripple wave: one emission per cycle, launching off the disc's edge and
624
- dissolving by ~2.1x. The 0.3 birth opacity matches the input ring's frozen
625
- weight, so a live ping and a stuck one read as the same object — subtle
626
- against the bar now that no static ring doubles it at birth. */
663
+ dissolving by ~2.1x. Two things are load-bearing and were both wrong in the
664
+ first version (the wave was there but nobody could see it):
665
+
666
+ - The travel is LINEAR while the opacity is shaped separately. An ease-in-out
667
+ shared by both properties spends the slow opening on top of the disc, where
668
+ the wave is indistinguishable from it, and then accelerates away exactly as
669
+ the opacity collapses — so the wave was only ever visible while it was
670
+ still merged with the disc. Constant-speed travel plus its own opacity
671
+ envelope keeps the wave legible across most of the cycle.
672
+ - The wave is BRIGHT at birth (0.95 after a 12% ramp-in, not 0.3) and holds
673
+ usable weight through the travel. The ramp-in is short enough to read as
674
+ instant while stopping the emission from popping in.
675
+
676
+ The disc's own glow shares this 1.2s period so the two cues stay phase-locked;
677
+ change both or neither. */
627
678
  @keyframes wt-working-ripple {
628
679
  0% {
629
680
  transform: scale(1);
630
- opacity: 0.3;
681
+ opacity: 0.15;
682
+ }
683
+
684
+ 12% {
685
+ opacity: 0.95;
686
+ }
687
+
688
+ 70% {
689
+ opacity: 0.45;
631
690
  }
632
691
 
633
692
  100% {
@@ -178,8 +178,11 @@
178
178
  text-overflow: ellipsis;
179
179
  white-space: nowrap;
180
180
  text-align: left;
181
- /* Ink-centering trim, same as .wt-tab-label (see 30-tabs.css). */
182
- text-box: trim-both cap alphabetic;
181
+ /* Ink-centering trim + its mandatory descender allowance, same as
182
+ .wt-tab-label (see 30-tabs.css for why `ex` and why the padding is not
183
+ optional). */
184
+ text-box: trim-both ex alphabetic;
185
+ padding-block: 0.5em;
183
186
  }
184
187
 
185
188
  /* Keyboard button: hidden until a keyboardToggle is wired; filled while the key
@@ -429,8 +432,11 @@
429
432
  overflow: hidden;
430
433
  text-overflow: ellipsis;
431
434
  white-space: nowrap;
432
- /* Ink-centering trim, same as .wt-tab-label (see 30-tabs.css). */
433
- text-box: trim-both cap alphabetic;
435
+ /* Ink-centering trim + its mandatory descender allowance, same as
436
+ .wt-tab-label (see 30-tabs.css for why `ex` and why the padding is not
437
+ optional). */
438
+ text-box: trim-both ex alphabetic;
439
+ padding-block: 0.5em;
434
440
  }
435
441
 
436
442
  /* The close sits flush at the row's right edge (no gap), vertically centered. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "4.7.0",
3
+ "version": "4.8.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",
@@ -38,19 +38,16 @@
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
+ import { createClickSwallow, placeMenuAt } from "./menu-position.js";
42
42
 
43
43
  // Slightly longer than the browser's ~500ms native long-press so a native word
44
44
  // selection (which we defer to) has registered — and fired selectionchange —
45
45
  // before this fires.
46
46
  const LONG_PRESS_MS = 550;
47
47
  const LONG_PRESS_MOVE_PX = 10;
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.
50
- // After a touch long-press opens the menu, swallow the trailing click/tap that
51
- // the same gesture emits on finger-release for this window, so the menu does not
52
- // immediately dismiss itself (the classic contextmenu-then-touchend race).
53
- const SWALLOW_MS = 350;
48
+ // Viewport clamping, the flip-above-the-fingertip gap, and the trailing-click
49
+ // swallow all live in the shared point-anchored menu module (menu-position.ts),
50
+ // shared with the tab menu.
54
51
 
55
52
  export interface ContextMenuOptions {
56
53
  /** The clipboard feature value, so the menu can offer Copy/Paste through its
@@ -98,8 +95,8 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
98
95
  // Computed once: on an Apple touch device, defer a touch contextmenu to
99
96
  // native text selection (see isAppleTouchDevice).
100
97
  const appleTouch = isAppleTouchDevice();
101
- // Timestamp until which a document click is swallowed (see SWALLOW_MS).
102
- let swallowUntil = 0;
98
+ // Swallows the trailing click a touch long-press emits on release.
99
+ const swallow = createClickSwallow();
103
100
  // A native selection was made during the current touch press (tracked via
104
101
  // selectionchange). Gates the touch menu: a selected long-press belongs to
105
102
  // the native callout, an empty one to us (Paste).
@@ -212,9 +209,9 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
212
209
  menu.classList.add("visible");
213
210
  placeMenuAt(menu, x, y);
214
211
  // A touch long-press is immediately followed by a synthetic click on
215
- // release; swallow it so the menu stays open (see SWALLOW_MS).
212
+ // release; swallow it so the menu stays open.
216
213
  if (touch) {
217
- swallowUntil = performance.now() + SWALLOW_MS;
214
+ swallow.arm();
218
215
  }
219
216
  }
220
217
 
@@ -295,7 +292,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
295
292
 
296
293
  const onDocClick = (): void => {
297
294
  // Swallow the release click of the long-press that just opened the menu.
298
- if (performance.now() < swallowUntil) {
295
+ if (swallow.swallowing()) {
299
296
  return;
300
297
  }
301
298
  hide();
@@ -13,6 +13,35 @@ const EDGE_MARGIN = 8;
13
13
  * hidden under the fingertip that long-pressed near the bottom edge. */
14
14
  const FLIP_GAP = 16;
15
15
 
16
+ /** After a touch long-press opens a menu, the SAME gesture emits a click on
17
+ * finger-release, and an outside-click dismiss reads that click as "the user
18
+ * clicked away" — the classic contextmenu-then-touchend race. Swallow document
19
+ * clicks for this window after a touch-opened menu. 350ms comfortably covers
20
+ * the release without eating a deliberate follow-up tap. */
21
+ const SWALLOW_MS = 350;
22
+
23
+ /** createClickSwallow returns the two halves a point-anchored menu needs to
24
+ * survive the contextmenu-then-touchend race: `arm()` right after a TOUCH-opened
25
+ * menu becomes visible, and `swallowing()` as the first guard in the
26
+ * outside-click dismiss.
27
+ *
28
+ * It lives here, beside the shared placement math, because both point-anchored
29
+ * menus need it and a hand-synced second copy is what this module was extracted
30
+ * to stop. The tab menu shipped without the guard entirely, so on iPadOS Safari
31
+ * — which fires `contextmenu` on a long-press, and where the desktop tab strip
32
+ * is the chrome that shows — a long-pressed tab menu dismissed itself on
33
+ * release. Arm it only for touch: a mouse right-click emits no trailing click,
34
+ * and swallowing there would eat a genuine dismiss. */
35
+ export function createClickSwallow(): { arm: () => void; swallowing: () => boolean } {
36
+ let until = 0;
37
+ return {
38
+ arm: (): void => {
39
+ until = performance.now() + SWALLOW_MS;
40
+ },
41
+ swallowing: (): boolean => performance.now() < until,
42
+ };
43
+ }
44
+
16
45
  /** Place an already-visible `menu` at viewport point (x, y): open just below
17
46
  * the point, flip above it when that would overflow the visible bottom, and
18
47
  * clamp into the visual viewport on both axes. The menu is absolute-positioned
@@ -13,7 +13,7 @@ import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/
13
13
  import type { ActivityMonitorApi } from "../activity-monitor.js";
14
14
  import type { MobileToolbarApi } from "../mobile-toolbar.js";
15
15
  import { fromHTML } from "../dom.js";
16
- import { placeMenuAt } from "../menu-position.js";
16
+ import { createClickSwallow, placeMenuAt } from "../menu-position.js";
17
17
  import type { SessionInfo, Tab } from "./model.js";
18
18
  import {
19
19
  ACTIVE_TAB_KEY,
@@ -2029,6 +2029,27 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2029
2029
  // the items for the target tab (disabled states reflect its position) and
2030
2030
  // clamps into the visible viewport, flipping above the pointer near the
2031
2031
  // bottom edge (mirrors context-menu.ts).
2032
+ //
2033
+ // menuSwallow covers the contextmenu-then-touchend race: iPadOS Safari
2034
+ // raises a context menu from a LONG-PRESS, and the same gesture emits a
2035
+ // click on release which onDocClickMenu would read as a click-away. The
2036
+ // terminal menu has guarded this since it shipped; this one did not, so a
2037
+ // long-pressed tab menu closed itself on release — on the one platform
2038
+ // where the desktop strip is the chrome you touch. menuTouch records
2039
+ // whether the press that raised the menu was a finger, since only touch
2040
+ // emits that trailing click.
2041
+ const menuSwallow = createClickSwallow();
2042
+ let menuTouch = false;
2043
+ // One listener on the bar rather than one per chip: chips are created and
2044
+ // destroyed as sessions come and go, and the pointer type belongs to the
2045
+ // gesture, not to a chip.
2046
+ bar.addEventListener(
2047
+ "pointerdown",
2048
+ (e) => {
2049
+ menuTouch = e.pointerType === "touch";
2050
+ },
2051
+ { passive: true },
2052
+ );
2032
2053
  function hideTabMenu(): void {
2033
2054
  tabMenu.classList.remove("visible");
2034
2055
  tabMenu.replaceChildren();
@@ -2102,6 +2123,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2102
2123
  // the visual viewport; flip above the pointer near the bottom edge).
2103
2124
  tabMenu.classList.add("visible");
2104
2125
  placeMenuAt(tabMenu, x, y);
2126
+ if (menuTouch) {
2127
+ menuSwallow.arm();
2128
+ }
2105
2129
  }
2106
2130
 
2107
2131
  // One-time "swipe to switch" hint on first multi-tab state, mobile only.
@@ -2336,6 +2360,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2336
2360
  // Dismiss the tab context menu on an outside click, on Escape, and on
2337
2361
  // scroll (it is anchored to a viewport point, so a scroll would detach it).
2338
2362
  const onDocClickMenu = (): void => {
2363
+ // The trailing click of the long-press that just opened the menu is that
2364
+ // gesture's own release, not a click-away.
2365
+ if (menuSwallow.swallowing()) {
2366
+ return;
2367
+ }
2339
2368
  hideTabMenu();
2340
2369
  };
2341
2370
  document.addEventListener("click", onDocClickMenu);