@cplieger/web-terminal-ui 8.1.0 → 8.1.2

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 CHANGED
@@ -164,7 +164,7 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
164
164
  background activity that outlives a turn (a workflow run, in the
165
165
  `web-terminal-kiro` case). It is a rounded-square ring rather than a disc, so
166
166
  it does not read as another status dot, and band width carries its three
167
- states: a 2px band with a breathing glow while a background task is running, a
167
+ states: a 2px band whose centre closes and reopens while a background task is running, a
168
168
  1px band with a halo while one is paused and resumable, and a 2px band with
169
169
  that same halo while one is blocked on the user. The state and the count are
170
170
  announced in the tab's accessible name and repeated as the mark's hover
@@ -488,15 +488,17 @@ Three ways in, all in the tab chrome, none a key chord:
488
488
  Shift+F10 on the focused tab, where the browser raises the menu event; Safari
489
489
  binds no key to it, so a keyboard-only person there reaches the menu through
490
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.
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.
495
497
  - **Dragging a tab** out of the row onto either pane of the terminal area,
496
498
  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.
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.
500
502
 
501
503
  A tab click follows one rule: an empty pane fills first, otherwise the selected
502
504
  pane's tab is replaced. A new tab ("+") also fills an empty pane first, but with
package/css/00-tokens.css CHANGED
@@ -178,7 +178,7 @@
178
178
  with nothing enforcing it.
179
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
- --dot-beat-dur: 1.2s;
181
+ --dot-beat-dur: 1.6s;
182
182
  --dur-standard: 0.2s;
183
183
  --dur-enter: 0.25s;
184
184
  --dur-exit: 0.15s;
package/css/30-tabs.css CHANGED
@@ -993,7 +993,8 @@
993
993
  three states the channel is BAND WIDTH, in a deliberate 2:1 ratio, because
994
994
  waiting and input share one ink.
995
995
 
996
- A literal 3px radius, not --radius-sm: the smallest token rung is 4px, which on
996
+ A 3px radius of its own (--wt-mark-radius, which the working state's clip reads
997
+ too), not --radius-sm: the smallest token rung is 4px, which on
997
998
  a 9px box reads as a circle and defeats the silhouette.
998
999
 
999
1000
  It costs ZERO layout while no state is set. The footprint cancel is a negative
@@ -1010,12 +1011,13 @@
1010
1011
  /* waiting and input both speak through this ink and separate by band alone;
1011
1012
  working overrides it. */
1012
1013
  --wt-mark-ink: var(--status-input);
1014
+ --wt-mark-radius: 3px;
1013
1015
 
1014
1016
  width: var(--wt-mark-size);
1015
1017
  height: var(--wt-mark-size);
1016
1018
  flex: none;
1017
1019
  position: relative;
1018
- border-radius: 3px;
1020
+ border-radius: var(--wt-mark-radius);
1019
1021
  /* Zero-width at rest, so the ring is drawn entirely by a state's band. The
1020
1022
  colour rides --wt-mark-ink here rather than in each state, so a state sets
1021
1023
  one variable and one width. */
@@ -1051,86 +1053,64 @@
1051
1053
  opacity var(--dur-enter) var(--ease-enter) var(--dur-standard);
1052
1054
  }
1053
1055
 
1054
- /* Working: the heavier bare band, plus a breathing glow. Its own compositing
1055
- layer for the reason the status dot's rule records — a nested overlay layer
1056
- snaps to device pixels independently of a fractional parent position, which at
1057
- this size reads as an off-centre pulse. */
1056
+ /* Working: the heavier bare band, plus a hole that closes on the beat. Its own
1057
+ compositing layer so the overlay layers snap against the ring rather than the
1058
+ page: their offset from it is whole pixels (the rounded inset below), so the
1059
+ closing circle stays centred however the chip centres a 9px mark (17.5px down). */
1058
1060
  :where(.wt-root) .wt-activity-mark[data-activity="working"] {
1059
1061
  --wt-mark-ink: var(--status-working);
1060
- /* The band, named because the MASK below has to know it: the overlay's hole is
1061
- the mark's inradius less this. Paired with the declaration it describes so the
1062
- two cannot drift apart on width. */
1062
+ /* The band, named because the closing overlay below derives the hole from it. */
1063
1063
  --wt-mark-band: 2px;
1064
1064
 
1065
1065
  border-width: var(--wt-mark-band);
1066
1066
  transform: translateZ(0);
1067
+ /* The overlays' clip. `overflow` clips at the padding box, which under border-box
1068
+ sizing IS the hole; where supported the clip is the mark's own outline instead,
1069
+ because the hole's rounded inner corners as a clip edge leave an anti-aliased
1070
+ seam against the band's own (measured at dpr 2: four pixels 15% dark at the
1071
+ peak). Over the band the ink paints the band's own colour. */
1072
+ overflow: hidden;
1067
1073
  }
1068
1074
 
1069
- /* The breath: a pre-painted radial animating OPACITY only, never colours, so the
1070
- beat costs compositing rather than a repaint per frame for as long as a
1071
- background task runs (the battery reason the dot's glow overlay records).
1072
-
1073
- PLUS A MASK, which is the one thing a ring needs and a disc does not, and whose
1074
- absence was a real defect rather than a missing nicety. Over a solid disc the
1075
- overlay's bright core has almost no headroom above its own backdrop; over a
1076
- ring's transparent HOLE it has all of it, so an unmasked core FILLED the hole at
1077
- the peak and the mark rendered as a solid rounded square. That is the one thing
1078
- this element may never be: the mark and the activity dot 8px away share their ink
1079
- at zero hue distance, so the ring-against-disc silhouette is the entire WCAG
1080
- 1.4.1 channel between them, and a fill collapses it for the brightest third of
1081
- every cycle. Masked, the beat lights the BAND and the hole's four corners, so
1082
- what a reader sees is the hole morphing between a square and the circle inscribed
1083
- in it — the ring survives the peak.
1084
-
1085
- EVERY NUMBER HERE IS A RELATION, and the port that copied marotte's LITERALS is
1086
- what left this rule running with nothing visible to show. Two of them were
1087
- arithmetic for an 8px mark with a 2px band: `inset: -1px` put the overlay on that
1088
- mark's own border box, and the core stop `70%` is where the core reaches the
1089
- hole's CORNER there — sqrt(2) * (size / 2 - band) / (size / 2) = 70.71%. At 9px
1090
- and 2px the same relation is 78.57%, so the copied 70% ended the solid core at
1091
- 2.45px while the mask opens only at 2.5px: core and visible mask did not
1092
- intersect at all, the beat lit nothing the mask kept, and 4 pixels of a 9x9 mark
1093
- moved between trough and peak against marotte's 20 (measured at dpr 1, both
1094
- marks, sRGB). Written as relations they survive any size: the inset IS the band,
1095
- the core stop IS the hole's corner radius, the mask's hole IS its inradius. So
1096
- port marotte's `.tab-run-dot` (`12-tabs.css`, "THE GLOW BEAT") — the same mark
1097
- solving the same problem, with the mask from the start — by its relations, never
1098
- by its numbers. sqrt(2) is spelled to four decimals because the synced stylelint
1099
- sets `number-max-precision: 4` outside colour functions.
1100
-
1101
- NOTHING HERE EXTENDS PAST THE MARK. `box-sizing: border-box` (01-scope.css) puts
1102
- the padding box — an absolutely positioned child's containing block — INSIDE the
1103
- band, so `inset: calc(-1 * var(--wt-mark-band))` grows the overlay back out to
1104
- exactly the mark's own border box and no further. Read it as "fill the mark", not
1105
- as "overhang it": a larger inset would reach outside, where a feathered edge's
1106
- visible radius moves with the beat and renders differently at every pixel
1107
- density. `border-radius: 50%` is the same discipline on the last literal, and it
1108
- is inert BY CONSTRUCTION rather than by measurement, which is why no assertion
1109
- pins it: for side S and radius r the clip's 45° boundary is (S/2 - r) * sqrt(2) + r,
1110
- monotonically decreasing in r, so the inscribed circle is the WORST case for
1111
- reaching the hole's corners — and it still clears them for any band at or above
1112
- S/2 * (1 - 1/sqrt(2)), 1.32px here against 2px. Do not assert it, and do not drop
1113
- it either: it is what keeps a literal radius out of a rule whose whole defect was
1114
- literals. */
1075
+ @supports (clip-path: inset(0 round 1px)) {
1076
+ :where(.wt-root) .wt-activity-mark[data-activity="working"] {
1077
+ overflow: visible;
1078
+ clip-path: inset(0 round var(--wt-mark-radius));
1079
+ }
1080
+ }
1081
+
1082
+ /* The close: the square hole fills from its corners, passes through its inscribed
1083
+ circle and shrinks to nothing, then reopens. Compositor-only: ::before is ink
1084
+ around a transparent circle of the hole's corner radius sqrt(2) * h, scaled down
1085
+ to --wt-mark-close and clipped by the parent; half-size (h + 1px) /
1086
+ --wt-mark-close keeps its edge under the band at the peak (a 71px layer, ~20 KB
1087
+ at dpr 1). Scaling leaves a 0.35px pinhole that reads as a dark pixel, so ::after
1088
+ fades a fill over the hole as the scale bottoms out. Every length is a relation to
1089
+ --wt-mark-size and --wt-mark-band (sqrt(2) to the stylelint's 4 decimals). */
1115
1090
  :where(.wt-root) .wt-activity-mark[data-activity="working"]::before {
1091
+ --wt-mark-hole: calc(var(--wt-mark-size) / 2 - var(--wt-mark-band));
1092
+ --wt-mark-close: 0.1;
1093
+
1116
1094
  content: "";
1117
1095
  position: absolute;
1118
- inset: calc(-1 * var(--wt-mark-band));
1119
- border-radius: 50%;
1096
+ inset: round(down, var(--wt-mark-hole) - (var(--wt-mark-hole) + 1px) / var(--wt-mark-close), 1px);
1120
1097
  background: radial-gradient(
1121
- closest-side,
1122
- color-mix(in oklch, var(--wt-mark-ink) 78%, #fff) 0
1123
- calc(1.4142 * (var(--wt-mark-size) / 2 - var(--wt-mark-band))),
1124
- transparent 92%
1098
+ circle,
1099
+ transparent 0 calc(1.4142 * var(--wt-mark-hole)),
1100
+ var(--wt-mark-ink) calc(1.4142 * var(--wt-mark-hole) + 0.5px)
1125
1101
  );
1126
- opacity: 0;
1127
1102
  animation: wt-activity-breath var(--dot-beat-dur) ease-in-out infinite;
1103
+ will-change: transform;
1104
+ }
1105
+
1106
+ :where(.wt-root) .wt-activity-mark[data-activity="working"]::after {
1107
+ content: "";
1108
+ position: absolute;
1109
+ inset: -1px;
1110
+ background: var(--wt-mark-ink);
1111
+ opacity: 0;
1112
+ animation: wt-activity-seal var(--dot-beat-dur) ease-in-out infinite;
1128
1113
  will-change: opacity;
1129
- mask: radial-gradient(
1130
- closest-side,
1131
- transparent 0 calc(var(--wt-mark-size) / 2 - var(--wt-mark-band)),
1132
- #000 calc(var(--wt-mark-size) / 2 - var(--wt-mark-band))
1133
- );
1134
1114
  }
1135
1115
 
1136
1116
  /* Waiting: the thin band plus a static halo. Stopped and resumable, nobody being
@@ -1205,25 +1185,30 @@
1205
1185
  }
1206
1186
  }
1207
1187
 
1208
- /* The activity mark's breath: rest, one swell, rest. Opacity only — a colour
1209
- keyframe here would reintroduce the per-frame repaint this shape exists to avoid.
1210
-
1211
- The dot's glow ENVELOPE, at a lower CEILING, and the ceiling is where the two
1212
- deliberately part. 0.55 rather than 1 is marotte's number for the same mark
1213
- (`12-tabs.css`), and it is what keeps a strip of busy tabs reading as one pulse
1214
- instead of a row of flashes — the dot can afford 1 because its overlay lifts a
1215
- solid disc of its own hue and has almost no headroom above its own backdrop,
1216
- while this one is masked onto a 2px band over the page and has all of it. Used by
1217
- the mark alone, so the value moves nothing else. */
1188
+ /* The activity mark's close: open, shut, open, on the dot glow's envelope so the
1189
+ hole shuts as the disc beside it peaks. Transform only, so it never repaints. */
1218
1190
  @keyframes wt-activity-breath {
1219
1191
  0%,
1220
1192
  70%,
1193
+ 100% {
1194
+ transform: scale(1);
1195
+ }
1196
+
1197
+ 35% {
1198
+ transform: scale(var(--wt-mark-close));
1199
+ }
1200
+ }
1201
+
1202
+ @keyframes wt-activity-seal {
1203
+ 0%,
1204
+ 20%,
1205
+ 50%,
1221
1206
  100% {
1222
1207
  opacity: 0;
1223
1208
  }
1224
1209
 
1225
1210
  35% {
1226
- opacity: 0.55;
1211
+ opacity: 1;
1227
1212
  }
1228
1213
  }
1229
1214
 
@@ -1270,13 +1255,14 @@
1270
1255
  by band alone (3px bare / 1px halo / 2px halo).
1271
1256
 
1272
1257
  Re-pointing --wt-mark-band rather than declaring `border-width` keeps the number
1273
- in the one place the state's own rule reads it from, the mask's divisor included
1258
+ in the one place the state's own rule reads it from, the overlay's hole included
1274
1259
  — which is moot here only because the overlay is gone one rule below. */
1275
1260
  :where(.wt-root) .wt-activity-mark[data-activity="working"] {
1276
1261
  --wt-mark-band: 3px;
1277
1262
  }
1278
1263
 
1279
- :where(.wt-root) .wt-activity-mark[data-activity="working"]::before {
1264
+ :where(.wt-root) .wt-activity-mark[data-activity="working"]::before,
1265
+ :where(.wt-root) .wt-activity-mark[data-activity="working"]::after {
1280
1266
  content: none;
1281
1267
  }
1282
1268
  }
package/css/32-split.css CHANGED
@@ -11,6 +11,12 @@
11
11
  calc((100% - var(--wt-split-gutter)) * var(--wt-split-ratio)),
12
12
  1px
13
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
+ );
14
20
 
15
21
  display: grid;
16
22
  grid-template-columns: minmax(0, 1fr);
@@ -107,10 +113,7 @@
107
113
  justify-self: center;
108
114
  inline-size: 24px;
109
115
  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
- );
116
+ margin-block-end: var(--wt-split-bottom);
114
117
  cursor: col-resize;
115
118
  touch-action: none;
116
119
  z-index: 4;
@@ -193,23 +196,30 @@
193
196
  pointer-events: none;
194
197
  }
195
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". */
196
203
  :where(.wt-root.wt-split.wt-drop-left)::before,
197
204
  :where(.wt-root.wt-split.wt-drop-right)::after {
198
205
  content: "";
199
206
  position: absolute;
200
- inset-block: 0 var(--wt-tabbar-h, 0);
207
+ box-sizing: border-box;
208
+ inset-block: calc(var(--vv-top, 0px) + var(--sp-2)) calc(var(--wt-split-bottom) + var(--sp-2));
201
209
  background: color-mix(in oklch, var(--accent) 12%, transparent);
210
+ border: 1px solid var(--tab-active-border);
211
+ border-radius: var(--radius-inner);
202
212
  pointer-events: none;
203
213
  z-index: 5;
204
214
  }
205
215
 
206
216
  :where(.wt-root.wt-split.wt-drop-left)::before {
207
- inset-inline-start: 0;
208
- inline-size: var(--wt-split-left);
217
+ inset-inline-start: var(--sp-2);
218
+ inline-size: calc(var(--wt-split-left) - 2 * var(--sp-2));
209
219
  }
210
220
 
211
221
  :where(.wt-root.wt-split.wt-drop-right)::after {
212
- inset-inline: calc(var(--wt-split-left) + var(--wt-split-gutter)) 0;
222
+ inset-inline: calc(var(--wt-split-left) + var(--wt-split-gutter) + var(--sp-2)) var(--sp-2);
213
223
  }
214
224
 
215
225
  :where(.wt-root.wt-split.wt-animate.wt-split-opening),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "8.1.0",
3
+ "version": "8.1.2",
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",
@@ -106,10 +106,12 @@ export interface TabsApi {
106
106
  * pane already shows has that pane selected instead. */
107
107
  switchTo(id: string): void;
108
108
  /** Show a tab on one side of the split, opening it when closed, and select its
109
- * pane. A tab the other pane shows trades places with the tab on that side
110
- * (or leaves its pane empty when the side showed none); any other tab there
111
- * becomes ordinary. A tab already shown on that side changes nothing. False
112
- * when refused: an unknown id, an invalid side, or a split the pane row is too
109
+ * pane. Opening the split keeps the single view's tab on the other side — or,
110
+ * when the moved tab IS it, its split partner — so neither pane opens empty. A
111
+ * tab the other pane shows trades places with the tab on that side (or leaves
112
+ * its pane empty when the side showed none); any other tab there becomes
113
+ * ordinary. A tab already shown on that side changes nothing. False when
114
+ * refused: an unknown id, an invalid side, or a split the pane row is too
113
115
  * narrow to open. */
114
116
  snap(id: string, side: PaneSide): boolean;
115
117
  /** The current tabs, first-to-last by creation; `active` marks a tab a pane
@@ -1678,17 +1680,27 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1678
1680
  return true;
1679
1681
  }
1680
1682
  const split = ctx.shell.split;
1681
- if (!split.isOpen() && (!split.canOpen() || !split.open())) {
1683
+ const opening = !split.isOpen();
1684
+ if (opening && (!split.canOpen() || !split.open())) {
1682
1685
  return false;
1683
1686
  }
1684
1687
  // Emptying the tab's old pane drops a keyboard its input held to the body.
1685
1688
  const typing = keyboardInPaneInput();
1686
- const displaced = from === null ? null : shownIn(side);
1689
+ const displaced = shownIn(side);
1687
1690
  if (!showIn(side, id)) {
1688
1691
  return false;
1689
1692
  }
1690
- if (from !== null && displaced !== null) {
1691
- showIn(from, displaced, { keepSelection: true });
1693
+ // Opening pairs the moved tab with the single view's tab, or its partner
1694
+ // when they are one, so no pane opens empty; an open split only re-homes
1695
+ // the displaced tab, into a pane the move emptied.
1696
+ const back = displaced ?? (opening ? splitPartner(id) : null);
1697
+ if (
1698
+ (from !== null || opening) &&
1699
+ back !== null &&
1700
+ back !== id &&
1701
+ shownIn(otherSide(side)) === null
1702
+ ) {
1703
+ showIn(otherSide(side), back, { keepSelection: true });
1692
1704
  }
1693
1705
  if (typing) {
1694
1706
  focusInput();
@@ -1702,20 +1714,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1702
1714
  split.close();
1703
1715
  return;
1704
1716
  }
1705
- const partner = splitPartner();
1717
+ const partner = splitPartner(selectedId());
1706
1718
  if (partner === null) {
1707
1719
  void create(true);
1708
1720
  } else if (split.open()) {
1709
1721
  showIn("right", partner, { keepSelection: true });
1710
1722
  }
1711
1723
  }
1712
- function splitPartner(): string | null {
1713
- const current = selectedId();
1714
- const used = recent.find((id) => id !== current && tabList.some((t) => t.id === id));
1724
+ function splitPartner(of: string | null): string | null {
1725
+ const used = recent.find((id) => id !== of && tabList.some((t) => t.id === id));
1715
1726
  if (used !== undefined) {
1716
1727
  return used;
1717
1728
  }
1718
- const at = tabList.findIndex((t) => t.id === current);
1729
+ const at = tabList.findIndex((t) => t.id === of);
1719
1730
  return (tabList[at + 1] ?? tabList[at - 1])?.id ?? null;
1720
1731
  }
1721
1732