@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,259 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import { useSurfaceMachine } from "./useSurfaceMachine";
5
+
6
+ afterEach(() => {
7
+ vi.useRealTimers();
8
+ vi.unstubAllGlobals();
9
+ document.body.innerHTML = "";
10
+ });
11
+
12
+ interface Rig {
13
+ phase: () => string;
14
+ mounted: () => boolean;
15
+ classesFor: (prefix: string) => string[];
16
+ send: (e: "OPEN" | "CLOSE" | "FLIP" | "DEADLINE" | "TEND" | "UNMOUNT") => void;
17
+ edges: Array<[string, string, string]>;
18
+ unmount: () => void;
19
+ }
20
+
21
+ /** Mount a machine with two test layers (like a modal's scrim+panel)
22
+ * and capture every phase edge it walks. `probed` wires live element
23
+ * accessors — in happy-dom the probe reads a 0 duration, which selects
24
+ * the no-transition fast path; without probes the machine runs on the
25
+ * configured fallback budgets (the starvation-era behavior). */
26
+ function mountRig(probed = false): Rig {
27
+ const container = document.createElement("div");
28
+ document.body.appendChild(container);
29
+ const edges: Array<[string, string, string]> = [];
30
+ const machineRef: { current: ReturnType<typeof useSurfaceMachine> | null } = { current: null };
31
+ const app = createApp(defineComponent({
32
+ setup() {
33
+ machineRef.current = useSurfaceMachine({
34
+ layers: [
35
+ {
36
+ prefix: "rig-scrim",
37
+ ...(probed ? { el: () => document.querySelector<HTMLElement>(".scrim") } : {}),
38
+ enterMs: () => 300,
39
+ leaveMs: () => 300,
40
+ },
41
+ {
42
+ prefix: "rig-panel",
43
+ ...(probed ? { el: () => document.querySelector<HTMLElement>(".panel") } : {}),
44
+ enterMs: () => 300,
45
+ leaveMs: () => 250,
46
+ },
47
+ ],
48
+ onPhase: (from, to, event) => { edges.push([from, to, event]); },
49
+ });
50
+ const m = machineRef.current;
51
+ return () =>
52
+ h("div", [
53
+ m.mounted.value ? h("div", { class: ["scrim", ...m.classesFor("rig-scrim")] }) : null,
54
+ m.mounted.value ? h("div", { class: ["panel", ...m.classesFor("rig-panel")] }) : null,
55
+ ]);
56
+ },
57
+ }));
58
+ app.mount(container);
59
+ const m = machineRef.current!;
60
+ return {
61
+ phase: () => m.phase.value,
62
+ mounted: () => m.mounted.value,
63
+ classesFor: m.classesFor,
64
+ send: m.send,
65
+ edges,
66
+ unmount: () => app.unmount(),
67
+ };
68
+ }
69
+
70
+ function scrimClasses(rig: Rig): string[] {
71
+ return Array.from(document.querySelector<HTMLElement>(".scrim")?.classList ?? [])
72
+ .filter((c) => c.startsWith("rig-scrim-"));
73
+ }
74
+
75
+ async function flush(): Promise<void> {
76
+ await nextTick();
77
+ await new Promise((resolve) => setTimeout(resolve, 0));
78
+ await nextTick();
79
+ }
80
+
81
+ describe("useSurfaceMachine driver", () => {
82
+ it("renders mounted with enter-from classes on OPEN, flips on rAF, rests on deadline", async () => {
83
+ vi.useFakeTimers();
84
+ const rig = mountRig();
85
+ rig.send("OPEN");
86
+ await nextTick();
87
+ expect(rig.mounted()).toBe(true);
88
+ expect(rig.phase()).toBe("openingFrom");
89
+ expect(scrimClasses(rig)).toEqual(["rig-scrim-enter-from", "rig-scrim-enter-active"]);
90
+
91
+ // Frame flip: the clock drives the (faked) rAF frames — well before
92
+ // the flip timer's own budget, so this exercises the frame path.
93
+ await vi.advanceTimersByTimeAsync(40);
94
+ expect(rig.phase()).toBe("openingTo");
95
+ expect(scrimClasses(rig)).toEqual(["rig-scrim-enter-to", "rig-scrim-enter-active"]);
96
+
97
+ // Deadline settles the surface at rest with no transition classes.
98
+ await vi.advanceTimersByTimeAsync(600);
99
+ expect(rig.phase()).toBe("open");
100
+ expect(scrimClasses(rig)).toEqual([]);
101
+ // No late events: nothing fires into the resting surface.
102
+ const edgesAtRest = rig.edges.length;
103
+ await vi.advanceTimersByTimeAsync(5000);
104
+ expect(rig.phase()).toBe("open");
105
+ expect(scrimClasses(rig)).toEqual([]);
106
+ expect(rig.edges.length).toBe(edgesAtRest);
107
+ });
108
+
109
+ it("settles open under total rAF starvation (flip timer + deadline only)", async () => {
110
+ vi.useFakeTimers();
111
+ vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
112
+ vi.stubGlobal("cancelAnimationFrame", () => {});
113
+ const rig = mountRig();
114
+ rig.send("OPEN");
115
+ await nextTick();
116
+ expect(rig.phase()).toBe("openingFrom");
117
+ // No frames ever arrive; the flip timer forces FLIP…
118
+ await vi.advanceTimersByTimeAsync(120);
119
+ expect(rig.phase()).toBe("openingTo");
120
+ // …and the deadline walks to rest. Bounded, no watchdogs.
121
+ await vi.advanceTimersByTimeAsync(600);
122
+ expect(rig.phase()).toBe("open");
123
+ expect(scrimClasses(rig)).toEqual([]);
124
+ });
125
+
126
+ it("close → deadline finalizes to closed and unmounts the DOM", async () => {
127
+ vi.useFakeTimers();
128
+ const rig = mountRig();
129
+ rig.send("OPEN");
130
+ await vi.advanceTimersByTimeAsync(800);
131
+ expect(rig.phase()).toBe("open");
132
+
133
+ rig.send("CLOSE");
134
+ await nextTick();
135
+ expect(rig.phase()).toBe("closingFrom");
136
+ rig.send("FLIP");
137
+ await nextTick();
138
+ expect(rig.phase()).toBe("closingTo");
139
+ expect(scrimClasses(rig)).toEqual(["rig-scrim-leave-to", "rig-scrim-leave-active"]);
140
+
141
+ await vi.advanceTimersByTimeAsync(600);
142
+ expect(rig.phase()).toBe("closed");
143
+ expect(rig.mounted()).toBe(false);
144
+ expect(document.querySelector(".panel")).toBeNull();
145
+ // No late events resurrect anything.
146
+ const edgesAtRest = rig.edges.length;
147
+ await vi.advanceTimersByTimeAsync(5000);
148
+ expect(rig.phase()).toBe("closed");
149
+ expect(rig.edges.length).toBe(edgesAtRest);
150
+ });
151
+
152
+ it("a reopen during the closing window reverses on the same element", async () => {
153
+ vi.useFakeTimers();
154
+ const rig = mountRig();
155
+ rig.send("OPEN");
156
+ await vi.advanceTimersByTimeAsync(800);
157
+ rig.send("CLOSE");
158
+ await vi.advanceTimersByTimeAsync(50); // mid-flight (still *.from window)
159
+ expect(["closingFrom", "closingTo"]).toContain(rig.phase());
160
+
161
+ rig.send("OPEN");
162
+ await nextTick();
163
+ expect(rig.phase()).toBe("openingFrom");
164
+ // Element identity preserved across the reversal.
165
+ const panel = document.querySelector(".panel")!;
166
+ expect(panel).not.toBeNull();
167
+ await vi.advanceTimersByTimeAsync(1000);
168
+ expect(rig.phase()).toBe("open");
169
+ expect(document.querySelector(".panel")).toBe(panel);
170
+ // The close never finalized — exactly one open edge, no ghost edges.
171
+ const closes = rig.edges.filter(([, to]) => to === "closed");
172
+ expect(closes).toHaveLength(0);
173
+ });
174
+
175
+ it("honors a measured CSS duration: settles only after duration+slack", async () => {
176
+ vi.useFakeTimers();
177
+ vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
178
+ vi.stubGlobal("cancelAnimationFrame", () => {});
179
+ // Real-browser shape: computed transition-duration of 0.3s. The
180
+ // probe tightens the deadline to flip(120)+300+slack(80)=500 for the
181
+ // from-phase and 300+slack for the to-phase — never the configured
182
+ // fallback, never the zero-duration fast path.
183
+ const realGCS = window.getComputedStyle.bind(window);
184
+ vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
185
+ const style = realGCS(el as Element, ...(rest as []));
186
+ return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
187
+ });
188
+ const rig = mountRig(true);
189
+ rig.send("OPEN");
190
+ await nextTick();
191
+ await vi.advanceTimersByTimeAsync(130); // flip timer forced the FLIP
192
+ expect(rig.phase()).toBe("openingTo");
193
+ // Inside duration+slack (380ms from the flip) nothing settles…
194
+ await vi.advanceTimersByTimeAsync(300);
195
+ expect(rig.phase()).toBe("openingTo");
196
+ // …past it, the deadline completes the open.
197
+ await vi.advanceTimersByTimeAsync(120);
198
+ expect(rig.phase()).toBe("open");
199
+ expect(scrimClasses(rig)).toEqual([]);
200
+ });
201
+
202
+ it("TEND: a matching transitionend completes the phase early", async () => {
203
+ vi.useFakeTimers();
204
+ vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
205
+ vi.stubGlobal("cancelAnimationFrame", () => {});
206
+ const realGCS = window.getComputedStyle.bind(window);
207
+ vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
208
+ const style = realGCS(el as Element, ...(rest as []));
209
+ return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
210
+ });
211
+ const rig = mountRig(true);
212
+ rig.send("OPEN");
213
+ await nextTick();
214
+ await vi.advanceTimersByTimeAsync(130); // flip forced → openingTo
215
+ expect(rig.phase()).toBe("openingTo");
216
+ const panel = document.querySelector<HTMLElement>(".panel")!;
217
+
218
+ // happy-dom's TransitionEvent ignores its init dict — synthesize
219
+ // the event and stamp elapsedTime on the instance.
220
+ const endEvent = (elapsedSeconds: number): Event => {
221
+ const ev = new Event("transitionend");
222
+ Object.defineProperty(ev, "elapsedTime", { value: elapsedSeconds });
223
+ return ev;
224
+ };
225
+
226
+ // A fast property's end event (elapsed < measured budget) is ignored…
227
+ panel.dispatchEvent(endEvent(0.1));
228
+ expect(rig.phase()).toBe("openingTo");
229
+
230
+ // …the slowest property's end event lands → the surface settles open
231
+ // immediately, ahead of the deadline timer.
232
+ panel.dispatchEvent(endEvent(0.3));
233
+ expect(rig.phase()).toBe("open");
234
+ await nextTick();
235
+ expect(scrimClasses(rig)).toEqual([]);
236
+ // No late deadline fires afterwards.
237
+ const edgesAtRest = rig.edges.length;
238
+ await vi.advanceTimersByTimeAsync(5000);
239
+ expect(rig.phase()).toBe("open");
240
+ expect(rig.edges.length).toBe(edgesAtRest);
241
+ });
242
+
243
+ it("UNMOUNT from any phase clears every clock and walks to closed", async () => {
244
+ vi.useFakeTimers();
245
+ const rig = mountRig();
246
+ rig.send("OPEN");
247
+ await vi.advanceTimersByTimeAsync(50); // mid opening
248
+ expect(rig.phase()).not.toBe("closed");
249
+ rig.unmount();
250
+ expect(vi.getTimerCount()).toBe(0);
251
+ // Nothing fires after teardown (no late timers).
252
+ await vi.advanceTimersByTimeAsync(5000);
253
+ expect(rig.edges.at(-1)).toEqual([
254
+ expect.any(String),
255
+ "closed",
256
+ "UNMOUNT",
257
+ ]);
258
+ });
259
+ });
@@ -0,0 +1,289 @@
1
+ import { computed, nextTick, onBeforeUnmount, shallowRef, type ComputedRef } from "vue";
2
+
3
+ import {
4
+ classPairOf,
5
+ classesForPair,
6
+ isMounted,
7
+ surfaceTransition,
8
+ type SurfaceEventType,
9
+ type SurfacePhase,
10
+ } from "../runtime/surfaceMachine";
11
+
12
+ export interface SurfaceMachineLayer {
13
+ /** Vue-style transition-name prefix (e.g. "hk-modal-overlay"). */
14
+ prefix: string;
15
+ /** Live element accessor — lets the driver read the layer's EFFECTIVE
16
+ * CSS duration (themes, reduced-motion, `data-css-animations="0"`). */
17
+ el?: () => HTMLElement | null | undefined;
18
+ /** Fallback enter budget, ms — the A2 bound when the element (or its
19
+ * computed style) cannot be read. */
20
+ enterMs: () => number;
21
+ /** Fallback leave budget, ms. */
22
+ leaveMs: () => number;
23
+ }
24
+
25
+ export interface SurfaceMachineOptions {
26
+ layers: SurfaceMachineLayer[];
27
+ /**
28
+ * How long a `.from` phase waits for a frame flip before the timer
29
+ * forces it (ms). Under A2 (rAF starved) this bounds the from-pair's
30
+ * lifetime; the forced flip snaps (nothing is painting anyway).
31
+ */
32
+ flipBudgetMs?: number;
33
+ /** Extra ms on top of the CSS duration before a deadline fires. */
34
+ deadlineSlackMs?: number;
35
+ /** Phase-edge side effects (registration, focus, morph, reports). */
36
+ onPhase?: (from: SurfacePhase, to: SurfacePhase, event: SurfaceEventType) => void;
37
+ }
38
+
39
+ export interface SurfaceMachine {
40
+ /** Current phase (reactive — render-time reads track it). */
41
+ phase: ComputedRef<SurfacePhase>;
42
+ /** Whether the surface DOM should render (v-if anchor). */
43
+ mounted: ComputedRef<boolean>;
44
+ /** Reactive transition classes for one layer's prefix. */
45
+ classesFor: (prefix: string) => string[];
46
+ /** Dispatch an event through the total transition table. */
47
+ send: (event: SurfaceEventType) => void;
48
+ }
49
+
50
+ /**
51
+ * useSurfaceMachine — the Vue driver for the pure surface lifecycle
52
+ * machine (runtime/surfaceMachine.ts).
53
+ *
54
+ * Ownership rules that make the machine's proofs carry over:
55
+ * - Classes are bound REACTIVELY from the phase (render reads
56
+ * `classesFor`), so the initial mount paints with its from-pair — no
57
+ * write-after-mount race, no Vue `<Transition>` engine to diverge.
58
+ * - The ONLY correctness clock is setTimeout: every animation phase arms
59
+ * exactly one deadline timer; the frame flip is double-rAF (smooth)
60
+ * with a flip timer as the starved fallback. Duplicate FLIPs are
61
+ * absorbed by the table (idempotent self-loops).
62
+ * - Phase changes clear every pending timer/rAF before arming the next
63
+ * phase's — at most one flip + one deadline exist at any moment, so
64
+ * timers cannot leak or fire stale (the machine has no "late
65
+ * completion" callbacks at all).
66
+ */
67
+ export function useSurfaceMachine(options: SurfaceMachineOptions): SurfaceMachine {
68
+ const flipBudget = options.flipBudgetMs ?? 120;
69
+ const slack = options.deadlineSlackMs ?? 80;
70
+
71
+ const phase = shallowRef<SurfacePhase>("closed");
72
+ const mounted = computed(() => isMounted(phase.value));
73
+
74
+ let flipTimer: ReturnType<typeof setTimeout> | null = null;
75
+ let deadlineTimer: ReturnType<typeof setTimeout> | null = null;
76
+ let raf1 = 0;
77
+ let raf2 = 0;
78
+
79
+ /** transitionend listeners for the TEND early completion (see
80
+ * armTend) — cleared with every clock reset so a phase change can
81
+ * never inherit the previous phase's listeners. */
82
+ let tendCleanups: Array<() => void> = [];
83
+
84
+ function clearClock(): void {
85
+ if (flipTimer !== null) {
86
+ clearTimeout(flipTimer);
87
+ flipTimer = null;
88
+ }
89
+ if (deadlineTimer !== null) {
90
+ clearTimeout(deadlineTimer);
91
+ deadlineTimer = null;
92
+ }
93
+ for (const off of tendCleanups) off();
94
+ tendCleanups = [];
95
+ if (raf1) cancelAnimationFrame(raf1);
96
+ if (raf2) cancelAnimationFrame(raf2);
97
+ raf1 = 0;
98
+ raf2 = 0;
99
+ }
100
+
101
+ /** TEND wiring — the advisory early-completion event. Real browsers
102
+ * fire transitionend when the slowest property lands; the deadline
103
+ * timer stays armed as the bound (starved or cancelled transitions
104
+ * never fire it — the machine stays correct either way, TEND only
105
+ * shortens the settle latency). Filter rules:
106
+ * - e.target must be the layer element itself (child bubbles — the
107
+ * content's own transitions — must not complete the surface);
108
+ * - elapsedTime must match the measured budget (the SLOWEST
109
+ * property's end event; faster properties of the same element are
110
+ * ignored). The table absorbs a TEND in any phase, so a late or
111
+ * duplicated event is a no-op. */
112
+ function armTend(expectedMs: number): void {
113
+ // Several layers may share one element (drawer sides, popover
114
+ // branches) — one listener per ELEMENT, not per layer.
115
+ const seen = new Set<HTMLElement>();
116
+ for (const layer of options.layers) {
117
+ const el = layer.el?.();
118
+ if (!el || !el.isConnected || seen.has(el)) continue;
119
+ seen.add(el);
120
+ const onEnd = (e: TransitionEvent) => {
121
+ if (e.target !== el) return;
122
+ // An event without a numeric elapsedTime cannot be validated
123
+ // against the budget — ignore it (the deadline stays the bound;
124
+ // TEND is strictly an optimization).
125
+ if (typeof e.elapsedTime !== "number") return;
126
+ const elapsedMs = e.elapsedTime * 1000;
127
+ // Two-sided epsilon: only the SLOWEST property's end event — the
128
+ // one landing at the measured budget — completes the surface.
129
+ // Faster properties are below the window; an unrelated LONGER
130
+ // transition on the element (consumer classes ride the panel)
131
+ // is above it and must not fire the surface's completion.
132
+ if (elapsedMs + 60 < expectedMs || elapsedMs - 60 > expectedMs) return;
133
+ send("TEND");
134
+ };
135
+ el.addEventListener("transitionend", onEnd);
136
+ tendCleanups.push(() => el.removeEventListener("transitionend", onEnd));
137
+ }
138
+ }
139
+
140
+ /** Worst-case deadline for the phase being entered, from the CONFIGURED
141
+ * budgets (the A2 bound when the live CSS cannot be read). */
142
+ function budgetFor(next: SurfacePhase): number {
143
+ switch (next) {
144
+ case "openingFrom":
145
+ return flipBudget + Math.max(...options.layers.map((l) => l.enterMs())) + slack;
146
+ case "openingTo":
147
+ return Math.max(...options.layers.map((l) => l.enterMs())) + slack;
148
+ case "closingFrom":
149
+ return flipBudget + Math.max(...options.layers.map((l) => l.leaveMs())) + slack;
150
+ case "closingTo":
151
+ return Math.max(...options.layers.map((l) => l.leaveMs())) + slack;
152
+ default:
153
+ return 0;
154
+ }
155
+ }
156
+
157
+ /** Effective CSS transition duration of one layer, ms — the maximum
158
+ * across the comma-separated duration list ("0.3s, 0.25s"). Reduced
159
+ * motion and `html[data-css-animations="0"]` both collapse this to 0,
160
+ * which is exactly the fast-path signal.
161
+ * Caveat: `transition-delay` is deliberately ignored (no current
162
+ * surface staggers its layers) — a future adopter adding delays must
163
+ * fold them into the layer's enter/leave fallback budgets. */
164
+ function cssDurationMs(el: HTMLElement): number {
165
+ const raw = window.getComputedStyle(el).transitionDuration;
166
+ if (!raw) return 0;
167
+ let max = 0;
168
+ for (const part of raw.split(",")) {
169
+ const value = parseFloat(part);
170
+ if (!Number.isFinite(value)) continue;
171
+ max = Math.max(max, part.includes("ms") ? value : value * 1000);
172
+ }
173
+ return max;
174
+ }
175
+
176
+ /** Max effective CSS duration across layers, or null when no layer
177
+ * element is readable (surface mid-mount) — the caller falls back to
178
+ * the configured budget. */
179
+ function measuredBudget(): number | null {
180
+ let max: number | null = null;
181
+ for (const layer of options.layers) {
182
+ const el = layer.el?.();
183
+ if (!el || !el.isConnected) continue;
184
+ max = Math.max(max ?? 0, cssDurationMs(el));
185
+ }
186
+ return max;
187
+ }
188
+
189
+ function rearmDeadline(ms: number): void {
190
+ if (deadlineTimer !== null) clearTimeout(deadlineTimer);
191
+ deadlineTimer = setTimeout(() => {
192
+ deadlineTimer = null;
193
+ send("DEADLINE");
194
+ }, Math.max(0, ms));
195
+ }
196
+
197
+ /** Zero-duration completion: no CSS transitions are running, so the
198
+ * surface's logical open/close can settle on the MICROtask queue —
199
+ * ahead of every macrotask (a 0ms timer loses macrotask-order races
200
+ * against caller setTimeout(0)s, e.g. test harnesses; real browsers
201
+ * never take this path). The table absorbs a DEADLINE in any phase,
202
+ * so a microtask that lands after an unrelated phase change is a
203
+ * no-op. */
204
+ function completeNow(): void {
205
+ if (deadlineTimer !== null) {
206
+ clearTimeout(deadlineTimer);
207
+ deadlineTimer = null;
208
+ }
209
+ queueMicrotask(() => { send("DEADLINE"); });
210
+ }
211
+
212
+ function armClock(next: SurfacePhase): void {
213
+ clearClock();
214
+ if (next === "closed" || next === "open") return;
215
+ // Frame flip: double-rAF guarantees a style recalc between the
216
+ // from-pair and the to-pair (a same-recalc flip would coalesce into
217
+ // an instant jump); the flip timer forces the same event when rAF
218
+ // starves. Both may fire — the table absorbs the duplicate.
219
+ if (next === "openingFrom" || next === "closingFrom") {
220
+ raf1 = requestAnimationFrame(() => {
221
+ raf1 = 0;
222
+ raf2 = requestAnimationFrame(() => {
223
+ raf2 = 0;
224
+ send("FLIP");
225
+ });
226
+ });
227
+ flipTimer = setTimeout(() => {
228
+ flipTimer = null;
229
+ send("FLIP");
230
+ }, flipBudget);
231
+ }
232
+ deadlineTimer = setTimeout(() => {
233
+ deadlineTimer = null;
234
+ send("DEADLINE");
235
+ }, budgetFor(next));
236
+
237
+ // Effective-duration probe: once the layer elements are live (one
238
+ // microtask after the mounting patch), replace the configured bound
239
+ // with what the CSS actually says. A zero duration (no transitions
240
+ // — happy-dom, reduced motion, the global animation switch) makes
241
+ // the deadline fire on the next macrotask, restoring the legacy
242
+ // instant-completion semantics; a long theme gets its real budget.
243
+ // The phase guard makes a late probe a no-op.
244
+ if (next === "openingFrom" || next === "closingFrom") {
245
+ const phaseAtArm = next;
246
+ void nextTick(() => {
247
+ if (phase.value !== phaseAtArm) return;
248
+ const measured = measuredBudget();
249
+ if (measured === null) return;
250
+ if (measured > 0) rearmDeadline(flipBudget + measured + slack);
251
+ else completeNow();
252
+ });
253
+ } else {
254
+ // *.to phases: the elements exist and styles settled at the flip.
255
+ const measured = measuredBudget();
256
+ if (measured !== null) {
257
+ if (measured > 0) {
258
+ rearmDeadline(measured + slack);
259
+ armTend(measured);
260
+ } else {
261
+ completeNow();
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ function send(event: SurfaceEventType): void {
268
+ const from = phase.value;
269
+ const to = surfaceTransition(from, event);
270
+ if (to === from) return; // table-idempotent absorb (no effects)
271
+ phase.value = to;
272
+ armClock(to);
273
+ options.onPhase?.(from, to, event);
274
+ }
275
+
276
+ onBeforeUnmount(() => {
277
+ clearClock();
278
+ // UNMOUNT walks any phase to `closed` so onPhase observers see a
279
+ // consistent final state even mid-animation.
280
+ send("UNMOUNT");
281
+ clearClock();
282
+ });
283
+
284
+ function classesFor(prefix: string): string[] {
285
+ return classesForPair(prefix, classPairOf(phase.value));
286
+ }
287
+
288
+ return { phase: computed(() => phase.value), mounted, classesFor, send };
289
+ }