@cplieger/web-terminal-ui 4.1.1 → 4.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.
- package/README.md +8 -8
- package/css/00-tokens.css +7 -0
- package/css/30-tabs.css +45 -30
- package/css/31-switcher.css +5 -4
- package/package.json +1 -1
- package/src/features/tabs/index.ts +71 -0
- package/src/kernel/a11y.ts +5 -0
package/README.md
CHANGED
|
@@ -137,14 +137,14 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
|
|
|
137
137
|
|
|
138
138
|
### Options
|
|
139
139
|
|
|
140
|
-
| Option | Default | Purpose
|
|
141
|
-
| ----------- | -------------------------- |
|
|
142
|
-
| `features` | _(none — bare kernel)_ | The feature list. Omitted or empty builds only the terminal (no chrome). Use a preset from `./presets` or a hand-picked array.
|
|
143
|
-
| `layout` | `"viewport"` | How the terminal claims space. `"viewport"`: the root becomes a fixed full-viewport box (the full-page product). `"container"`: the root fills your container element, which becomes the styling and positioning boundary (the embedded case).
|
|
144
|
-
| `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to.
|
|
145
|
-
| `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback.
|
|
146
|
-
| `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders.
|
|
147
|
-
| `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-bg`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`, `--tab-active-border
|
|
140
|
+
| Option | Default | Purpose |
|
|
141
|
+
| ----------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
142
|
+
| `features` | _(none — bare kernel)_ | The feature list. Omitted or empty builds only the terminal (no chrome). Use a preset from `./presets` or a hand-picked array. |
|
|
143
|
+
| `layout` | `"viewport"` | How the terminal claims space. `"viewport"`: the root becomes a fixed full-viewport box (the full-page product). `"container"`: the root fills your container element, which becomes the styling and positioning boundary (the embedded case). |
|
|
144
|
+
| `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
|
|
145
|
+
| `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
|
|
146
|
+
| `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
|
|
147
|
+
| `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-bg`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`, `--tab-active-border`, plus the activity-dot palette `--status-working`, `--status-done`, `--status-input` (the working and input rings derive from their own tokens). The library ships neutral defaults. |
|
|
148
148
|
|
|
149
149
|
`createTerminal()` returns a handle: `focus()` re-focuses the terminal input
|
|
150
150
|
(and opens the soft keyboard on touch); `send(bytes)` sends bytes to the active
|
package/css/00-tokens.css
CHANGED
|
@@ -44,6 +44,13 @@
|
|
|
44
44
|
var(--text) 25%
|
|
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
|
+
/* 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 */
|
|
52
|
+
--status-done: #22c55e; /* turn finished: solid green */
|
|
53
|
+
--status-input: #fb923c; /* action required: solid orange + ring */
|
|
47
54
|
--radius: 0.75rem;
|
|
48
55
|
--radius-inner: 0.5rem;
|
|
49
56
|
/* Small square icon buttons (e.g. the 24px tab-close x): at that size the
|
package/css/30-tabs.css
CHANGED
|
@@ -376,33 +376,42 @@
|
|
|
376
376
|
border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
/* Agent working: solid pulsing pale lime
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
379
|
+
/* Agent working: solid pulsing dot (--status-working; pale lime by default —
|
|
380
|
+
its high lightness keeps it clear on every tab state) wearing the same 2px
|
|
381
|
+
ring as input, so the two agent-turn states share one silhouette: a live
|
|
382
|
+
turn pulses (dot + ring together), a turn blocked on the user is that same
|
|
383
|
+
silhouette frozen and recolored (input, below). Motion separates working
|
|
384
|
+
from input; the ring separates both from done's bare disc. Server-side this
|
|
385
|
+
is driven by the OSC 9;4 progress signal, so it tracks agent activity, not
|
|
386
|
+
the user typing at the prompt. */
|
|
384
387
|
:where(.wt-root) .wt-status-dot[data-status="working"] {
|
|
385
|
-
background:
|
|
386
|
-
border-color:
|
|
388
|
+
background: var(--status-working);
|
|
389
|
+
border-color: var(--status-working);
|
|
390
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
|
|
387
391
|
animation: wt-pulse 1.2s ease-in-out infinite;
|
|
388
392
|
}
|
|
389
393
|
|
|
390
|
-
/* Agent done / ready: solid
|
|
391
|
-
(#22c55e),
|
|
392
|
-
pale lime
|
|
394
|
+
/* Agent done / ready: solid disc (--status-done), no motion. The default green
|
|
395
|
+
matches Kiro IDE's "Ready" (#22c55e), darker and more saturated than
|
|
396
|
+
working's default pale lime so the two states differ by lightness as well as
|
|
397
|
+
motion. */
|
|
393
398
|
:where(.wt-root) .wt-status-dot[data-status="done"] {
|
|
394
|
-
background:
|
|
395
|
-
border-color:
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/* User action required (permission / approval):
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
399
|
+
background: var(--status-done);
|
|
400
|
+
border-color: var(--status-done);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/* User action required (permission / approval): working's exact silhouette —
|
|
404
|
+
solid dot + 2px ring — frozen and recolored (--status-input; the default
|
|
405
|
+
orange is shifted away from the old amber so it separates from both
|
|
406
|
+
default-lime working and green done). The stillness is the message: the
|
|
407
|
+
agent's turn is mid-flight but blocked on you. The ring keeps it distinct
|
|
408
|
+
from done's bare disc without leaning on hue; it derives from the same token
|
|
409
|
+
at 40% alpha, so a theme override recolors dot + ring together. It persists
|
|
410
|
+
until you act on or switch into the tab. */
|
|
402
411
|
:where(.wt-root) .wt-status-dot[data-status="input"] {
|
|
403
|
-
background:
|
|
404
|
-
border-color:
|
|
405
|
-
box-shadow: 0 0 0 2px
|
|
412
|
+
background: var(--status-input);
|
|
413
|
+
border-color: var(--status-input);
|
|
414
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-input) 40%, transparent);
|
|
406
415
|
}
|
|
407
416
|
|
|
408
417
|
/* Exited: a dim hollow dot, static; distinct from idle by its lower opacity. */
|
|
@@ -417,15 +426,18 @@
|
|
|
417
426
|
100% {
|
|
418
427
|
/* Pin the animated layer's paint as well as its opacity. When working begins
|
|
419
428
|
in the same frame that input/done ends, an opacity-only animation can
|
|
420
|
-
pulse the previously rasterized
|
|
421
|
-
|
|
422
|
-
|
|
429
|
+
pulse the previously rasterized input/done paint (the input ring
|
|
430
|
+
included, hence the box-shadow pin). */
|
|
431
|
+
background-color: var(--status-working);
|
|
432
|
+
border-color: var(--status-working);
|
|
433
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
|
|
423
434
|
opacity: 1;
|
|
424
435
|
}
|
|
425
436
|
|
|
426
437
|
50% {
|
|
427
|
-
background-color:
|
|
428
|
-
border-color:
|
|
438
|
+
background-color: var(--status-working);
|
|
439
|
+
border-color: var(--status-working);
|
|
440
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
|
|
429
441
|
opacity: 0.4;
|
|
430
442
|
}
|
|
431
443
|
}
|
|
@@ -434,10 +446,13 @@
|
|
|
434
446
|
@media (prefers-reduced-motion: reduce) {
|
|
435
447
|
:where(.wt-root) .wt-status-dot[data-status="working"] {
|
|
436
448
|
animation: none;
|
|
437
|
-
/* The pulse is working's
|
|
438
|
-
|
|
439
|
-
from
|
|
440
|
-
|
|
449
|
+
/* The pulse is what separates working from input's frozen silhouette; with
|
|
450
|
+
motion removed, punch an inner hole (dot -> donut) so working differs
|
|
451
|
+
from input (solid ringed disc) and done (bare disc) by shape, not hue
|
|
452
|
+
alone (WCAG 1.4.1). The outer ring stays. */
|
|
453
|
+
box-shadow:
|
|
454
|
+
inset 0 0 0 2px var(--bg),
|
|
455
|
+
0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
|
|
441
456
|
}
|
|
442
457
|
}
|
|
443
458
|
|
package/css/31-switcher.css
CHANGED
|
@@ -280,10 +280,11 @@
|
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
/* Latest-wins background-tab notification dot on the switch button — the moved
|
|
283
|
-
attention cue
|
|
284
|
-
turn finished. Reuses the exact .wt-status-dot
|
|
285
|
-
matches the per-tab dots. Hidden
|
|
286
|
-
cleared when the list opens
|
|
283
|
+
attention cue: the input colour when a background terminal needs input, the
|
|
284
|
+
done colour when a background turn finished. Reuses the exact .wt-status-dot
|
|
285
|
+
rules (30-tabs.css, --status-* tokens) so it matches the per-tab dots. Hidden
|
|
286
|
+
until the tabs feature sets data-status; cleared when the list opens
|
|
287
|
+
(expandSwitcher). */
|
|
287
288
|
:where(.wt-root) .wt-switcher-switch-dot {
|
|
288
289
|
position: absolute;
|
|
289
290
|
top: 6px;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.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",
|
|
@@ -908,6 +908,48 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
908
908
|
}
|
|
909
909
|
switchTo(tab.id);
|
|
910
910
|
});
|
|
911
|
+
// Keyboard interaction for the ARIA tabs pattern (WCAG 2.1.1): arrows
|
|
912
|
+
// move selection (wrapping), Home/End jump to the boundaries, Delete
|
|
913
|
+
// closes the focused tab. Pairs with the roving tabindex the kernel's
|
|
914
|
+
// registerTab manages (selected tab is tabIndex 0, others -1).
|
|
915
|
+
el.addEventListener("keydown", (e) => {
|
|
916
|
+
const current = tabList.indexOf(tab);
|
|
917
|
+
if (current < 0) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (e.key === "Delete") {
|
|
922
|
+
e.preventDefault();
|
|
923
|
+
void close_(tab.id);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
let targetIndex: number;
|
|
928
|
+
switch (e.key) {
|
|
929
|
+
case "ArrowLeft":
|
|
930
|
+
targetIndex = (current - 1 + tabList.length) % tabList.length;
|
|
931
|
+
break;
|
|
932
|
+
case "ArrowRight":
|
|
933
|
+
targetIndex = (current + 1) % tabList.length;
|
|
934
|
+
break;
|
|
935
|
+
case "Home":
|
|
936
|
+
targetIndex = 0;
|
|
937
|
+
break;
|
|
938
|
+
case "End":
|
|
939
|
+
targetIndex = tabList.length - 1;
|
|
940
|
+
break;
|
|
941
|
+
default:
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
e.preventDefault();
|
|
946
|
+
const target = tabList[targetIndex];
|
|
947
|
+
if (!target) {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
switchTo(target.id);
|
|
951
|
+
target.el.focus();
|
|
952
|
+
});
|
|
911
953
|
close.addEventListener("click", (e) => {
|
|
912
954
|
e.stopPropagation();
|
|
913
955
|
void close_(tab.id);
|
|
@@ -1169,6 +1211,29 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1169
1211
|
}
|
|
1170
1212
|
}
|
|
1171
1213
|
|
|
1214
|
+
// moveTab shifts a tab one slot left or right in tabList and re-inserts
|
|
1215
|
+
// every tab element in list order (before the "+") so the DOM matches.
|
|
1216
|
+
// It is the single-pointer / non-drag alternative to the drag-and-drop
|
|
1217
|
+
// reorder (WCAG 2.5.7), surfaced as Move left / Move right in the tab
|
|
1218
|
+
// context menu.
|
|
1219
|
+
function moveTab(id: string, delta: -1 | 1): void {
|
|
1220
|
+
const from = tabList.findIndex((t) => t.id === id);
|
|
1221
|
+
const to = from + delta;
|
|
1222
|
+
if (from < 0 || to < 0 || to >= tabList.length) {
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const [tab] = tabList.splice(from, 1);
|
|
1226
|
+
if (!tab) {
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
tabList.splice(to, 0, tab);
|
|
1230
|
+
for (const item of tabList) {
|
|
1231
|
+
scroller.insertBefore(item.el, newBtn);
|
|
1232
|
+
}
|
|
1233
|
+
syncChrome();
|
|
1234
|
+
ctx.announce(`Moved ${tab.display} to position ${String(to + 1)}`);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1172
1237
|
// adoptSession adds a tab for a session that exists server-side but has no
|
|
1173
1238
|
// local tab yet, e.g. one created in another browser (the server pushes it
|
|
1174
1239
|
// over the status SSE, and the poll fallback lists it). This keeps every
|
|
@@ -1410,6 +1475,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1410
1475
|
return;
|
|
1411
1476
|
}
|
|
1412
1477
|
const n = tabList.length;
|
|
1478
|
+
tabMenuItem("Move left", idx <= 0, () => {
|
|
1479
|
+
moveTab(id, -1);
|
|
1480
|
+
});
|
|
1481
|
+
tabMenuItem("Move right", idx >= n - 1, () => {
|
|
1482
|
+
moveTab(id, 1);
|
|
1483
|
+
});
|
|
1413
1484
|
tabMenuItem("Close", false, () => {
|
|
1414
1485
|
void close_(id);
|
|
1415
1486
|
});
|
package/src/kernel/a11y.ts
CHANGED
|
@@ -97,10 +97,14 @@ export function createTablist(panel: HTMLElement): TablistController {
|
|
|
97
97
|
tab.setAttribute("role", "tab");
|
|
98
98
|
tab.setAttribute("aria-controls", panelId);
|
|
99
99
|
tab.setAttribute("aria-selected", "false");
|
|
100
|
+
// Roving tabindex (WAI-ARIA APG Tabs pattern): only the selected tab is in
|
|
101
|
+
// the Tab sequence; the others are focusable programmatically (arrow keys).
|
|
102
|
+
tab.tabIndex = -1;
|
|
100
103
|
|
|
101
104
|
return {
|
|
102
105
|
setSelected(selected: boolean): void {
|
|
103
106
|
tab.setAttribute("aria-selected", selected ? "true" : "false");
|
|
107
|
+
tab.tabIndex = selected ? 0 : -1;
|
|
104
108
|
if (selected) {
|
|
105
109
|
// The panel is labelled by whichever tab is currently selected.
|
|
106
110
|
panel.setAttribute("aria-labelledby", tabId);
|
|
@@ -113,6 +117,7 @@ export function createTablist(panel: HTMLElement): TablistController {
|
|
|
113
117
|
tab.removeAttribute("role");
|
|
114
118
|
tab.removeAttribute("aria-controls");
|
|
115
119
|
tab.removeAttribute("aria-selected");
|
|
120
|
+
tab.removeAttribute("tabindex");
|
|
116
121
|
if (panel.getAttribute("aria-labelledby") === tabId) {
|
|
117
122
|
panel.removeAttribute("aria-labelledby");
|
|
118
123
|
}
|