@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 +18 -4
- package/css/30-tabs.css +74 -35
- package/package.json +1 -1
- package/src/features/tabs/index.ts +96 -16
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
|
|
49
|
-
|
|
50
|
-
recolors dot +
|
|
51
|
-
|
|
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
|
|
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
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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:
|
|
577
|
-
-2px, the 9->13px
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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)
|
|
594
|
-
|
|
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):
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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.
|
|
645
|
-
|
|
646
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2045
|
-
// gesture,
|
|
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();
|