@cplieger/web-terminal-ui 5.4.0 → 5.4.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/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.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",
@@ -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,8 @@ import {
43
43
  } from "./model.js";
44
44
  import { browserNotifierEnv, createNotifier } from "./notify.js";
45
45
  import {
46
- REORDER_DWELL_MS,
47
- REORDER_LEAN_PX,
46
+ REORDER_MOVE_EPS_PX,
47
+ REORDER_REST_MS,
48
48
  REORDER_SETTLE_MS,
49
49
  REORDER_SHIFT_TRANS,
50
50
  REORDER_SLOT_FADE_MS,
@@ -666,27 +666,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
666
666
  let activeId: string | null = null;
667
667
  let draggingEl: HTMLElement | null = null;
668
668
  // --- Desktop reorder preview state (mechanism below, near dragTargetBefore) ---
669
- // The chips currently carrying an inline transform: the lean during a hold,
670
- // or the slide that commits it. One set, because both stages write the same
671
- // two properties on the same elements and one settle function has to be able
672
- // to hand every one of them back to the stylesheet.
669
+ // The chips currently carrying an inline displacement from the commit slide.
670
+ // A set plus one settle function, so whatever a slide wrote can always be handed
671
+ // back to the stylesheet — including when a second drag interrupts the first.
673
672
  const shifted = new Set<HTMLElement>();
674
673
  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;
674
+ // The slot waiting to open and the rest window that will open it.
675
+ // `restTimer !== null` is the only "a slot is pending" flag — `restBefore` cannot
676
+ // be one, because null is a legitimate value there meaning "past the last chip".
677
+ // restX is the last pointer position seen, which is how movement is told from
678
+ // stillness (see trackRest).
679
+ let restTimer: ReturnType<typeof setTimeout> | null = null;
680
+ let restBefore: HTMLElement | null = null;
681
+ let restX: number | null = null;
680
682
  // Whether a `drop` fired for the drag in flight. It is the exact signal for
681
683
  // "the user released deliberately" as opposed to "the drag was abandoned":
682
684
  // Escape and a refused release fire dragend with no drop at all. dragend
683
685
  // without it reverts the preview, which is the cancel this reorder never had.
684
686
  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
687
  // The chip mid slot-fade, and the timer that ends it.
691
688
  let slotFadeEl: HTMLElement | null = null;
692
689
  let slotFadeTimer: ReturnType<typeof setTimeout> | null = null;
@@ -1826,7 +1823,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1826
1823
  }
1827
1824
  }
1828
1825
 
1829
- // --- Desktop reorder preview: hold, lean, slide -------------------------
1826
+ // --- Desktop reorder preview: sweep, rest, slide ------------------------
1830
1827
  //
1831
1828
  // The old reorder moved the dragged chip on EVERY dragover, so the strip
1832
1829
  // rearranged itself continuously under the pointer and — every chip being
@@ -1834,22 +1831,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1834
1831
  // in. The chip you were dragging looked like the slot it left, the slot it
1835
1832
  // was over, and the slot it would end up in.
1836
1833
  //
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):
1834
+ // The preview answers a MOVING pointer and a STOPPED one differently, and the
1835
+ // dragged chip stays in the flow throughout as the slot it will land in
1836
+ // (`.wt-tab-dragging`, which 30-tabs.css renders as an empty dashed outline
1837
+ // rather than a dimmed copy of the tab — the solid copy is the drag image
1838
+ // under the pointer, and one of the two had to stop pretending to be the tab):
1842
1839
  //
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.
1840
+ // - SWEEPING across the strip rearranges NOTHING. Crossing five tabs used to
1841
+ // move all five, which is the whole complaint.
1842
+ // - COMING TO REST opens the slot (trackRest -> commitSlot): one DOM move,
1843
+ // the displaced chips slide from their old positions to their new ones, and
1844
+ // the slot fades in at its new home.
1850
1845
  //
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.
1846
+ // Rest is DETECTED, not waited out: REORDER_REST_MS is re-armed by movement, so
1847
+ // it expires a rest window after the last MOVEMENT rather than a fixed time
1848
+ // after a decision. Two shapes were tried and removed before this one, and both
1849
+ // failures are worth keeping: a fixed one-second HOLD (wrong shape for a direct
1850
+ // manipulation — it delayed the one case that should be instant, and grew a
1851
+ // progress bar to explain the wait, which is the tell that the wait should not
1852
+ // have been there), and a sweep-time LEAN of the chips a commit would displace
1853
+ // (it read as a second, competing preview and its distance from the real move
1854
+ // was too small to tell them apart).
1855
+ //
1856
+ // A release never waits either: `drop` commits whatever slot is pending
1857
+ // (flushRest), so dropping mid-sweep still lands the tab where it was headed.
1853
1858
  //
1854
1859
  // dropTargetBefore returns the first tab whose horizontal midpoint is past
1855
1860
  // x (the element the dragged tab should sit before), or null to drop at the
@@ -1859,8 +1864,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1859
1864
  //
1860
1865
  // It hit-tests LAYOUT geometry (offsetLeft/offsetWidth), never
1861
1866
  // getBoundingClientRect. That is not an optimisation, it is what makes an
1862
- // animated reorder hit-testable at all: a rect read while a chip is mid-lean or
1863
- // mid-slide returns the INTERPOLATED position, so the preview's own motion feeds
1867
+ // animated reorder hit-testable at all: a rect read while a chip is mid-slide
1868
+ // returns the INTERPOLATED position, so the preview's own motion feeds
1864
1869
  // back into the decision that produced it and the strip oscillates between two
1865
1870
  // slots for as long as the pointer sits near a boundary. Layout offsets are the
1866
1871
  // chip's position in the FLOW, which a transform does not affect at all.
@@ -1932,163 +1937,70 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1932
1937
  shifted.clear();
1933
1938
  }
1934
1939
 
1935
- // 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.
1940
+ // trackRest is the whole gate, called on every dragover over the strip.
1939
1941
  //
1940
- // Chips that were leaning and no longer would be are written back to zero in
1941
- // the SAME pass, so a changed candidate eases from one lean into the other
1942
- // rather than snapping through rest first.
1943
- function leanToward(before: HTMLElement | null): void {
1944
- if (!draggingEl) {
1945
- return;
1946
- }
1947
- // The preference is read live, so it can flip mid-gesture. Clearing here rather
1948
- // than returning bare is what honours a mid-hold opt-in: a bare return would
1949
- // leave the lean already applied under the previous preference sitting there.
1950
- if (prefersReduce()) {
1951
- endShift();
1942
+ // Sweeping and settling are DIFFERENT actions and get different answers. A
1943
+ // travelling pointer rearranges nothing at all; the moment it comes to REST the
1944
+ // slot opens.
1945
+ //
1946
+ // Rest is detected, not waited out: the timer is re-armed by MOVEMENT, so it
1947
+ // expires a rest window after the last movement rather than a fixed time after a
1948
+ // decision. The window has a hard FLOOR set by the platform rather than by taste —
1949
+ // HTML5 drag-and-drop only guarantees a `dragover` every 350ms, so a window at or
1950
+ // below that cadence expires between two events of a fast sweep and commits every
1951
+ // slot the pointer crosses (see REORDER_REST_MS).
1952
+ function trackRest(clientX: number): void {
1953
+ const dragged = draggingEl;
1954
+ if (!dragged) {
1952
1955
  return;
1953
1956
  }
1954
- const chips = [...scroller.querySelectorAll<HTMLElement>(".wt-tab")];
1955
- const from = chips.indexOf(draggingEl);
1956
- const to = before === null ? chips.length : chips.indexOf(before);
1957
- if (from < 0 || to < 0) {
1957
+ const moved = restX === null || Math.abs(clientX - restX) > REORDER_MOVE_EPS_PX;
1958
+ restX = clientX;
1959
+ const before = dropTargetBefore(clientX);
1960
+ // Already the slot the tab holds, so there is nothing to preview or commit.
1961
+ // Withdraw anything pending: the pointer came back.
1962
+ if (before === dragged || before === dragged.nextElementSibling) {
1963
+ cancelRest();
1958
1964
  return;
1959
1965
  }
1960
- // Moving right past the chips in between shifts them LEFT, and moving left
1961
- // shifts the chips it displaces RIGHT — the direction the user reads as
1962
- // "that tab is getting out of the way".
1963
- const movers = from < to ? chips.slice(from + 1, to) : chips.slice(to, from);
1964
- const dx = from < to ? -REORDER_LEAN_PX : REORDER_LEAN_PX;
1965
- const next = new Map<HTMLElement, number>();
1966
- for (const el of movers) {
1967
- next.set(el, dx);
1968
- }
1969
- for (const el of shifted) {
1970
- if (!next.has(el)) {
1971
- next.set(el, 0);
1966
+ restBefore = before;
1967
+ // Movement pushes the commit out; stillness lets the armed timer run down to it.
1968
+ // A stationary pointer that has nothing armed still needs one — a browser may
1969
+ // deliver dragover repeatedly at an unchanged position, and the first of those is
1970
+ // the only signal that the pointer arrived and stopped.
1971
+ if (moved || restTimer === null) {
1972
+ if (restTimer !== null) {
1973
+ clearTimeout(restTimer);
1972
1974
  }
1975
+ restTimer = setTimeout(() => {
1976
+ restTimer = null;
1977
+ const target = restBefore;
1978
+ restBefore = null;
1979
+ commitSlot(target);
1980
+ }, REORDER_REST_MS);
1973
1981
  }
1974
- applyShift(next, REORDER_SHIFT_TRANS);
1975
- }
1976
- // 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.
1980
- function releaseLean(): void {
1981
- if (shifted.size === 0) {
1982
- return;
1983
- }
1984
- // ...except under reduced motion, where snapping IS the requested behaviour and
1985
- // easing back would animate the very motion the preference opted out of.
1986
- if (prefersReduce()) {
1987
- endShift();
1988
- return;
1989
- }
1990
- const rest = new Map<HTMLElement, number>();
1991
- for (const el of shifted) {
1992
- rest.set(el, 0);
1993
- }
1994
- applyShift(rest, REORDER_SHIFT_TRANS);
1995
- shiftTimer = setTimeout(endShift, REORDER_SETTLE_MS);
1996
1982
  }
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.
1999
- //
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.
2008
- //
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();
1983
+ function cancelRest(): void {
1984
+ if (restTimer === null) {
2019
1985
  return;
2020
1986
  }
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)";
1987
+ clearTimeout(restTimer);
1988
+ restTimer = null;
1989
+ restBefore = null;
2034
1990
  }
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) {
2046
- return;
2047
- }
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();
2052
- return;
2053
- }
2054
- if (dwellTimer !== null && before === dwellBefore) {
2055
- return; // same candidate, still holding
2056
- }
2057
- if (dwellTimer !== null) {
2058
- clearTimeout(dwellTimer);
2059
- }
2060
- dwellBefore = before;
2061
- leanToward(before);
2062
- showDwellRail(before);
2063
- dwellTimer = setTimeout(() => {
2064
- dwellTimer = null;
2065
- commitSlot(before);
2066
- }, REORDER_DWELL_MS);
2067
- }
2068
- function cancelDwell(): void {
2069
- hideDwellRail();
2070
- if (dwellTimer === null) {
2071
- return;
2072
- }
2073
- clearTimeout(dwellTimer);
2074
- dwellTimer = null;
2075
- dwellBefore = null;
2076
- releaseLean();
2077
- }
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) {
1991
+ // flushRest commits a pending slot at once, because a release is a decision. It is
1992
+ // what stops the rest window from ever being felt as a floor on the gesture: drop
1993
+ // mid-sweep and the tab still lands where it was headed. Only a `drop` calls it;
1994
+ // an abandoned drag reaches dragend without one and reverts.
1995
+ function flushRest(): void {
1996
+ if (restTimer === null) {
2085
1997
  return;
2086
1998
  }
2087
- const before = dwellBefore;
2088
- clearTimeout(dwellTimer);
2089
- dwellTimer = null;
2090
- dwellBefore = null;
2091
- commitSlot(before);
1999
+ const target = restBefore;
2000
+ clearTimeout(restTimer);
2001
+ restTimer = null;
2002
+ restBefore = null;
2003
+ commitSlot(target);
2092
2004
  }
2093
2005
 
2094
2006
  // flipTo runs a rearranging mutation and animates its result: every chip that
@@ -2108,18 +2020,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2108
2020
  // belongs HERE and not only in the CSS, because these transitions are written
2109
2021
  // inline and no stylesheet gate can reach them; the switcher's release reel
2110
2022
  // guards its own call site the same way. Read live, so toggling the OS setting
2111
- // mid-drag takes effect on the next commit and strips any existing lean.
2023
+ // mid-drag takes effect on the very next commit.
2112
2024
  if (prefersReduce()) {
2113
2025
  endShift();
2114
2026
  mutate();
2115
2027
  return;
2116
2028
  }
2117
- // FIRST — where each chip is right now, LEAN INCLUDED, so a slide continues
2118
- // out of the lean instead of jumping back through rest first. The switcher's
2119
- // release reel captures its live swipe preview the same way, and for the
2120
- // same reason. Rects, not layout offsets: this is VISUAL position, which is
2121
- // what the animation interpolates (hit-testing wants the opposite, see
2122
- // dropTargetBefore).
2029
+ // FIRST — where each chip is right now, MID-SLIDE INCLUDED, so a second commit
2030
+ // continues from wherever the first one got to instead of jumping back through
2031
+ // rest first. The switcher's release reel captures its live swipe preview the
2032
+ // same way, and for the same reason. Rects, not layout offsets: this is VISUAL
2033
+ // position, which is what the animation interpolates (hit-testing wants the
2034
+ // opposite, see dropTargetBefore).
2123
2035
  const first = new Map<HTMLElement, number>();
2124
2036
  for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
2125
2037
  if (el !== hold) {
@@ -2164,31 +2076,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2164
2076
  // an OSC title) renders the committed order rather than flickering through
2165
2077
  // whatever the pointer is hovering.
2166
2078
  //
2167
- // 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.
2079
+ // The caller nulls `restTimer` BEFORE calling in (both the rest timer and
2080
+ // flushRest do), so `cancelRest` is already a no-op by the time control arrives
2081
+ // here: an early return must leave nothing behind that only cancelRest would
2082
+ // Nothing exists pre-commit for it to have cleared, and a slide already in flight
2083
+ // is owned by its own settle timer.
2171
2084
  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
2085
  const dragged = draggingEl;
2176
2086
  if (!dragged?.isConnected) {
2177
- releaseLean();
2178
- return; // the dragged session was closed elsewhere mid-hold
2087
+ return; // the dragged session was closed elsewhere mid-gesture
2179
2088
  }
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
2089
+ // The pending slot was chosen a rest window ago, and a session closed in another
2090
+ // window (an SSE push, or the poll reconcile) removes chips through dropTab while
2091
+ // this gesture is still open — so the reference node may be gone by now, and
2092
+ // insertBefore throws NotFoundError on a reference that is no longer a
2184
2093
  // child, which would abandon the drop mid-way and leave DOM order and tabList
2185
2094
  // disagreeing. null stays legal: it means "past the last chip".
2186
2095
  if (before !== null && before.parentNode !== scroller) {
2187
- releaseLean();
2188
2096
  return;
2189
2097
  }
2190
2098
  if (before === dragged || before === dragged.nextElementSibling) {
2191
- releaseLean();
2192
2099
  return; // already in that slot
2193
2100
  }
2194
2101
  flipTo(() => {
@@ -2278,8 +2185,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2278
2185
  }
2279
2186
  }
2280
2187
 
2281
- // endReorderPreview ends the GESTURE's state: the pending hold (easing any
2282
- // lean home as it goes) and the slot's fade.
2188
+ // endReorderPreview ends the GESTURE's state: the pending rest and the slot's
2189
+ // fade.
2283
2190
  //
2284
2191
  // It deliberately does NOT stop a slide that is already playing. A slide is an
2285
2192
  // animation with its own settle timer, and cutting it here is exactly what
@@ -2289,8 +2196,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2289
2196
  // start from rest, and a torn-down feature may leave no inline style on an
2290
2197
  // element it no longer owns.
2291
2198
  function endReorderPreview(): void {
2292
- cancelDwell();
2293
- hideDwellRail(); // cancelDwell returns early when no hold was pending
2199
+ cancelRest();
2200
+ restX = null;
2294
2201
  endSlotFade();
2295
2202
  }
2296
2203
 
@@ -2299,9 +2206,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2299
2206
  // sweep). Two removals matter, for different reasons:
2300
2207
  //
2301
2208
  // - the pending reference chip: commitSlot would hand a detached node to
2302
- // insertBefore. It guards that too, but standing the hold down here means the
2303
- // user's lean comes off at the moment the target vanishes rather than a second
2304
- // later, and a fresh dragover can arm a slot that still exists.
2209
+ // insertBefore. It guards that too, but withdrawing the pending slot here means
2210
+ // a fresh dragover arms against a chip that still exists rather than waiting out
2211
+ // a rest window against one that does not.
2305
2212
  // - the DRAGGED chip: the gesture is over and there is no source left to deliver
2306
2213
  // a dragend. A browser is not obliged to fire one for a removed source, and
2307
2214
  // without this the feature would keep `draggingEl` set to a detached node for
@@ -2309,8 +2216,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2309
2216
  // in progress" and so would preventDefault every unrelated file or text drop on
2310
2217
  // the page from then on.
2311
2218
  function abortReorderFor(removed: HTMLElement): void {
2312
- if (removed === dwellBefore) {
2313
- cancelDwell();
2219
+ if (removed === restBefore) {
2220
+ cancelRest();
2314
2221
  }
2315
2222
  if (removed !== draggingEl) {
2316
2223
  return;
@@ -3360,9 +3267,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3360
3267
  }
3361
3268
  return false;
3362
3269
  });
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).
3270
+ // Drive the reorder preview while dragging a tab over the strip. A sweep only
3271
+ // rearranges nothing; the slot opens when the pointer comes to rest (trackRest).
3366
3272
  bar.addEventListener("dragover", (e) => {
3367
3273
  if (!draggingEl) {
3368
3274
  return;
@@ -3371,7 +3277,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3371
3277
  if (e.dataTransfer) {
3372
3278
  e.dataTransfer.dropEffect = "move";
3373
3279
  }
3374
- armDwell(dropTargetBefore(e.clientX));
3280
+ trackRest(e.clientX);
3375
3281
  });
3376
3282
  // Commit on drop — and, load-bearing, CANCEL the drop's default action. An
3377
3283
  // uncancelled drop leaves the browser free to act on the drag payload
@@ -3386,16 +3292,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3386
3292
  }
3387
3293
  e.preventDefault();
3388
3294
  dropped = true;
3389
- flushDwell(); // a release decides: do not make a fast drag wait out the hold
3295
+ flushRest(); // a release decides: never make a drop wait on the rest window
3390
3296
  syncOrderFromDom();
3391
3297
  announceMoved(moved); // the ONE completed-move announcement per gesture
3392
3298
  });
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.
3299
+ // Leaving the strip withdraws a pending slot. The document dragover below does
3300
+ // this too and covers more ground, but it cannot cover the case that matters most
3301
+ // here: the strip is docked at the viewport EDGE, so a pointer can leave it by
3302
+ // leaving the window entirely, and then no other element in the document receives a
3303
+ // dragover to notice with. A pointer that leaves the window is also, by definition,
3304
+ // one that has stopped moving over the strip — so without this the rest window
3305
+ // would run down and open a slot the pointer had already abandoned.
3399
3306
  //
3400
3307
  // relatedTarget is what makes dragleave usable at all: it fires on every
3401
3308
  // child-to-child transition inside the bar (chip to label, label to close), and
@@ -3408,7 +3315,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3408
3315
  }
3409
3316
  const next = e.relatedTarget;
3410
3317
  if (next === null || !bar.contains(next as Node)) {
3411
- cancelDwell();
3318
+ cancelRest();
3412
3319
  }
3413
3320
  });
3414
3321
  // A tab released anywhere OTHER than the strip — over the terminal, over
@@ -3435,17 +3342,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3435
3342
  // refused release fell through to dragend and reverted. That distinction is
3436
3343
  // invisible to the person doing the dragging.
3437
3344
  //
3438
- // So: leave `dropped` false, stand the hold down, and let dragend run the
3345
+ // So: leave `dropped` false, withdraw anything pending, and let dragend run the
3439
3346
  // revert. Nothing is lost silently — the revert animates and announces, so a
3440
3347
  // mis-release reads as "that did not take" rather than as a mystery.
3441
- cancelDwell();
3348
+ cancelRest();
3442
3349
  return;
3443
3350
  }
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();
3351
+ // dragover, off the strip: no candidate slot exists out here, so withdraw a
3352
+ // pending one rather than let the rest window open a gap for a target the pointer
3353
+ // has already left. A slot already COMMITTED stays put; the pointer may yet come
3354
+ // back onto the strip and release there.
3355
+ cancelRest();
3449
3356
  };
3450
3357
  document.addEventListener("dragover", onDocTabDrop);
3451
3358
  document.addEventListener("drop", onDocTabDrop);
@@ -21,29 +21,29 @@ 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: it is re-armed by MOVEMENT, so it expires a
28
+ * rest window after the last movement rather than a fixed time after a decision.
29
+ * Sweeping the strip rearranges nothing; stopping is what commits.
32
30
  *
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;
38
-
39
- /** How far (px) each chip a commit would displace leans toward its destination
40
- * while the hold runs.
31
+ * **The floor is set by the drag loop, not by taste.** HTML5 drag-and-drop only
32
+ * guarantees a `dragover` every 350ms — the processing model runs on that cadence,
33
+ * not per mouse movement — so any window at or below it expires BETWEEN two events
34
+ * of a fast sweep, and the strip commits every slot the pointer crosses. That is
35
+ * exactly what 120ms did in production: a quick pass over five tabs moved all five.
36
+ * 500ms clears the cadence with margin and is the reason this number is not smaller.
41
37
  *
42
- * 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. */
46
- export const REORDER_LEAN_PX = 7;
38
+ * It costs a decisive user nothing, which is what makes the margin affordable: a
39
+ * release commits whatever slot is pending at once (flushRest), so the window is only
40
+ * ever felt by a pointer that has stopped and is waiting to see the gap open. */
41
+ export const REORDER_REST_MS = 500;
42
+
43
+ /** Movement (px) between two dragover events below which the pointer counts as still.
44
+ * A hand resting on a mouse is never perfectly still, and treating a 1px tremor as a
45
+ * sweep would keep pushing the commit out for as long as someone held the tab. */
46
+ export const REORDER_MOVE_EPS_PX = 3;
47
47
 
48
48
  /** The one transition both preview stages use (the lean, and the slide that
49
49
  * commits it).