@cplieger/web-terminal-ui 8.0.0 → 8.1.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/NOTICE ADDED
@@ -0,0 +1,3 @@
1
+ web-terminal-ui
2
+ Copyright 2026 cplieger
3
+ https://github.com/cplieger/web-terminal-ui
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, vibekit's shell
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,55 @@ 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. Opening, closing and resizing the split
470
- is a display re-arrangement only: no tab or session is ever created or closed by
471
- it, and a tab that leaves a pane stays in the row as an ordinary tab.
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. Opening puts the shown tab in
477
- the left pane with the right pane empty; the next tab clicked, or the next "+",
478
- fills the empty pane. Closing keeps the SELECTED pane's tab and hides the
479
- other pane. It is an ordinary button: Tab reaches it, Enter or Space toggles
480
- it, `aria-expanded` is its state.
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
- whatever was there becomes an ordinary tab, and the tab's old side is left
487
- empty.
488
- - **Dragging a tab** out of the row onto the left or right half of the terminal
489
- area, which highlights the half under the pointer while the drag lasts.
490
+ an assistive technology's own command). A snapped tab shows on the named side
491
+ and its pane is selected. A tab the other pane shows trades places with the
492
+ tab on the named side, so both stay on screen (into an empty pane it simply
493
+ moves); any other tab there becomes an ordinary tab. Snapping a tab onto the
494
+ side that already shows it changes nothing.
495
+ - **Dragging a tab** out of the row onto either pane of the terminal area,
496
+ following the same rule. While the drag lasts the pane under the pointer is
497
+ highlighted; the two highlights match the panes' widths at the divider's
498
+ position (the two halves while the split is closed), and a release decides
499
+ by the divider's centre.
490
500
 
491
501
  A tab click follows one rule: an empty pane fills first, otherwise the selected
492
- pane's tab is replaced. The selected pane is the one that receives typing, the
493
- last one clicked, touched or typed in. The grip handle between the panes shows
494
- it: a 6 px pill in the 10 px gutter whose accent edge faces the selected pane,
495
- and the selected pane keeps the filled blinking cursor while the other pane's is
496
- hollow and steady. The tab row does not change for selection; both shown tabs
497
- render identically active. A screen reader hears "Left terminal selected" or
498
- "Right terminal selected" as a polite announcement, and each pane's tabpanel is
499
- described by its side and whether it is selected.
502
+ pane's tab is replaced. A new tab ("+") also fills an empty pane first, but with
503
+ both panes showing a tab it replaces the UNSELECTED pane's tab and its pane
504
+ becomes the selected one, so the tab typed in before stays on screen. A session
505
+ created in another browser lands the same way while the split is open; with the
506
+ split closed it joins the row as an ordinary tab. Closing a
507
+ shown tab closes the split: the other pane's tab fills the view and is selected,
508
+ or, when the other pane was empty, the neighbouring tab is shown as it is with
509
+ one pane. The selected pane is the one that receives typing, the
510
+ last one clicked, touched or typed in. The divider between the panes shows it:
511
+ an 8 px bar in the 10 px gutter holding a short accent pill that sits against
512
+ the selected pane's side and slides across when the selection changes (it jumps
513
+ under reduced motion). The selected pane keeps the filled blinking cursor while
514
+ the other pane's is hollow and steady, and in the tab row both shown tabs render
515
+ active, the selected pane's a step brighter. A screen reader hears "Left
516
+ terminal selected" or "Right terminal selected" as a polite announcement, and
517
+ each pane's tabpanel is described by its side and whether it is selected.
500
518
 
501
519
  While the split is open each shown pane's input is a stop in the Tab order, so
502
520
  Tab runs left pane, divider, right pane, then the tab row; an empty pane adds no
@@ -505,10 +523,14 @@ from outside the terminal lands on the tab row.
505
523
 
506
524
  The handle is a focusable `role="separator"` with `aria-valuenow`, `aria-valuemin`
507
525
  and `aria-valuemax` as percentages of the left share. Dragging it (mouse or
508
- touch, a 24 px hit area) resizes both panes as the pointer moves; the shells are
509
- told their new size at most every 100 ms and once on release. A pane squeezed
510
- under 360 px dims, and releasing there closes that pane; releasing above 360 px
511
- after such a dip snaps the handle back to where the drag started. From the
526
+ touch; the hit area is 24 px wide for a fine pointer and 44 px for a coarse one,
527
+ overlapping the pane edges) resizes both panes as the pointer moves; the shells
528
+ are told their new size at most every 100 ms and once on release. Past the
529
+ 360 px minimum the pane holds at exactly 360 px for 120 px of further pointer
530
+ travel, and a release there leaves it at 360 px. Beyond that the pane follows
531
+ the pointer again and dims, and releasing there closes it; coming back above
532
+ 360 px after such a trip and releasing snaps the handle back to where the drag
533
+ started. From the
512
534
  keyboard ArrowLeft and ArrowRight nudge the divider by 16 px and Home and End take
513
535
  it to the bounds, and every key stops AT the 360 px minimum rather than closing a
514
536
  pane; the record is written when the key is released. The default is 50/50.
@@ -604,7 +626,7 @@ The web-terminal family:
604
626
 
605
627
  Consumers that ship this UI:
606
628
 
607
- - [`vibekit`](https://github.com/cplieger/vibekit)
629
+ - [`marotte`](https://github.com/cplieger/marotte)
608
630
  - [`web-terminal-kiro`](https://github.com/cplieger/web-terminal-kiro)
609
631
 
610
632
  ## Contributing
@@ -627,3 +649,5 @@ Apache-2.0 work, and its licence travels with the FONT FILE — which this packa
627
649
  does not ship, it only names the URL. So no new obligation lands on an npm or JSR
628
650
  consumer; the obligation is the serving host's, which is why the full-page host
629
651
  notes above ask for the font's `LICENSE` and `NOTICE` beside the `.woff2`.
652
+
653
+ Third-party attributions are in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
@@ -0,0 +1,7 @@
1
+ # Third-party notices
2
+
3
+ No third-party code is included in this package. Three designs are followed, and each is named at the line of ours that follows it:
4
+
5
+ - The IME composition module mirrors [xterm.js](https://github.com/xtermjs/xterm.js)'s `CompositionHelper` (MIT): the `\u200E` marker pair wrapped around the in-progress text (`src/composition.ts:197`) and the deferred finaliser that reads the textarea one tick after `compositionend` instead of trusting the event's `data` property (`src/composition.ts:211`).
6
+ - The mobile switcher's flick-to-commit thresholds take [@use-gesture](https://github.com/pmndrs/use-gesture)'s drag defaults (MIT): `SWIPE_VELOCITY` and `SWIPE_DURATION` (`src/features/tabs/switcher.ts:20`, `:21`) carry its `DEFAULT_SWIPE_VELOCITY` and `DEFAULT_SWIPE_DURATION`, and `VELOCITY_STALE_MS` (`src/features/tabs/switcher.ts:25`) answers the stale release velocity reported in its issue 332.
7
+ - The composition staleness bound `COMPOSITION_IDLE_MS` (`src/composition.ts:71`) takes the five-second idle value from [Slate](https://github.com/ianstormtaylor/slate)'s `android-input-manager` (MIT), where it bounds a composition that has gone quiet.
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 vibekit's `--dot-beat-dur` (01-tokens.css) for the reason the
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 vibekit/subflux treatment. scrollbar-color
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. */
@@ -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 vibekit/subflux convention: 2px accent, 2px out).
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
- /* Desktop + tabbed: the tab strip docks at the bottom, so lift the button above
64
- it with a clear gap. */
65
- @media (pointer: fine) {
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
- /* Pull the terminal surface up off the docked BOTTOM tab strip so it does not
628
- overlap the last rows (a mirror of the old top offset). The tabs feature
629
- marks the surface (.wt-with-tabbar) and keeps the measured strip height in
630
- --wt-tabbar-h; the surface is absolute inset:0, so a bottom offset clears it.
631
- Applies whenever the desktop strip is shown — a fine pointer OR a root that
632
- is not narrow (an iPad) — the complement of the narrow-coarse switcher gate
633
- in css/31-switcher.css. On the narrow-coarse phone the strip is hidden and
634
- the mobile switcher's inset is applied through viewport.ts
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 vibekit's LITERALS is
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 vibekit's 20 (measured at dpr 1, both
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 vibekit's `.tab-run-dot` (`12-tabs.css`, "THE GLOW BEAT") — the same mark
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 vibekit's number for the same mark
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,
@@ -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,23 @@
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
+ );
4
14
 
5
15
  display: grid;
6
16
  grid-template-columns: minmax(0, 1fr);
7
17
  }
8
18
 
9
19
  :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);
20
+ grid-template-columns: var(--wt-split-left) var(--wt-split-gutter) minmax(0, 1fr);
13
21
  }
14
22
 
15
23
  /* Two panes given the same column (the collapse) would otherwise be auto-placed
@@ -83,6 +91,14 @@
83
91
  display: none;
84
92
  }
85
93
 
94
+ /* The handle ends where the panes' content does: above the tab bar, or above
95
+ the switcher bar and the soft keyboard, the two values viewport.ts insets the
96
+ panes by. Its box is composited, and Blink promotes a bar overlapping it to a
97
+ layer of its own (kOverlap,
98
+ https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/platform/graphics/compositing_reasons.cc),
99
+ whose origin is off the device grid wherever the bar's height times the DPR
100
+ is fractional (61px at 1.5 dppx), so everything in the bar draws half a
101
+ device pixel off. */
86
102
  :where(.wt-root.wt-split.wt-split-open) > .wt-split-handle {
87
103
  display: block;
88
104
  grid-column: 2;
@@ -91,28 +107,53 @@
91
107
  justify-self: center;
92
108
  inline-size: 24px;
93
109
  margin-inline: -7px;
110
+ margin-block-end: max(
111
+ calc(var(--wt-tabbar-h, 0px) + var(--safe-bottom, 0px)),
112
+ calc(var(--wt-reserve-bottom, 0px) + var(--kb-inset, 0px))
113
+ );
94
114
  cursor: col-resize;
95
115
  touch-action: none;
96
116
  z-index: 4;
97
117
  }
98
118
 
119
+ @media (pointer: coarse) {
120
+ :where(.wt-root.wt-split.wt-split-open) > .wt-split-handle {
121
+ inline-size: 44px;
122
+ margin-inline: -17px;
123
+ }
124
+ }
125
+
99
126
  :where(.wt-root.wt-split) .wt-split-handle::before {
100
127
  content: "";
101
128
  position: absolute;
102
- inset-inline-start: 9px;
103
- inline-size: 6px;
104
- inset-block: 12px calc(var(--wt-tabbar-h, 0px) + 12px);
105
- border-radius: 3px;
129
+ inset-inline: 0;
130
+ margin-inline: auto;
131
+ inline-size: 8px;
132
+ inset-block: 12px;
133
+ border-radius: 4px;
106
134
  background: var(--tab-bg);
107
135
  border-inline: 1px solid var(--border);
108
136
  }
109
137
 
110
- :where(.wt-root.wt-split) .wt-split-handle[data-faces="left"]::before {
111
- border-inline-start: 2px solid var(--accent);
138
+ /* The pill's 3px is half the 8px bar less its 1px edge, and its 4px of travel
139
+ reaches the other inner edge: change the bar and these follow. */
140
+ :where(.wt-root.wt-split) .wt-split-handle::after {
141
+ content: "";
142
+ position: absolute;
143
+ inset-inline-start: calc(50% - 3px);
144
+ inline-size: 2px;
145
+ block-size: 48px;
146
+ inset-block-start: calc((100% - 48px) / 2);
147
+ border-radius: 1px;
148
+ background: var(--accent);
149
+ }
150
+
151
+ :where(.wt-root.wt-split) .wt-split-handle[data-faces="right"]::after {
152
+ translate: 4px 0;
112
153
  }
113
154
 
114
- :where(.wt-root.wt-split) .wt-split-handle[data-faces="right"]::before {
115
- border-inline-end: 2px solid var(--accent);
155
+ :where(.wt-root.wt-split.wt-animate) .wt-split-handle::after {
156
+ transition: translate var(--dur-standard) var(--ease-standard);
116
157
  }
117
158
 
118
159
  :where(.wt-root.wt-split) .wt-split-handle.wt-handle-held::before {
@@ -157,7 +198,6 @@
157
198
  content: "";
158
199
  position: absolute;
159
200
  inset-block: 0 var(--wt-tabbar-h, 0);
160
- inline-size: 50%;
161
201
  background: color-mix(in oklch, var(--accent) 12%, transparent);
162
202
  pointer-events: none;
163
203
  z-index: 5;
@@ -165,12 +205,14 @@
165
205
 
166
206
  :where(.wt-root.wt-split.wt-drop-left)::before {
167
207
  inset-inline-start: 0;
208
+ inline-size: var(--wt-split-left);
168
209
  }
169
210
 
170
211
  :where(.wt-root.wt-split.wt-drop-right)::after {
171
- inset-inline-start: 50%;
212
+ inset-inline: calc(var(--wt-split-left) + var(--wt-split-gutter)) 0;
172
213
  }
173
214
 
215
+ :where(.wt-root.wt-split.wt-animate.wt-split-opening),
174
216
  :where(.wt-root.wt-split.wt-animate.wt-split-closing) {
175
217
  transition: grid-template-columns var(--dur-standard) var(--ease-standard);
176
218
  }
@@ -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 (vibekit's embedded panel serves exactly this).
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
- (vibekit) serves a per-preset manifest instead and owns its own page.
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.0.0",
3
+ "version": "8.1.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": "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.1",
53
+ "@types/node": "26.6.3",
54
54
  "@typescript/native": "npm:typescript@7.0.2",
55
- "@vitest/browser": "5.0.1",
56
- "@vitest/browser-playwright": "5.0.1",
57
- "@vitest/coverage-v8": "5.0.1",
58
- "eslint": "10.10.0",
59
- "fast-check": "4.10.1",
60
- "html-validate": "11.16.0",
61
- "knip": "6.36.0",
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.7",
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.70.0",
68
- "vitest": "5.0.1"
67
+ "typescript-eslint": "8.71.0",
68
+ "vitest": "5.0.2"
69
69
  },
70
70
  "publishConfig": {
71
71
  "access": "public"
@@ -95,6 +95,8 @@
95
95
  "css/",
96
96
  "scaffold/",
97
97
  "LICENSE",
98
+ "NOTICE",
99
+ "THIRD_PARTY_NOTICES.md",
98
100
  "README.md",
99
101
  "!src/**/*.test.ts",
100
102
  "!src/**/*-setup.ts",
@@ -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
+ }