@cplieger/web-terminal-ui 8.0.1 → 8.1.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/README.md +53 -27
- package/css/00-tokens.css +1 -1
- package/css/01-scope.css +1 -1
- package/css/10-primitives.css +1 -1
- package/css/21-scroll-to-bottom.css +3 -3
- package/css/30-tabs.css +82 -29
- package/css/31-switcher.css +5 -0
- package/css/32-split.css +67 -15
- package/css/MANIFEST.touch +1 -1
- package/css/page.css +1 -1
- package/package.json +12 -12
- package/src/features/tabs/device-snap.ts +56 -0
- package/src/features/tabs/index.ts +212 -99
- package/src/features/tabs/strip.ts +8 -3
- package/src/kernel/pane.ts +34 -14
- package/src/kernel/shell.ts +91 -16
- package/src/kernel/split-handle.ts +37 -13
- package/src/kernel/types.ts +8 -1
- package/src/viewport.ts +166 -73
package/README.md
CHANGED
|
@@ -254,7 +254,7 @@ createTerminal("#terminal", {
|
|
|
254
254
|
});
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
-
All three reference apps (web-terminal-kiro, web-terminal-server,
|
|
257
|
+
All three reference apps (web-terminal-kiro, web-terminal-server, marotte's shell
|
|
258
258
|
panel) enable it that way; web-terminal-server additionally exposes an operator
|
|
259
259
|
opt-out, because it runs a command its operator chooses.
|
|
260
260
|
|
|
@@ -466,37 +466,57 @@ feature, because everything that drives it lives there: the shared tab row, the
|
|
|
466
466
|
split button, the snap items and the layout record. Your root becomes the shell
|
|
467
467
|
root holding one root per pane and the shared chrome; each pane is a complete
|
|
468
468
|
terminal with its own engine, so an open split holds up to two WebSocket
|
|
469
|
-
connections, one per shown pane.
|
|
470
|
-
|
|
471
|
-
|
|
469
|
+
connections, one per shown pane. Closing and resizing the split is a display
|
|
470
|
+
re-arrangement only: no tab or session is ever created or closed by it, and a tab
|
|
471
|
+
that leaves a pane stays in the row as an ordinary tab. Opening it is one too,
|
|
472
|
+
except from the split button with a single tab, which creates the second tab.
|
|
472
473
|
|
|
473
474
|
Three ways in, all in the tab chrome, none a key chord:
|
|
474
475
|
|
|
475
476
|
- **The split button** at the right edge of the tab row (and in the mobile
|
|
476
|
-
switcher bar on a touchscreen) toggles the split
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
477
|
+
switcher bar on a touchscreen) toggles the split, and never opens an empty
|
|
478
|
+
pane. The current tab stays in the left pane and selected, and the right pane
|
|
479
|
+
shows the tab used before it, or with no such tab the next one in the row, else
|
|
480
|
+
the previous one. With a single tab it creates a new tab and shows it on the
|
|
481
|
+
right, selected; the split opens once the new tab exists. Closing keeps the
|
|
482
|
+
SELECTED pane's tab and hides the other pane. Opening slides the divider in
|
|
483
|
+
from the right edge and closing slides it toward the hidden side, both instant
|
|
484
|
+
under reduced motion. It is an ordinary button: Tab
|
|
485
|
+
reaches it, Enter or Space toggles it, `aria-expanded` is its state.
|
|
481
486
|
- **"Snap to left" and "Snap to right"** in a tab's context menu, on a
|
|
482
487
|
right-click, a touch long-press, or the keyboard (the context-menu key or
|
|
483
488
|
Shift+F10 on the focused tab, where the browser raises the menu event; Safari
|
|
484
489
|
binds no key to it, so a keyboard-only person there reaches the menu through
|
|
485
|
-
an assistive technology's own command). A snapped tab shows on the named side
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
+
an assistive technology's own command). A snapped tab shows on the named side
|
|
491
|
+
and its pane is selected. When the snap opens the split, the other pane keeps
|
|
492
|
+
the tab the single view showed (or, when that is the tab being moved, its
|
|
493
|
+
split partner), so neither pane opens empty. A tab the other pane shows trades
|
|
494
|
+
places with the tab on the named side, so both stay on screen (into an empty
|
|
495
|
+
pane it simply moves); any other tab there becomes an ordinary tab. Snapping a
|
|
496
|
+
tab onto the side that already shows it changes nothing.
|
|
497
|
+
- **Dragging a tab** out of the row onto either pane of the terminal area,
|
|
498
|
+
following the same rule. While the drag lasts the pane under the pointer is
|
|
499
|
+
highlighted; each highlight covers that pane's terminal area at the divider's
|
|
500
|
+
position (the two halves while the split is closed), inset a little with the
|
|
501
|
+
tab chips' rounded border, and a release decides by the divider's centre.
|
|
490
502
|
|
|
491
503
|
A tab click follows one rule: an empty pane fills first, otherwise the selected
|
|
492
|
-
pane's tab is replaced.
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
504
|
+
pane's tab is replaced. A new tab ("+") also fills an empty pane first, but with
|
|
505
|
+
both panes showing a tab it replaces the UNSELECTED pane's tab and its pane
|
|
506
|
+
becomes the selected one, so the tab typed in before stays on screen. A session
|
|
507
|
+
created in another browser lands the same way while the split is open; with the
|
|
508
|
+
split closed it joins the row as an ordinary tab. Closing a
|
|
509
|
+
shown tab closes the split: the other pane's tab fills the view and is selected,
|
|
510
|
+
or, when the other pane was empty, the neighbouring tab is shown as it is with
|
|
511
|
+
one pane. The selected pane is the one that receives typing, the
|
|
512
|
+
last one clicked, touched or typed in. The divider between the panes shows it:
|
|
513
|
+
an 8 px bar in the 10 px gutter holding a short accent pill that sits against
|
|
514
|
+
the selected pane's side and slides across when the selection changes (it jumps
|
|
515
|
+
under reduced motion). The selected pane keeps the filled blinking cursor while
|
|
516
|
+
the other pane's is hollow and steady, and in the tab row both shown tabs render
|
|
517
|
+
active, the selected pane's a step brighter. A screen reader hears "Left
|
|
518
|
+
terminal selected" or "Right terminal selected" as a polite announcement, and
|
|
519
|
+
each pane's tabpanel is described by its side and whether it is selected.
|
|
500
520
|
|
|
501
521
|
While the split is open each shown pane's input is a stop in the Tab order, so
|
|
502
522
|
Tab runs left pane, divider, right pane, then the tab row; an empty pane adds no
|
|
@@ -505,10 +525,14 @@ from outside the terminal lands on the tab row.
|
|
|
505
525
|
|
|
506
526
|
The handle is a focusable `role="separator"` with `aria-valuenow`, `aria-valuemin`
|
|
507
527
|
and `aria-valuemax` as percentages of the left share. Dragging it (mouse or
|
|
508
|
-
touch
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
528
|
+
touch; the hit area is 24 px wide for a fine pointer and 44 px for a coarse one,
|
|
529
|
+
overlapping the pane edges) resizes both panes as the pointer moves; the shells
|
|
530
|
+
are told their new size at most every 100 ms and once on release. Past the
|
|
531
|
+
360 px minimum the pane holds at exactly 360 px for 120 px of further pointer
|
|
532
|
+
travel, and a release there leaves it at 360 px. Beyond that the pane follows
|
|
533
|
+
the pointer again and dims, and releasing there closes it; coming back above
|
|
534
|
+
360 px after such a trip and releasing snaps the handle back to where the drag
|
|
535
|
+
started. From the
|
|
512
536
|
keyboard ArrowLeft and ArrowRight nudge the divider by 16 px and Home and End take
|
|
513
537
|
it to the bounds, and every key stops AT the 360 px minimum rather than closing a
|
|
514
538
|
pane; the record is written when the key is released. The default is 50/50.
|
|
@@ -604,7 +628,7 @@ The web-terminal family:
|
|
|
604
628
|
|
|
605
629
|
Consumers that ship this UI:
|
|
606
630
|
|
|
607
|
-
- [`
|
|
631
|
+
- [`marotte`](https://github.com/cplieger/marotte)
|
|
608
632
|
- [`web-terminal-kiro`](https://github.com/cplieger/web-terminal-kiro)
|
|
609
633
|
|
|
610
634
|
## Contributing
|
|
@@ -627,3 +651,5 @@ Apache-2.0 work, and its licence travels with the FONT FILE — which this packa
|
|
|
627
651
|
does not ship, it only names the URL. So no new obligation lands on an npm or JSR
|
|
628
652
|
consumer; the obligation is the serving host's, which is why the full-page host
|
|
629
653
|
notes above ask for the font's `LICENSE` and `NOTICE` beside the `.woff2`.
|
|
654
|
+
|
|
655
|
+
Third-party attributions are in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
package/css/00-tokens.css
CHANGED
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
retuning one alone desynchronises cues a reader sees side by side. It was a
|
|
177
177
|
comment saying "change both or neither" until 2026-09, which is the same rule
|
|
178
178
|
with nothing enforcing it.
|
|
179
|
-
Shared with
|
|
179
|
+
Shared with marotte's `--dot-beat-dur` (01-tokens.css) for the reason the
|
|
180
180
|
--status-* inks are: one signal across the two apps, beat included. */
|
|
181
181
|
--dot-beat-dur: 1.2s;
|
|
182
182
|
--dur-standard: 0.2s;
|
package/css/01-scope.css
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
color-scheme: dark;
|
|
51
51
|
/* Design-system scrollbar COLOR on every scroller in the subtree (the
|
|
52
52
|
terminal, the tab scroller, the expanded switcher list): border-grey
|
|
53
|
-
thumb, no track — the
|
|
53
|
+
thumb, no track — the marotte/subflux treatment. scrollbar-color
|
|
54
54
|
INHERITS, so the boundary is the one place to set it. Where it is not
|
|
55
55
|
supported the color-scheme above still renders the UA fallback dark.
|
|
56
56
|
Overlay-scrollbar platforms are unaffected. */
|
package/css/10-primitives.css
CHANGED
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
real <button>: tabs, +, keyboard, switcher rows, menu items, the FAB).
|
|
79
79
|
:focus-visible only — a tap/click never shows the ring (the UA heuristic),
|
|
80
80
|
so the touch-first surfaces stay clean while Tab-key navigation gets the
|
|
81
|
-
family's accent ring (the
|
|
81
|
+
family's accent ring (the marotte/subflux convention: 2px accent, 2px out).
|
|
82
82
|
Follows each control's border-radius; themeable via --accent like the "+"
|
|
83
83
|
glyphs. The terminal's own hidden textarea is not a button and keeps its
|
|
84
84
|
outline: none (02-terminal.css). */
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/*
|
|
64
|
-
|
|
65
|
-
@media (pointer:
|
|
63
|
+
/* Above the docked strip, which shows under every pointer but a coarse one, no
|
|
64
|
+
pointer at all included: the gate is not `(pointer: fine)`. */
|
|
65
|
+
@media not (pointer: coarse) {
|
|
66
66
|
:where(.wt-root.wt-tabbed) .wt-scroll-bottom {
|
|
67
67
|
bottom: calc(var(--wt-tabbar-h, 44px) + var(--sp-3));
|
|
68
68
|
}
|
package/css/30-tabs.css
CHANGED
|
@@ -225,6 +225,22 @@
|
|
|
225
225
|
background: color-mix(in oklch, var(--tab-active-bg), var(--tab-active-fg) 22%);
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/* This rest rule outranks the plain active hover, so it carries its own hover and
|
|
229
|
+
press rungs. */
|
|
230
|
+
:where(.wt-root) .wt-tab-active.wt-tab-selected {
|
|
231
|
+
background: color-mix(in oklch, var(--tab-active-bg), var(--tab-active-fg) 10%);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@media (any-hover: hover) {
|
|
235
|
+
:where(.wt-root) .wt-tab-active.wt-tab-selected:hover {
|
|
236
|
+
background: color-mix(in oklch, var(--tab-active-bg), var(--tab-active-fg) 20%);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
:where(.wt-root) .wt-tab-active.wt-tab-selected:active {
|
|
241
|
+
background: color-mix(in oklch, var(--tab-active-bg), var(--tab-active-fg) 30%);
|
|
242
|
+
}
|
|
243
|
+
|
|
228
244
|
/* Centres the label's INK (cap top to baseline), not its line box; the shift is
|
|
229
245
|
measured per site by features/tabs/ink-centre.ts, which owns why. Not
|
|
230
246
|
`text-box: trim-both` (Firefox lacks it; a gate is two geometries, one
|
|
@@ -454,6 +470,60 @@
|
|
|
454
470
|
}
|
|
455
471
|
}
|
|
456
472
|
|
|
473
|
+
:where(.wt-root) .wt-tab-split[aria-expanded="true"] {
|
|
474
|
+
background: var(--tab-hover-bg);
|
|
475
|
+
color: var(--tab-active-fg);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* The split glyph (both split buttons) is filled, never stroked: a stroke is
|
|
479
|
+
centred on its outline, so its edges sit half a stroke off the shape's.
|
|
480
|
+
20px is a whole number of device px at 1, 1.25, 1.5 and 2 dppx, and at 1.25
|
|
481
|
+
and 1.5 the shapes move to that DPR's device grid, written as device px over
|
|
482
|
+
the ratio (25 and 30 across, both mirrored about the centre). */
|
|
483
|
+
:where(.wt-root) .wt-btn > .wt-split-icon {
|
|
484
|
+
width: 20px;
|
|
485
|
+
height: 20px;
|
|
486
|
+
fill: currentcolor;
|
|
487
|
+
stroke: none;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
@media (resolution: 1.25dppx) {
|
|
491
|
+
:where(.wt-root) .wt-split-icon-left {
|
|
492
|
+
x: calc(1px / 1.25);
|
|
493
|
+
width: calc(8px / 1.25);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
:where(.wt-root) .wt-split-icon-divider {
|
|
497
|
+
x: calc(11px / 1.25);
|
|
498
|
+
y: calc(2px / 1.25);
|
|
499
|
+
width: calc(3px / 1.25);
|
|
500
|
+
height: calc(21px / 1.25);
|
|
501
|
+
rx: calc(1.5px / 1.25);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
:where(.wt-root) .wt-split-icon-right {
|
|
505
|
+
x: calc(16px / 1.25);
|
|
506
|
+
width: calc(8px / 1.25);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
@media (resolution: 1.5dppx) {
|
|
511
|
+
:where(.wt-root) .wt-split-icon-left {
|
|
512
|
+
x: calc(2px / 1.5);
|
|
513
|
+
width: calc(9px / 1.5);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
:where(.wt-root) .wt-split-icon-divider {
|
|
517
|
+
x: calc(14px / 1.5);
|
|
518
|
+
width: calc(2px / 1.5);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
:where(.wt-root) .wt-split-icon-right {
|
|
522
|
+
x: calc(19px / 1.5);
|
|
523
|
+
width: calc(9px / 1.5);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
457
527
|
/* ===== Reorder drop slot (tabs feature, desktop drag) =====
|
|
458
528
|
The dragged chip stays in the strip's flow for the whole gesture and renders as
|
|
459
529
|
the SLOT it will land in: an empty dashed outline in the accent over a faint
|
|
@@ -624,31 +694,14 @@
|
|
|
624
694
|
background: var(--border);
|
|
625
695
|
}
|
|
626
696
|
|
|
627
|
-
/*
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
(--wt-reserve-bottom) instead. Two rules because the class half cannot mix
|
|
636
|
-
into a media condition; they set the same declaration.
|
|
637
|
-
|
|
638
|
-
The clearance is the strip's height PLUS --safe-bottom, because that is where
|
|
639
|
-
the strip is: .wt-tab-bar sits at `bottom: var(--safe-bottom)`, so the band it
|
|
640
|
-
occupies runs from --safe-bottom to --safe-bottom + --wt-tabbar-h. Clearing
|
|
641
|
-
the height alone left the surface reaching exactly env(safe-area-inset-bottom)
|
|
642
|
-
past the strip's top edge — 0 on a desktop, 20px in iPad Safari (viewport-fit
|
|
643
|
-
is cover, so the insets are live), where the terminal grew one extra row that
|
|
644
|
-
rendered behind the strip. --wt-tabbar-h is the bar's OWN height and must stay
|
|
645
|
-
that: the wide-touchscreen key grid (31-switcher.css) already adds the inset to
|
|
646
|
-
it itself, and folding the inset into the variable would double-count there.
|
|
647
|
-
The mobile switcher reaches the same total from the other direction, measuring
|
|
648
|
-
`innerHeight - rect.top` so the inset rides along inside --wt-reserve-bottom.
|
|
649
|
-
--kb-inset is deliberately absent: viewport.ts owns the keyboard case by
|
|
650
|
-
writing an inline bottom on the surface, which outranks this rule entirely. */
|
|
651
|
-
@media (pointer: fine) {
|
|
697
|
+
/* The surface clears the docked strip wherever the strip shows: any pointer but
|
|
698
|
+
a coarse one, or a root that is not narrow, the exact complement of the
|
|
699
|
+
switcher's gate in 31-switcher.css (two rules, since a class cannot join a
|
|
700
|
+
media condition). The strip sits at `bottom: var(--safe-bottom)`, hence the
|
|
701
|
+
added inset; --wt-tabbar-h stays the bar's own height, because the key grid
|
|
702
|
+
in 31-switcher.css adds the inset to it too. An inline bottom written by
|
|
703
|
+
viewport.ts (keyboard, switcher reserve) outranks both rules. */
|
|
704
|
+
@media not (pointer: coarse) {
|
|
652
705
|
:where(.wt-root) .term.wt-with-tabbar {
|
|
653
706
|
bottom: calc(var(--wt-tabbar-h, 44px) + var(--safe-bottom, 0px));
|
|
654
707
|
}
|
|
@@ -1029,7 +1082,7 @@
|
|
|
1029
1082
|
what a reader sees is the hole morphing between a square and the circle inscribed
|
|
1030
1083
|
in it — the ring survives the peak.
|
|
1031
1084
|
|
|
1032
|
-
EVERY NUMBER HERE IS A RELATION, and the port that copied
|
|
1085
|
+
EVERY NUMBER HERE IS A RELATION, and the port that copied marotte's LITERALS is
|
|
1033
1086
|
what left this rule running with nothing visible to show. Two of them were
|
|
1034
1087
|
arithmetic for an 8px mark with a 2px band: `inset: -1px` put the overlay on that
|
|
1035
1088
|
mark's own border box, and the core stop `70%` is where the core reaches the
|
|
@@ -1037,10 +1090,10 @@
|
|
|
1037
1090
|
and 2px the same relation is 78.57%, so the copied 70% ended the solid core at
|
|
1038
1091
|
2.45px while the mask opens only at 2.5px: core and visible mask did not
|
|
1039
1092
|
intersect at all, the beat lit nothing the mask kept, and 4 pixels of a 9x9 mark
|
|
1040
|
-
moved between trough and peak against
|
|
1093
|
+
moved between trough and peak against marotte's 20 (measured at dpr 1, both
|
|
1041
1094
|
marks, sRGB). Written as relations they survive any size: the inset IS the band,
|
|
1042
1095
|
the core stop IS the hole's corner radius, the mask's hole IS its inradius. So
|
|
1043
|
-
port
|
|
1096
|
+
port marotte's `.tab-run-dot` (`12-tabs.css`, "THE GLOW BEAT") — the same mark
|
|
1044
1097
|
solving the same problem, with the mask from the start — by its relations, never
|
|
1045
1098
|
by its numbers. sqrt(2) is spelled to four decimals because the synced stylelint
|
|
1046
1099
|
sets `number-max-precision: 4` outside colour functions.
|
|
@@ -1156,7 +1209,7 @@
|
|
|
1156
1209
|
keyframe here would reintroduce the per-frame repaint this shape exists to avoid.
|
|
1157
1210
|
|
|
1158
1211
|
The dot's glow ENVELOPE, at a lower CEILING, and the ceiling is where the two
|
|
1159
|
-
deliberately part. 0.55 rather than 1 is
|
|
1212
|
+
deliberately part. 0.55 rather than 1 is marotte's number for the same mark
|
|
1160
1213
|
(`12-tabs.css`), and it is what keeps a strip of busy tabs reading as one pulse
|
|
1161
1214
|
instead of a row of flashes — the dot can afford 1 because its overlay lifts a
|
|
1162
1215
|
solid disc of its own hue and has almost no headroom above its own backdrop,
|
package/css/31-switcher.css
CHANGED
|
@@ -302,6 +302,11 @@
|
|
|
302
302
|
display: none;
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
:where(.wt-root) .wt-switcher-split[aria-expanded="true"] {
|
|
306
|
+
background: var(--tab-hover-bg);
|
|
307
|
+
color: var(--tab-active-fg);
|
|
308
|
+
}
|
|
309
|
+
|
|
305
310
|
/* Tab-switcher button: toggles the list open/closed, sitting between the
|
|
306
311
|
keyboard and "+" buttons. position: relative anchors its notification dot,
|
|
307
312
|
absolutely placed in the top-right corner. It only earns its place with ≥2
|
package/css/32-split.css
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
:where(.wt-root.wt-split) {
|
|
2
2
|
--wt-split-gutter: 10px;
|
|
3
3
|
--wt-split-ratio: 0.5;
|
|
4
|
+
/* The left pane's width. Its 100% resolves where it is used, against the shell
|
|
5
|
+
root in both the grid track and the drop highlights. Rounded because Blink
|
|
6
|
+
scales the percentage in single precision and truncates a length to 1/64 px
|
|
7
|
+
(LayoutUnit, https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/platform/geometry/layout_unit.h):
|
|
8
|
+
the share of a 360 px pane on a 1280 px row drew 359.98 px, a column short. */
|
|
9
|
+
--wt-split-left: round(
|
|
10
|
+
nearest,
|
|
11
|
+
calc((100% - var(--wt-split-gutter)) * var(--wt-split-ratio)),
|
|
12
|
+
1px
|
|
13
|
+
);
|
|
14
|
+
/* Content's bottom edge: tab bar plus safe area, or switcher reserve plus
|
|
15
|
+
keyboard, whichever is lower. Shared by the divider and the drop halves. */
|
|
16
|
+
--wt-split-bottom: max(
|
|
17
|
+
calc(var(--wt-tabbar-h, 0px) + var(--safe-bottom, 0px)),
|
|
18
|
+
calc(var(--wt-reserve-bottom, 0px) + var(--kb-inset, 0px))
|
|
19
|
+
);
|
|
4
20
|
|
|
5
21
|
display: grid;
|
|
6
22
|
grid-template-columns: minmax(0, 1fr);
|
|
7
23
|
}
|
|
8
24
|
|
|
9
25
|
:where(.wt-root.wt-split.wt-split-open) {
|
|
10
|
-
grid-template-columns:
|
|
11
|
-
calc((100% - var(--wt-split-gutter)) * var(--wt-split-ratio)) var(--wt-split-gutter)
|
|
12
|
-
minmax(0, 1fr);
|
|
26
|
+
grid-template-columns: var(--wt-split-left) var(--wt-split-gutter) minmax(0, 1fr);
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
/* Two panes given the same column (the collapse) would otherwise be auto-placed
|
|
@@ -83,6 +97,14 @@
|
|
|
83
97
|
display: none;
|
|
84
98
|
}
|
|
85
99
|
|
|
100
|
+
/* The handle ends where the panes' content does: above the tab bar, or above
|
|
101
|
+
the switcher bar and the soft keyboard, the two values viewport.ts insets the
|
|
102
|
+
panes by. Its box is composited, and Blink promotes a bar overlapping it to a
|
|
103
|
+
layer of its own (kOverlap,
|
|
104
|
+
https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/platform/graphics/compositing_reasons.cc),
|
|
105
|
+
whose origin is off the device grid wherever the bar's height times the DPR
|
|
106
|
+
is fractional (61px at 1.5 dppx), so everything in the bar draws half a
|
|
107
|
+
device pixel off. */
|
|
86
108
|
:where(.wt-root.wt-split.wt-split-open) > .wt-split-handle {
|
|
87
109
|
display: block;
|
|
88
110
|
grid-column: 2;
|
|
@@ -91,28 +113,50 @@
|
|
|
91
113
|
justify-self: center;
|
|
92
114
|
inline-size: 24px;
|
|
93
115
|
margin-inline: -7px;
|
|
116
|
+
margin-block-end: var(--wt-split-bottom);
|
|
94
117
|
cursor: col-resize;
|
|
95
118
|
touch-action: none;
|
|
96
119
|
z-index: 4;
|
|
97
120
|
}
|
|
98
121
|
|
|
122
|
+
@media (pointer: coarse) {
|
|
123
|
+
:where(.wt-root.wt-split.wt-split-open) > .wt-split-handle {
|
|
124
|
+
inline-size: 44px;
|
|
125
|
+
margin-inline: -17px;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
99
129
|
:where(.wt-root.wt-split) .wt-split-handle::before {
|
|
100
130
|
content: "";
|
|
101
131
|
position: absolute;
|
|
102
|
-
inset-inline
|
|
103
|
-
inline
|
|
104
|
-
|
|
105
|
-
|
|
132
|
+
inset-inline: 0;
|
|
133
|
+
margin-inline: auto;
|
|
134
|
+
inline-size: 8px;
|
|
135
|
+
inset-block: 12px;
|
|
136
|
+
border-radius: 4px;
|
|
106
137
|
background: var(--tab-bg);
|
|
107
138
|
border-inline: 1px solid var(--border);
|
|
108
139
|
}
|
|
109
140
|
|
|
110
|
-
|
|
111
|
-
|
|
141
|
+
/* The pill's 3px is half the 8px bar less its 1px edge, and its 4px of travel
|
|
142
|
+
reaches the other inner edge: change the bar and these follow. */
|
|
143
|
+
:where(.wt-root.wt-split) .wt-split-handle::after {
|
|
144
|
+
content: "";
|
|
145
|
+
position: absolute;
|
|
146
|
+
inset-inline-start: calc(50% - 3px);
|
|
147
|
+
inline-size: 2px;
|
|
148
|
+
block-size: 48px;
|
|
149
|
+
inset-block-start: calc((100% - 48px) / 2);
|
|
150
|
+
border-radius: 1px;
|
|
151
|
+
background: var(--accent);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
:where(.wt-root.wt-split) .wt-split-handle[data-faces="right"]::after {
|
|
155
|
+
translate: 4px 0;
|
|
112
156
|
}
|
|
113
157
|
|
|
114
|
-
:where(.wt-root.wt-split) .wt-split-handle
|
|
115
|
-
|
|
158
|
+
:where(.wt-root.wt-split.wt-animate) .wt-split-handle::after {
|
|
159
|
+
transition: translate var(--dur-standard) var(--ease-standard);
|
|
116
160
|
}
|
|
117
161
|
|
|
118
162
|
:where(.wt-root.wt-split) .wt-split-handle.wt-handle-held::before {
|
|
@@ -152,25 +196,33 @@
|
|
|
152
196
|
pointer-events: none;
|
|
153
197
|
}
|
|
154
198
|
|
|
199
|
+
/* Confined to each pane's terminal box (--vv-top to --wt-split-bottom) so
|
|
200
|
+
neither half runs under the translucent strip. The --sp-2 inset is the
|
|
201
|
+
strip's own chip padding; the border reuses the active chip's tokens because
|
|
202
|
+
a half means "this pane will show the tab". */
|
|
155
203
|
:where(.wt-root.wt-split.wt-drop-left)::before,
|
|
156
204
|
:where(.wt-root.wt-split.wt-drop-right)::after {
|
|
157
205
|
content: "";
|
|
158
206
|
position: absolute;
|
|
159
|
-
|
|
160
|
-
|
|
207
|
+
box-sizing: border-box;
|
|
208
|
+
inset-block: calc(var(--vv-top, 0px) + var(--sp-2)) calc(var(--wt-split-bottom) + var(--sp-2));
|
|
161
209
|
background: color-mix(in oklch, var(--accent) 12%, transparent);
|
|
210
|
+
border: 1px solid var(--tab-active-border);
|
|
211
|
+
border-radius: var(--radius-inner);
|
|
162
212
|
pointer-events: none;
|
|
163
213
|
z-index: 5;
|
|
164
214
|
}
|
|
165
215
|
|
|
166
216
|
:where(.wt-root.wt-split.wt-drop-left)::before {
|
|
167
|
-
inset-inline-start:
|
|
217
|
+
inset-inline-start: var(--sp-2);
|
|
218
|
+
inline-size: calc(var(--wt-split-left) - 2 * var(--sp-2));
|
|
168
219
|
}
|
|
169
220
|
|
|
170
221
|
:where(.wt-root.wt-split.wt-drop-right)::after {
|
|
171
|
-
inset-inline
|
|
222
|
+
inset-inline: calc(var(--wt-split-left) + var(--wt-split-gutter) + var(--sp-2)) var(--sp-2);
|
|
172
223
|
}
|
|
173
224
|
|
|
225
|
+
:where(.wt-root.wt-split.wt-animate.wt-split-opening),
|
|
174
226
|
:where(.wt-root.wt-split.wt-animate.wt-split-closing) {
|
|
175
227
|
transition: grid-template-columns var(--dur-standard) var(--ease-standard);
|
|
176
228
|
}
|
package/css/MANIFEST.touch
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Component bundle for presetTouch() compositions (single + the mobile key
|
|
2
2
|
# toolbar). Library styles only — root-scoped, no page-level rules; the host
|
|
3
|
-
# page owns its own reset/fonts (
|
|
3
|
+
# page owns its own reset/fonts (marotte's embedded panel serves exactly this).
|
|
4
4
|
# Files concatenate in declared order; the order IS the cascade.
|
|
5
5
|
00-tokens.css
|
|
6
6
|
01-scope.css
|
package/css/page.css
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
owns when the terminal IS the page (web-terminal-server, web-terminal-kiro,
|
|
4
4
|
scaffold/index.html). The component bundle (00-10) never touches the host
|
|
5
5
|
document; everything here is deliberately page-scoped. An embedding consumer
|
|
6
|
-
(
|
|
6
|
+
(marotte) serves a per-preset manifest instead and owns its own page.
|
|
7
7
|
Concatenated FIRST by the reference MANIFEST. */
|
|
8
8
|
|
|
9
9
|
*,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.1.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": "MPL-2.0",
|
|
@@ -50,22 +50,22 @@
|
|
|
50
50
|
"@stryker-mutator/core": "10.0.0",
|
|
51
51
|
"@stryker-mutator/typescript-checker": "10.0.0",
|
|
52
52
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
53
|
-
"@types/node": "26.6.
|
|
53
|
+
"@types/node": "26.6.3",
|
|
54
54
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
55
|
-
"@vitest/browser": "5.0.
|
|
56
|
-
"@vitest/browser-playwright": "5.0.
|
|
57
|
-
"@vitest/coverage-v8": "5.0.
|
|
58
|
-
"eslint": "10.
|
|
59
|
-
"fast-check": "4.10.
|
|
60
|
-
"html-validate": "11.16.
|
|
61
|
-
"knip": "6.
|
|
55
|
+
"@vitest/browser": "5.0.2",
|
|
56
|
+
"@vitest/browser-playwright": "5.0.2",
|
|
57
|
+
"@vitest/coverage-v8": "5.0.2",
|
|
58
|
+
"eslint": "10.11.0",
|
|
59
|
+
"fast-check": "4.10.2",
|
|
60
|
+
"html-validate": "11.16.1",
|
|
61
|
+
"knip": "6.38.0",
|
|
62
62
|
"playwright": "1.63.0",
|
|
63
|
-
"prettier": "3.9.
|
|
63
|
+
"prettier": "3.9.9",
|
|
64
64
|
"stylelint": "17.15.0",
|
|
65
65
|
"stylelint-config-standard": "40.0.0",
|
|
66
66
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
67
|
-
"typescript-eslint": "8.
|
|
68
|
-
"vitest": "5.0.
|
|
67
|
+
"typescript-eslint": "8.71.0",
|
|
68
|
+
"vitest": "5.0.2"
|
|
69
69
|
},
|
|
70
70
|
"publishConfig": {
|
|
71
71
|
"access": "public"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// A box that starts between device pixels draws every edge of a filled shape
|
|
2
|
+
// as a partial pixel. Layout decides where the box starts, and CSS cannot round
|
|
3
|
+
// a position to the device grid, so the correction is measured and written.
|
|
4
|
+
|
|
5
|
+
import { windowOf } from "../../kernel/realm.js";
|
|
6
|
+
|
|
7
|
+
/** CSS px that move `v` onto the nearest whole device pixel. `v` is read to
|
|
8
|
+
* 1/1000 of a device pixel first: subtracting an applied offset leaves float
|
|
9
|
+
* residue, which would tip a half-pixel position either way. */
|
|
10
|
+
function toWholeDevicePx(v: number, dpr: number): number {
|
|
11
|
+
const device = Math.round(v * dpr * 1000) / 1000;
|
|
12
|
+
return (Math.round(device) - device) / dpr;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Keeps `el`'s box on whole device pixels, moved by at most half of one
|
|
16
|
+
* through its `translate`, which it owns from then on. Re-measured when `el`
|
|
17
|
+
* or an ancestor up to `root` changes size and when the device pixel ratio
|
|
18
|
+
* changes; a move that resizes nothing on that chain is not followed.
|
|
19
|
+
* @returns the release, which stops following and keeps the last offset. */
|
|
20
|
+
export function snapToDevicePixels(el: SVGElement, root: HTMLElement): () => void {
|
|
21
|
+
const win = windowOf(el.ownerDocument);
|
|
22
|
+
let applied = { x: 0, y: 0 };
|
|
23
|
+
const snap = (): void => {
|
|
24
|
+
const box = el.getBoundingClientRect();
|
|
25
|
+
const dpr = win.devicePixelRatio;
|
|
26
|
+
const x = toWholeDevicePx(box.left - applied.x, dpr);
|
|
27
|
+
const y = toWholeDevicePx(box.top - applied.y, dpr);
|
|
28
|
+
applied = { x, y };
|
|
29
|
+
el.style.translate = x === 0 && y === 0 ? "" : `${String(x)}px ${String(y)}px`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const resize = new win.ResizeObserver(snap);
|
|
33
|
+
for (let node: Element | null = el; node !== null; node = node.parentElement) {
|
|
34
|
+
resize.observe(node);
|
|
35
|
+
if (node === root) {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// A ratio change resizes nothing in CSS px, and each query matches one ratio.
|
|
40
|
+
let ratio: MediaQueryList | undefined;
|
|
41
|
+
const onRatio = (): void => {
|
|
42
|
+
watchRatio();
|
|
43
|
+
snap();
|
|
44
|
+
};
|
|
45
|
+
function watchRatio(): void {
|
|
46
|
+
ratio?.removeEventListener("change", onRatio);
|
|
47
|
+
ratio = win.matchMedia(`(resolution: ${String(win.devicePixelRatio)}dppx)`);
|
|
48
|
+
ratio.addEventListener("change", onRatio);
|
|
49
|
+
}
|
|
50
|
+
watchRatio();
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
resize.disconnect();
|
|
54
|
+
ratio?.removeEventListener("change", onRatio);
|
|
55
|
+
};
|
|
56
|
+
}
|