@celestia-island/hikari 0.40.18 → 0.40.21

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.
@@ -0,0 +1,186 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ SURFACE_EVENTS,
5
+ SURFACE_PHASES,
6
+ classPairOf,
7
+ classesForPair,
8
+ isMounted,
9
+ isQuiescent,
10
+ surfaceTransition,
11
+ type SurfaceEventType,
12
+ type SurfacePhase,
13
+ } from "./surfaceMachine";
14
+
15
+ /** The spec table as data — the single reviewable proof artifact. The
16
+ * reducer must agree with it on every cell; this test is the diff. */
17
+ const SPEC: Record<SurfacePhase, Record<SurfaceEventType, SurfacePhase>> = {
18
+ closed: {
19
+ OPEN: "openingFrom",
20
+ CLOSE: "closed",
21
+ FLIP: "closed",
22
+ DEADLINE: "closed",
23
+ TEND: "closed",
24
+ UNMOUNT: "closed",
25
+ },
26
+ openingFrom: {
27
+ OPEN: "openingFrom",
28
+ CLOSE: "closingFrom",
29
+ FLIP: "openingTo",
30
+ DEADLINE: "open",
31
+ TEND: "open",
32
+ UNMOUNT: "closed",
33
+ },
34
+ openingTo: {
35
+ OPEN: "openingTo",
36
+ CLOSE: "closingFrom",
37
+ FLIP: "openingTo",
38
+ DEADLINE: "open",
39
+ TEND: "open",
40
+ UNMOUNT: "closed",
41
+ },
42
+ open: {
43
+ OPEN: "open",
44
+ CLOSE: "closingFrom",
45
+ FLIP: "open",
46
+ DEADLINE: "open",
47
+ TEND: "open",
48
+ UNMOUNT: "closed",
49
+ },
50
+ closingFrom: {
51
+ OPEN: "openingFrom",
52
+ CLOSE: "closingFrom",
53
+ FLIP: "closingTo",
54
+ DEADLINE: "closed",
55
+ TEND: "closed",
56
+ UNMOUNT: "closed",
57
+ },
58
+ closingTo: {
59
+ OPEN: "openingFrom",
60
+ CLOSE: "closingTo",
61
+ FLIP: "closingTo",
62
+ DEADLINE: "closed",
63
+ TEND: "closed",
64
+ UNMOUNT: "closed",
65
+ },
66
+ };
67
+
68
+ describe("surfaceMachine table", () => {
69
+ // T1 (totality + conformance): all 36 cells defined and matching the
70
+ // spec table — the reducer IS the table.
71
+ it("defines every (phase, event) cell exactly as the spec table", () => {
72
+ expect(SURFACE_PHASES).toHaveLength(6);
73
+ expect(SURFACE_EVENTS).toHaveLength(6);
74
+ for (const phase of SURFACE_PHASES) {
75
+ for (const event of SURFACE_EVENTS) {
76
+ const expected = SPEC[phase][event];
77
+ if (surfaceTransition(phase, event) !== expected) {
78
+ throw new Error(`table cell (${phase}, ${event}) diverges from the spec table`);
79
+ }
80
+ }
81
+ }
82
+ });
83
+
84
+ // T2/T3 (outputs are pure functions of state): quiescent states carry
85
+ // no transition classes; only non-closed states mount.
86
+ it("quiescent phases paint no transition classes", () => {
87
+ for (const phase of SURFACE_PHASES) {
88
+ const classes = classesForPair("hk-x", classPairOf(phase));
89
+ if (isQuiescent(phase)) {
90
+ expect(classes).toEqual([]);
91
+ } else {
92
+ expect(classes.length).toBe(2);
93
+ }
94
+ expect(isMounted(phase) === (phase !== "closed")).toBe(true);
95
+ }
96
+ });
97
+
98
+ // T4 (liveness): one DEADLINE walks any animation phase to a
99
+ // quiescent phase — the A2 (rAF/transitionend starved) column.
100
+ it("a single DEADLINE settles every animation phase (starvation column)", () => {
101
+ for (const phase of SURFACE_PHASES) {
102
+ const next = surfaceTransition(phase, "DEADLINE");
103
+ expect(isQuiescent(next), `${phase} + DEADLINE`).toBe(true);
104
+ }
105
+ });
106
+
107
+ // Idempotence: duplicating any event never moves the machine.
108
+ it("every event is idempotent in its target state", () => {
109
+ for (const phase of SURFACE_PHASES) {
110
+ for (const event of SURFACE_EVENTS) {
111
+ const once = surfaceTransition(phase, event);
112
+ expect(surfaceTransition(once, event)).toBe(once);
113
+ }
114
+ }
115
+ });
116
+ });
117
+
118
+ // ── Property-based exploration ─────────────────────────────────────
119
+ // Deterministic seeded RNG (xorshift32) — no PBT dependency, full
120
+ // reproducibility from the printed seed on failure.
121
+
122
+ function rng(seed: number): () => number {
123
+ let s = seed | 0 || 1;
124
+ return () => {
125
+ s ^= s << 13; s ^= s >>> 17; s ^= s << 5;
126
+ return (s >>> 0) / 0x100000000;
127
+ };
128
+ }
129
+
130
+ describe("surfaceMachine property-based exploration", () => {
131
+ const RUNS = 4000;
132
+ const MAX_EVENTS = 40;
133
+
134
+ // Invariants checked after EVERY event of every run:
135
+ // I-mounted: mounted(phase) ⟺ phase !== closed
136
+ // I-classes: transition classes only on animation phases (implied by
137
+ // classPairOf being total/pure — asserted via quiescence)
138
+ // I-quiesce: after draining (each request followed by its DEADLINE),
139
+ // the machine rests in {closed, open} — bounded settling
140
+ // without any FLIP/TEND ever arriving (axiom A2).
141
+ it("every random event stream settles quiescently under total frame starvation", () => {
142
+ for (let run = 0; run < RUNS; run++) {
143
+ const rand = rng(run + 1);
144
+ let phase: SurfacePhase = "closed";
145
+ for (let i = 0; i < MAX_EVENTS; i++) {
146
+ const pool: SurfaceEventType[] =
147
+ rand() < 0.5
148
+ ? ["OPEN", "CLOSE", "UNMOUNT"] // starvation diet: no FLIP/TEND
149
+ : [...SURFACE_EVENTS];
150
+ const event = pool[Math.floor(rand() * pool.length)]!;
151
+ phase = surfaceTransition(phase, event);
152
+ // I-mounted
153
+ expect(phase === "closed" || isMounted(phase)).toBe(true);
154
+ // UNMOUNT absorbs: once sent, only OPEN can leave closed.
155
+ if (event === "UNMOUNT") {
156
+ expect(phase).toBe("closed");
157
+ }
158
+ }
159
+ // Drain: one DEADLINE settles any animation phase (T4).
160
+ phase = surfaceTransition(phase, "DEADLINE");
161
+ expect(isQuiescent(phase), `run ${run} drained to ${phase}`).toBe(true);
162
+ }
163
+ });
164
+
165
+ // Reversal safety: OPEN/CLOSE may interleave arbitrarily inside the
166
+ // animation windows; the machine must never enter a phase that paints
167
+ // resting classes while mounted=false (the "invisible with content"
168
+ // inversion) or transition classes while quiescent.
169
+ it("request interleavings never invert the mounted/classes relationship", () => {
170
+ for (let run = 0; run < 2000; run++) {
171
+ const rand = rng(10_000 + run);
172
+ let phase: SurfacePhase = "closed";
173
+ for (let i = 0; i < 30; i++) {
174
+ const event: SurfaceEventType =
175
+ rand() < 0.7 ? (rand() < 0.5 ? "OPEN" : "CLOSE") : "FLIP";
176
+ phase = surfaceTransition(phase, event);
177
+ const mounted = isMounted(phase);
178
+ const paintingTransitionClasses = !isQuiescent(phase);
179
+ // A mounted surface either rests (no classes) or animates
180
+ // (classes) — and classes imply mounted.
181
+ if (paintingTransitionClasses) expect(mounted).toBe(true);
182
+ if (!mounted) expect(phase).toBe("closed");
183
+ }
184
+ }
185
+ });
186
+ });
@@ -0,0 +1,229 @@
1
+ // Surface lifecycle state machine — the provable core of every overlay
2
+ // surface (modal scrim+panel, sheet scrim+panel, popover) in hikari.
3
+ //
4
+ // WHY A MACHINE. The pre-machine design drove two independent Vue
5
+ // <Transition> engines off one boolean, with rAF/transitionend as the
6
+ // class-flip clock. Those events can starve (occluded webviews — the
7
+ // 2026-09 field reports), and two engines can diverge through the same
8
+ // event stream (the mobile "panel visible, scrim gone" state, later the
9
+ // full-opacity close flash — the "black rectangle"). The fixes of that
10
+ // era (#407 leave watchdog, #414 enter watchdog) REPAIRED observed bad
11
+ // states; a repair list is never complete.
12
+ //
13
+ // This module inverts the approach: the lifecycle is ONE explicit
14
+ // finite-state machine whose transition table is total by construction,
15
+ // whose outputs are pure functions of the state, and whose only
16
+ // correctness clock is setTimeout (the one primitive field-verified to
17
+ // fire under the starvation conditions that starve rAF). Bad states —
18
+ // divergent layers, stuck transition classes at rest — are UNREPRESENT-
19
+ // ABLE in the state space rather than detected and repaired.
20
+ //
21
+ // AXIOMS (the proof's ground rules):
22
+ // A1 setTimeout callbacks fire exactly once, within a bounded delay.
23
+ // A2 rAF and transitionend may NEVER fire. (The machine stays correct
24
+ // under A2 alone; both are advisory optimizations in the driver.)
25
+ // A3 Synchronous class/style writes take effect at the next style
26
+ // recalc. (What the compositor PAINTS is outside the axioms.)
27
+ //
28
+ // STATES — six, including the two-phase animation windows (a `.from`
29
+ // phase holds the from-class pair until a frame flip; a `.to` phase
30
+ // runs the CSS transition to the resting pair):
31
+ //
32
+ // closed no DOM, no timers, nothing registered
33
+ // openingFrom mounted, enter-from classes, flip pending
34
+ // openingTo mounted, enter-to classes, animating
35
+ // open mounted, resting classes, morph armed — the only
36
+ // state where size measurements are taken
37
+ // closingFrom mounted, leave-from classes, flip pending
38
+ // closingTo mounted, leave-to classes, animating
39
+ //
40
+ // EVENTS:
41
+ // OPEN/CLOSE surface request (user logic, back gesture, closeAll)
42
+ // FLIP frame flip (double-rAF or the flip timer — same event)
43
+ // DEADLINE phase deadline timer (the correctness clock)
44
+ // TEND transitionend (advisory early completion; the driver
45
+ // does not emit it — the cell exists so the table stays
46
+ // total for future adopters)
47
+ // UNMOUNT component teardown
48
+ //
49
+ // THE TABLE. Completeness is the table itself — every (state, event)
50
+ // cell is defined; table conformance is enforced by a test that holds
51
+ // the spec table as data and diffs the reducer against it cell by cell.
52
+ // Duplicate/inappropriate events are idempotent self-loops (absorbing),
53
+ // so no interleaving — however adversarial — leaves the machine
54
+ // undefined or divergent.
55
+
56
+ export type SurfacePhase =
57
+ | "closed"
58
+ | "openingFrom"
59
+ | "openingTo"
60
+ | "open"
61
+ | "closingFrom"
62
+ | "closingTo";
63
+
64
+ export type SurfaceEventType =
65
+ | "OPEN"
66
+ | "CLOSE"
67
+ | "FLIP"
68
+ | "DEADLINE"
69
+ | "TEND"
70
+ | "UNMOUNT";
71
+
72
+ export type SurfaceEvent = { type: SurfaceEventType };
73
+
74
+ export const SURFACE_PHASES: readonly SurfacePhase[] = [
75
+ "closed",
76
+ "openingFrom",
77
+ "openingTo",
78
+ "open",
79
+ "closingFrom",
80
+ "closingTo",
81
+ ] as const;
82
+
83
+ export const SURFACE_EVENTS: readonly SurfaceEventType[] = [
84
+ "OPEN",
85
+ "CLOSE",
86
+ "FLIP",
87
+ "DEADLINE",
88
+ "TEND",
89
+ "UNMOUNT",
90
+ ] as const;
91
+
92
+ /**
93
+ * The total transition function δ: S × E → S.
94
+ *
95
+ * | state | OPEN | CLOSE | FLIP | DEADLINE | TEND | UNMOUNT |
96
+ * |-------------|--------------|--------------|-------------|-------------|-------------|---------|
97
+ * | closed | openingFrom | closed | closed | closed | closed | closed |
98
+ * | openingFrom | openingFrom | closingFrom | openingTo | open | open | closed |
99
+ * | openingTo | openingTo | closingFrom | openingTo | open | open | closed |
100
+ * | open | open | closingFrom | open | open | open | closed |
101
+ * | closingFrom | openingFrom | closingFrom | closingTo | closed | closed | closed |
102
+ * | closingTo | openingFrom | closingTo | closingTo | closed | closed | closed |
103
+ *
104
+ * Reading guide:
105
+ * - Requests are idempotent (OPEN in any opening/open state is a no-op)
106
+ * — user flapping converges instead of thrashing.
107
+ * - A CLOSE during the opening window reverses from the CURRENT computed
108
+ * style (CSS re-targets the running transition); a OPEN during the
109
+ * closing window likewise reverses. Interruption is a first-class cell,
110
+ * not a corner case — the f108–f112 churn of the 2026-09 recording.
111
+ * - DEADLINE alone walks any state to a quiescent state: openingFrom →
112
+ * open, closingFrom → closed — starving frames skip the animation, not
113
+ * the state). This is the liveness column: under A1+A2 every request
114
+ * settles within flipBudget + maxDuration + slack.
115
+ */
116
+ export function surfaceTransition(
117
+ phase: SurfacePhase,
118
+ event: SurfaceEventType,
119
+ ): SurfacePhase {
120
+ switch (phase) {
121
+ case "closed":
122
+ switch (event) {
123
+ case "OPEN": return "openingFrom";
124
+ case "CLOSE":
125
+ case "FLIP":
126
+ case "DEADLINE":
127
+ case "TEND":
128
+ case "UNMOUNT": return "closed";
129
+ }
130
+ break;
131
+ case "openingFrom":
132
+ switch (event) {
133
+ case "OPEN": return "openingFrom";
134
+ case "CLOSE": return "closingFrom";
135
+ case "FLIP": return "openingTo";
136
+ case "DEADLINE":
137
+ case "TEND": return "open";
138
+ case "UNMOUNT": return "closed";
139
+ }
140
+ break;
141
+ case "openingTo":
142
+ switch (event) {
143
+ case "OPEN": return "openingTo";
144
+ case "CLOSE": return "closingFrom";
145
+ case "FLIP": return "openingTo";
146
+ case "DEADLINE":
147
+ case "TEND": return "open";
148
+ case "UNMOUNT": return "closed";
149
+ }
150
+ break;
151
+ case "open":
152
+ switch (event) {
153
+ case "OPEN":
154
+ case "FLIP":
155
+ case "DEADLINE":
156
+ case "TEND": return "open";
157
+ case "CLOSE": return "closingFrom";
158
+ case "UNMOUNT": return "closed";
159
+ }
160
+ break;
161
+ case "closingFrom":
162
+ switch (event) {
163
+ case "OPEN": return "openingFrom";
164
+ case "CLOSE": return "closingFrom";
165
+ case "FLIP": return "closingTo";
166
+ case "DEADLINE":
167
+ case "TEND": return "closed";
168
+ case "UNMOUNT": return "closed";
169
+ }
170
+ break;
171
+ case "closingTo":
172
+ switch (event) {
173
+ case "OPEN": return "openingFrom";
174
+ case "CLOSE":
175
+ case "FLIP": return "closingTo";
176
+ case "DEADLINE":
177
+ case "TEND": return "closed";
178
+ case "UNMOUNT": return "closed";
179
+ }
180
+ break;
181
+ }
182
+ // Exhaustiveness guard: TypeScript proves every phase/event pair
183
+ // returned above; reaching this line is a compile-time impossibility
184
+ // that we still assert at runtime (a built table can never be wrong
185
+ // silently).
186
+ throw new Error(
187
+ `[surfaceMachine] unhandled transition ${phase} x ${event} — the table has a hole (bug: table not total)`,
188
+ );
189
+ }
190
+
191
+ /** Whether the phase is one of the two quiescent (resting) states. */
192
+ export function isQuiescent(phase: SurfacePhase): boolean {
193
+ return phase === "closed" || phase === "open";
194
+ }
195
+
196
+ /** Whether the surface's DOM is mounted (everything except `closed`). */
197
+ export function isMounted(phase: SurfacePhase): boolean {
198
+ return phase !== "closed";
199
+ }
200
+
201
+ /** The class-pair family a phase paints (pure Moore output). */
202
+ export type SurfaceClassPair = "none" | "enter-from" | "enter-to" | "leave-from" | "leave-to";
203
+
204
+ export function classPairOf(phase: SurfacePhase): SurfaceClassPair {
205
+ switch (phase) {
206
+ case "closed": return "none";
207
+ case "openingFrom": return "enter-from";
208
+ case "openingTo": return "enter-to";
209
+ case "open": return "none";
210
+ case "closingFrom": return "leave-from";
211
+ case "closingTo": return "leave-to";
212
+ }
213
+ }
214
+
215
+ /** Vue-style transition classes for one layer prefix (Moore output).
216
+ *
217
+ * The names match what the family's SCSS already animates
218
+ * (`${prefix}-enter-from` etc.), so adopting the machine changes WHO
219
+ * flips the classes and WHEN — never WHAT the classes are.
220
+ */
221
+ export function classesForPair(prefix: string, pair: SurfaceClassPair): string[] {
222
+ switch (pair) {
223
+ case "none": return [];
224
+ case "enter-from": return [`${prefix}-enter-from`, `${prefix}-enter-active`];
225
+ case "enter-to": return [`${prefix}-enter-to`, `${prefix}-enter-active`];
226
+ case "leave-from": return [`${prefix}-leave-from`, `${prefix}-leave-active`];
227
+ case "leave-to": return [`${prefix}-leave-to`, `${prefix}-leave-active`];
228
+ }
229
+ }
@@ -1,79 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
2
-
3
- import { armTransitionClassWatchdog, stripTransitionClasses } from "./transitionWatchdog";
4
-
5
- afterEach(() => {
6
- vi.useRealTimers();
7
- });
8
-
9
- function makeEl(...classes: string[]): HTMLElement {
10
- const el = document.createElement("div");
11
- el.className = classes.join(" ");
12
- return el;
13
- }
14
-
15
- describe("stripTransitionClasses", () => {
16
- it("removes every name-prefixed transition class and keeps the rest", () => {
17
- const el = makeEl(
18
- "hk-modal-overlay",
19
- "hk-modal-overlay-enter-from",
20
- "hk-modal-overlay-enter-active",
21
- "other-class",
22
- );
23
- stripTransitionClasses(el, "hk-modal-overlay");
24
- expect(el.className).toBe("hk-modal-overlay other-class");
25
- });
26
-
27
- it("never strips a class merely containing the name mid-word", () => {
28
- const el = makeEl("hk-modal-overlay-v2");
29
- stripTransitionClasses(el, "hk-modal-overlay");
30
- expect(el.className).toBe("hk-modal-overlay-v2");
31
- });
32
-
33
- it("is a no-op on a class-less element", () => {
34
- const el = makeEl("hk-modal-overlay");
35
- stripTransitionClasses(el, "hk-modal-overlay");
36
- expect(el.className).toBe("hk-modal-overlay");
37
- });
38
- });
39
-
40
- describe("armTransitionClassWatchdog", () => {
41
- it("strips stuck transition classes once the budget lapses while open", () => {
42
- vi.useFakeTimers();
43
- const el = makeEl(
44
- "hk-modal-overlay",
45
- "hk-modal-overlay-enter-from",
46
- "hk-modal-overlay-enter-active",
47
- );
48
- armTransitionClassWatchdog(el, "hk-modal-overlay", () => true);
49
- vi.advanceTimersByTime(599);
50
- expect(el.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
51
- vi.advanceTimersByTime(2);
52
- expect(el.className).toBe("hk-modal-overlay");
53
- });
54
-
55
- it("leaves the element alone once the surface closed (leave owns it)", () => {
56
- vi.useFakeTimers();
57
- const el = makeEl("hk-modal-overlay", "hk-modal-overlay-enter-from");
58
- armTransitionClassWatchdog(el, "hk-modal-overlay", () => false);
59
- vi.advanceTimersByTime(1000);
60
- expect(el.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
61
- });
62
-
63
- it("disarming cancels the strip (a normal enter finalized)", () => {
64
- vi.useFakeTimers();
65
- const el = makeEl("hk-modal-overlay", "hk-modal-overlay-enter-active");
66
- const disarm = armTransitionClassWatchdog(el, "hk-modal-overlay", () => true);
67
- disarm();
68
- vi.advanceTimersByTime(1000);
69
- expect(el.classList.contains("hk-modal-overlay-enter-active")).toBe(true);
70
- });
71
-
72
- it("does nothing when no transition class is stuck", () => {
73
- vi.useFakeTimers();
74
- const el = makeEl("hk-modal-overlay");
75
- armTransitionClassWatchdog(el, "hk-modal-overlay", () => true);
76
- vi.advanceTimersByTime(1000);
77
- expect(el.className).toBe("hk-modal-overlay");
78
- });
79
- });
@@ -1,73 +0,0 @@
1
- // Transition-class repair kit — the enter-side counterpart of HkModal's
2
- // leave watchdog.
3
- //
4
- // A Vue <Transition> flips enter-from → enter-to through rAF and waits on
5
- // transitionend. When rAF starves (occluded/backgrounded webview — the
6
- // same pathology the leave watchdog in HkModal bounds), the enter classes
7
- // FREEZE on the element: the layer keeps `*-enter-from` (opacity: 0,
8
- // translateY(100%), …) while the surface is logically open. The modal's
9
- // scrim then stays invisible with the panel floating above it, and the
10
- // eventual close flashes the resurrected curtain at full opacity — the
11
- // 2026-09 mobile "black rectangle" report (the leave pair
12
- // leave-from → leave-to snaps the scrim to opacity: 1 before fading).
13
- //
14
- // Two primitives:
15
- // stripTransitionClasses — remove every `name-*` transition class so the
16
- // element snaps to its resting state (no fade — this is a repair
17
- // path, the animation already failed).
18
- // armTransitionClassWatchdog — bounded safety net: if the element still
19
- // carries `name-enter-*` classes once the budget lapses (larger than
20
- // any themed duration), strip them. Normal enters disarm it; a
21
- // completed enter is a no-op (the classes are already gone).
22
-
23
- /** The exact set of classes Vue's <Transition name=…> stamps on an
24
- * element — matched precisely so a hand-written class that merely
25
- * starts with the name (e.g. `name-v2`) is never touched. */
26
- const TRANSITION_CLASS_SUFFIXES = [
27
- "-enter-from",
28
- "-enter-active",
29
- "-enter-to",
30
- "-leave-from",
31
- "-leave-active",
32
- "-leave-to",
33
- ] as const;
34
-
35
- function transitionClassesOf(el: HTMLElement, name: string): string[] {
36
- const names = TRANSITION_CLASS_SUFFIXES.map((suffix) => `${name}${suffix}`);
37
- return Array.from(el.classList).filter((cls) => names.includes(cls));
38
- }
39
-
40
- /** Strip all `name-*` transition classes off `el`, snapping it to its
41
- * resting (non-transition) state. Safe on class-less elements. */
42
- export function stripTransitionClasses(el: HTMLElement, name: string): void {
43
- for (const cls of transitionClassesOf(el, name)) el.classList.remove(cls);
44
- }
45
-
46
- /**
47
- * Bound a stuck transition: if the element still carries ANY `name-*`
48
- * transition class after `budgetMs` while the surface is open (the enter
49
- * never finalized — or an interrupted leave left its pair behind — rAF
50
- * starvation), strip them so the layer renders in its open state instead
51
- * of staying frozen at a from/to pair. The budget is larger than any
52
- * themed CSS duration, so a legitimate animation never sees the strip.
53
- *
54
- * `isStillOpen` gates the repair: once the surface closed, the leave owns
55
- * the element and a late strip must not fight it.
56
- *
57
- * @returns the disarm function (call on after-enter / enter-cancelled /
58
- * close / unmount).
59
- */
60
- export function armTransitionClassWatchdog(
61
- el: HTMLElement,
62
- name: string,
63
- isStillOpen: () => boolean,
64
- budgetMs = 600,
65
- ): () => void {
66
- const timer = setTimeout(() => {
67
- if (!isStillOpen()) return;
68
- if (transitionClassesOf(el, name).length > 0) {
69
- stripTransitionClasses(el, name);
70
- }
71
- }, budgetMs);
72
- return () => clearTimeout(timer);
73
- }