@cplieger/web-terminal-ui 4.2.0 → 4.3.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 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`. The library ships neutral defaults. |
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
@@ -13,10 +13,10 @@
13
13
 
14
14
  :where(.wt-root) .wt-tab-bar {
15
15
  /* Absolute against .wt-root, like all chrome (identical geometry in
16
- wt-viewport mode; see 01-scope.css). The bar itself never scrolls: the
17
- tabs + "+" scroll inside .wt-tab-scroll (below), so the keyboard button —
18
- the bar's last flex item, outside the scroller — stays pinned at the right
19
- edge however many tabs are open. */
16
+ wt-viewport mode; see 01-scope.css). The bar itself never scrolls: only
17
+ the tabs scroll, inside .wt-tab-scroll (below); the "+" and the keyboard
18
+ button are fixed bar items after it, so both stay visible at the bar's
19
+ right edge however many tabs are open. */
20
20
  position: absolute;
21
21
  bottom: var(--safe-bottom);
22
22
  left: 0;
@@ -32,17 +32,22 @@
32
32
  z-index: 15;
33
33
  }
34
34
 
35
- /* The scrolling tab list inside the bar: the tabs plus the "+", which floats
36
- right of the last tab and scrolls with the list. Horizontal scrolling lives
37
- HERE, not on the bar, so the strip-end keyboard button never scrolls away.
38
- flex: 1 + min-width: 0 make it shrink to leave room for the keyboard button
39
- when that is shown (a wide touchscreen) and stretch to the full bar width
40
- when it is hidden (a fine-pointer desktop), so the tabs reclaim the space. */
35
+ /* The scrolling tab list inside the bar: the tabs and nothing else — the "+"
36
+ and the keyboard button are fixed bar siblings after it. Horizontal
37
+ scrolling lives HERE, not on the bar, so neither control ever scrolls away.
38
+ flex: 0 1 auto + min-width: 0 shrink-WRAP the list: while the tabs fit, the
39
+ scroller is exactly as wide as its content and the "+" trails the last tab
40
+ across the bar gap (visually identical to living in the list); once the
41
+ tabs overflow, the scroller caps at the space the fixed controls leave and
42
+ the "+" reads as pinned at the bar's right edge (with the keyboard button,
43
+ when shown on a wide touchscreen, anchored to its right). The tabs feature
44
+ also maps a vertical wheel over the bar to scrollLeft here — the strip has
45
+ no vertical scroll to spend it on. */
41
46
  :where(.wt-root) .wt-tab-scroll {
42
47
  display: flex;
43
48
  align-items: center;
44
49
  gap: var(--sp-1);
45
- flex: 1 1 auto;
50
+ flex: 0 1 auto;
46
51
  min-width: 0;
47
52
  overflow-x: auto;
48
53
  /* A wheel/trackpad/touch scroll that runs out of tabs must not chain into
@@ -164,9 +169,12 @@
164
169
  }
165
170
  }
166
171
 
167
- /* The add-tab (+) button: its hover box matches an active tab's fill and
168
- stretches to the strip height; the 2px strip gap (not a custom margin) spaces
169
- it from the last tab. Its glyph is an inline SVG. */
172
+ /* The add-tab (+) button: a fixed bar item OUTSIDE the tab scroller (it never
173
+ scrolls away with an overflowing list; the shrink-wrapped scroller keeps it
174
+ visually trailing the last tab while the tabs fit — see .wt-tab-scroll). Its
175
+ hover box matches an active tab's fill and stretches to the strip height;
176
+ the bar gap (not a custom margin) spaces it from the list. Its glyph is an
177
+ inline SVG. */
170
178
  :where(.wt-root) .wt-tab-new {
171
179
  display: inline-flex;
172
180
  align-items: center;
@@ -226,8 +234,10 @@
226
234
  tab scroller: pinned at the bar's right edge in the same column as the
227
235
  scroll-to-bottom button floating above it (both 44px boxes whose right edge
228
236
  sits --sp-2 from the root's right edge), where an overflowing tab list can
229
- never push or scroll it away; the "+" stays with the tabs inside the
230
- scroller. Hidden on a fine-pointer desktop (a real keyboard is present; the
237
+ never push or scroll it away — margin-left: auto (below) keeps it there
238
+ whatever the tab count. The "+" is likewise a fixed bar item outside the
239
+ scroller, but it TRAILS the tab list (the auto margin sits between them)
240
+ and packs up against the kb only when the strip overflows. Hidden on a fine-pointer desktop (a real keyboard is present; the
231
241
  scroller then stretches to the full bar width) and until a keyboardToggle
232
242
  feature is wired ([hidden]); shown only on a coarse pointer at strip widths
233
243
  (the .wt-narrow complement below; the breakpoint itself is the kernel's,
@@ -235,6 +245,11 @@
235
245
  :where(.wt-root) .wt-tab-kb {
236
246
  display: none;
237
247
  flex: none;
248
+ /* Pin at the bar's FAR right edge always: the auto margin absorbs whatever
249
+ width the shrink-wrapped scroller + "+" leave free, so the kb button's
250
+ position is independent of the tab count (the "+" trails the tab list and
251
+ only packs up against the kb once the strip overflows). */
252
+ margin-left: auto;
238
253
  }
239
254
 
240
255
  /* Grid open: filled so the toggled state reads at a glance (mirrors the
@@ -376,33 +391,42 @@
376
391
  border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
377
392
  }
378
393
 
379
- /* Agent working: solid pulsing pale lime. Its high lightness keeps it clear on
380
- every tab state, while its yellow-green hue complements the Kiro purple active
381
- tab. Motion is the primary non-hue cue. Server-side this is driven by the OSC
382
- 9;4 progress signal, so it tracks agent activity, not the user typing at the
383
- prompt. */
394
+ /* Agent working: solid pulsing dot (--status-working; pale lime by default —
395
+ its high lightness keeps it clear on every tab state) wearing the same 2px
396
+ ring as input, so the two agent-turn states share one silhouette: a live
397
+ turn pulses (dot + ring together), a turn blocked on the user is that same
398
+ silhouette frozen and recolored (input, below). Motion separates working
399
+ from input; the ring separates both from done's bare disc. Server-side this
400
+ is driven by the OSC 9;4 progress signal, so it tracks agent activity, not
401
+ the user typing at the prompt. */
384
402
  :where(.wt-root) .wt-status-dot[data-status="working"] {
385
- background: #d9f99d;
386
- border-color: #d9f99d;
403
+ background: var(--status-working);
404
+ border-color: var(--status-working);
405
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
387
406
  animation: wt-pulse 1.2s ease-in-out infinite;
388
407
  }
389
408
 
390
- /* Agent done / ready: solid saturated green, matching Kiro IDE's "Ready"
391
- (#22c55e), no motion. Keeping it darker and more saturated than working's
392
- pale lime makes the two states distinct by lightness as well as motion. */
409
+ /* Agent done / ready: solid disc (--status-done), no motion. The default green
410
+ matches Kiro IDE's "Ready" (#22c55e), darker and more saturated than
411
+ working's default pale lime so the two states differ by lightness as well as
412
+ motion. */
393
413
  :where(.wt-root) .wt-status-dot[data-status="done"] {
394
- background: #22c55e;
395
- border-color: #22c55e;
396
- }
397
-
398
- /* User action required (permission / approval): solid orange (#fb923c), shifted
399
- away from the old amber so it is clearly separated from both pale-lime working
400
- and green done, plus a matching ring so it reads as "needs you" via shape as
401
- well as hue. It persists until you act on or switch into the tab. */
414
+ background: var(--status-done);
415
+ border-color: var(--status-done);
416
+ }
417
+
418
+ /* User action required (permission / approval): working's exact silhouette —
419
+ solid dot + 2px ring — frozen and recolored (--status-input; the default
420
+ orange is shifted away from the old amber so it separates from both
421
+ default-lime working and green done). The stillness is the message: the
422
+ agent's turn is mid-flight but blocked on you. The ring keeps it distinct
423
+ from done's bare disc without leaning on hue; it derives from the same token
424
+ at 40% alpha, so a theme override recolors dot + ring together. It persists
425
+ until you act on or switch into the tab. */
402
426
  :where(.wt-root) .wt-status-dot[data-status="input"] {
403
- background: #fb923c;
404
- border-color: #fb923c;
405
- box-shadow: 0 0 0 2px rgb(251 146 60 / 40%);
427
+ background: var(--status-input);
428
+ border-color: var(--status-input);
429
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-input) 40%, transparent);
406
430
  }
407
431
 
408
432
  /* Exited: a dim hollow dot, static; distinct from idle by its lower opacity. */
@@ -417,15 +441,18 @@
417
441
  100% {
418
442
  /* Pin the animated layer's paint as well as its opacity. When working begins
419
443
  in the same frame that input/done ends, an opacity-only animation can
420
- pulse the previously rasterized orange/green dot. */
421
- background-color: #d9f99d;
422
- border-color: #d9f99d;
444
+ pulse the previously rasterized input/done paint (the input ring
445
+ included, hence the box-shadow pin). */
446
+ background-color: var(--status-working);
447
+ border-color: var(--status-working);
448
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
423
449
  opacity: 1;
424
450
  }
425
451
 
426
452
  50% {
427
- background-color: #d9f99d;
428
- border-color: #d9f99d;
453
+ background-color: var(--status-working);
454
+ border-color: var(--status-working);
455
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
429
456
  opacity: 0.4;
430
457
  }
431
458
  }
@@ -434,10 +461,13 @@
434
461
  @media (prefers-reduced-motion: reduce) {
435
462
  :where(.wt-root) .wt-status-dot[data-status="working"] {
436
463
  animation: none;
437
- /* The pulse is working's ONLY non-hue cue; with motion removed,
438
- add a static shape cue (an inner hole -> a ring) so working differs
439
- from done (a solid green disc) by shape, not hue alone (WCAG 1.4.1). */
440
- box-shadow: inset 0 0 0 2px var(--bg);
464
+ /* The pulse is what separates working from input's frozen silhouette; with
465
+ motion removed, punch an inner hole (dot -> donut) so working differs
466
+ from input (solid ringed disc) and done (bare disc) by shape, not hue
467
+ alone (WCAG 1.4.1). The outer ring stays. */
468
+ box-shadow:
469
+ inset 0 0 0 2px var(--bg),
470
+ 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
441
471
  }
442
472
  }
443
473
 
@@ -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. Amber = a background terminal needs input, green = a background
284
- turn finished. Reuses the exact .wt-status-dot colours (30-tabs.css) so it
285
- matches the per-tab dots. Hidden until the tabs feature sets data-status;
286
- cleared when the list opens (expandSwitcher). */
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.2.0",
3
+ "version": "4.3.1",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
6
6
  "license": "GPL-3.0-or-later",
@@ -225,17 +225,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
225
225
 
226
226
  // --- Desktop tab strip (top-bar region) ---
227
227
  // Two layers: the bar itself never scrolls; an inner scroller
228
- // (.wt-tab-scroll, the tablist) holds the tabs plus the "+", which floats
229
- // right of the tab list and scrolls WITH it when many tabs overflow. The
230
- // keyboard button sits OUTSIDE the scroller as the bar's last flex item,
231
- // pinned at the bar's right edge in the scroll-to-bottom button's column
232
- // (an overflowing tab list can never push or scroll it away). On a fine
233
- // pointer it is display:none (CSS), and the scroller stretches to the
234
- // full bar width so the tabs reclaim that space. Both controls are built
235
- // + wired by the same shared factories as the mobile switcher's; the kb
236
- // button is CSS-gated to a wide touchscreen and un-hidden below only when
237
- // a keyboardToggle is wired. addTabChrome inserts each tab before newBtn,
238
- // keeping the scroller [tabs… +].
228
+ // (.wt-tab-scroll, the tablist) holds ONLY the tabs. The "+" and the
229
+ // keyboard button sit OUTSIDE the scroller as fixed bar items in the
230
+ // order [scroller | + | kb], so an overflowing tab list can never push
231
+ // or scroll either control away. The scroller shrink-wraps its content
232
+ // (CSS flex: 0 1 auto), so while the tabs fit the "+" trails the last
233
+ // tab exactly as if it were in the list, while the kb button (a wide
234
+ // touchscreen; hidden on a fine pointer) stays pinned at the bar's FAR
235
+ // right edge via margin-left: auto whatever the tab count. Once the
236
+ // tabs overflow, the scroller caps at the remaining bar width and the
237
+ // "+" packs right, up against the kb. Both controls are built + wired
238
+ // by the same shared factories as the mobile switcher's; the kb button
239
+ // is CSS-gated to a wide touchscreen and un-hidden below only when a
240
+ // keyboardToggle is wired. addTabChrome appends each tab to the
241
+ // scroller's end.
239
242
  const slot = ctx.region("top-bar", "tabs");
240
243
  const bar = document.createElement("div");
241
244
  bar.className = "wt-tab-bar";
@@ -245,9 +248,38 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
245
248
  scroller.setAttribute("role", "tablist");
246
249
  bar.appendChild(scroller);
247
250
  const newBtn = makeNewButton("wt-tab-new");
248
- scroller.appendChild(newBtn);
251
+ bar.appendChild(newBtn);
249
252
  const deskKb = makeKbButton("wt-tab-kb wt-btn");
250
253
  bar.appendChild(deskKb);
254
+ // A vertical mouse wheel anywhere over the strip scrolls the tab list
255
+ // horizontally — the strip has no vertical dimension to spend the delta
256
+ // on, and this is the affordance browser tab bars train. Bound on the
257
+ // BAR so the empty strip area and the fixed controls translate too.
258
+ // Horizontal-dominant deltas (a trackpad pan) keep native handling, and
259
+ // a wheel over a non-overflowing strip falls through untouched. The
260
+ // listener is non-passive because a translated tick must preventDefault
261
+ // so an embedding page (wt-container mode) does not also scroll.
262
+ bar.addEventListener(
263
+ "wheel",
264
+ (e) => {
265
+ if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) {
266
+ return; // horizontal-dominant: native scroll already handles it
267
+ }
268
+ if (scroller.scrollWidth <= scroller.clientWidth) {
269
+ return; // nothing to scroll: let the page have the wheel
270
+ }
271
+ e.preventDefault();
272
+ // deltaMode: 0 = pixels, 1 = lines (Firefox wheel), 2 = pages.
273
+ const step =
274
+ e.deltaMode === 1
275
+ ? e.deltaY * 32
276
+ : e.deltaMode === 2
277
+ ? e.deltaY * scroller.clientWidth
278
+ : e.deltaY;
279
+ scroller.scrollLeft += step;
280
+ },
281
+ { passive: false },
282
+ );
251
283
 
252
284
  // Pull the terminal surface up off the docked BOTTOM strip on desktop so
253
285
  // the bar does not overlap the last rows. The surface is absolute
@@ -515,13 +547,27 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
515
547
  ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
516
548
  }
517
549
 
518
- // paintActive updates the desktop strip's active state.
550
+ // paintActive updates the desktop strip's active state. When the ACTIVE
551
+ // TAB CHANGES (tracked via lastRevealedActive — not on every chrome
552
+ // sync, so a user browsing a scrolled strip is never yanked back by an
553
+ // unrelated repaint), the newly active chip is brought into view in the
554
+ // overflowed scroller; inline: "nearest" is a no-op when it is already
555
+ // visible. The typeof guard covers happy-dom, which lacks
556
+ // scrollIntoView.
557
+ let lastRevealedActive = "";
519
558
  function paintActive(): void {
520
559
  for (const t of tabList) {
521
560
  const on = t.id === activeId;
522
561
  t.el.classList.toggle("wt-tab-active", on);
523
562
  t.aria.setSelected(on);
524
563
  }
564
+ if (activeId && activeId !== lastRevealedActive) {
565
+ lastRevealedActive = activeId;
566
+ const active = tabList.find((t) => t.id === activeId);
567
+ if (active && typeof active.el.scrollIntoView === "function") {
568
+ active.el.scrollIntoView({ block: "nearest", inline: "nearest" });
569
+ }
570
+ }
525
571
  }
526
572
 
527
573
  // syncMobile updates the bottom bar: active label + dot, and the aggregate
@@ -868,9 +914,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
868
914
  }
869
915
  paintStatusDot(dot, info.status, info.reportsActivity ?? false);
870
916
  const aria = tablist.registerTab(el);
871
- // Insert before the "+" so it stays the last item of the scrolling tab
872
- // list (the keyboard button lives outside the scroller, at the bar end).
873
- scroller.insertBefore(el, newBtn);
917
+ // Append to the scroller's end: the tab list is ALL the scroller holds
918
+ // (the "+" and keyboard button are fixed bar items outside it).
919
+ scroller.appendChild(el);
874
920
  // Runtime-added tabs animate in; initial tabs do not (see `started`).
875
921
  // The timer (not animationend) also clears the class on the hidden mobile
876
922
  // strip, where the animation never fires.
@@ -1183,8 +1229,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1183
1229
  }
1184
1230
 
1185
1231
  // dragTargetBefore returns the first tab whose horizontal midpoint is past
1186
- // x (the element the dragged tab should sit before), or null to drop at the
1187
- // end (before the "+"). syncOrderFromDom rebuilds tabList to
1232
+ // x (the element the dragged tab should sit before), or null to drop at
1233
+ // the end of the tab list. syncOrderFromDom rebuilds tabList to
1188
1234
  // match the strip's DOM order after a drag, so position indicators, the
1189
1235
  // switcher, and close-to-the-right/left all follow the visible order.
1190
1236
  function dragTargetBefore(x: number): HTMLElement | null {
@@ -1211,8 +1257,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1211
1257
  }
1212
1258
  }
1213
1259
 
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.
1260
+ // moveTab shifts a tab one slot left or right in tabList and re-appends
1261
+ // every tab element in list order so the DOM matches.
1216
1262
  // It is the single-pointer / non-drag alternative to the drag-and-drop
1217
1263
  // reorder (WCAG 2.5.7), surfaced as Move left / Move right in the tab
1218
1264
  // context menu.
@@ -1228,7 +1274,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1228
1274
  }
1229
1275
  tabList.splice(to, 0, tab);
1230
1276
  for (const item of tabList) {
1231
- scroller.insertBefore(item.el, newBtn);
1277
+ scroller.appendChild(item.el);
1232
1278
  }
1233
1279
  syncChrome();
1234
1280
  ctx.announce(`Moved ${tab.display} to position ${String(to + 1)}`);
@@ -1694,7 +1740,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1694
1740
  if (e.dataTransfer) {
1695
1741
  e.dataTransfer.dropEffect = "move";
1696
1742
  }
1697
- scroller.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? newBtn);
1743
+ scroller.insertBefore(draggingEl, dragTargetBefore(e.clientX));
1698
1744
  });
1699
1745
  // Active-row close (x): closes the current tab (mirrors a listed row's x).
1700
1746
  // stopPropagation so it is not read as a tap/swipe on the row surface.