@celestia-island/hikari 0.55.45 → 0.55.47
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/HkModal.stepflowswap.test.tsx +329 -0
- package/src/components/HkModal.tsx +81 -11
- package/src/components/HkStepFlow.scss +76 -39
- package/src/components/HkStepFlow.slide.contract.test.ts +171 -50
- package/src/components/HkStepFlow.test.tsx +808 -59
- package/src/components/HkStepFlow.tsx +527 -116
- package/src/composables/useSizeMorph.test.ts +79 -0
- package/src/composables/useSizeMorph.ts +76 -2
package/package.json
CHANGED
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
import {
|
|
6
|
+
SHEET_SWEEP_SETTLE_EVENT,
|
|
7
|
+
SHEET_SWEEP_STAGE_EVENT,
|
|
8
|
+
STEPFLOW_SWAP_EVENT,
|
|
9
|
+
} from "./HkStepFlow";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Behavioural contract for the stepflow → sheet morph handoff
|
|
13
|
+
* (2026-09-22 user directive, round 10): a HkStepFlow swap announces the
|
|
14
|
+
* phase that owns the height change (`{ delta, durationMs, phase }`) and
|
|
15
|
+
* the hosting modal must morph its clip over ONE phase, not the 0.3s
|
|
16
|
+
* family default.
|
|
17
|
+
*
|
|
18
|
+
* Round-1 verification (2026-09-22) found this wiring DEAD in the real
|
|
19
|
+
* consumer: the listener was attached in `onMounted` while the modal
|
|
20
|
+
* renders nothing until `machine.mounted` flips, so `bodyRef.value` was
|
|
21
|
+
* undefined at attach time and every mount-closed-then-open consumer
|
|
22
|
+
* (chest's wizards) silently got no handoff at all — the sheet fell back
|
|
23
|
+
* to the morph's 150ms settle debounce plus a 0.3s sweep. These tests
|
|
24
|
+
* therefore drive the REAL pattern (mount closed, open later) and assert
|
|
25
|
+
* the observable token, so the defect cannot return unnoticed.
|
|
26
|
+
*
|
|
27
|
+
* happy-dom has no transition engine: durations are stubbed and rAF is
|
|
28
|
+
* frozen (same harness as HkModal.contenthold.test.tsx), with fake timers
|
|
29
|
+
* driving the machine's phase edges.
|
|
30
|
+
*/
|
|
31
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
32
|
+
const containers: HTMLElement[] = [];
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
36
|
+
for (const el of containers.splice(0)) el.remove();
|
|
37
|
+
vi.unstubAllGlobals();
|
|
38
|
+
vi.useRealTimers();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function freezeRaf(): void {
|
|
42
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
43
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stubDurations(): void {
|
|
47
|
+
const realGCS = window.getComputedStyle.bind(window);
|
|
48
|
+
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
49
|
+
const style = realGCS(el as Element, ...(rest as []));
|
|
50
|
+
return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Rig {
|
|
55
|
+
open: { value: boolean };
|
|
56
|
+
bodyEl: () => HTMLElement | null;
|
|
57
|
+
frameEl: () => HTMLElement | null;
|
|
58
|
+
/** Fire the swap passthrough the way HkStepFlow does (bubbling from a
|
|
59
|
+
* descendant of the modal body). */
|
|
60
|
+
announceSwap: (detail: { delta: number; durationMs: number; phase: string }) => void;
|
|
61
|
+
morphToken: () => string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function mountRig(): Promise<Rig> {
|
|
65
|
+
const container = document.createElement("div");
|
|
66
|
+
document.body.appendChild(container);
|
|
67
|
+
containers.push(container);
|
|
68
|
+
|
|
69
|
+
const open = ref(false);
|
|
70
|
+
const Wrapper = defineComponent({
|
|
71
|
+
setup() {
|
|
72
|
+
return () =>
|
|
73
|
+
h(HkModal, {
|
|
74
|
+
modelValue: open.value,
|
|
75
|
+
closable: true,
|
|
76
|
+
title: "swap-test",
|
|
77
|
+
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
78
|
+
}, {
|
|
79
|
+
default: () => h("div", { class: "swap-body" }, "step content"),
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const app = createApp(Wrapper);
|
|
84
|
+
mounts.push(app);
|
|
85
|
+
app.mount(container);
|
|
86
|
+
await nextTick();
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
open,
|
|
90
|
+
bodyEl: () => document.querySelector<HTMLElement>(".hk-modal-body"),
|
|
91
|
+
frameEl: () => document.querySelector<HTMLElement>(".hk-modal-content"),
|
|
92
|
+
announceSwap: (detail) => {
|
|
93
|
+
const host = document.querySelector<HTMLElement>(".hk-modal-body .swap-body")
|
|
94
|
+
?? document.querySelector<HTMLElement>(".hk-modal-body");
|
|
95
|
+
if (!host) throw new Error("announceSwap: modal body missing");
|
|
96
|
+
host.dispatchEvent(
|
|
97
|
+
new CustomEvent(STEPFLOW_SWAP_EVENT, { bubbles: true, detail }),
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
morphToken: () =>
|
|
101
|
+
document
|
|
102
|
+
.querySelector<HTMLElement>(".hk-modal-content")
|
|
103
|
+
?.style.getPropertyValue("--hk-modal-morph-duration") ?? "",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("HkModal stepflow swap handoff", () => {
|
|
108
|
+
it("morphs over the announced phase for a modal mounted CLOSED (the real consumer path)", async () => {
|
|
109
|
+
vi.useFakeTimers();
|
|
110
|
+
freezeRaf();
|
|
111
|
+
stubDurations();
|
|
112
|
+
const rig = await mountRig();
|
|
113
|
+
|
|
114
|
+
// The premise of the round-1 defect: while closed there is no body to
|
|
115
|
+
// attach to, so an onMounted-time attach could never have worked.
|
|
116
|
+
expect(rig.bodyEl()).toBeNull();
|
|
117
|
+
|
|
118
|
+
rig.open.value = true;
|
|
119
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
120
|
+
expect(rig.bodyEl()).not.toBeNull();
|
|
121
|
+
expect(rig.morphToken()).toBe("");
|
|
122
|
+
|
|
123
|
+
// A swap that owns the height in its FIRST phase.
|
|
124
|
+
rig.announceSwap({ delta: 120, durationMs: 150, phase: "exit" });
|
|
125
|
+
await nextTick();
|
|
126
|
+
expect(rig.morphToken()).toBe("150ms");
|
|
127
|
+
|
|
128
|
+
// …and the override is scoped to that one sweep window.
|
|
129
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
130
|
+
expect(rig.morphToken()).toBe("");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("leaves the stylesheet default in place for an instant settlement", async () => {
|
|
134
|
+
vi.useFakeTimers();
|
|
135
|
+
freezeRaf();
|
|
136
|
+
stubDurations();
|
|
137
|
+
const rig = await mountRig();
|
|
138
|
+
rig.open.value = true;
|
|
139
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
140
|
+
|
|
141
|
+
// Reduced motion / stylesheet-less runtimes announce durationMs 0:
|
|
142
|
+
// there is no phase to match, so no override may be written.
|
|
143
|
+
rig.announceSwap({ delta: 0, durationMs: 0, phase: "instant" });
|
|
144
|
+
await nextTick();
|
|
145
|
+
expect(rig.morphToken()).toBe("");
|
|
146
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
147
|
+
expect(rig.morphToken()).toBe("");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("ignores announcements while the surface is still unfolding", async () => {
|
|
151
|
+
vi.useFakeTimers();
|
|
152
|
+
freezeRaf();
|
|
153
|
+
stubDurations();
|
|
154
|
+
const rig = await mountRig();
|
|
155
|
+
rig.open.value = true;
|
|
156
|
+
// The machine's phase is opening* here: the morph is not armed yet, so
|
|
157
|
+
// an override would be stranded on the frame.
|
|
158
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
159
|
+
if (rig.bodyEl()) {
|
|
160
|
+
rig.announceSwap({ delta: 60, durationMs: 150, phase: "exit" });
|
|
161
|
+
await nextTick();
|
|
162
|
+
}
|
|
163
|
+
expect(rig.morphToken()).toBe("");
|
|
164
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
165
|
+
expect(rig.morphToken()).toBe("");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("drops the override when the modal unmounts mid-sweep", async () => {
|
|
169
|
+
vi.useFakeTimers();
|
|
170
|
+
freezeRaf();
|
|
171
|
+
stubDurations();
|
|
172
|
+
const rig = await mountRig();
|
|
173
|
+
rig.open.value = true;
|
|
174
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
175
|
+
|
|
176
|
+
rig.announceSwap({ delta: 80, durationMs: 150, phase: "exit" });
|
|
177
|
+
await nextTick();
|
|
178
|
+
const frame = rig.frameEl();
|
|
179
|
+
expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("150ms");
|
|
180
|
+
|
|
181
|
+
// Unmount with the sweep still in flight: the timer must not outlive
|
|
182
|
+
// the component, and the token must not survive on the detached node.
|
|
183
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
184
|
+
await nextTick();
|
|
185
|
+
expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("HkModal publishes its own fold for the step flow", () => {
|
|
190
|
+
/** Box model: the frame hugs its content up to `cap`; the content probe
|
|
191
|
+
* reports `content` plus the chrome the calibration measures. */
|
|
192
|
+
let content = 0;
|
|
193
|
+
let cap = Number.POSITIVE_INFINITY;
|
|
194
|
+
const realOffsetHeight = Object.getOwnPropertyDescriptor(
|
|
195
|
+
HTMLElement.prototype,
|
|
196
|
+
"offsetHeight",
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
function stubBox(initialContent: number, capPx: number): void {
|
|
200
|
+
content = initialContent;
|
|
201
|
+
cap = capPx;
|
|
202
|
+
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
203
|
+
configurable: true,
|
|
204
|
+
get(this: HTMLElement) {
|
|
205
|
+
if (this.classList.contains("hk-modal-content")) {
|
|
206
|
+
return Math.min(content + 96, cap);
|
|
207
|
+
}
|
|
208
|
+
if (this.classList.contains("hk-modal-body-inner")) return content;
|
|
209
|
+
return 0;
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function restoreBox(): void {
|
|
215
|
+
if (realOffsetHeight) {
|
|
216
|
+
Object.defineProperty(
|
|
217
|
+
HTMLElement.prototype,
|
|
218
|
+
"offsetHeight",
|
|
219
|
+
realOffsetHeight,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function stubClip(): void {
|
|
225
|
+
const real = window.getComputedStyle.bind(window);
|
|
226
|
+
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
227
|
+
const style = real(el as Element, ...(rest as []));
|
|
228
|
+
if (!(el instanceof HTMLElement)) return style;
|
|
229
|
+
if (el.classList.contains("hk-modal-content")) {
|
|
230
|
+
return {
|
|
231
|
+
...style,
|
|
232
|
+
transitionDuration: "0.3s",
|
|
233
|
+
getPropertyValue: (name: string) =>
|
|
234
|
+
name === "--hk-sheet-morph" ? "clip" : style.getPropertyValue(name),
|
|
235
|
+
} as CSSStyleDeclaration;
|
|
236
|
+
}
|
|
237
|
+
return style;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
afterEach(() => {
|
|
242
|
+
restoreBox();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("republishes the morph's real fold span and its landing", async () => {
|
|
246
|
+
vi.useFakeTimers();
|
|
247
|
+
freezeRaf();
|
|
248
|
+
stubClip();
|
|
249
|
+
// Content starts at 500 (frame 596) and the sheet is uncapped, so the
|
|
250
|
+
// morph can fold: shrinking the content must publish a conceal.
|
|
251
|
+
stubBox(500, Number.POSITIVE_INFINITY);
|
|
252
|
+
const rig = await mountRig();
|
|
253
|
+
rig.open.value = true;
|
|
254
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
255
|
+
expect(rig.bodyEl()).not.toBeNull();
|
|
256
|
+
|
|
257
|
+
const stages: unknown[] = [];
|
|
258
|
+
let settled = 0;
|
|
259
|
+
rig.bodyEl()!.addEventListener(SHEET_SWEEP_STAGE_EVENT, (e) => {
|
|
260
|
+
stages.push((e as CustomEvent).detail);
|
|
261
|
+
});
|
|
262
|
+
rig.bodyEl()!.addEventListener(SHEET_SWEEP_SETTLE_EVENT, () => {
|
|
263
|
+
settled += 1;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// The sweep needs real frames: pump rAF from here on (a frozen frame
|
|
267
|
+
// loop would strand the morph's warmup, as its own docs warn).
|
|
268
|
+
const frames: FrameRequestCallback[] = [];
|
|
269
|
+
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
270
|
+
frames.push(cb);
|
|
271
|
+
return frames.length;
|
|
272
|
+
});
|
|
273
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
274
|
+
|
|
275
|
+
// The flow owns the new height and announces; the modal measures,
|
|
276
|
+
// stages its conceal, and publishes the span it will sweep.
|
|
277
|
+
content = 300;
|
|
278
|
+
rig.announceSwap({ delta: -200, durationMs: 150, phase: "enter" });
|
|
279
|
+
await nextTick();
|
|
280
|
+
expect(stages).toEqual([
|
|
281
|
+
{ direction: "conceal", from: 596, to: 396, sweep: expect.any(Number) },
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
// …and the landing is published when the sweep finishes, carrying the
|
|
285
|
+
// sweep's own identity (without it a consumer cannot tell its landing
|
|
286
|
+
// from another dance's teardown).
|
|
287
|
+
const landed: Array<{ sweep?: number }> = [];
|
|
288
|
+
rig.bodyEl()!.addEventListener(SHEET_SWEEP_SETTLE_EVENT, (e) => {
|
|
289
|
+
landed.push((e as CustomEvent<{ sweep?: number }>).detail ?? {});
|
|
290
|
+
});
|
|
291
|
+
for (let i = 0; i < 4; i += 1) {
|
|
292
|
+
for (const cb of frames.splice(0)) cb(i * 16);
|
|
293
|
+
await vi.advanceTimersByTimeAsync(60);
|
|
294
|
+
}
|
|
295
|
+
await vi.advanceTimersByTimeAsync(1200);
|
|
296
|
+
expect(settled).toBeGreaterThan(0);
|
|
297
|
+
expect(landed.length).toBeGreaterThan(0);
|
|
298
|
+
expect(typeof landed[0]?.sweep).toBe("number");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("publishes NOTHING when the sheet is capped and cannot fold", async () => {
|
|
302
|
+
vi.useFakeTimers();
|
|
303
|
+
freezeRaf();
|
|
304
|
+
stubClip();
|
|
305
|
+
// Content that already overflows the cap: the natural height cannot
|
|
306
|
+
// change, so the morph stages no sweep at all — the flow must park
|
|
307
|
+
// nothing (its span defaults to 0).
|
|
308
|
+
stubBox(900, 596);
|
|
309
|
+
const rig = await mountRig();
|
|
310
|
+
rig.open.value = true;
|
|
311
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
312
|
+
|
|
313
|
+
const stages: unknown[] = [];
|
|
314
|
+
let settled = 0;
|
|
315
|
+
rig.bodyEl()!.addEventListener(SHEET_SWEEP_STAGE_EVENT, (e) => {
|
|
316
|
+
stages.push((e as CustomEvent).detail);
|
|
317
|
+
});
|
|
318
|
+
rig.bodyEl()!.addEventListener(SHEET_SWEEP_SETTLE_EVENT, () => {
|
|
319
|
+
settled += 1;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
content = 850;
|
|
323
|
+
rig.announceSwap({ delta: -50, durationMs: 150, phase: "enter" });
|
|
324
|
+
await nextTick();
|
|
325
|
+
await vi.advanceTimersByTimeAsync(1200);
|
|
326
|
+
expect(stages).toEqual([]);
|
|
327
|
+
expect(settled).toBe(0);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -13,7 +13,11 @@ import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
|
13
13
|
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
14
14
|
import { useSizeMorph } from "../composables/useSizeMorph";
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
SHEET_SWEEP_SETTLE_EVENT,
|
|
18
|
+
SHEET_SWEEP_STAGE_EVENT,
|
|
19
|
+
STEPFLOW_SWAP_EVENT,
|
|
20
|
+
} from "./HkStepFlow";
|
|
17
21
|
import HButton from "./HkButton";
|
|
18
22
|
import HFab from "./HkFab";
|
|
19
23
|
import HSpinner from "./HkSpinner";
|
|
@@ -205,24 +209,70 @@ export default defineComponent({
|
|
|
205
209
|
deferRemeasure: () =>
|
|
206
210
|
machine.phase.value === "openingFrom" ||
|
|
207
211
|
machine.phase.value === "openingTo",
|
|
212
|
+
// Republish the morph's real sweep span and its landing on the body
|
|
213
|
+
// element (bubbling), so content choreography can park against the
|
|
214
|
+
// landing geometry and release exactly when it happens instead of
|
|
215
|
+
// guessing the cap and the warmup (2026-09-22 verification finding).
|
|
216
|
+
onSweepStage: (info) => {
|
|
217
|
+
bodyRef.value?.dispatchEvent(
|
|
218
|
+
new CustomEvent(SHEET_SWEEP_STAGE_EVENT, { bubbles: true, detail: info }),
|
|
219
|
+
);
|
|
220
|
+
},
|
|
221
|
+
onSweepSettle: (info) => {
|
|
222
|
+
bodyRef.value?.dispatchEvent(
|
|
223
|
+
new CustomEvent(SHEET_SWEEP_SETTLE_EVENT, {
|
|
224
|
+
bubbles: true,
|
|
225
|
+
detail: info,
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
},
|
|
208
229
|
});
|
|
209
230
|
let previouslyFocused: HTMLElement | null = null;
|
|
210
231
|
let unmounted = false;
|
|
211
232
|
|
|
212
233
|
// HkStepFlow swaps dispatch STEPFLOW_SWAP_EVENT (bubbling) from the
|
|
213
|
-
// flow root the
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
234
|
+
// flow root at the edge where the sheet must morph: the exit edge for
|
|
235
|
+
// a GROW (the new height is already in the flow, so the sheet finishes
|
|
236
|
+
// growing while the old body leaves) and the enter edge for a SHRINK
|
|
237
|
+
// (the flow held its old height until the old body finished playing).
|
|
238
|
+
// The morph's settle debounce is tuned for streaming bursts; a step
|
|
239
|
+
// swap is one clean change whose sweep must land INSIDE its 0.15s
|
|
240
|
+
// phase (2026-09-22 user directive, round 10) — so the event
|
|
241
|
+
// short-circuits straight into remeasure() and overrides the sheet's
|
|
242
|
+
// own sweep duration for this one morph (the stylesheet default stays
|
|
243
|
+
// the 0.3s family standard for every other morph). The override is
|
|
244
|
+
// cleared on the same duration+grace watchdog the sweep itself uses.
|
|
245
|
+
let morphDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
246
|
+
const clearMorphDuration = (): void => {
|
|
247
|
+
if (morphDurationTimer !== null) {
|
|
248
|
+
clearTimeout(morphDurationTimer);
|
|
249
|
+
morphDurationTimer = null;
|
|
250
|
+
}
|
|
251
|
+
contentRef.value?.style.removeProperty("--hk-modal-morph-duration");
|
|
252
|
+
};
|
|
253
|
+
const onStepflowSwap = (event: Event): void => {
|
|
254
|
+
if (machine.phase.value !== "open") {
|
|
255
|
+
// A swap announced while the surface is closing/opening: drop any
|
|
256
|
+
// override a previous morph left behind instead of letting it ride
|
|
257
|
+
// into the next open (it is otherwise only cleared by its own
|
|
258
|
+
// ms+350 timer).
|
|
259
|
+
clearMorphDuration();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const frame = contentRef.value;
|
|
263
|
+
const ms = (event as CustomEvent<{ durationMs?: number }>).detail?.durationMs;
|
|
264
|
+
if (frame && typeof ms === "number" && ms > 0) {
|
|
265
|
+
frame.style.setProperty("--hk-modal-morph-duration", `${ms}ms`);
|
|
266
|
+
morph.remeasure();
|
|
267
|
+
if (morphDurationTimer !== null) clearTimeout(morphDurationTimer);
|
|
268
|
+
morphDurationTimer = setTimeout(clearMorphDuration, ms + 350);
|
|
269
|
+
} else {
|
|
270
|
+
morph.remeasure();
|
|
271
|
+
}
|
|
220
272
|
};
|
|
221
|
-
onMounted(() => {
|
|
222
|
-
bodyRef.value?.addEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
223
|
-
});
|
|
224
273
|
onBeforeUnmount(() => {
|
|
225
274
|
bodyRef.value?.removeEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
275
|
+
clearMorphDuration();
|
|
226
276
|
});
|
|
227
277
|
|
|
228
278
|
const overlayZ = computed(() => handle.value?.zIndex ?? 0);
|
|
@@ -430,6 +480,26 @@ export default defineComponent({
|
|
|
430
480
|
},
|
|
431
481
|
});
|
|
432
482
|
|
|
483
|
+
// The swap listener must follow the ELEMENT, not the component
|
|
484
|
+
// lifecycle: the render returns null until `machine.mounted` flips, so
|
|
485
|
+
// an onMounted-time attach silently missed for every
|
|
486
|
+
// mount-closed-then-open consumer — i.e. every chest wizard
|
|
487
|
+
// (2026-09-22 R1 verification finding: this wiring was dead at
|
|
488
|
+
// runtime, which is why the sheet kept morphing on the observer's
|
|
489
|
+
// 150ms debounce and the 0.3s stylesheet default). Post-flush on the
|
|
490
|
+
// mount flag instead: the listener lands on the real node and comes
|
|
491
|
+
// off with it.
|
|
492
|
+
watch(
|
|
493
|
+
machine.mounted,
|
|
494
|
+
(isMounted) => {
|
|
495
|
+
const el = bodyRef.value;
|
|
496
|
+
if (!el) return;
|
|
497
|
+
if (isMounted) el.addEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
498
|
+
else el.removeEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
499
|
+
},
|
|
500
|
+
{ flush: "post", immediate: true },
|
|
501
|
+
);
|
|
502
|
+
|
|
433
503
|
// --- Windowed mode ---
|
|
434
504
|
|
|
435
505
|
function setupWindowed(container: HTMLElement) {
|
|
@@ -1,19 +1,29 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Two-phase step bodies for HkStepFlow.
|
|
2
2
|
//
|
|
3
|
-
// A swap
|
|
4
|
-
// the
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
3
|
+
// A swap runs TWO phases, each half of `--hk-stepflow-duration` (0.15s of
|
|
4
|
+
// the 0.3s family standard):
|
|
5
|
+
//
|
|
6
|
+
// EXIT — the OLD body alone is visible: `hk-stepflow-leave-to` slides
|
|
7
|
+
// it out (forward exits left, back exits right) and fades it,
|
|
8
|
+
// with the sharp ease-in;
|
|
9
|
+
// ENTER — the old body is gone and the NEW one fades in PLACE (no
|
|
10
|
+
// travel) with the expressive ease-out.
|
|
11
|
+
//
|
|
12
|
+
// The new body is mounted from frame one with `hk-stepflow-enter-pending`
|
|
13
|
+
// (invisible, but laid out so the flow owns the new height immediately),
|
|
14
|
+
// which is what makes the two bodies structurally unable to overlap —
|
|
15
|
+
// the doubled-ghost flash of the simultaneous cross-slide (2026-09-22
|
|
16
|
+
// user directive, round 10).
|
|
17
|
+
//
|
|
18
|
+
// This stylesheet owns ALL of the motion: direction, travel distance and
|
|
19
|
+
// easings are driven by the `data-direction` attribute and those two
|
|
20
|
+
// classes; the component only flips them and schedules the phases. The
|
|
21
|
+
// sheet's own morph is phase-matched by the hosting modal, which reads
|
|
22
|
+
// the swap event's `durationMs`.
|
|
13
23
|
//
|
|
14
24
|
// Duration and travel resolve from local custom properties so consumers
|
|
15
25
|
// can tune every phase at once; the 0.3s default matches the phone
|
|
16
|
-
// sheet's morph
|
|
26
|
+
// sheet's morph window so the sweep and the slide land together.
|
|
17
27
|
|
|
18
28
|
.hk-step-flow {
|
|
19
29
|
--hk-stepflow-duration: 0.3s;
|
|
@@ -52,62 +62,89 @@
|
|
|
52
62
|
padding-bottom: var(--hk-stepflow-header-gap, var(--space-16, 1rem));
|
|
53
63
|
}
|
|
54
64
|
|
|
55
|
-
// The swap stage: entering
|
|
56
|
-
// overlays it, out of flow and pointer-transparent for the
|
|
65
|
+
// The swap stage: the entering body owns the flow's height; the leaving
|
|
66
|
+
// one overlays it, out of flow and pointer-transparent for the exit.
|
|
57
67
|
.hk-stepflow-bodies {
|
|
58
68
|
position: relative;
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
.hk-stepflow-body {
|
|
62
|
-
//
|
|
63
|
-
//
|
|
72
|
+
// ENTER grammar (what a body computes once its `hk-stepflow-enter-pending`
|
|
73
|
+
// class lifts at the phase-2 edge): a plain fade in place — the new
|
|
74
|
+
// content never travels (2026-09-22 user directive). A STANDARD ease, not
|
|
75
|
+
// the expressive ease-out: the front-loaded curve put the first painted
|
|
76
|
+
// frame at ~50% opacity (half the distance in ~11% of the phase), which
|
|
77
|
+
// read on the device as the new content flashing in — the standard curve
|
|
78
|
+
// starts at ~20% instead (2026-09-22 device report, round 11).
|
|
64
79
|
transition:
|
|
65
|
-
opacity var(--hk-stepflow-duration, 0.3s) cubic-bezier(0.
|
|
66
|
-
transform var(--hk-stepflow-duration, 0.3s) cubic-bezier(0.16, 1, 0.3, 1);
|
|
80
|
+
opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.2, 1);
|
|
67
81
|
}
|
|
68
82
|
|
|
69
83
|
.hk-stepflow-body.leaving {
|
|
70
84
|
position: absolute;
|
|
85
|
+
// Default anchor: the stage's TOP. The component measures how far the
|
|
86
|
+
// stage travelled during the swap patch and writes that as an inline
|
|
87
|
+
// `top`, which is the only correct answer across host shapes — a
|
|
88
|
+
// bottom-docked sheet whose box grew, a capped box that could not grow,
|
|
89
|
+
// and an in-flow stage all differ, and a CSS anchor can only encode one
|
|
90
|
+
// of them (a guessed bottom anchor cut 276px off a capped sheet's
|
|
91
|
+
// outgoing step).
|
|
71
92
|
top: 0;
|
|
72
93
|
left: 0;
|
|
73
94
|
right: 0;
|
|
74
95
|
pointer-events: none;
|
|
75
|
-
//
|
|
76
|
-
//
|
|
96
|
+
// LEAVE grammar: the slide keeps its accelerating ease-in; the FADE gets
|
|
97
|
+
// its own gentler curve so the old content eases out over several frames
|
|
98
|
+
// instead of dropping 0.40 -> 0.03 in one (same device report).
|
|
77
99
|
transition:
|
|
78
|
-
opacity var(--hk-stepflow-duration, 0.3s) cubic-bezier(0.
|
|
79
|
-
transform var(--hk-stepflow-duration, 0.3s) cubic-bezier(0.5, 0, 0.75, 0);
|
|
100
|
+
opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.6, 1),
|
|
101
|
+
transform calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.5, 0, 0.75, 0);
|
|
80
102
|
}
|
|
81
103
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
.hk-stepflow-
|
|
87
|
-
|
|
104
|
+
// The parked new body of a shrink whose sheet will fold. Out of flow (the
|
|
105
|
+
// stage keeps the OLD height through the fold) and offset by the span the
|
|
106
|
+
// sheet published, so it already sits on the line the fold lands on; the
|
|
107
|
+
// component releases both when the sheet reports that landing.
|
|
108
|
+
.hk-stepflow-body.hk-stepflow-enter-tail {
|
|
109
|
+
position: absolute;
|
|
110
|
+
top: 0;
|
|
111
|
+
left: 0;
|
|
112
|
+
right: 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The staged new body: LAID OUT (so the flow owns the new height from
|
|
116
|
+
// frame one — the sheet can grow through the exit phase) but invisible,
|
|
117
|
+
// which is what guarantees the old and new bodies are never on screen
|
|
118
|
+
// together. `visibility` (not display/removal) keeps it measurable and
|
|
119
|
+
// keeps a re-swap from ever destroying its reveal.
|
|
120
|
+
.hk-stepflow-body.hk-stepflow-enter-pending {
|
|
88
121
|
opacity: 0;
|
|
89
|
-
|
|
122
|
+
visibility: hidden;
|
|
90
123
|
}
|
|
91
124
|
|
|
92
|
-
|
|
93
|
-
|
|
125
|
+
// Direction-aware travel, on the LEAVING body only: forward exits LEFT,
|
|
126
|
+
// back exits RIGHT (the classic vocabulary). The entering body appears in
|
|
127
|
+
// place, so it carries no direction rule at all.
|
|
128
|
+
.hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-leave-to {
|
|
94
129
|
opacity: 0;
|
|
95
130
|
transform: translateX(calc(-1 * var(--hk-stepflow-travel, 24px)));
|
|
96
131
|
}
|
|
97
132
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
[dir="rtl"] .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-enter-from,
|
|
102
|
-
[dir="rtl"] .hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-leave-to {
|
|
103
|
-
transform: translateX(calc(-1 * var(--hk-stepflow-travel, 24px)));
|
|
133
|
+
.hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-leave-to {
|
|
134
|
+
opacity: 0;
|
|
135
|
+
transform: translateX(var(--hk-stepflow-travel, 24px));
|
|
104
136
|
}
|
|
105
137
|
|
|
106
|
-
|
|
107
|
-
[dir="rtl"] .
|
|
138
|
+
/* RTL: reading direction flips, so the exit mirrors — forward exits right
|
|
139
|
+
* (house pattern: HkListTransition.scss [dir="rtl"] form). */
|
|
140
|
+
[dir="rtl"] .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-leave-to {
|
|
108
141
|
transform: translateX(var(--hk-stepflow-travel, 24px));
|
|
109
142
|
}
|
|
110
143
|
|
|
144
|
+
[dir="rtl"] .hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-leave-to {
|
|
145
|
+
transform: translateX(calc(-1 * var(--hk-stepflow-travel, 24px)));
|
|
146
|
+
}
|
|
147
|
+
|
|
111
148
|
/* Reduced motion: bodies swap instantly. The component's duration probe
|
|
112
149
|
* reads the zeroed duration and settles without staging, so this block
|
|
113
150
|
* is the single switch for the whole choreography (the sheet morph
|