@cplieger/web-terminal-ui 5.3.1 → 5.4.0

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.
@@ -192,66 +192,6 @@ export const ACTIVE_TAB_KEY = "wt-active-session";
192
192
  // One-time "swipe to switch" hint, remembered across loads.
193
193
  export const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
194
194
 
195
- /** localStorage key for the user's tab ARRANGEMENT: the strip's session ids in
196
- * the order they are shown, so a drag-reorder (or a Move left/right) survives a
197
- * reload instead of snapping back to the server's creation order.
198
- *
199
- * Deliberately client-side, alongside ACTIVE_TAB_KEY: the arrangement is a
200
- * per-viewer preference, not a property of the session. Keeping it here costs
201
- * no engine API — the session REST surface is a fixed four routes, and adding a
202
- * fifth for cosmetics would be a release-noted change to every consumer. The
203
- * tradeoff is that two devices do not share an arrangement (each keeps its own),
204
- * and that when several windows of the same browser are open the last order
205
- * change written wins. */
206
- export const TAB_ORDER_KEY = "wt-tab-order";
207
-
208
- /** Bound on the persisted arrangement. Every id is a live PTY server-side, so a
209
- * real strip is nowhere near this; the cap is there so a corrupted or hostile
210
- * stored value cannot make the restore path do unbounded work. */
211
- export const MAX_PERSISTED_TAB_ORDER = 200;
212
-
213
- /** parseTabOrder reads a stored arrangement into a clean id list. Anything the
214
- * restore path cannot trust yields [] (or is dropped), because falling back to
215
- * the server's creation order is always a valid arrangement: a non-JSON or
216
- * non-array value, a non-string or empty entry, and a duplicate id (which would
217
- * make the insert position ambiguous) are all discarded rather than repaired.
218
- * Pure, so it is testable without a storage backend; the caller owns the
219
- * localStorage read and its try/catch. */
220
- export function parseTabOrder(raw: string | null): string[] {
221
- if (raw === null || raw === "") {
222
- return [];
223
- }
224
- let data: unknown;
225
- try {
226
- data = JSON.parse(raw);
227
- } catch {
228
- return [];
229
- }
230
- if (!Array.isArray(data)) {
231
- return [];
232
- }
233
- const out: string[] = [];
234
- const seen = new Set<string>();
235
- for (const entry of data) {
236
- if (typeof entry !== "string" || entry === "" || seen.has(entry)) {
237
- continue;
238
- }
239
- seen.add(entry);
240
- out.push(entry);
241
- if (out.length >= MAX_PERSISTED_TAB_ORDER) {
242
- break;
243
- }
244
- }
245
- return out;
246
- }
247
-
248
- /** serializeTabOrder encodes an arrangement for storage, capped the same way the
249
- * read is so a write can never produce a value its own parser would truncate
250
- * differently. */
251
- export function serializeTabOrder(ids: readonly string[]): string {
252
- return JSON.stringify(ids.slice(0, MAX_PERSISTED_TAB_ORDER));
253
- }
254
-
255
195
  /** The session statuses that raise the mobile switcher's aggregate attention
256
196
  * cue. The rule is "only states that want the user — it's like a notification":
257
197
  * a background terminal blocked on the user ("input"), one whose turn finished
@@ -278,11 +218,12 @@ export function isCueStatus(status: string): status is CueStatus {
278
218
  * back on the next page load — and, since the snapshot is re-pushed on every
279
219
  * SSE reconnect, on a phone simply returning to a backgrounded page.
280
220
  *
281
- * Client-side for the same reason as ACTIVE_TAB_KEY and TAB_ORDER_KEY: "I have
282
- * seen this" is a property of the VIEWER, not of the session. A phone
283
- * acknowledging a finished turn must not blank the dot on the desktop watching
284
- * the same server, so this needs no engine API and the session REST surface
285
- * stays the fixed four routes.
221
+ * Client-side for the same reason as ACTIVE_TAB_KEY: "I have seen this" is a
222
+ * property of the VIEWER, not of the session. A phone acknowledging a finished
223
+ * turn must not blank the dot on the desktop watching the same server, so this
224
+ * needs no engine API. Contrast the tab ARRANGEMENT, which is a property of the
225
+ * session set and so is the server's (see compareTabOrder): a reorder is meant
226
+ * to reach the other device, and an acknowledgement is not.
286
227
  *
287
228
  * Keyed per session rather than as one latest-wins slot: several background tabs
288
229
  * can hold a latched status at once while the cue only ever shows the newest, so
@@ -290,10 +231,9 @@ export function isCueStatus(status: string): status is CueStatus {
290
231
  * the next load. */
291
232
  export const CUE_SEEN_KEY = "wt-cue-seen";
292
233
 
293
- /** Bound on the acknowledgement map, same reasoning as MAX_PERSISTED_TAB_ORDER:
294
- * every key is a live session server-side, so a real one is nowhere near this,
295
- * and a corrupted or hostile stored value cannot make the restore path do
296
- * unbounded work. */
234
+ /** Bound on the acknowledgement map: every key is a live session server-side, so
235
+ * a real one is nowhere near this, and a corrupted or hostile stored value
236
+ * cannot make the restore path do unbounded work. */
297
237
  export const MAX_PERSISTED_CUE_SEEN = 200;
298
238
 
299
239
  /** parseCueSeen reads stored acknowledgements into a clean map. Anything it
@@ -336,31 +276,89 @@ export function serializeCueSeen(seen: ReadonlyMap<string, CueStatus>): string {
336
276
  return JSON.stringify(Object.fromEntries(seen));
337
277
  }
338
278
 
339
- /** orderedInsertIndex returns where `id` belongs in `current` (a list of tab ids
340
- * in display order) to honour the stored arrangement `saved`.
279
+ /** TabOrderKey is what the strip's order is decided by: the server's shared
280
+ * position for the session, its creation timestamp, and its id. Both `Tab` and
281
+ * the wire `SessionInfo` carry these three fields, so either can be passed
282
+ * without a projection. */
283
+ export interface TabOrderKey {
284
+ readonly id: string;
285
+ readonly createdAt: string;
286
+ /** the server's shared position, absent when the server keeps no order (an
287
+ * engine before 3.9.0). Explicitly `| undefined` so a Tab, whose field is
288
+ * always present and sometimes undefined, satisfies this under
289
+ * exactOptionalPropertyTypes. */
290
+ readonly order?: number | undefined;
291
+ }
292
+
293
+ /** ORDER_ABSENT is the position given to a session the server did not place, so
294
+ * it sorts after every session the server did. Absent must not read as 0, which
295
+ * is a real position at the front of the strip. */
296
+ const ORDER_ABSENT = Number.MAX_SAFE_INTEGER;
297
+
298
+ /** createdMillis reads a wire createdAt as a number for comparison. An
299
+ * unparseable value sorts LAST rather than first: the caller's question is where
300
+ * a tab belongs in a strip, and the end is the answer that reads as "new" —
301
+ * putting a tab the client cannot date at the head would rewrite the top of the
302
+ * strip on the strength of a value it failed to read.
303
+ *
304
+ * Milliseconds, deliberately, against a wire value carrying nanoseconds: the
305
+ * server's own tiebreak is the id, this one's is too, and the pair therefore
306
+ * agree on every input a human can produce. Comparing the STRINGS instead would
307
+ * be wrong, not merely coarse — Go's RFC 3339 encoding drops trailing zeros
308
+ * from the fraction, so ".15" sorts before ".1" lexically. */
309
+ function createdMillis(raw: string): number {
310
+ const when = Date.parse(raw);
311
+ return Number.isNaN(when) ? Number.MAX_SAFE_INTEGER : when;
312
+ }
313
+
314
+ /** compareTabOrder is the total order the strip is built in: the server's shared
315
+ * position, then age, then id.
341
316
  *
342
- * This is what makes the restore independent of the order tabs ARRIVE in, which
343
- * matters because they arrive from two racing sources: the status stream pushes
344
- * a snapshot of the existing sessions on open, and the bootstrap's
345
- * GET /api/sessions lands separately — whichever wins, each session is placed
346
- * by the saved arrangement rather than by arrival.
317
+ * The server owns the arrangement (engine 3.9.0, `PUT /api/sessions/order`), so
318
+ * a drag on one device moves the tab on every device, and a browser with no
319
+ * history of this server still opens on the arrangement its owner chose. This
320
+ * replaced a per-browser `localStorage` arrangement, which by construction could
321
+ * not travel and which left a second device with no arrangement at all.
347
322
  *
348
- * An id absent from `saved` goes last: it is a session created after the
349
- * arrangement was stored (here or in another browser), and a new tab belongs at
350
- * the end. Ids in `current` that are absent from `saved` are transparent to the
351
- * scan, so a saved tab can still slot ahead of them. */
352
- export function orderedInsertIndex(
353
- current: readonly string[],
354
- saved: readonly string[],
355
- id: string,
356
- ): number {
357
- const rank = saved.indexOf(id);
358
- if (rank < 0) {
359
- return current.length;
323
+ * Age and id remain, and each answers a case the position cannot: age orders
324
+ * every session when the server keeps no order (an older engine sends no field,
325
+ * so all positions are absent and creation order is the honest fallback), and
326
+ * the id makes the order TOTAL, so two sessions created inside one millisecond
327
+ * cannot swap places between two runs.
328
+ *
329
+ * Age is read from the `createdAt` field rather than from the wire SEQUENCE on
330
+ * purpose. Sessions arrive from two racing sources — the status stream's
331
+ * snapshot on open, and the bootstrap's GET /api/sessions — so a client that
332
+ * merges them sees neither source's order, however well each source sorted. */
333
+ export function compareTabOrder(a: TabOrderKey, b: TabOrderKey): number {
334
+ const posA = a.order ?? ORDER_ABSENT;
335
+ const posB = b.order ?? ORDER_ABSENT;
336
+ if (posA !== posB) {
337
+ return posA < posB ? -1 : 1;
338
+ }
339
+ const bornA = createdMillis(a.createdAt);
340
+ const bornB = createdMillis(b.createdAt);
341
+ if (bornA !== bornB) {
342
+ return bornA < bornB ? -1 : 1;
343
+ }
344
+ if (a.id === b.id) {
345
+ return 0;
360
346
  }
347
+ return a.id < b.id ? -1 : 1;
348
+ }
349
+
350
+ /** orderedInsertIndex returns where `incoming` belongs in `current` (the tabs
351
+ * already on the strip, in display order) under compareTabOrder: the index of
352
+ * the first tab that sorts after it, else the end.
353
+ *
354
+ * This is what makes the strip independent of the order tabs ARRIVE in. Each
355
+ * insertion goes before the first tab that outranks the new one, which keeps a
356
+ * list built from an empty strip sorted at every step, so all arrival orders of
357
+ * one session set converge on the same strip. */
358
+ export function orderedInsertIndex(current: readonly TabOrderKey[], incoming: TabOrderKey): number {
361
359
  for (let i = 0; i < current.length; i++) {
362
- const other = saved.indexOf(current[i] ?? "");
363
- if (other > rank) {
360
+ const other = current[i];
361
+ if (other !== undefined && compareTabOrder(other, incoming) > 0) {
364
362
  return i;
365
363
  }
366
364
  }
@@ -387,6 +385,10 @@ export interface Tab {
387
385
  /** The computed, de-duplicated label actually shown in the chrome. */
388
386
  display: string;
389
387
  createdAt: string;
388
+ /** The server's shared position for this session, or undefined against a server
389
+ * that keeps no order. Updated from every status event, so a reorder made on
390
+ * another device reaches this strip (see compareTabOrder). */
391
+ order: number | undefined;
390
392
  store: LineStore;
391
393
  el: HTMLElement;
392
394
  label: HTMLElement;
@@ -480,6 +482,17 @@ export interface SessionAPI {
480
482
  /** Remove a session's pinned name so its label falls back to the automatic
481
483
  * sources. Throws on failure, for the same reason as setPinnedTitle. */
482
484
  clearPinnedTitle(id: string): Promise<void>;
485
+ /** Replace the display order every viewer of this server shares, by sending
486
+ * every live session id in the wanted order.
487
+ *
488
+ * THROWS on failure, and the status is the point. A 409 means the server's
489
+ * session set is not the one the caller listed, so this client has not yet
490
+ * seen a session created or closed elsewhere; the caller answers it by
491
+ * re-listing and sending again, not by telling the user. Any other status is a
492
+ * genuine failure of a reorder the user performed.
493
+ *
494
+ * Absent from a server before engine 3.9.0, where it answers 404. */
495
+ setOrder(ids: readonly string[]): Promise<void>;
483
496
  }
484
497
 
485
498
  const SESSION_API_TIMEOUT_MS = 15000;
@@ -639,6 +652,19 @@ export function createSessionAPI(apiBase: string): SessionAPI {
639
652
  throw await sessionError("clear pinned title", r);
640
653
  }
641
654
  },
655
+ // The shared display order. The path is a literal segment rather than an id,
656
+ // so nothing is interpolated into it.
657
+ async setOrder(ids: readonly string[]): Promise<void> {
658
+ const r = await fetch(`${apiBase}/order`, {
659
+ method: "PUT",
660
+ headers: { "Content-Type": "application/json" },
661
+ body: JSON.stringify({ order: ids }),
662
+ signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
663
+ });
664
+ if (!r.ok) {
665
+ throw await sessionError("set order", r);
666
+ }
667
+ },
642
668
  };
643
669
  }
644
670
 
@@ -1,16 +1,84 @@
1
- // tabs/strip.ts — the desktop tab strip's chrome vocabulary: the shared SVG
2
- // glyphs, the one chip-content builder every chip site reuses, the strip tab
3
- // template, the shared control markup factories, the status-dot painter, and
4
- // the required-descendant picker. Everything here is closure-free markup +
5
- // tiny pure helpers; element construction with event wiring stays in index.ts
6
- // (it closes over the feature state). The mobile chrome templates live in
7
- // switcher.ts; the session model in model.ts.
1
+ // tabs/strip.ts — the desktop tab strip's chrome vocabulary: the reorder-preview
2
+ // constants, the shared SVG glyphs, the one chip-content builder every chip site
3
+ // reuses, the strip tab template, the shared control markup factories, the
4
+ // status-dot painter, and the required-descendant picker. Everything here is
5
+ // closure-free markup + tiny pure helpers; element construction with event wiring
6
+ // stays in index.ts (it closes over the feature state). The mobile chrome
7
+ // templates and its gesture constants live in switcher.ts; the session model in
8
+ // model.ts.
8
9
 
9
10
  // The status vocabulary itself (which statuses reveal a dot, and how each one is
10
11
  // worded for a human) lives in the DOM-free model, so the painters here and the
11
12
  // accessible names index.ts builds read one definition.
12
13
  import { statusPhrase, statusRevealsDot } from "./model.js";
13
14
 
15
+ // --- Reorder preview -------------------------------------------------------
16
+ // Every chip in this strip is the same width (see .wt-tab in 30-tabs.css: a
17
+ // definite 300px with flex-grow 0, so a title changes the label and never the
18
+ // box). That is what makes a reorder hard to read: the chip a release would
19
+ // displace looks exactly like the one beside it, so the only thing that can say
20
+ // where the drag will land is the strip's own motion. These are the numbers that
21
+ // motion is spelled in — index.ts owns the mechanism, strip.ts owns the values,
22
+ // the same split switcher.ts keeps for the mobile swipe.
23
+
24
+ /** How long (ms) the pointer holds over one candidate slot before the strip
25
+ * opens it.
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.
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;
38
+
39
+ /** How far (px) each chip a commit would displace leans toward its destination
40
+ * while the hold runs.
41
+ *
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;
47
+
48
+ /** The one transition both preview stages use (the lean, and the slide that
49
+ * commits it).
50
+ *
51
+ * --dur-standard and --ease-standard written out as literals. JS-driven motion
52
+ * in this package does not read tokens back out of the cascade; the switcher's
53
+ * release reel spells its own transition the same way. Keep the two in step: a
54
+ * token edit has to be mirrored here by hand.
55
+ *
56
+ * The property is `translate`, NOT `transform`, and that is load-bearing rather
57
+ * than stylistic. Declarations from a running CSS animation out-rank every normal
58
+ * author declaration, inline style included, so a chip in the middle of
59
+ * `wt-slot-in` or `wt-tab-in` (both animate `transform: scale`) would ignore an
60
+ * inline `transform` outright and refuse to move. `translate` is a separate
61
+ * property that composes with `transform` instead of competing for it. */
62
+ export const REORDER_SHIFT_TRANS = "translate 0.2s cubic-bezier(0.2, 0, 0, 1)";
63
+
64
+ /** When (ms after the slide starts) the inline transform and transition come off
65
+ * the chips and the stylesheet has them back.
66
+ *
67
+ * A margin past the 200ms transition, not a race with its own end event: an
68
+ * interrupted transition fires no transitionend, and the strip must not be left
69
+ * holding inline styles because a second drag arrived mid-slide. Mirrors the
70
+ * switcher reel's 300ms net over a 250ms transition. */
71
+ export const REORDER_SETTLE_MS = 300;
72
+
73
+ /** How long (ms) the .wt-tab-slotted class stays on, one margin past the
74
+ * --dur-enter fade the stylesheet runs with it.
75
+ *
76
+ * A timer rather than animationend for the same reason .wt-tab-enter uses one:
77
+ * the class must also come off when no animation ran at all — the animations
78
+ * feature is optional, reduced motion flattens it, and a consumer stylesheet can
79
+ * drop the rule. */
80
+ export const REORDER_SLOT_FADE_MS = 300;
81
+
14
82
  // The +/x/keyboard glyphs are inline SVG (not font glyphs) so they center
15
83
  // exactly in their flex-centered buttons and stay symmetric regardless of the UI
16
84
  // font's metrics. Each is defined ONCE here and shared by every chip site and
@@ -0,0 +1,29 @@
1
+ // The switch-animation class → keyframe-name map, in one place because two
2
+ // unrelated languages have to agree on it and neither compiler checks the other.
3
+ //
4
+ // `css/40-animations.css` declares the animation on `.term-output` for each of the
5
+ // three `wt-switching*` classes the tabs feature toggles, and `features/tabs`
6
+ // listens for the `animationend` of the ONE name that belongs to the switch it
7
+ // started (a listener accepting any of the three lets a completed animation end the
8
+ // NEXT switch a frame in). So the JS holds a copy of three CSS keyframe names, and a
9
+ // rename on either side is silent: the listener stops matching, the class comes off
10
+ // on the 360 ms fallback timer instead of the animation's own end, and every test
11
+ // still passes because the tests dispatch the JS constant.
12
+ //
13
+ // Living here rather than inside the feature is what lets `css-contract.test.ts`
14
+ // import it and assert the CSS agrees. Same reason `input-placeholder.ts` and
15
+ // `kernel/gesture.ts` exist: a constant two places must keep in lockstep gets its
16
+ // own module. NOT part of the package's `exports` map, so it is internal and can
17
+ // change without a release note; the PUBLIC name contracts live in
18
+ // `kernel/style-contract.ts`.
19
+ export const SWITCH_ANIMATIONS = {
20
+ "wt-switching": "wt-switch-in",
21
+ "wt-switching-next": "wt-switch-next",
22
+ "wt-switching-prev": "wt-switch-prev",
23
+ } as const;
24
+
25
+ /** A class the tabs feature puts on the terminal surface to play a switch animation. */
26
+ export type SwitchClass = keyof typeof SWITCH_ANIMATIONS;
27
+
28
+ /** The three classes, for a caller that has to clear whichever one is present. */
29
+ export const SWITCH_CLASSES = Object.keys(SWITCH_ANIMATIONS) as SwitchClass[];
@@ -513,9 +513,12 @@ function buildTerminal(
513
513
  // WITHIN history never toggles follow, and that is exactly when paging has
514
514
  // to work. The trigger re-evaluates its own guards, so firing for the
515
515
  // browser's own clamps as well as gestures costs nothing.
516
- onScrollPosition: () => {
517
- render.maybeFetchHistory();
518
- },
516
+ //
517
+ // ONE hook, deliberately. The renderer's scroll-position contract is two jobs
518
+ // (re-evaluate the paging trigger, and finish a drain that stopped with rows
519
+ // queued) and it owns the ordering and the guards for both. A consumer wiring
520
+ // them as two calls is a consumer that can wire one.
521
+ onScrollPosition: render.handleScrollPosition,
519
522
  });
520
523
 
521
524
  // The size this client would announce right now, or null when it cannot