@celestia-island/hikari 0.55.0 → 0.55.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/package.json +1 -1
- package/src/components/HkDrawer.tsx +13 -5
- package/src/components/HkModal.contenthold.test.tsx +156 -0
- package/src/components/HkModal.leavepin.test.tsx +169 -0
- package/src/components/HkModal.tsx +61 -11
- package/src/composables/useSizeMorph.test.ts +75 -3
- package/src/composables/useSizeMorph.ts +51 -4
- package/src/composables/useSurfaceContentHold.ts +58 -0
- package/src/composables/useSurfaceMachine.test.ts +50 -0
- package/src/composables/useSurfaceMachine.ts +35 -1
package/package.json
CHANGED
|
@@ -19,6 +19,7 @@ import { createBackGuard } from "../runtime/backStack";
|
|
|
19
19
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
20
20
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
21
21
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
22
|
+
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
22
23
|
import HIconButton from "./HkIconButton";
|
|
23
24
|
import HIcon from "./HkIcon";
|
|
24
25
|
import "./window-close.scss";
|
|
@@ -269,6 +270,8 @@ export default defineComponent({
|
|
|
269
270
|
cleanup();
|
|
270
271
|
});
|
|
271
272
|
|
|
273
|
+
const contentHold = useSurfaceContentHold(machine.phase);
|
|
274
|
+
|
|
272
275
|
return () => {
|
|
273
276
|
if (!machine.mounted.value) return null;
|
|
274
277
|
const panelPrefix = `hk-drawer-${props.side}`;
|
|
@@ -295,7 +298,11 @@ export default defineComponent({
|
|
|
295
298
|
else if (e.key === "Tab" && panelRef.value) trapFocus(panelRef.value, e);
|
|
296
299
|
}}
|
|
297
300
|
>
|
|
298
|
-
{
|
|
301
|
+
{/* Same close-fold content hold as HkModal: the sheet's
|
|
302
|
+
slide-away plays over the last live content even when
|
|
303
|
+
the consumer tears its state down on close. */}
|
|
304
|
+
{contentHold.hold(() => [
|
|
305
|
+
props.title || slots.header ? (
|
|
299
306
|
<div class="hk-drawer-header">
|
|
300
307
|
{slots.header ? (
|
|
301
308
|
slots.header()
|
|
@@ -314,13 +321,14 @@ export default defineComponent({
|
|
|
314
321
|
</HIconButton>
|
|
315
322
|
) : null}
|
|
316
323
|
</div>
|
|
317
|
-
) : null
|
|
324
|
+
) : null,
|
|
318
325
|
<div ref={bodyWrapRef} class="hk-drawer-body-wrap">
|
|
319
326
|
<div ref={bodyRef} class="hk-drawer-body">{slots.default?.()}</div>
|
|
320
|
-
</div
|
|
321
|
-
|
|
327
|
+
</div>,
|
|
328
|
+
slots.footer ? (
|
|
322
329
|
<div class="hk-drawer-footer">{slots.footer()}</div>
|
|
323
|
-
) : null
|
|
330
|
+
) : null,
|
|
331
|
+
])}
|
|
324
332
|
</div>
|
|
325
333
|
</Teleport>
|
|
326
334
|
);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkModal from "./HkModal";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Source contract for the close-fold content hold (chest field report
|
|
8
|
+
* 2026-09-16): consumers routinely tear down the state that feeds the
|
|
9
|
+
* window's body in the same tick as the close (TodoLogModal's
|
|
10
|
+
* clear-on-close watcher emptied a 697px conversation window to a 200px
|
|
11
|
+
* stub BEFORE the fold started). While the machine is in a closing phase
|
|
12
|
+
* HkModal serves the last live-rendered children, so the fold always
|
|
13
|
+
* plays over the content the user was looking at.
|
|
14
|
+
*
|
|
15
|
+
* happy-dom has no transition engine, so the machine's duration probe
|
|
16
|
+
* reads a stubbed 0.3s (same harness trick as the surface-machine tests)
|
|
17
|
+
* and rAF is frozen — the flip timers drive the phase edges
|
|
18
|
+
* deterministically.
|
|
19
|
+
*/
|
|
20
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
21
|
+
const containers: HTMLElement[] = [];
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
25
|
+
for (const el of containers.splice(0)) el.remove();
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
vi.useRealTimers();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function freezeRaf(): void {
|
|
31
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
32
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stubDurations(): void {
|
|
36
|
+
const realGCS = window.getComputedStyle.bind(window);
|
|
37
|
+
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
38
|
+
const style = realGCS(el as Element, ...(rest as []));
|
|
39
|
+
return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Rig {
|
|
44
|
+
open: { value: boolean };
|
|
45
|
+
setLines: (lines: string[]) => void;
|
|
46
|
+
closeWithTeardown: () => void;
|
|
47
|
+
bodyText: () => string;
|
|
48
|
+
frameEl: () => HTMLElement | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Mount HkModal behind a wrapper that models the chest clear-on-close
|
|
52
|
+
* pattern: the body state clears in the SAME handler that flips the
|
|
53
|
+
* v-model false. */
|
|
54
|
+
async function mountRig(initialLines: string[]): Promise<Rig> {
|
|
55
|
+
const container = document.createElement("div");
|
|
56
|
+
document.body.appendChild(container);
|
|
57
|
+
containers.push(container);
|
|
58
|
+
|
|
59
|
+
const open = ref(false);
|
|
60
|
+
const lines = ref<string[]>(initialLines);
|
|
61
|
+
|
|
62
|
+
const Wrapper = defineComponent({
|
|
63
|
+
setup() {
|
|
64
|
+
return () =>
|
|
65
|
+
h(HkModal, {
|
|
66
|
+
modelValue: open.value,
|
|
67
|
+
closable: true,
|
|
68
|
+
title: "hold-test",
|
|
69
|
+
"onUpdate:modelValue": (v: boolean) => {
|
|
70
|
+
open.value = v;
|
|
71
|
+
// The reported chest pattern: destroy the content with the
|
|
72
|
+
// close request, before any leave animation runs.
|
|
73
|
+
if (!v) lines.value = [];
|
|
74
|
+
},
|
|
75
|
+
}, {
|
|
76
|
+
default: () => h("div", lines.value.map((l, i) => h("p", { key: i }, l))),
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const app = createApp(Wrapper);
|
|
81
|
+
mounts.push(app);
|
|
82
|
+
app.mount(container);
|
|
83
|
+
await nextTick();
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
open,
|
|
87
|
+
setLines: (next: string[]) => { lines.value = next; },
|
|
88
|
+
/** Faithful chest pattern: the store-watcher teardown runs in the SAME
|
|
89
|
+
* tick as the v-model flip, regardless of who flipped it — clearing
|
|
90
|
+
* only inside onUpdate:modelValue would miss programmatic closes and
|
|
91
|
+
* the test would pass vacuously (R1 finding: the hold was never
|
|
92
|
+
* exercised). */
|
|
93
|
+
closeWithTeardown: () => {
|
|
94
|
+
open.value = false;
|
|
95
|
+
lines.value = [];
|
|
96
|
+
},
|
|
97
|
+
bodyText: () => document.querySelector(".hk-modal-body-inner")?.textContent ?? "",
|
|
98
|
+
frameEl: () => document.querySelector<HTMLElement>(".hk-modal-content"),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("HkModal close-fold content hold", () => {
|
|
103
|
+
it("folds over the last live content even when the consumer clears state on close", async () => {
|
|
104
|
+
vi.useFakeTimers();
|
|
105
|
+
freezeRaf();
|
|
106
|
+
stubDurations();
|
|
107
|
+
const rig = await mountRig(["alpha", "beta"]);
|
|
108
|
+
|
|
109
|
+
// Open the surface to rest (flip timer 120ms + duration 300 + slack).
|
|
110
|
+
rig.open.value = true;
|
|
111
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
112
|
+
expect(rig.bodyText()).toBe("alphabeta");
|
|
113
|
+
|
|
114
|
+
// Close with the SAME-TICK teardown — this is the exact moment the
|
|
115
|
+
// hold must earn its keep (without it the body blanks immediately).
|
|
116
|
+
rig.closeWithTeardown();
|
|
117
|
+
await vi.advanceTimersByTimeAsync(150); // mid-leave (past the flip)
|
|
118
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
119
|
+
// THE contract: the fold plays over the ORIGINAL content — the
|
|
120
|
+
// same-tick teardown must not blank the window mid-fold.
|
|
121
|
+
expect(rig.bodyText()).toBe("alphabeta");
|
|
122
|
+
|
|
123
|
+
// The leave settles and the DOM goes away with it.
|
|
124
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
125
|
+
expect(rig.frameEl()).toBeNull();
|
|
126
|
+
expect(rig.bodyText()).toBe("");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("a reopen interrupt drops the hold and serves live content again", async () => {
|
|
130
|
+
vi.useFakeTimers();
|
|
131
|
+
freezeRaf();
|
|
132
|
+
stubDurations();
|
|
133
|
+
const rig = await mountRig(["alpha", "beta"]);
|
|
134
|
+
|
|
135
|
+
rig.open.value = true;
|
|
136
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
137
|
+
const frameBeforeClose = rig.frameEl();
|
|
138
|
+
|
|
139
|
+
// Close with the same-tick teardown, then interrupt mid-leave.
|
|
140
|
+
rig.closeWithTeardown();
|
|
141
|
+
await vi.advanceTimersByTimeAsync(150);
|
|
142
|
+
expect(rig.bodyText()).toBe("alphabeta");
|
|
143
|
+
|
|
144
|
+
// Reopen with FRESH content — the hold releases with the phase.
|
|
145
|
+
rig.open.value = true;
|
|
146
|
+
rig.setLines(["gamma"]);
|
|
147
|
+
await vi.advanceTimersByTimeAsync(30);
|
|
148
|
+
expect(rig.bodyText()).toBe("gamma");
|
|
149
|
+
|
|
150
|
+
// …and the same element survives the reversal (no remount flash).
|
|
151
|
+
expect(rig.frameEl()).toBe(frameBeforeClose);
|
|
152
|
+
await vi.advanceTimersByTimeAsync(800);
|
|
153
|
+
expect(rig.bodyText()).toBe("gamma");
|
|
154
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkModal from "./HkModal";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Source contract for the leave-window height pin (chest field report
|
|
8
|
+
* 2026-09-16): the close fold owns the frame's geometry for the whole
|
|
9
|
+
* leave, so HkModal must KEEP the size morph's pin through closingFrom →
|
|
10
|
+
* closingTo (morph.hold()) and only release it on the finalize edge. The
|
|
11
|
+
* composable-level semantics are pinned by useSizeMorph.test.ts; this file
|
|
12
|
+
* pins the HkModal INTEGRATION — the wiring bug class (calling stop()
|
|
13
|
+
* instead of hold()) leaves the composable tests green while every real
|
|
14
|
+
* modal folds over a resizing frame.
|
|
15
|
+
*
|
|
16
|
+
* happy-dom has no layout engine, so the frame's offsetHeight is stubbed
|
|
17
|
+
* at the HTMLElement prototype level BEFORE mount — the morph's
|
|
18
|
+
* openingFrom-tick arm then measures 500px and pins it, and every
|
|
19
|
+
* mid-leave assertion reads the inline height string directly.
|
|
20
|
+
*/
|
|
21
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
22
|
+
const containers: HTMLElement[] = [];
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
26
|
+
for (const el of containers.splice(0)) el.remove();
|
|
27
|
+
vi.unstubAllGlobals();
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function freezeRaf(): void {
|
|
32
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
33
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stubDurations(): void {
|
|
37
|
+
const realGCS = window.getComputedStyle.bind(window);
|
|
38
|
+
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
39
|
+
const style = realGCS(el as Element, ...(rest as []));
|
|
40
|
+
return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Prototype-level layout stub: every element measures `layoutH` tall.
|
|
45
|
+
* Mutable so a test can simulate content growth mid-enter. */
|
|
46
|
+
let layoutH = 500;
|
|
47
|
+
function stubLayout(initial: number): void {
|
|
48
|
+
layoutH = initial;
|
|
49
|
+
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
50
|
+
configurable: true,
|
|
51
|
+
get: () => layoutH,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function restoreLayout(): void {
|
|
56
|
+
delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface Rig {
|
|
60
|
+
open: { value: boolean };
|
|
61
|
+
pinHeight: () => string;
|
|
62
|
+
frameEl: () => HTMLElement | null;
|
|
63
|
+
clickClose: () => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function mountRig(): Promise<Rig> {
|
|
67
|
+
const container = document.createElement("div");
|
|
68
|
+
document.body.appendChild(container);
|
|
69
|
+
containers.push(container);
|
|
70
|
+
|
|
71
|
+
const open = ref(false);
|
|
72
|
+
|
|
73
|
+
const Wrapper = defineComponent({
|
|
74
|
+
setup() {
|
|
75
|
+
return () =>
|
|
76
|
+
h(HkModal, {
|
|
77
|
+
modelValue: open.value,
|
|
78
|
+
closable: true,
|
|
79
|
+
title: "pin-test",
|
|
80
|
+
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
81
|
+
}, { default: () => h("div", "content") });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
const app = createApp(Wrapper);
|
|
85
|
+
mounts.push(app);
|
|
86
|
+
app.mount(container);
|
|
87
|
+
await nextTick();
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
open,
|
|
91
|
+
pinHeight: () => document.querySelector<HTMLElement>(".hk-modal-content")?.style.height ?? "",
|
|
92
|
+
frameEl: () => document.querySelector<HTMLElement>(".hk-modal-content"),
|
|
93
|
+
clickClose: () => {
|
|
94
|
+
const btn = document.querySelector<HTMLElement>(".hk-modal-close");
|
|
95
|
+
btn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("HkModal leave-window height pin", () => {
|
|
101
|
+
it("keeps the morph pin through the whole leave and releases at finalize", async () => {
|
|
102
|
+
vi.useFakeTimers();
|
|
103
|
+
freezeRaf();
|
|
104
|
+
stubDurations();
|
|
105
|
+
stubLayout(500);
|
|
106
|
+
try {
|
|
107
|
+
const rig = await mountRig();
|
|
108
|
+
|
|
109
|
+
// Open to rest: the enter-edge arm measured 500px and pinned it.
|
|
110
|
+
rig.open.value = true;
|
|
111
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
112
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
113
|
+
expect(rig.pinHeight()).toBe("500px");
|
|
114
|
+
|
|
115
|
+
// Close through the modal's own close button (the real path).
|
|
116
|
+
rig.clickClose();
|
|
117
|
+
await vi.advanceTimersByTimeAsync(150); // mid-leave, past the flip
|
|
118
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
119
|
+
// THE contract: the pin survives the whole closing window — a
|
|
120
|
+
// stop()-instead-of-hold() wiring drops it here and the fold plays
|
|
121
|
+
// over a frame handed back to `height: auto`.
|
|
122
|
+
expect(rig.pinHeight()).toBe("500px");
|
|
123
|
+
|
|
124
|
+
// Still pinned late in the leave.
|
|
125
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
126
|
+
if (rig.frameEl()) {
|
|
127
|
+
expect(rig.pinHeight()).toBe("500px");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Finalize: the surface unmounts (the release write is invisible).
|
|
131
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
132
|
+
expect(rig.frameEl()).toBeNull();
|
|
133
|
+
} finally {
|
|
134
|
+
restoreLayout();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("flushes growth deferred through the enter with an open-edge remeasure", async () => {
|
|
139
|
+
vi.useFakeTimers();
|
|
140
|
+
freezeRaf();
|
|
141
|
+
stubDurations();
|
|
142
|
+
stubLayout(400);
|
|
143
|
+
try {
|
|
144
|
+
const rig = await mountRig();
|
|
145
|
+
|
|
146
|
+
// Open: the enter-edge arm pins the enter-time height (400px).
|
|
147
|
+
rig.open.value = true;
|
|
148
|
+
await vi.advanceTimersByTimeAsync(150); // mid-enter, past the flip
|
|
149
|
+
expect(rig.pinHeight()).toBe("400px");
|
|
150
|
+
|
|
151
|
+
// Content grows mid-enter — with the enter's defer gate the pin
|
|
152
|
+
// must NOT chase it (the choreography's height-relative geometry
|
|
153
|
+
// stays put; in a real browser the RO fires and is deferred too).
|
|
154
|
+
layoutH = 700;
|
|
155
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
156
|
+
expect(rig.pinHeight()).toBe("400px");
|
|
157
|
+
|
|
158
|
+
// The open edge flushes the deferred growth one tick after the
|
|
159
|
+
// transition classes clear — the pin lands at the grown height.
|
|
160
|
+
await vi.advanceTimersByTimeAsync(450);
|
|
161
|
+
expect(rig.pinHeight()).toBe("700px");
|
|
162
|
+
|
|
163
|
+
// Sanity: the surface really rested open before the flush assertion.
|
|
164
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
165
|
+
} finally {
|
|
166
|
+
restoreLayout();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -21,6 +21,7 @@ import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
|
21
21
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
22
22
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
23
23
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
24
|
+
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
24
25
|
import { useSizeMorph } from "../composables/useSizeMorph";
|
|
25
26
|
import HButton from "./HkButton";
|
|
26
27
|
import HFab from "./HkFab";
|
|
@@ -201,8 +202,19 @@ export default defineComponent({
|
|
|
201
202
|
* useSizeMorph). */
|
|
202
203
|
const innerRef = ref<HTMLElement>();
|
|
203
204
|
// Content-driven size morphing: the frame follows content growth with
|
|
204
|
-
// the height transition instead of snapping (see useSizeMorph).
|
|
205
|
-
|
|
205
|
+
// the height transition instead of snapping (see useSizeMorph). The
|
|
206
|
+
// gate freezes resize-driven re-measurement through the enter unfold:
|
|
207
|
+
// the choreography's geometry is height-relative (translateY 5% +
|
|
208
|
+
// bottom-10% clip of the frame height), so content streaming in
|
|
209
|
+
// mid-enter would otherwise recompute the pixel geometry under the
|
|
210
|
+
// running animation (2026-09-16 chest field report — the frame
|
|
211
|
+
// snapped 459→697px mid-unfold). The open phase edge flushes the
|
|
212
|
+
// deferred growth with an animated remeasure.
|
|
213
|
+
const morph = useSizeMorph(contentRef, innerRef, {
|
|
214
|
+
deferRemeasure: () =>
|
|
215
|
+
machine.phase.value === "openingFrom" ||
|
|
216
|
+
machine.phase.value === "openingTo",
|
|
217
|
+
});
|
|
206
218
|
let previouslyFocused: HTMLElement | null = null;
|
|
207
219
|
let unmounted = false;
|
|
208
220
|
|
|
@@ -347,13 +359,29 @@ export default defineComponent({
|
|
|
347
359
|
backGuard.push();
|
|
348
360
|
}
|
|
349
361
|
surfTrack.run();
|
|
362
|
+
// Arm the size morph WITH the enter (one tick later, once the
|
|
363
|
+
// mounting patch landed): the pin locks the frame's height for
|
|
364
|
+
// the whole unfold so late-streaming content cannot move the
|
|
365
|
+
// choreography mid-flight (see the morph's gate above). The
|
|
366
|
+
// zero-duration fast paths reach `open` before the tick — the
|
|
367
|
+
// open branch's own start() covers them.
|
|
368
|
+
void nextTick(() => {
|
|
369
|
+
const p = machine.phase.value;
|
|
370
|
+
if (p === "openingFrom" || p === "openingTo") morph.start();
|
|
371
|
+
});
|
|
350
372
|
} else if (to === "open") {
|
|
351
373
|
surfTrack.cancel();
|
|
352
374
|
onAfterEnter();
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
// transition.
|
|
375
|
+
// Idempotent re-arm for the fast paths (see openingFrom); the
|
|
376
|
+
// morph is usually armed already by the enter-edge tick.
|
|
356
377
|
morph.start();
|
|
378
|
+
// Flush the growth deferred through the unfold. One tick later
|
|
379
|
+
// the transition classes are off the frame, so the base height
|
|
380
|
+
// transition animates the pin change instead of the enter's
|
|
381
|
+
// transform/clip-only transition list swallowing it as a snap.
|
|
382
|
+
void nextTick(() => {
|
|
383
|
+
if (machine.phase.value === "open") morph.remeasure();
|
|
384
|
+
});
|
|
357
385
|
} else if (to === "closingFrom") {
|
|
358
386
|
// Close-request bookkeeping (was the watcher's close arm): the
|
|
359
387
|
// registries forget the surface at request time; the finalize
|
|
@@ -361,8 +389,18 @@ export default defineComponent({
|
|
|
361
389
|
surfTrack.run();
|
|
362
390
|
overlay.close();
|
|
363
391
|
backGuard.release();
|
|
364
|
-
//
|
|
365
|
-
|
|
392
|
+
// Keep the height PIN through the leave: the fold owns the
|
|
393
|
+
// frame's geometry and a mid-leave content change must not
|
|
394
|
+
// resize it. The pin releases on the finalize edge below.
|
|
395
|
+
morph.hold();
|
|
396
|
+
// The leave must not chase a live tail — the window folds over
|
|
397
|
+
// the final frame of content, not over a moving stream. A
|
|
398
|
+
// self-updating child inside the held content could still
|
|
399
|
+
// mutate the DOM mid-fold (the observer would re-pin the
|
|
400
|
+
// scroll), so auto-follow tears down with the close request;
|
|
401
|
+
// a reopen interrupt re-arms it at the open edge
|
|
402
|
+
// (onAfterEnter → setupAutoFollow).
|
|
403
|
+
teardownAutoFollow();
|
|
366
404
|
} else if (
|
|
367
405
|
to === "closed" &&
|
|
368
406
|
(from === "closingFrom" || from === "closingTo") &&
|
|
@@ -373,6 +411,10 @@ export default defineComponent({
|
|
|
373
411
|
event !== "UNMOUNT"
|
|
374
412
|
) {
|
|
375
413
|
surfTrack.cancel();
|
|
414
|
+
// Bookkeeping reset for the NEXT open cycle: zeroes the morph's
|
|
415
|
+
// pin so the next enter starts clean (release() writes on the
|
|
416
|
+
// invisible, about-to-unmount frame — no visual effect).
|
|
417
|
+
morph.stop();
|
|
376
418
|
onAfterLeaveFinalize();
|
|
377
419
|
}
|
|
378
420
|
// to === "closed" via UNMOUNT: teardown is owned by
|
|
@@ -743,6 +785,8 @@ export default defineComponent({
|
|
|
743
785
|
return null;
|
|
744
786
|
}
|
|
745
787
|
|
|
788
|
+
const contentHold = useSurfaceContentHold(machine.phase);
|
|
789
|
+
|
|
746
790
|
return () => {
|
|
747
791
|
if (!machine.mounted.value) return null;
|
|
748
792
|
|
|
@@ -773,7 +817,12 @@ export default defineComponent({
|
|
|
773
817
|
onClick={onContentClick}
|
|
774
818
|
tabindex={-1}
|
|
775
819
|
>
|
|
776
|
-
{
|
|
820
|
+
{/* Content hold: while the machine is in a closing
|
|
821
|
+
phase this serves the last live-rendered children,
|
|
822
|
+
so a consumer tearing down its state on close
|
|
823
|
+
cannot blank the window mid-fold. */}
|
|
824
|
+
{contentHold.hold(() => [
|
|
825
|
+
headerShown ? (
|
|
777
826
|
<>
|
|
778
827
|
<div
|
|
779
828
|
class={[
|
|
@@ -807,7 +856,7 @@ export default defineComponent({
|
|
|
807
856
|
<div class="hk-modal-subheader">{slots.header()}</div>
|
|
808
857
|
)}
|
|
809
858
|
</>
|
|
810
|
-
)
|
|
859
|
+
) : null,
|
|
811
860
|
<div ref={bodyRef} class="hk-modal-body">
|
|
812
861
|
<div
|
|
813
862
|
ref={scrollContainerRef}
|
|
@@ -851,8 +900,9 @@ export default defineComponent({
|
|
|
851
900
|
)}
|
|
852
901
|
</>
|
|
853
902
|
)}
|
|
854
|
-
</div
|
|
855
|
-
|
|
903
|
+
</div>,
|
|
904
|
+
renderFooter(),
|
|
905
|
+
])}
|
|
856
906
|
</div>
|
|
857
907
|
</div>
|
|
858
908
|
</Teleport>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, h, nextTick, ref } from "vue";
|
|
3
3
|
|
|
4
|
-
import { useSizeMorph } from "./useSizeMorph";
|
|
4
|
+
import { useSizeMorph, type SizeMorphOptions } from "./useSizeMorph";
|
|
5
5
|
|
|
6
6
|
/** Injectable ResizeObserver: captures the callback so tests can fire
|
|
7
7
|
* content changes deterministically (happy-dom's own RO never fires —
|
|
@@ -35,10 +35,11 @@ interface Harness {
|
|
|
35
35
|
setContentNatural(height: number): void;
|
|
36
36
|
start(): void;
|
|
37
37
|
stop(): void;
|
|
38
|
+
hold(): void;
|
|
38
39
|
remeasure(): void;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function mountHarness(initialHeight: number, initialContentHeight = 0): Harness {
|
|
42
|
+
function mountHarness(initialHeight: number, initialContentHeight = 0, options?: SizeMorphOptions): Harness {
|
|
42
43
|
const container = document.createElement("div");
|
|
43
44
|
document.body.appendChild(container);
|
|
44
45
|
let frameEl: HTMLElement | null = null;
|
|
@@ -50,7 +51,7 @@ function mountHarness(initialHeight: number, initialContentHeight = 0): Harness
|
|
|
50
51
|
setup() {
|
|
51
52
|
const frame = ref<HTMLElement | null>(null);
|
|
52
53
|
const content = ref<HTMLElement | null>(null);
|
|
53
|
-
morph = useSizeMorph(frame, content);
|
|
54
|
+
morph = useSizeMorph(frame, content, options);
|
|
54
55
|
return () =>
|
|
55
56
|
h("div", [
|
|
56
57
|
h("div", {
|
|
@@ -96,6 +97,7 @@ function mountHarness(initialHeight: number, initialContentHeight = 0): Harness
|
|
|
96
97
|
},
|
|
97
98
|
start: () => morph!.start(),
|
|
98
99
|
stop: () => morph!.stop(),
|
|
100
|
+
hold: () => morph!.hold(),
|
|
99
101
|
remeasure: () => morph!.remeasure(),
|
|
100
102
|
};
|
|
101
103
|
}
|
|
@@ -427,3 +429,73 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
427
429
|
expect(h.frame.style.clipPath).toBe("");
|
|
428
430
|
});
|
|
429
431
|
});
|
|
432
|
+
|
|
433
|
+
// ── Leave-window hold + enter-window defer (2026-09-16 modal report) ──
|
|
434
|
+
// The modal's close fold owns the frame's geometry for the whole leave:
|
|
435
|
+
// hold() keeps the pin (a mid-leave content change must not resize the
|
|
436
|
+
// folding frame) and the reopen-interrupt path animates FROM that pin.
|
|
437
|
+
// The enter unfold is height-relative geometry (translateY 5% + bottom
|
|
438
|
+
// 10% clip of the frame height), so deferRemeasure freezes resize-driven
|
|
439
|
+
// re-pins through the enter and the open edge flushes them.
|
|
440
|
+
|
|
441
|
+
describe("useSizeMorph hold + deferRemeasure", () => {
|
|
442
|
+
it("hold keeps the pin and stops observing (leave-window stability)", () => {
|
|
443
|
+
const h = mountHarness(120, 100);
|
|
444
|
+
h.start();
|
|
445
|
+
expect(h.frame.style.height).toBe("120px");
|
|
446
|
+
|
|
447
|
+
h.hold();
|
|
448
|
+
// The pin STAYS (unlike stop's release to auto)…
|
|
449
|
+
expect(h.frame.style.height).toBe("120px");
|
|
450
|
+
// …and the observer is disarmed: no content change can re-pin.
|
|
451
|
+
expect(FakeResizeObserver.instances[0]!.disconnected).toBe(true);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("stop after a hold still releases (full-close bookkeeping)", () => {
|
|
455
|
+
const h = mountHarness(120, 100);
|
|
456
|
+
h.start();
|
|
457
|
+
h.hold();
|
|
458
|
+
h.stop();
|
|
459
|
+
expect(h.frame.style.height).toBe("");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("start after a hold re-arms and animates from the held pin", () => {
|
|
463
|
+
const h = mountHarness(120, 100);
|
|
464
|
+
h.start();
|
|
465
|
+
h.hold();
|
|
466
|
+
// Reopen interrupt: the natural height changed while held — the next
|
|
467
|
+
// arm re-pins to it (from the held 120px, per the dance's re-pin).
|
|
468
|
+
h.setNatural(200);
|
|
469
|
+
h.start();
|
|
470
|
+
expect(h.frame.style.height).toBe("200px");
|
|
471
|
+
// A FRESH observer owns the new cycle.
|
|
472
|
+
expect(FakeResizeObserver.instances.length).toBe(2);
|
|
473
|
+
expect(FakeResizeObserver.instances[1]!.disconnected).toBe(false);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("deferRemeasure freezes RO-driven re-pins; explicit remeasure flushes", async () => {
|
|
477
|
+
let gated = true;
|
|
478
|
+
const h = mountHarness(120, 100, { deferRemeasure: () => gated });
|
|
479
|
+
h.start();
|
|
480
|
+
// The initial pin is NOT gated (arming must pin immediately).
|
|
481
|
+
expect(h.frame.style.height).toBe("120px");
|
|
482
|
+
|
|
483
|
+
// Content streams in mid-enter: the RO fires but the pin must not
|
|
484
|
+
// move — the unfold's height-relative geometry stays put.
|
|
485
|
+
h.setNatural(160);
|
|
486
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
487
|
+
await settle();
|
|
488
|
+
expect(h.frame.style.height).toBe("120px");
|
|
489
|
+
|
|
490
|
+
// The open edge flushes the deferred growth.
|
|
491
|
+
h.remeasure();
|
|
492
|
+
expect(h.frame.style.height).toBe("160px");
|
|
493
|
+
|
|
494
|
+
// Gate off: RO-driven updates flow again.
|
|
495
|
+
gated = false;
|
|
496
|
+
h.setNatural(200);
|
|
497
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
498
|
+
await settle();
|
|
499
|
+
expect(h.frame.style.height).toBe("200px");
|
|
500
|
+
});
|
|
501
|
+
});
|
|
@@ -17,16 +17,41 @@ export interface SizeMorph {
|
|
|
17
17
|
/** Arm the morph: observe the content and pin the frame's natural
|
|
18
18
|
* height on every change. Call once the surface finished its open
|
|
19
19
|
* enter transition — pinning during enter would override the
|
|
20
|
-
* choreography's own height animation.
|
|
20
|
+
* choreography's own height animation. (Surfaces whose enter is
|
|
21
|
+
* height-INDEPENDENT — HkModal's clip+transform unfold — may arm
|
|
22
|
+
* earlier with a `deferRemeasure` gate, see below.) */
|
|
21
23
|
start(): void;
|
|
22
24
|
/** Disarm the morph and release the frame to `height: auto` — call
|
|
23
25
|
* before a surface's leave/close so the exit animation owns the
|
|
24
26
|
* height again. */
|
|
25
27
|
stop(): void;
|
|
28
|
+
/** Leave-window hold: disarm the observer/timers and cancel any
|
|
29
|
+
* mid-flight reveal like stop(), but KEEP the height pin — the close
|
|
30
|
+
* choreography owns the frame's geometry through the whole leave and
|
|
31
|
+
* the pin keeps it stable (a mid-leave content change must not resize
|
|
32
|
+
* the folding frame). The next start() re-arms and, because the pin
|
|
33
|
+
* was kept, a re-pin to the SAME natural height is a no-op (the
|
|
34
|
+
* typical reopen-interrupt case — held content is unchanged, delta
|
|
35
|
+
* zero; a re-pin to a CHANGED natural while an enter's transition
|
|
36
|
+
* classes own the frame lands without a height animation, i.e. snaps
|
|
37
|
+
* — acceptable, the enter's own choreography owns that moment). */
|
|
38
|
+
hold(): void;
|
|
26
39
|
/** Re-measure and pin now (resize events, open flows). */
|
|
27
40
|
remeasure(): void;
|
|
28
41
|
}
|
|
29
42
|
|
|
43
|
+
export interface SizeMorphOptions {
|
|
44
|
+
/** While this returns true, resize-driven re-measurements are deferred
|
|
45
|
+
* (the current pin stays). HkModal freezes the frame's height during
|
|
46
|
+
* the enter unfold: the choreography's translateY(5%) + bottom-10%
|
|
47
|
+
* clip are fractions of the frame height, so late-streaming content
|
|
48
|
+
* resizing the frame mid-enter would recompute the geometry under the
|
|
49
|
+
* running animation (2026-09-16 chest field report — the frame snapped
|
|
50
|
+
* 459→697px mid-unfold). The caller flushes the deferred growth with
|
|
51
|
+
* an explicit remeasure() at the open edge. */
|
|
52
|
+
deferRemeasure?: () => boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
/**
|
|
31
56
|
* useSizeMorph — smooth size morphing for content-hugging surfaces.
|
|
32
57
|
*
|
|
@@ -71,6 +96,7 @@ export interface SizeMorph {
|
|
|
71
96
|
export function useSizeMorph(
|
|
72
97
|
frame: Ref<HTMLElement | null | undefined>,
|
|
73
98
|
content: Ref<HTMLElement | null | undefined>,
|
|
99
|
+
options: SizeMorphOptions = {},
|
|
74
100
|
): SizeMorph {
|
|
75
101
|
let ro: ResizeObserver | null = null;
|
|
76
102
|
let raf = 0;
|
|
@@ -155,7 +181,13 @@ export function useSizeMorph(
|
|
|
155
181
|
* finished) frame — the only moment guaranteed free of transition-class
|
|
156
182
|
* flex rules. Never calibrate from a remeasure sample: the first
|
|
157
183
|
* remeasure can itself be the contaminated one (a frozen enter leaves
|
|
158
|
-
* the flex rules behind when the surface is mid-repair).
|
|
184
|
+
* the flex rules behind when the surface is mid-repair). HkModal's
|
|
185
|
+
* reopen-interrupt arm violates the precondition on purpose (it
|
|
186
|
+
* calibrates a pinned, enter-classed frame): benign today because no
|
|
187
|
+
* current enter/leave class carries height/flex rules (the inline pin
|
|
188
|
+
* beats the mobile sheet's static height:auto), and the contamination
|
|
189
|
+
* guard below degrades any future SCSS regression to a dropped pin
|
|
190
|
+
* rather than a corrupted one. */
|
|
159
191
|
function calibrate(): void {
|
|
160
192
|
const f = frame.value;
|
|
161
193
|
const c = content.value;
|
|
@@ -271,6 +303,10 @@ export function useSizeMorph(
|
|
|
271
303
|
}
|
|
272
304
|
|
|
273
305
|
function onResize(): void {
|
|
306
|
+
// Frozen window (e.g. HkModal's enter unfold): keep the current pin;
|
|
307
|
+
// the caller flushes the accumulated change with an explicit
|
|
308
|
+
// remeasure() once the choreography hands the height back.
|
|
309
|
+
if (options.deferRemeasure?.()) return;
|
|
274
310
|
// Debounce the choreography: content can change in a burst (a list
|
|
275
311
|
// transition shrinking rows over several frames, a textarea growing
|
|
276
312
|
// per keystroke). Dancing to every intermediate would restart the
|
|
@@ -304,7 +340,7 @@ export function useSizeMorph(
|
|
|
304
340
|
remeasure();
|
|
305
341
|
}
|
|
306
342
|
|
|
307
|
-
function
|
|
343
|
+
function hold(): void {
|
|
308
344
|
if (!armed) return;
|
|
309
345
|
armed = false;
|
|
310
346
|
ro?.disconnect();
|
|
@@ -320,6 +356,17 @@ export function useSizeMorph(
|
|
|
320
356
|
// The next start() re-calibrates against whatever chrome that open
|
|
321
357
|
// cycle carries.
|
|
322
358
|
chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
|
|
359
|
+
stopReveal();
|
|
360
|
+
// Deliberately no release(): the pin stays on the frame so the close
|
|
361
|
+
// fold plays on a stable box, and a reopen interrupt animates from it.
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function stop(): void {
|
|
365
|
+
// Disarm only when armed — after hold() the morph is already
|
|
366
|
+
// disarmed and only the release is owed. release() is internally
|
|
367
|
+
// guarded (no pin / no frame → no-op), so a never-armed stop()
|
|
368
|
+
// stays the no-op it always was.
|
|
369
|
+
if (armed) hold();
|
|
323
370
|
release();
|
|
324
371
|
}
|
|
325
372
|
|
|
@@ -330,5 +377,5 @@ export function useSizeMorph(
|
|
|
330
377
|
stopReveal();
|
|
331
378
|
});
|
|
332
379
|
|
|
333
|
-
return { start, stop, remeasure };
|
|
380
|
+
return { start, stop, hold, remeasure };
|
|
334
381
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { computed, watch, type ComputedRef } from "vue";
|
|
2
|
+
|
|
3
|
+
import type { SurfacePhase } from "../runtime/surfaceMachine";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* useSurfaceContentHold — keep a surface's last live content on screen
|
|
7
|
+
* for the whole close fold.
|
|
8
|
+
*
|
|
9
|
+
* Consumers routinely tear down the very state that feeds a window's body
|
|
10
|
+
* the moment its v-model flips false (clear-on-close watchers, store
|
|
11
|
+
* resets, socket disconnects). Without a hold, the leave transition then
|
|
12
|
+
* plays on an emptied shell: the frame collapses to its chrome BEFORE the
|
|
13
|
+
* fold starts (chest TodoLogModal, 2026-09-16 field report — the 697px
|
|
14
|
+
* conversation window became a 200px stub in the same tick as the close).
|
|
15
|
+
*
|
|
16
|
+
* While the machine is in a closing phase this serves the most recent
|
|
17
|
+
* live-rendered children instead of re-invoking the slots, so the fold
|
|
18
|
+
* always plays over the content the user was actually looking at. Vue's
|
|
19
|
+
* patch skips identical vnode references, so the held DOM is never
|
|
20
|
+
* re-patched during the close; child components with their own store
|
|
21
|
+
* subscriptions may still self-update (harmless — the failure mode under
|
|
22
|
+
* repair is content VANISHING, not content ticking).
|
|
23
|
+
*
|
|
24
|
+
* The hold releases the moment the surface leaves a closing phase: a
|
|
25
|
+
* reopen interrupt (closing → openingFrom) serves live slots again, and
|
|
26
|
+
* `closed` drops the cache so a closed surface retains nothing.
|
|
27
|
+
*/
|
|
28
|
+
export function useSurfaceContentHold(phase: ComputedRef<SurfacePhase>) {
|
|
29
|
+
const closing = computed(
|
|
30
|
+
() => phase.value === "closingFrom" || phase.value === "closingTo",
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
let held: unknown = null;
|
|
34
|
+
|
|
35
|
+
// A closed surface renders nothing — the cache would only pin a dead
|
|
36
|
+
// vnode tree. Drop it on the closed edge.
|
|
37
|
+
watch(phase, (p) => {
|
|
38
|
+
if (p === "closed") held = null;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wrap one render-region producer: live while opening/open, frozen
|
|
43
|
+
* while closing. The first closing render falls through to live
|
|
44
|
+
* production when no snapshot exists (the surface opened directly into
|
|
45
|
+
* a close is not a real path, but the guard keeps the contract total).
|
|
46
|
+
*/
|
|
47
|
+
function hold<T>(produce: () => T): T {
|
|
48
|
+
if (closing.value) {
|
|
49
|
+
if (held !== null) return held as T;
|
|
50
|
+
return produce();
|
|
51
|
+
}
|
|
52
|
+
const next = produce();
|
|
53
|
+
held = next;
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { hold, closing };
|
|
58
|
+
}
|
|
@@ -240,6 +240,56 @@ describe("useSurfaceMachine driver", () => {
|
|
|
240
240
|
expect(rig.edges.length).toBe(edgesAtRest);
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
+
it("transitionstart re-arms the deadline once so a late-latched transition completes", async () => {
|
|
244
|
+
vi.useFakeTimers();
|
|
245
|
+
vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
|
|
246
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
247
|
+
const realGCS = window.getComputedStyle.bind(window);
|
|
248
|
+
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
249
|
+
const style = realGCS(el as Element, ...(rest as []));
|
|
250
|
+
return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
|
|
251
|
+
});
|
|
252
|
+
const rig = mountRig(true);
|
|
253
|
+
rig.send("OPEN");
|
|
254
|
+
await nextTick();
|
|
255
|
+
await vi.advanceTimersByTimeAsync(130); // flip timer forced the FLIP
|
|
256
|
+
expect(rig.phase()).toBe("openingTo");
|
|
257
|
+
// Flip-relative deadline: 300 + 80 → settles at t≈500 unless the
|
|
258
|
+
// browser reports the transition ACTUALLY started late (frame
|
|
259
|
+
// starvation latches the transition on the first post-flip frame).
|
|
260
|
+
const panel = document.querySelector<HTMLElement>(".panel")!;
|
|
261
|
+
|
|
262
|
+
// t=330: the transition latches 200ms after the flip.
|
|
263
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
264
|
+
panel.dispatchEvent(new Event("transitionstart", { bubbles: true }));
|
|
265
|
+
// → deadline re-armed to 330 + 300 + 80 = 710.
|
|
266
|
+
|
|
267
|
+
// t=390: a bubbled report from a NON-layer descendant (target filter)
|
|
268
|
+
// and a duplicate report on the panel (one re-arm per phase) are
|
|
269
|
+
// both ignored — either would push the deadline to 770.
|
|
270
|
+
await vi.advanceTimersByTimeAsync(60);
|
|
271
|
+
const child = document.createElement("span");
|
|
272
|
+
panel.appendChild(child);
|
|
273
|
+
child.dispatchEvent(new Event("transitionstart", { bubbles: true }));
|
|
274
|
+
panel.dispatchEvent(new Event("transitionstart", { bubbles: true }));
|
|
275
|
+
|
|
276
|
+
// The flip-relative deadline (t=500) passes without settling — the
|
|
277
|
+
// re-arm is real…
|
|
278
|
+
await vi.advanceTimersByTimeAsync(300); // t=690
|
|
279
|
+
expect(rig.phase()).toBe("openingTo");
|
|
280
|
+
// …and the latch-relative deadline completes the open (t=710), ahead
|
|
281
|
+
// of where a second re-arm would have put it (t=770).
|
|
282
|
+
await vi.advanceTimersByTimeAsync(30); // t=720
|
|
283
|
+
expect(rig.phase()).toBe("open");
|
|
284
|
+
await nextTick();
|
|
285
|
+
expect(scrimClasses(rig)).toEqual([]);
|
|
286
|
+
// No late timers fire afterwards.
|
|
287
|
+
const edgesAtRest = rig.edges.length;
|
|
288
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
289
|
+
expect(rig.phase()).toBe("open");
|
|
290
|
+
expect(rig.edges.length).toBe(edgesAtRest);
|
|
291
|
+
});
|
|
292
|
+
|
|
243
293
|
it("UNMOUNT from any phase clears every clock and walks to closed", async () => {
|
|
244
294
|
vi.useFakeTimers();
|
|
245
295
|
const rig = mountRig();
|
|
@@ -58,7 +58,10 @@ export interface SurfaceMachine {
|
|
|
58
58
|
* - The ONLY correctness clock is setTimeout: every animation phase arms
|
|
59
59
|
* exactly one deadline timer; the frame flip is double-rAF (smooth)
|
|
60
60
|
* with a flip timer as the starved fallback. Duplicate FLIPs are
|
|
61
|
-
* absorbed by the table (idempotent self-loops).
|
|
61
|
+
* absorbed by the table (idempotent self-loops). transitionend (TEND)
|
|
62
|
+
* settles a phase early; transitionstart re-arms the deadline once so
|
|
63
|
+
* a frame-starved, late-latched transition still completes — both are
|
|
64
|
+
* advisory optimizations, never the correctness clock.
|
|
62
65
|
* - Phase changes clear every pending timer/rAF before arming the next
|
|
63
66
|
* phase's — at most one flip + one deadline exist at any moment, so
|
|
64
67
|
* timers cannot leak or fire stale (the machine has no "late
|
|
@@ -257,6 +260,7 @@ export function useSurfaceMachine(options: SurfaceMachineOptions): SurfaceMachin
|
|
|
257
260
|
if (measured > 0) {
|
|
258
261
|
rearmDeadline(measured + slack);
|
|
259
262
|
armTend(measured);
|
|
263
|
+
armStart(measured);
|
|
260
264
|
} else {
|
|
261
265
|
completeNow();
|
|
262
266
|
}
|
|
@@ -264,6 +268,36 @@ export function useSurfaceMachine(options: SurfaceMachineOptions): SurfaceMachin
|
|
|
264
268
|
}
|
|
265
269
|
}
|
|
266
270
|
|
|
271
|
+
/** transitionstart re-arm: under frame starvation the CSS transition
|
|
272
|
+
* only LATCHES when the first frame after the flip is produced, while
|
|
273
|
+
* this phase's deadline was armed at the flip — a transition starting
|
|
274
|
+
* 300ms late would be cut mid-flight by its own deadline, snapping the
|
|
275
|
+
* surface to rest (2026-09-16 chest field report: the desktop modal
|
|
276
|
+
* enter freezes at its low/transparent from-pair, then jumps). When
|
|
277
|
+
* the browser reports the transition actually started, re-arm the
|
|
278
|
+
* deadline from that moment so a late-started transition still runs
|
|
279
|
+
* to completion. A never-started transition (frames never come) fires
|
|
280
|
+
* nothing — the flip-relative deadline stays the bound (A2), so this
|
|
281
|
+
* changes settle latency only when the animation genuinely runs.
|
|
282
|
+
* One re-arm per layer per phase: every animated property fires its
|
|
283
|
+
* own transitionstart on the same frame. */
|
|
284
|
+
function armStart(expectedMs: number): void {
|
|
285
|
+
const seen = new Set<HTMLElement>();
|
|
286
|
+
for (const layer of options.layers) {
|
|
287
|
+
const el = layer.el?.();
|
|
288
|
+
if (!el || !el.isConnected || seen.has(el)) continue;
|
|
289
|
+
seen.add(el);
|
|
290
|
+
let rearmed = false;
|
|
291
|
+
const onStart = (e: TransitionEvent) => {
|
|
292
|
+
if (e.target !== el || rearmed) return;
|
|
293
|
+
rearmed = true;
|
|
294
|
+
rearmDeadline(expectedMs + slack);
|
|
295
|
+
};
|
|
296
|
+
el.addEventListener("transitionstart", onStart);
|
|
297
|
+
tendCleanups.push(() => el.removeEventListener("transitionstart", onStart));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
267
301
|
function send(event: SurfaceEventType): void {
|
|
268
302
|
const from = phase.value;
|
|
269
303
|
const to = surfaceTransition(from, event);
|