@cplieger/web-terminal-ui 4.7.1 → 4.8.1

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
@@ -555,17 +555,18 @@
555
555
  /* Agent working: a solid disc with two animated cues (the status-badge
556
556
  "processing" idiom — Ant Design's processing badge, the classic CSS
557
557
  live-indicator ripple): the disc keeps full opacity and gently GLOWS (its
558
- 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
559
559
  disc's edge and dissolves, re-emitted each cycle. No static ring at rest —
560
- the disc + live ripple ARE the working read; input freezes that exact ping
561
- at its birth frame (see input below), so motion separates working from
562
- input, and the ring separates both from done's bare disc. The old
563
- single-layer pulse faded disc + ring together (opacity on the element),
564
- which read as a blink and coupled the layers — an element's opacity always
565
- multiplies onto its pseudos, so true independence requires animating disc
566
- COLORS, not element opacity. Server-side this is driven by the OSC 9;4
567
- progress signal, so it tracks agent activity, not the user typing at the
568
- 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. */
569
570
  :where(.wt-root) .wt-status-dot[data-status="working"] {
570
571
  position: relative;
571
572
  background: var(--status-working);
@@ -573,40 +574,58 @@
573
574
  animation: wt-working-glow 1.2s ease-in-out infinite;
574
575
  }
575
576
 
576
- /* The ripple layer: at scale(1) its 2px border hugs the disc's edge (inset
577
- -2px, the 9->13px band), then the wave detaches, expanding and fading.
578
- transform + opacity only, so it composites without repaints. It exists only
579
- in the working state — no stale layer to pin when a dot changes status.
580
- (The switcher notification dot never shows working — input/done only — so
581
- its position: absolute placement never meets this rule's
582
- 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. */
583
594
  :where(.wt-root) .wt-status-dot[data-status="working"]::after {
584
595
  content: "";
585
596
  position: absolute;
586
597
  inset: -2px;
587
- border: 2px solid var(--status-working);
588
598
  border-radius: 50%;
589
- 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;
590
606
  }
591
607
 
592
608
  /* Agent done / ready: solid disc (--status-done), no motion. The default green
593
- matches Kiro IDE's "Ready" (#22c55e), darker and more saturated than
594
- working's default pale lime so the two states differ by lightness as well as
595
- 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. */
596
613
  :where(.wt-root) .wt-status-dot[data-status="done"] {
597
614
  background: var(--status-done);
598
615
  border-color: var(--status-done);
599
616
  }
600
617
 
601
- /* User action required (permission / approval): the working ping frozen at
602
- its birth frame — solid dot + the same 2px ring at the ripple's 30% birth
603
- opacity, motionless (--status-input; the default orange is shifted away
604
- from the old amber so it separates from both default-lime working and green
605
- done). The stillness is the message: the agent's turn is mid-flight but
606
- blocked on you. The ring keeps it distinct from done's bare disc without
607
- leaning on hue; it derives from the same token, so a theme override
608
- recolors dot + ring together. It persists until you act on or switch into
609
- 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. */
610
629
  :where(.wt-root) .wt-status-dot[data-status="input"] {
611
630
  background: var(--status-input);
612
631
  border-color: var(--status-input);
@@ -641,13 +660,33 @@
641
660
  }
642
661
 
643
662
  /* The ripple wave: one emission per cycle, launching off the disc's edge and
644
- dissolving by ~2.1x. The 0.3 birth opacity matches the input ring's frozen
645
- weight, so a live ping and a stuck one read as the same object — subtle
646
- 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. */
647
678
  @keyframes wt-working-ripple {
648
679
  0% {
649
680
  transform: scale(1);
650
- opacity: 0.3;
681
+ opacity: 0.15;
682
+ }
683
+
684
+ 12% {
685
+ opacity: 0.95;
686
+ }
687
+
688
+ 70% {
689
+ opacity: 0.45;
651
690
  }
652
691
 
653
692
  100% {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "4.7.1",
3
+ "version": "4.8.1",
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",
@@ -79,7 +79,8 @@ const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
79
79
  // terminal context menu (formerly two hand-synced copies of the same math).
80
80
 
81
81
  export interface TabsApi {
82
- /** Spawn a fresh session and switch to it. */
82
+ /** Spawn a fresh session and switch to it. Calls made while a create is in
83
+ * flight share that create, so one gesture opens exactly one terminal. */
83
84
  create(): Promise<void>;
84
85
  /** Close a session (kills its process) and drop its tab + cache. */
85
86
  close(id: string): Promise<void>;
@@ -297,7 +298,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
297
298
  // the FIRST tap to blur the input (so "+" only fired create on the second
298
299
  // tap — the reported double-tap). preventDefault on pointerdown fires the
299
300
  // action on the first tap; switchTo then re-focuses the input for the new
300
- // tab on a physical-keyboard device.
301
+ // tab (see focusAfterSwitch). It does NOT make one press one activation —
302
+ // that is create()'s in-flight coalescing, for the opposite failure.
301
303
  btn.addEventListener("pointerdown", (e) => {
302
304
  e.preventDefault();
303
305
  });
@@ -657,8 +659,58 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
657
659
  }
658
660
  }
659
661
 
662
+ const termInput = (): HTMLElement | null =>
663
+ ctx.surface().querySelector<HTMLElement>(".term-input");
660
664
  function focusInput(): void {
661
- ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
665
+ termInput()?.focus({ preventScroll: true });
666
+ }
667
+
668
+ // Did the terminal input own the keyboard when the pointer press now being
669
+ // handled began? A press on tab chrome focuses the control it landed on
670
+ // (the browser's mousedown default action), so by the time the click
671
+ // handler runs the input is already blurred and only a pointerdown-time
672
+ // snapshot still knows where the keyboard came from. Recorded for the whole
673
+ // chrome by the two pointerdown listeners below (the strip and the
674
+ // switcher), not per control: chips come and go with sessions, and the fact
675
+ // belongs to the gesture rather than to what it hit.
676
+ let inputFocusedAtPress = false;
677
+ function noteChromePress(): void {
678
+ const el = termInput();
679
+ inputFocusedAtPress = el !== null && document.activeElement === el;
680
+ }
681
+ function keyboardParkedOnChrome(): boolean {
682
+ const active = document.activeElement;
683
+ return active !== null && (bar.contains(active) || switcher.contains(active));
684
+ }
685
+
686
+ // focusAfterSwitch applies the tab-switch focus rule. Two independent
687
+ // reasons to hand the keyboard to the terminal input:
688
+ //
689
+ // - A physical keyboard is (likely) present, so a switch should leave you
690
+ // able to type at once, with no extra tap (the iPad + Magic Keyboard
691
+ // ask). Skipped on a keyboard-less touchscreen, or every switch would
692
+ // pop the virtual keyboard (#7).
693
+ // - The press that drove this switch took the keyboard OFF the terminal —
694
+ // it focused the chip (or the x, or a switcher row) it pressed, and the
695
+ // keyboard is still parked there. Handing it back is not NEW focus, so
696
+ // it pops no soft keyboard: it restores what the press displaced. Both
697
+ // halves are required for exactly that reason — a switch with no press
698
+ // behind it (a remote adopt, ensureActive), or a press on a device where
699
+ // the input was not focused to begin with, must not summon a keyboard.
700
+ // Leaving the keyboard parked on a chip is not neutral either: the
701
+ // strip's own keydown handling reads arrows as "switch tab" and Delete
702
+ // as "close tab", so every keystroke meant for the terminal is eaten.
703
+ //
704
+ // Never while a rename field is open: it owns the keyboard until it closes,
705
+ // and a switch (the leading click of a double-click, a remote-driven
706
+ // ensureActive) would otherwise yank the caret out of the field.
707
+ function focusAfterSwitch(): void {
708
+ if (editingId !== null) {
709
+ return;
710
+ }
711
+ if (physicalKeyboardLikely() || (inputFocusedAtPress && keyboardParkedOnChrome())) {
712
+ focusInput();
713
+ }
662
714
  }
663
715
 
664
716
  // paintActive updates the desktop strip's active state. When the ACTIVE
@@ -1249,6 +1301,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1249
1301
 
1250
1302
  function switchTo(id: string, dir?: "next" | "prev"): void {
1251
1303
  if (id === activeId) {
1304
+ // Already active, so there is no switch to perform — but the FOCUS rule
1305
+ // still owes an answer, because the press that delivered this click has
1306
+ // already moved the keyboard onto the chip. Two pointer paths land here:
1307
+ // clicking the tab that is already active, and the SECOND activation of
1308
+ // one press on another tab (see create() for the evidence that this
1309
+ // device delivers those). The first activation switches and focuses the
1310
+ // terminal; the second one's mousedown re-focuses the chip and then fell
1311
+ // out here with the keyboard stranded on it — the reported "the
1312
+ // invisible input loses focus when I click another tab", and the same
1313
+ // hole a plain mouse click on the active tab hit on every platform.
1314
+ focusAfterSwitch();
1252
1315
  return;
1253
1316
  }
1254
1317
  const next = tabList.find((t) => t.id === id);
@@ -1339,16 +1402,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1339
1402
  }
1340
1403
  });
1341
1404
  ctx.announce(`Switched to ${next.display}`);
1342
- if (physicalKeyboardLikely() && editingId === null) {
1343
- // A physical keyboard is (likely) present, so focus the input on
1344
- // switch — switch and type immediately (the iPad + Magic Keyboard
1345
- // ask). On a keyboard-less touchscreen this is skipped, or every
1346
- // switch would pop the virtual keyboard (#7). Skipped entirely while a
1347
- // rename field is open: a switch (e.g. the leading click of a
1348
- // double-click, or a remote-driven ensureActive) would otherwise yank
1349
- // the caret out of the field the user is typing in.
1350
- focusInput();
1351
- }
1405
+ focusAfterSwitch();
1352
1406
  }
1353
1407
 
1354
1408
  // armCatchup shows the "catching up" cue only if the resume delta has not
@@ -1590,7 +1644,28 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1590
1644
  }
1591
1645
  }
1592
1646
 
1593
- async function create(): Promise<void> {
1647
+ // create is the coalesced door: while one create is in flight, every caller
1648
+ // asking for another gets THAT one, so a single gesture can only ever open
1649
+ // one terminal. Spawning a session is expensive and non-idempotent, and one
1650
+ // press does not reliably arrive as one activation — the web-terminal-kiro
1651
+ // server logged two POST /api/sessions 0-3ms apart (three times in one
1652
+ // afternoon, both answered 201) for single "+" taps from an iPad + Magic
1653
+ // Keyboard, so the user got two terminals and had to close one. Sharing the
1654
+ // in-flight promise needs no threshold to make that decision, unlike a
1655
+ // click-level debounce: a duplicate activation lands while the POST is still
1656
+ // open and collapses into it, while a deliberate second tap (the POST
1657
+ // resolves in milliseconds) still opens a second terminal. It also stops "+"
1658
+ // mashing from queueing sessions across the server's 503 install window,
1659
+ // where createSessionHonouringRetry legitimately waits minutes.
1660
+ let creating: Promise<void> | null = null;
1661
+ function create(): Promise<void> {
1662
+ creating ??= openNewSession().finally(() => {
1663
+ creating = null;
1664
+ });
1665
+ return creating;
1666
+ }
1667
+
1668
+ async function openNewSession(): Promise<void> {
1594
1669
  let info: SessionInfo;
1595
1670
  try {
1596
1671
  info = await createSessionHonouringRetry(api, ctx, () => tornDown);
@@ -2041,15 +2116,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2041
2116
  const menuSwallow = createClickSwallow();
2042
2117
  let menuTouch = false;
2043
2118
  // 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.
2119
+ // destroyed as sessions come and go, and both facts recorded here (the
2120
+ // gesture's pointer type, and whether it took the keyboard off the
2121
+ // terminal) belong to the gesture, not to a chip.
2046
2122
  bar.addEventListener(
2047
2123
  "pointerdown",
2048
2124
  (e) => {
2049
2125
  menuTouch = e.pointerType === "touch";
2126
+ noteChromePress();
2050
2127
  },
2051
2128
  { passive: true },
2052
2129
  );
2130
+ // The switcher's half of that snapshot (its rows and the x are buttons, so
2131
+ // pressing one focuses it and blurs the terminal exactly as a chip does).
2132
+ switcher.addEventListener("pointerdown", noteChromePress, { passive: true });
2053
2133
  function hideTabMenu(): void {
2054
2134
  tabMenu.classList.remove("visible");
2055
2135
  tabMenu.replaceChildren();