@cplieger/web-terminal-ui 5.4.0 → 5.4.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/css/30-tabs.css CHANGED
@@ -495,39 +495,6 @@
495
495
  }
496
496
  }
497
497
 
498
- /* ===== Pending drop edge (tabs feature, desktop drag) =====
499
- The exact boundary the dragged tab will be inserted at, marked while the hold runs
500
- and filling from nothing to full over the hold's own duration (the tabs feature
501
- sets the duration inline from REORDER_DWELL_MS, so the bar and the timer cannot
502
- drift apart).
503
-
504
- It rides the neighbouring chip: the leading edge of the chip being pushed aside,
505
- or the trailing edge of the last chip when the tab is heading past the end of the
506
- strip (.wt-tab-dwell-end). The chip is already position: relative for its progress
507
- bar, and its overflow clip keeps the rail inside the same rounded padding box.
508
-
509
- 3px of solid accent rather than a dashed treatment: the dashed vocabulary belongs
510
- to the SLOT, which is a destination that has been decided. This is a boundary
511
- still being chosen, so it reads as a caret. inset-block keeps it clear of the
512
- chip's rounded corners without needing its own radius arithmetic. */
513
- :where(.wt-root) .wt-tab-dwell {
514
- position: absolute;
515
- inset-block: var(--sp-1);
516
- inset-inline-start: 0;
517
- width: 3px;
518
- border-radius: var(--radius-sm);
519
- background: var(--accent);
520
- /* Grows upward like a gauge. The feature writes scaleY inline; a transform on this
521
- element cannot collide with the chip's own animations because it is a separate
522
- element, which is the whole reason the rail is not a pseudo-element on the chip. */
523
- transform-origin: bottom;
524
- pointer-events: none;
525
- }
526
-
527
- :where(.wt-root) .wt-tab-dwell-end {
528
- inset-inline: auto 0;
529
- }
530
-
531
498
  /* ===== Reorder drop slot (tabs feature, desktop drag) =====
532
499
  The dragged chip stays in the strip's flow for the whole gesture and renders as
533
500
  the SLOT it will land in: an empty dashed outline in the accent over a faint
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "5.4.0",
3
+ "version": "5.4.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,19 +50,19 @@
50
50
  "@stryker-mutator/core": "9.6.1",
51
51
  "@stryker-mutator/typescript-checker": "9.6.1",
52
52
  "@stryker-mutator/vitest-runner": "9.6.1",
53
- "@types/node": "25.9.5",
53
+ "@types/node": "26.2.0",
54
54
  "@typescript/native": "npm:typescript@7.0.2",
55
55
  "@vitest/coverage-v8": "4.1.10",
56
56
  "eslint": "10.8.1",
57
57
  "fast-check": "4.9.0",
58
58
  "happy-dom": "20.11.2",
59
59
  "html-validate": "11.6.2",
60
- "knip": "6.32.0",
60
+ "knip": "6.32.1",
61
61
  "prettier": "3.9.6",
62
62
  "stylelint": "17.14.1",
63
63
  "stylelint-config-standard": "40.0.0",
64
64
  "typescript": "npm:@typescript/typescript6@6.0.2",
65
- "typescript-eslint": "8.66.0",
65
+ "typescript-eslint": "8.67.0",
66
66
  "vitest": "4.1.10"
67
67
  },
68
68
  "publishConfig": {
@@ -43,8 +43,9 @@ import {
43
43
  } from "./model.js";
44
44
  import { browserNotifierEnv, createNotifier } from "./notify.js";
45
45
  import {
46
- REORDER_DWELL_MS,
47
46
  REORDER_LEAN_PX,
47
+ REORDER_MOVE_EPS_PX,
48
+ REORDER_REST_MS,
48
49
  REORDER_SETTLE_MS,
49
50
  REORDER_SHIFT_TRANS,
50
51
  REORDER_SLOT_FADE_MS,
@@ -672,21 +673,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
672
673
  // to hand every one of them back to the stylesheet.
673
674
  const shifted = new Set<HTMLElement>();
674
675
  let shiftTimer: ReturnType<typeof setTimeout> | null = null;
675
- // The pending slot and its hold. `dwellTimer !== null` is the only "a slot is
676
- // pending" flag — `dwellBefore` cannot be one, because null is a legitimate
677
- // value there meaning "past the last chip".
678
- let dwellTimer: ReturnType<typeof setTimeout> | null = null;
679
- let dwellBefore: HTMLElement | null = null;
676
+ // The slot waiting to open and the rest window that will open it.
677
+ // `restTimer !== null` is the only "a slot is pending" flag — `restBefore` cannot
678
+ // be one, because null is a legitimate value there meaning "past the last chip".
679
+ // restX is the last pointer position seen, which is how movement is told from
680
+ // stillness (see trackRest).
681
+ let restTimer: ReturnType<typeof setTimeout> | null = null;
682
+ let restBefore: HTMLElement | null = null;
683
+ let restX: number | null = null;
680
684
  // Whether a `drop` fired for the drag in flight. It is the exact signal for
681
685
  // "the user released deliberately" as opposed to "the drag was abandoned":
682
686
  // Escape and a refused release fire dragend with no drop at all. dragend
683
687
  // without it reverts the preview, which is the cancel this reorder never had.
684
688
  let dropped = false;
685
- // The pending-drop rail: ONE element, moved to whichever edge the current hold
686
- // is counting down on (see showDwellRail).
687
- const dwellRail = document.createElement("span");
688
- dwellRail.className = "wt-tab-dwell";
689
- dwellRail.setAttribute("aria-hidden", "true");
690
689
  // The chip mid slot-fade, and the timer that ends it.
691
690
  let slotFadeEl: HTMLElement | null = null;
692
691
  let slotFadeTimer: ReturnType<typeof setTimeout> | null = null;
@@ -1826,7 +1825,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1826
1825
  }
1827
1826
  }
1828
1827
 
1829
- // --- Desktop reorder preview: hold, lean, slide -------------------------
1828
+ // --- Desktop reorder preview: sweep, rest, slide ------------------------
1830
1829
  //
1831
1830
  // The old reorder moved the dragged chip on EVERY dragover, so the strip
1832
1831
  // rearranged itself continuously under the pointer and — every chip being
@@ -1834,22 +1833,33 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1834
1833
  // in. The chip you were dragging looked like the slot it left, the slot it
1835
1834
  // was over, and the slot it would end up in.
1836
1835
  //
1837
- // The reorder now runs in three stages, and the dragged chip stays in the
1838
- // flow throughout as the slot it will land in (`.wt-tab-dragging`, which
1839
- // 30-tabs.css renders as an empty dashed outline rather than a dimmed copy
1840
- // of the tab — the solid copy is the drag image under the pointer, and one
1841
- // of the two had to stop pretending to be the tab):
1836
+ // The preview splits that into two different answers for two different
1837
+ // actions, and the dragged chip stays in the flow throughout as the slot it
1838
+ // will land in (`.wt-tab-dragging`, which 30-tabs.css renders as an empty
1839
+ // dashed outline rather than a dimmed copy of the tab — the solid copy is
1840
+ // the drag image under the pointer, and one of the two had to stop
1841
+ // pretending to be the tab):
1842
1842
  //
1843
- // 1. dragover picks a candidate slot and ARMS a hold. Nothing reorders.
1844
- // 2. Every chip the commit would displace LEANS a few px toward where it
1845
- // is going, at once, so the hold reads as "held here, opening that way"
1846
- // instead of as a second of nothing.
1847
- // 3. After REORDER_DWELL_MS on the same candidate the slot COMMITS: one
1848
- // DOM move, the displaced chips slide from their old positions to their
1849
- // new ones, and the slot fades in at its new home.
1843
+ // - SWEEPING across the strip only LEANS the chips a commit would displace,
1844
+ // a few px toward where they would go (leanToward). Instant, and it is
1845
+ // what says where the tab will fall while you are still looking for the
1846
+ // spot. Nothing is rearranged, so crossing five tabs does not reshuffle
1847
+ // the strip five times.
1848
+ // - COMING TO REST opens the slot for real (trackRest -> commitSlot): one
1849
+ // DOM move, the displaced chips slide from their old positions to their
1850
+ // new ones, and the slot fades in at its new home.
1850
1851
  //
1851
- // A release never waits for the hold: `drop` commits whatever slot is
1852
- // pending (flushDwell), so a fast drag-and-drop reorders exactly as before.
1852
+ // Rest is DETECTED, not waited out: REORDER_REST_MS is re-armed by movement,
1853
+ // so it expires a rest window after the last movement rather than a fixed
1854
+ // time after a decision. An earlier version made every commit serve a fixed
1855
+ // hold, which is the wrong shape for a direct manipulation — it delayed the
1856
+ // one case that should be instant (you found the spot and stopped) to
1857
+ // protect a case the lean already covers (you are still moving). It also
1858
+ // grew a progress bar to explain the wait, which is the tell that the wait
1859
+ // should not have been there.
1860
+ //
1861
+ // A release never waits either: `drop` commits whatever slot is pending
1862
+ // (flushRest), so dropping mid-sweep lands the tab where the lean said.
1853
1863
  //
1854
1864
  // dropTargetBefore returns the first tab whose horizontal midpoint is past
1855
1865
  // x (the element the dragged tab should sit before), or null to drop at the
@@ -1933,9 +1943,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1933
1943
  }
1934
1944
 
1935
1945
  // leanToward tips the chips a commit would displace a few px toward their
1936
- // destination, for as long as the hold lasts. No settle timer: the lean is a
1937
- // held state, not an animation with an end, and a timer that expired mid-hold
1938
- // would snap it away while the user is still waiting on it.
1946
+ // destination, for as long as the pointer is still choosing. No settle timer: the
1947
+ // lean is a held state, not an animation with an end, and a timer that expired
1948
+ // under it would snap it away mid-sweep.
1949
+ //
1950
+ // This is the SWEEP preview and it is the only thing a moving pointer produces:
1951
+ // it says where the tab will fall without rearranging anything, which is what lets
1952
+ // a drag cross five tabs without the strip reshuffling five times.
1939
1953
  //
1940
1954
  // Chips that were leaning and no longer would be are written back to zero in
1941
1955
  // the SAME pass, so a changed candidate eases from one lean into the other
@@ -1974,9 +1988,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1974
1988
  applyShift(next, REORDER_SHIFT_TRANS);
1975
1989
  }
1976
1990
  // releaseLean eases every leaning chip back to rest and settles it, for when
1977
- // a candidate is withdrawn (the pointer came back to the committed slot, or
1978
- // left the strip). endShift would be wrong here: it strips the transition in
1979
- // the same write, so the lean would snap home.
1991
+ // a candidate is withdrawn (the pointer came back to the committed slot, or left
1992
+ // the strip). endShift would be wrong here: it strips the transition in the same
1993
+ // write, so the lean would snap home.
1980
1994
  function releaseLean(): void {
1981
1995
  if (shifted.size === 0) {
1982
1996
  return;
@@ -1994,101 +2008,82 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1994
2008
  applyShift(rest, REORDER_SHIFT_TRANS);
1995
2009
  shiftTimer = setTimeout(endShift, REORDER_SETTLE_MS);
1996
2010
  }
1997
- // showDwellRail marks the exact boundary the tab will be inserted at, on the edge
1998
- // of the chip beside it, and fills that mark over the hold's own duration.
2011
+ // trackRest is the whole gate, called on every dragover over the strip.
1999
2012
  //
2000
- // Without it the hold was a second of near-silence, and the silence was worse than
2001
- // it sounds: the most authoritative thing on screen is the dashed slot, and the
2002
- // slot sits at the tab's CURRENT position, so the strongest cue was describing the
2003
- // state being left rather than the one being chosen. The 7px lean says which
2004
- // DIRECTION the strip will open without saying which boundary, and nothing at all
2005
- // said how much longer. The rail answers both of those, which makes the gesture
2006
- // read as three ordered states: the tab under the pointer, the edge about to open,
2007
- // then the gap that opened.
2013
+ // Sweeping and settling are DIFFERENT actions and get different answers. While the
2014
+ // pointer is still travelling the strip only LEANS the chips it would displace
2015
+ // (leanToward): instant, cheap, and it is what says where the tab will fall while
2016
+ // you are still looking for the spot. Nothing is rearranged, so crossing five tabs
2017
+ // does not rearrange the strip five times. The moment the pointer comes to REST the
2018
+ // slot opens for real.
2008
2019
  //
2009
- // The fill duration comes from REORDER_DWELL_MS, so the bar and the timer it
2010
- // depicts cannot drift apart. That is also why the rail is a real element rather
2011
- // than a pseudo-element on the chip: a pseudo-element's duration would have to
2012
- // travel through a custom property, which is a second place to keep in step.
2013
- function showDwellRail(before: HTMLElement | null): void {
2014
- // The leading edge of the chip being pushed aside, or the trailing edge of the
2015
- // last chip when the tab is heading past the end of the strip.
2016
- const host = before ?? scroller.querySelector<HTMLElement>(".wt-tab:last-of-type");
2017
- if (host === null || host === draggingEl) {
2018
- hideDwellRail();
2019
- return;
2020
- }
2021
- dwellRail.classList.toggle("wt-tab-dwell-end", before === null);
2022
- host.appendChild(dwellRail);
2023
- if (prefersReduce()) {
2024
- // No countdown, but the boundary still has to be legible, so show it filled.
2025
- dwellRail.style.transition = "none";
2026
- dwellRail.style.transform = "scaleY(1)";
2027
- return;
2028
- }
2029
- dwellRail.style.transition = "none";
2030
- dwellRail.style.transform = "scaleY(0)";
2031
- dwellRail.getBoundingClientRect(); // commit the from-state before the fill
2032
- dwellRail.style.transition = `transform ${String(REORDER_DWELL_MS)}ms linear`;
2033
- dwellRail.style.transform = "scaleY(1)";
2034
- }
2035
- function hideDwellRail(): void {
2036
- dwellRail.remove(); // idempotent: removing a detached node does nothing
2037
- }
2038
-
2039
- // armDwell registers the slot a release would land in and starts (or keeps)
2040
- // the hold that commits it. Called on every dragover, so it must be cheap
2041
- // and idempotent for an unchanged candidate: restarting the timer on each
2042
- // event would mean a hand that never holds perfectly still could never
2043
- // commit anything, which is the bug a naive dwell always ships with.
2044
- function armDwell(before: HTMLElement | null): void {
2045
- if (!draggingEl) {
2020
+ // Rest is detected, not waited out. An earlier version made every commit wait a
2021
+ // fixed hold, which is the wrong shape for a direct manipulation: it delayed the
2022
+ // one case that should be instant (you have found the spot and stopped) in order to
2023
+ // protect the case that never needed protecting (you are still moving, and the lean
2024
+ // already covers it). The timer here is re-armed by MOVEMENT, so it expires a rest
2025
+ // window after the last movement rather than a fixed time after a decision.
2026
+ function trackRest(clientX: number): void {
2027
+ const dragged = draggingEl;
2028
+ if (!dragged) {
2046
2029
  return;
2047
2030
  }
2048
- // Already the committed slot, so there is nothing to preview. Withdraw any
2049
- // pending commitment — the pointer came back.
2050
- if (before === draggingEl || before === draggingEl.nextElementSibling) {
2051
- cancelDwell();
2031
+ const moved = restX === null || Math.abs(clientX - restX) > REORDER_MOVE_EPS_PX;
2032
+ restX = clientX;
2033
+ const before = dropTargetBefore(clientX);
2034
+ // Already the slot the tab holds, so there is nothing to preview or commit.
2035
+ // Withdraw anything pending: the pointer came back.
2036
+ if (before === dragged || before === dragged.nextElementSibling) {
2037
+ cancelRest();
2052
2038
  return;
2053
2039
  }
2054
- if (dwellTimer !== null && before === dwellBefore) {
2055
- return; // same candidate, still holding
2040
+ // Lean toward a NEW candidate. The test is guarded on a pending rest rather than
2041
+ // on `before !== restBefore` alone, because null is a legitimate candidate here
2042
+ // ("past the last chip") and would otherwise be indistinguishable from the
2043
+ // initial "nothing pending" state — which is exactly how the first sweep of a
2044
+ // drag toward the end of the strip ended up leaning nothing at all.
2045
+ if (restTimer === null || before !== restBefore) {
2046
+ restBefore = before;
2047
+ leanToward(before); // the sweep preview: where this will fall
2056
2048
  }
2057
- if (dwellTimer !== null) {
2058
- clearTimeout(dwellTimer);
2049
+ // Movement pushes the commit out; stillness lets the armed timer run down to it.
2050
+ // A stationary pointer that has nothing armed still needs one — a browser may
2051
+ // deliver dragover repeatedly at an unchanged position, and the first of those is
2052
+ // the only signal that the pointer arrived and stopped.
2053
+ if (moved || restTimer === null) {
2054
+ if (restTimer !== null) {
2055
+ clearTimeout(restTimer);
2056
+ }
2057
+ restTimer = setTimeout(() => {
2058
+ restTimer = null;
2059
+ const target = restBefore;
2060
+ restBefore = null;
2061
+ commitSlot(target);
2062
+ }, REORDER_REST_MS);
2059
2063
  }
2060
- dwellBefore = before;
2061
- leanToward(before);
2062
- showDwellRail(before);
2063
- dwellTimer = setTimeout(() => {
2064
- dwellTimer = null;
2065
- commitSlot(before);
2066
- }, REORDER_DWELL_MS);
2067
2064
  }
2068
- function cancelDwell(): void {
2069
- hideDwellRail();
2070
- if (dwellTimer === null) {
2065
+ function cancelRest(): void {
2066
+ if (restTimer === null) {
2071
2067
  return;
2072
2068
  }
2073
- clearTimeout(dwellTimer);
2074
- dwellTimer = null;
2075
- dwellBefore = null;
2069
+ clearTimeout(restTimer);
2070
+ restTimer = null;
2071
+ restBefore = null;
2076
2072
  releaseLean();
2077
2073
  }
2078
- // flushDwell commits a pending slot at once. A release is a decision, so a
2079
- // drop does not have to wait out a hold it interrupted: this is what keeps a
2080
- // quick drag-and-drop reordering exactly as it did before the preview
2081
- // existed, and it is why the hold can afford to be a full second. Only a
2082
- // `drop` calls it; an abandoned drag reaches dragend without one and reverts.
2083
- function flushDwell(): void {
2084
- if (dwellTimer === null) {
2074
+ // flushRest commits a pending slot at once, because a release is a decision. It is
2075
+ // what stops the rest window from ever being felt as a floor on the gesture: drop
2076
+ // mid-sweep and the tab lands where the lean said it would. Only a `drop` calls it;
2077
+ // an abandoned drag reaches dragend without one and reverts.
2078
+ function flushRest(): void {
2079
+ if (restTimer === null) {
2085
2080
  return;
2086
2081
  }
2087
- const before = dwellBefore;
2088
- clearTimeout(dwellTimer);
2089
- dwellTimer = null;
2090
- dwellBefore = null;
2091
- commitSlot(before);
2082
+ const target = restBefore;
2083
+ clearTimeout(restTimer);
2084
+ restTimer = null;
2085
+ restBefore = null;
2086
+ commitSlot(target);
2092
2087
  }
2093
2088
 
2094
2089
  // flipTo runs a rearranging mutation and animates its result: every chip that
@@ -2165,22 +2160,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2165
2160
  // whatever the pointer is hovering.
2166
2161
  //
2167
2162
  // Every early return releases the lean, and that is not belt-and-braces. The
2168
- // caller nulls `dwellTimer` BEFORE calling in (both the timer callback and
2169
- // flushDwell do), so by the time control arrives here `cancelDwell` has already
2170
- // become a no-op and nothing else would ever hand those chips back.
2163
+ // caller nulls `restTimer` BEFORE calling in (both the rest timer and flushRest
2164
+ // do), so by the time control arrives here `cancelRest` has already become a no-op
2165
+ // and nothing else would ever hand those chips back.
2171
2166
  function commitSlot(before: HTMLElement | null): void {
2172
- // The countdown is over however this call ends: the rail's whole job was to
2173
- // depict a hold that has now expired.
2174
- hideDwellRail();
2175
2167
  const dragged = draggingEl;
2176
2168
  if (!dragged?.isConnected) {
2177
2169
  releaseLean();
2178
- return; // the dragged session was closed elsewhere mid-hold
2170
+ return; // the dragged session was closed elsewhere mid-gesture
2179
2171
  }
2180
- // The hold captured a live DOM node a whole second ago. A session closed in
2181
- // another window (an SSE push, or the poll reconcile) removes chips through
2182
- // dropTab while this gesture is still open, so the reference node may be gone
2183
- // — and insertBefore throws NotFoundError on a reference that is no longer a
2172
+ // The pending slot was chosen a rest window ago, and a session closed in another
2173
+ // window (an SSE push, or the poll reconcile) removes chips through dropTab while
2174
+ // this gesture is still open — so the reference node may be gone by now, and
2175
+ // insertBefore throws NotFoundError on a reference that is no longer a
2184
2176
  // child, which would abandon the drop mid-way and leave DOM order and tabList
2185
2177
  // disagreeing. null stays legal: it means "past the last chip".
2186
2178
  if (before !== null && before.parentNode !== scroller) {
@@ -2289,8 +2281,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2289
2281
  // start from rest, and a torn-down feature may leave no inline style on an
2290
2282
  // element it no longer owns.
2291
2283
  function endReorderPreview(): void {
2292
- cancelDwell();
2293
- hideDwellRail(); // cancelDwell returns early when no hold was pending
2284
+ cancelRest();
2285
+ restX = null;
2294
2286
  endSlotFade();
2295
2287
  }
2296
2288
 
@@ -2309,8 +2301,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2309
2301
  // in progress" and so would preventDefault every unrelated file or text drop on
2310
2302
  // the page from then on.
2311
2303
  function abortReorderFor(removed: HTMLElement): void {
2312
- if (removed === dwellBefore) {
2313
- cancelDwell();
2304
+ if (removed === restBefore) {
2305
+ cancelRest();
2314
2306
  }
2315
2307
  if (removed !== draggingEl) {
2316
2308
  return;
@@ -3360,9 +3352,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3360
3352
  }
3361
3353
  return false;
3362
3354
  });
3363
- // Arm the reorder preview while dragging a tab over the strip. This no longer
3364
- // reorders anything: it picks the slot a release would land in and starts the
3365
- // hold that commits it (see the reorder-preview block above).
3355
+ // Drive the reorder preview while dragging a tab over the strip. A sweep only
3356
+ // leans the chips; the slot opens when the pointer comes to rest (trackRest).
3366
3357
  bar.addEventListener("dragover", (e) => {
3367
3358
  if (!draggingEl) {
3368
3359
  return;
@@ -3371,7 +3362,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3371
3362
  if (e.dataTransfer) {
3372
3363
  e.dataTransfer.dropEffect = "move";
3373
3364
  }
3374
- armDwell(dropTargetBefore(e.clientX));
3365
+ trackRest(e.clientX);
3375
3366
  });
3376
3367
  // Commit on drop — and, load-bearing, CANCEL the drop's default action. An
3377
3368
  // uncancelled drop leaves the browser free to act on the drag payload
@@ -3386,16 +3377,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3386
3377
  }
3387
3378
  e.preventDefault();
3388
3379
  dropped = true;
3389
- flushDwell(); // a release decides: do not make a fast drag wait out the hold
3380
+ flushRest(); // a release decides: never make a drop wait on the rest window
3390
3381
  syncOrderFromDom();
3391
3382
  announceMoved(moved); // the ONE completed-move announcement per gesture
3392
3383
  });
3393
- // Leaving the strip stands a pending hold down. The document dragover below
3394
- // does this too and covers more ground, but it cannot cover the case that
3395
- // matters most here: the strip is docked at the viewport EDGE, so a pointer can
3396
- // leave it by leaving the window entirely, and then no other element in the
3397
- // document receives a dragover to notice with. Without this listener the hold
3398
- // would run to completion and commit a slot the pointer had already abandoned.
3384
+ // Leaving the strip withdraws a pending slot. The document dragover below does
3385
+ // this too and covers more ground, but it cannot cover the case that matters most
3386
+ // here: the strip is docked at the viewport EDGE, so a pointer can leave it by
3387
+ // leaving the window entirely, and then no other element in the document receives a
3388
+ // dragover to notice with. A pointer that leaves the window is also, by definition,
3389
+ // one that has stopped moving over the strip — so without this the rest window
3390
+ // would run down and open a slot the pointer had already abandoned.
3399
3391
  //
3400
3392
  // relatedTarget is what makes dragleave usable at all: it fires on every
3401
3393
  // child-to-child transition inside the bar (chip to label, label to close), and
@@ -3408,7 +3400,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3408
3400
  }
3409
3401
  const next = e.relatedTarget;
3410
3402
  if (next === null || !bar.contains(next as Node)) {
3411
- cancelDwell();
3403
+ cancelRest();
3412
3404
  }
3413
3405
  });
3414
3406
  // A tab released anywhere OTHER than the strip — over the terminal, over
@@ -3435,17 +3427,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3435
3427
  // refused release fell through to dragend and reverted. That distinction is
3436
3428
  // invisible to the person doing the dragging.
3437
3429
  //
3438
- // So: leave `dropped` false, stand the hold down, and let dragend run the
3430
+ // So: leave `dropped` false, withdraw anything pending, and let dragend run the
3439
3431
  // revert. Nothing is lost silently — the revert animates and announces, so a
3440
3432
  // mis-release reads as "that did not take" rather than as a mystery.
3441
- cancelDwell();
3433
+ cancelRest();
3442
3434
  return;
3443
3435
  }
3444
- // dragover, off the strip: no candidate slot exists out here, so stand a
3445
- // pending hold down rather than let it open a gap for a target the pointer
3446
- // has already left. A slot already COMMITTED stays put; the pointer may yet
3447
- // come back onto the strip and release there.
3448
- cancelDwell();
3436
+ // dragover, off the strip: no candidate slot exists out here, so withdraw a
3437
+ // pending one rather than let the rest window open a gap for a target the pointer
3438
+ // has already left. A slot already COMMITTED stays put; the pointer may yet come
3439
+ // back onto the strip and release there.
3440
+ cancelRest();
3449
3441
  };
3450
3442
  document.addEventListener("dragover", onDocTabDrop);
3451
3443
  document.addEventListener("drop", onDocTabDrop);
@@ -21,28 +21,35 @@ import { statusPhrase, statusRevealsDot } from "./model.js";
21
21
  // motion is spelled in — index.ts owns the mechanism, strip.ts owns the values,
22
22
  // the same split switcher.ts keeps for the mobile swipe.
23
23
 
24
- /** How long (ms) the pointer holds over one candidate slot before the strip
25
- * opens it.
24
+ /** The quiet window (ms) that means the pointer has STOPPED. When it elapses with no
25
+ * movement, the strip opens the slot the pointer is over.
26
26
  *
27
- * Long on purpose. A sweep across a six-tab strip crosses five slots, and
28
- * committing each one in passing is what made the old reorder unreadable: the
29
- * strip rearranged itself continuously and the user chose a slot by stopping the
30
- * mouse at the right millisecond. A hold is a deliberate act, so the strip only
31
- * rearranges where the pointer actually rests.
27
+ * This is a rest detector, not a hold. The difference is the whole interaction: a hold
28
+ * makes you wait a fixed time whatever you do, which is exactly the wrong shape here —
29
+ * a reorder is a direct manipulation and the answer to "when does it move" is "when you
30
+ * stop looking for the spot". Sweeping across the strip only leans the chips (cheap,
31
+ * instant, and it is what says where the tab will fall); coming to rest is what commits.
32
32
  *
33
- * It costs a fast drag nothing, because a release COMMITS the pending slot
34
- * (flushDwell) instead of discarding it: drop early and the reorder lands
35
- * immediately, exactly as it did before the preview existed. The hold is what
36
- * buys the animation, not a floor on the gesture. */
37
- export const REORDER_DWELL_MS = 1000;
33
+ * Sized to sit above the gap between two dragover events during a real sweep and below
34
+ * human perception of delay, so stopping reads as immediate. If a browser's dragover
35
+ * cadence is ever slower than this, the failure mode is benign: the strip commits the
36
+ * slot the pointer is over slightly eagerly, which is what it would have done anyway.
37
+ *
38
+ * A release never waits for it either — a drop commits whatever is pending (flushRest). */
39
+ export const REORDER_REST_MS = 120;
40
+
41
+ /** Movement (px) between two dragover events below which the pointer counts as still.
42
+ * A hand resting on a mouse is never perfectly still, and treating a 1px tremor as a
43
+ * sweep would keep pushing the commit out for as long as someone held the tab. */
44
+ export const REORDER_MOVE_EPS_PX = 3;
38
45
 
39
- /** How far (px) each chip a commit would displace leans toward its destination
40
- * while the hold runs.
46
+ /** How far (px) each chip a commit would displace leans toward its destination while
47
+ * the pointer is still sweeping.
41
48
  *
42
49
  * Small enough to read as a lean rather than a move, and large enough to be
43
- * unambiguous at a glance — it answers "the strip heard me, and it is about to
44
- * open THAT way" during a wait that is otherwise a second of nothing. The same
45
- * purpose the mobile switcher's PREVIEW_PEEK_MAX serves for its live swipe. */
50
+ * unambiguous at a glance: it is what answers "where will this fall" during the sweep,
51
+ * before anything has actually moved. The same purpose the mobile switcher's
52
+ * PREVIEW_PEEK_MAX serves for its live swipe. */
46
53
  export const REORDER_LEAN_PX = 7;
47
54
 
48
55
  /** The one transition both preview stages use (the lean, and the slide that