@cplieger/web-terminal-ui 5.3.1 → 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/LICENSE +371 -690
- package/README.md +1 -1
- package/css/01-scope.css +10 -0
- package/css/30-tabs.css +42 -3
- package/css/40-animations.css +34 -1
- package/package.json +6 -6
- package/src/features/tabs/index.ts +798 -123
- package/src/features/tabs/model.ts +116 -90
- package/src/features/tabs/strip.ts +82 -7
- package/src/features/tabs/switch-anim.ts +29 -0
- package/src/kernel/kernel.ts +6 -3
|
@@ -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
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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
|
-
/**
|
|
340
|
-
*
|
|
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
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
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
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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 =
|
|
363
|
-
if (other >
|
|
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,91 @@
|
|
|
1
|
-
// tabs/strip.ts — the desktop tab strip's chrome vocabulary: the
|
|
2
|
-
// glyphs, the one chip-content builder every chip site
|
|
3
|
-
// template, the shared control markup factories, the
|
|
4
|
-
// the required-descendant picker. Everything here is
|
|
5
|
-
// tiny pure helpers; element construction with event wiring
|
|
6
|
-
// (it closes over the feature state). The mobile chrome
|
|
7
|
-
// switcher.ts; the session model in
|
|
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
|
+
/** 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
|
+
*
|
|
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
|
+
*
|
|
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;
|
|
45
|
+
|
|
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
|
+
/** The one transition both preview stages use (the lean, and the slide that
|
|
56
|
+
* commits it).
|
|
57
|
+
*
|
|
58
|
+
* --dur-standard and --ease-standard written out as literals. JS-driven motion
|
|
59
|
+
* in this package does not read tokens back out of the cascade; the switcher's
|
|
60
|
+
* release reel spells its own transition the same way. Keep the two in step: a
|
|
61
|
+
* token edit has to be mirrored here by hand.
|
|
62
|
+
*
|
|
63
|
+
* The property is `translate`, NOT `transform`, and that is load-bearing rather
|
|
64
|
+
* than stylistic. Declarations from a running CSS animation out-rank every normal
|
|
65
|
+
* author declaration, inline style included, so a chip in the middle of
|
|
66
|
+
* `wt-slot-in` or `wt-tab-in` (both animate `transform: scale`) would ignore an
|
|
67
|
+
* inline `transform` outright and refuse to move. `translate` is a separate
|
|
68
|
+
* property that composes with `transform` instead of competing for it. */
|
|
69
|
+
export const REORDER_SHIFT_TRANS = "translate 0.2s cubic-bezier(0.2, 0, 0, 1)";
|
|
70
|
+
|
|
71
|
+
/** When (ms after the slide starts) the inline transform and transition come off
|
|
72
|
+
* the chips and the stylesheet has them back.
|
|
73
|
+
*
|
|
74
|
+
* A margin past the 200ms transition, not a race with its own end event: an
|
|
75
|
+
* interrupted transition fires no transitionend, and the strip must not be left
|
|
76
|
+
* holding inline styles because a second drag arrived mid-slide. Mirrors the
|
|
77
|
+
* switcher reel's 300ms net over a 250ms transition. */
|
|
78
|
+
export const REORDER_SETTLE_MS = 300;
|
|
79
|
+
|
|
80
|
+
/** How long (ms) the .wt-tab-slotted class stays on, one margin past the
|
|
81
|
+
* --dur-enter fade the stylesheet runs with it.
|
|
82
|
+
*
|
|
83
|
+
* A timer rather than animationend for the same reason .wt-tab-enter uses one:
|
|
84
|
+
* the class must also come off when no animation ran at all — the animations
|
|
85
|
+
* feature is optional, reduced motion flattens it, and a consumer stylesheet can
|
|
86
|
+
* drop the rule. */
|
|
87
|
+
export const REORDER_SLOT_FADE_MS = 300;
|
|
88
|
+
|
|
14
89
|
// The +/x/keyboard glyphs are inline SVG (not font glyphs) so they center
|
|
15
90
|
// exactly in their flex-centered buttons and stay symmetric regardless of the UI
|
|
16
91
|
// 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[];
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -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
|
-
|
|
517
|
-
|
|
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
|