@cplieger/web-terminal-ui 5.4.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "5.4.1",
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",
@@ -43,7 +43,6 @@ import {
43
43
  } from "./model.js";
44
44
  import { browserNotifierEnv, createNotifier } from "./notify.js";
45
45
  import {
46
- REORDER_LEAN_PX,
47
46
  REORDER_MOVE_EPS_PX,
48
47
  REORDER_REST_MS,
49
48
  REORDER_SETTLE_MS,
@@ -667,10 +666,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
667
666
  let activeId: string | null = null;
668
667
  let draggingEl: HTMLElement | null = null;
669
668
  // --- Desktop reorder preview state (mechanism below, near dragTargetBefore) ---
670
- // The chips currently carrying an inline transform: the lean during a hold,
671
- // or the slide that commits it. One set, because both stages write the same
672
- // two properties on the same elements and one settle function has to be able
673
- // 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.
674
672
  const shifted = new Set<HTMLElement>();
675
673
  let shiftTimer: ReturnType<typeof setTimeout> | null = null;
676
674
  // The slot waiting to open and the rest window that will open it.
@@ -1833,33 +1831,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1833
1831
  // in. The chip you were dragging looked like the slot it left, the slot it
1834
1832
  // was over, and the slot it would end up in.
1835
1833
  //
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):
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
- // - 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.
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.
1851
1845
  //
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.
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).
1860
1855
  //
1861
1856
  // A release never waits either: `drop` commits whatever slot is pending
1862
- // (flushRest), so dropping mid-sweep lands the tab where the lean said.
1857
+ // (flushRest), so dropping mid-sweep still lands the tab where it was headed.
1863
1858
  //
1864
1859
  // dropTargetBefore returns the first tab whose horizontal midpoint is past
1865
1860
  // x (the element the dragged tab should sit before), or null to drop at the
@@ -1869,8 +1864,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1869
1864
  //
1870
1865
  // It hit-tests LAYOUT geometry (offsetLeft/offsetWidth), never
1871
1866
  // getBoundingClientRect. That is not an optimisation, it is what makes an
1872
- // animated reorder hit-testable at all: a rect read while a chip is mid-lean or
1873
- // 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
1874
1869
  // back into the decision that produced it and the strip oscillates between two
1875
1870
  // slots for as long as the pointer sits near a boundary. Layout offsets are the
1876
1871
  // chip's position in the FLOW, which a transform does not affect at all.
@@ -1942,87 +1937,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1942
1937
  shifted.clear();
1943
1938
  }
1944
1939
 
1945
- // leanToward tips the chips a commit would displace a few px toward their
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.
1953
- //
1954
- // Chips that were leaning and no longer would be are written back to zero in
1955
- // the SAME pass, so a changed candidate eases from one lean into the other
1956
- // rather than snapping through rest first.
1957
- function leanToward(before: HTMLElement | null): void {
1958
- if (!draggingEl) {
1959
- return;
1960
- }
1961
- // The preference is read live, so it can flip mid-gesture. Clearing here rather
1962
- // than returning bare is what honours a mid-hold opt-in: a bare return would
1963
- // leave the lean already applied under the previous preference sitting there.
1964
- if (prefersReduce()) {
1965
- endShift();
1966
- return;
1967
- }
1968
- const chips = [...scroller.querySelectorAll<HTMLElement>(".wt-tab")];
1969
- const from = chips.indexOf(draggingEl);
1970
- const to = before === null ? chips.length : chips.indexOf(before);
1971
- if (from < 0 || to < 0) {
1972
- return;
1973
- }
1974
- // Moving right past the chips in between shifts them LEFT, and moving left
1975
- // shifts the chips it displaces RIGHT — the direction the user reads as
1976
- // "that tab is getting out of the way".
1977
- const movers = from < to ? chips.slice(from + 1, to) : chips.slice(to, from);
1978
- const dx = from < to ? -REORDER_LEAN_PX : REORDER_LEAN_PX;
1979
- const next = new Map<HTMLElement, number>();
1980
- for (const el of movers) {
1981
- next.set(el, dx);
1982
- }
1983
- for (const el of shifted) {
1984
- if (!next.has(el)) {
1985
- next.set(el, 0);
1986
- }
1987
- }
1988
- applyShift(next, REORDER_SHIFT_TRANS);
1989
- }
1990
- // releaseLean eases every leaning chip back to rest and settles it, for when
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.
1994
- function releaseLean(): void {
1995
- if (shifted.size === 0) {
1996
- return;
1997
- }
1998
- // ...except under reduced motion, where snapping IS the requested behaviour and
1999
- // easing back would animate the very motion the preference opted out of.
2000
- if (prefersReduce()) {
2001
- endShift();
2002
- return;
2003
- }
2004
- const rest = new Map<HTMLElement, number>();
2005
- for (const el of shifted) {
2006
- rest.set(el, 0);
2007
- }
2008
- applyShift(rest, REORDER_SHIFT_TRANS);
2009
- shiftTimer = setTimeout(endShift, REORDER_SETTLE_MS);
2010
- }
2011
1940
  // trackRest is the whole gate, called on every dragover over the strip.
2012
1941
  //
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.
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.
2019
1945
  //
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.
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).
2026
1952
  function trackRest(clientX: number): void {
2027
1953
  const dragged = draggingEl;
2028
1954
  if (!dragged) {
@@ -2037,15 +1963,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2037
1963
  cancelRest();
2038
1964
  return;
2039
1965
  }
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
2048
- }
1966
+ restBefore = before;
2049
1967
  // Movement pushes the commit out; stillness lets the armed timer run down to it.
2050
1968
  // A stationary pointer that has nothing armed still needs one — a browser may
2051
1969
  // deliver dragover repeatedly at an unchanged position, and the first of those is
@@ -2069,11 +1987,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2069
1987
  clearTimeout(restTimer);
2070
1988
  restTimer = null;
2071
1989
  restBefore = null;
2072
- releaseLean();
2073
1990
  }
2074
1991
  // flushRest commits a pending slot at once, because a release is a decision. It is
2075
1992
  // 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;
1993
+ // mid-sweep and the tab still lands where it was headed. Only a `drop` calls it;
2077
1994
  // an abandoned drag reaches dragend without one and reverts.
2078
1995
  function flushRest(): void {
2079
1996
  if (restTimer === null) {
@@ -2103,18 +2020,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2103
2020
  // belongs HERE and not only in the CSS, because these transitions are written
2104
2021
  // inline and no stylesheet gate can reach them; the switcher's release reel
2105
2022
  // guards its own call site the same way. Read live, so toggling the OS setting
2106
- // mid-drag takes effect on the next commit and strips any existing lean.
2023
+ // mid-drag takes effect on the very next commit.
2107
2024
  if (prefersReduce()) {
2108
2025
  endShift();
2109
2026
  mutate();
2110
2027
  return;
2111
2028
  }
2112
- // FIRST — where each chip is right now, LEAN INCLUDED, so a slide continues
2113
- // out of the lean instead of jumping back through rest first. The switcher's
2114
- // release reel captures its live swipe preview the same way, and for the
2115
- // same reason. Rects, not layout offsets: this is VISUAL position, which is
2116
- // what the animation interpolates (hit-testing wants the opposite, see
2117
- // 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).
2118
2035
  const first = new Map<HTMLElement, number>();
2119
2036
  for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
2120
2037
  if (el !== hold) {
@@ -2159,14 +2076,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2159
2076
  // an OSC title) renders the committed order rather than flickering through
2160
2077
  // whatever the pointer is hovering.
2161
2078
  //
2162
- // Every early return releases the lean, and that is not belt-and-braces. The
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.
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.
2166
2084
  function commitSlot(before: HTMLElement | null): void {
2167
2085
  const dragged = draggingEl;
2168
2086
  if (!dragged?.isConnected) {
2169
- releaseLean();
2170
2087
  return; // the dragged session was closed elsewhere mid-gesture
2171
2088
  }
2172
2089
  // The pending slot was chosen a rest window ago, and a session closed in another
@@ -2176,11 +2093,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2176
2093
  // child, which would abandon the drop mid-way and leave DOM order and tabList
2177
2094
  // disagreeing. null stays legal: it means "past the last chip".
2178
2095
  if (before !== null && before.parentNode !== scroller) {
2179
- releaseLean();
2180
2096
  return;
2181
2097
  }
2182
2098
  if (before === dragged || before === dragged.nextElementSibling) {
2183
- releaseLean();
2184
2099
  return; // already in that slot
2185
2100
  }
2186
2101
  flipTo(() => {
@@ -2270,8 +2185,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2270
2185
  }
2271
2186
  }
2272
2187
 
2273
- // endReorderPreview ends the GESTURE's state: the pending hold (easing any
2274
- // 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.
2275
2190
  //
2276
2191
  // It deliberately does NOT stop a slide that is already playing. A slide is an
2277
2192
  // animation with its own settle timer, and cutting it here is exactly what
@@ -2291,9 +2206,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2291
2206
  // sweep). Two removals matter, for different reasons:
2292
2207
  //
2293
2208
  // - the pending reference chip: commitSlot would hand a detached node to
2294
- // insertBefore. It guards that too, but standing the hold down here means the
2295
- // user's lean comes off at the moment the target vanishes rather than a second
2296
- // 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.
2297
2212
  // - the DRAGGED chip: the gesture is over and there is no source left to deliver
2298
2213
  // a dragend. A browser is not obliged to fire one for a removed source, and
2299
2214
  // without this the feature would keep `draggingEl` set to a detached node for
@@ -3353,7 +3268,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3353
3268
  return false;
3354
3269
  });
3355
3270
  // 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).
3271
+ // rearranges nothing; the slot opens when the pointer comes to rest (trackRest).
3357
3272
  bar.addEventListener("dragover", (e) => {
3358
3273
  if (!draggingEl) {
3359
3274
  return;
@@ -24,34 +24,27 @@ import { statusPhrase, statusRevealsDot } from "./model.js";
24
24
  /** The quiet window (ms) that means the pointer has STOPPED. When it elapses with no
25
25
  * movement, the strip opens the slot the pointer is over.
26
26
  *
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.
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
- * 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.
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.
37
37
  *
38
- * A release never waits for it either — a drop commits whatever is pending (flushRest). */
39
- export const REORDER_REST_MS = 120;
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;
40
42
 
41
43
  /** Movement (px) between two dragover events below which the pointer counts as still.
42
44
  * A hand resting on a mouse is never perfectly still, and treating a 1px tremor as a
43
45
  * sweep would keep pushing the commit out for as long as someone held the tab. */
44
46
  export const REORDER_MOVE_EPS_PX = 3;
45
47
 
46
- /** How far (px) each chip a commit would displace leans toward its destination while
47
- * the pointer is still sweeping.
48
- *
49
- * Small enough to read as a lean rather than a move, and large enough to be
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. */
53
- export const REORDER_LEAN_PX = 7;
54
-
55
48
  /** The one transition both preview stages use (the lean, and the slide that
56
49
  * commits it).
57
50
  *