@celestia-island/hikari 0.55.56 → 0.55.58
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 +28 -37
- package/src/components/HkModal.tsx +7 -7
- package/src/components/HkStepFlow.test.tsx +1 -1
- package/src/components/HkStepFlow.tsx +20 -12
- package/src/composables/useSizeMorph.test.ts +46 -0
- package/src/composables/useSizeMorph.ts +84 -1
package/package.json
CHANGED
|
@@ -119,13 +119,9 @@ describe("HkModal stepflow swap handoff", () => {
|
|
|
119
119
|
expect(rig.bodyEl()).not.toBeNull();
|
|
120
120
|
expect(rig.morphToken()).toBe("");
|
|
121
121
|
|
|
122
|
-
// The morph window (
|
|
122
|
+
// The morph window (round 18: heightMorph, no clip override).
|
|
123
123
|
rig.announceSwap({ delta: 120, durationMs: 150, phase: "morph" });
|
|
124
124
|
await nextTick();
|
|
125
|
-
expect(rig.morphToken()).toBe("150ms");
|
|
126
|
-
|
|
127
|
-
// …and the override is scoped to that one sweep window.
|
|
128
|
-
await vi.advanceTimersByTimeAsync(500);
|
|
129
125
|
expect(rig.morphToken()).toBe("");
|
|
130
126
|
});
|
|
131
127
|
|
|
@@ -237,7 +233,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
237
233
|
// The flow owns the new height and announces; the modal measures,
|
|
238
234
|
// stages its conceal, and publishes the span it will sweep.
|
|
239
235
|
content = 300;
|
|
240
|
-
rig.announceSwap({ delta: -200, durationMs:
|
|
236
|
+
rig.announceSwap({ delta: -200, durationMs: 0, phase: "instant" });
|
|
241
237
|
await nextTick();
|
|
242
238
|
expect(stages).toEqual([
|
|
243
239
|
{ direction: "conceal", from: 596, to: 396, sweep: expect.any(Number) },
|
|
@@ -308,12 +304,13 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
308
304
|
await nextTick();
|
|
309
305
|
expect(rig.morphToken()).toBe("");
|
|
310
306
|
|
|
311
|
-
// The morph window: the freeze lifts and the
|
|
312
|
-
//
|
|
313
|
-
|
|
307
|
+
// The morph window: the freeze lifts and the height transition
|
|
308
|
+
// runs (round 18 — no clip override token; heightMorph owns the
|
|
309
|
+
// frame's inline transition).
|
|
310
|
+
rig.announceSwap({ delta: 120, durationMs: 300, phase: "morph" });
|
|
314
311
|
await nextTick();
|
|
315
|
-
expect(rig.morphToken()).toBe("
|
|
316
|
-
await vi.advanceTimersByTimeAsync(
|
|
312
|
+
expect(rig.morphToken()).toBe("");
|
|
313
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
317
314
|
expect(rig.morphToken()).toBe("");
|
|
318
315
|
});
|
|
319
316
|
|
|
@@ -343,10 +340,10 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
343
340
|
rig.open.value = true;
|
|
344
341
|
await vi.advanceTimersByTimeAsync(600);
|
|
345
342
|
|
|
346
|
-
rig.announceSwap({ delta: 80, durationMs:
|
|
343
|
+
rig.announceSwap({ delta: 80, durationMs: 0, phase: "instant" });
|
|
347
344
|
await nextTick();
|
|
348
345
|
const frame = rig.frameEl()!;
|
|
349
|
-
expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("
|
|
346
|
+
expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("");
|
|
350
347
|
|
|
351
348
|
// Unmount with the sweep still in flight: the timer must not outlive
|
|
352
349
|
// the component, and the token must not survive on the detached node.
|
|
@@ -459,9 +456,9 @@ describe("HkModal rides its chrome and gates the slide window", () => {
|
|
|
459
456
|
await vi.advanceTimersByTimeAsync(700);
|
|
460
457
|
expect(frame.style.height).toBe("656px");
|
|
461
458
|
// …and a later morph event still works (no stranded freeze).
|
|
462
|
-
rig.announceSwap({ delta: -40, durationMs:
|
|
459
|
+
rig.announceSwap({ delta: -40, durationMs: 300, phase: "morph" });
|
|
463
460
|
await nextTick();
|
|
464
|
-
expect(rig.morphToken()).toBe("
|
|
461
|
+
expect(rig.morphToken()).toBe("");
|
|
465
462
|
await vi.advanceTimersByTimeAsync(700);
|
|
466
463
|
expect(rig.morphToken()).toBe("");
|
|
467
464
|
restoreBox();
|
|
@@ -518,12 +515,9 @@ describe("HkModal rides its chrome and gates the slide window", () => {
|
|
|
518
515
|
restoreBox();
|
|
519
516
|
});
|
|
520
517
|
|
|
521
|
-
it("
|
|
522
|
-
// Round
|
|
523
|
-
//
|
|
524
|
-
// choreography. This pins the modal-level collection + the resident
|
|
525
|
-
// promotion (round 15); the ride's flip/settle lifecycle is
|
|
526
|
-
// unit-witnessed in useSizeMorph.test.ts.
|
|
518
|
+
it("animates the height with a plain CSS transition at the morph edge", async () => {
|
|
519
|
+
// Round 18: the stepflow morph no longer stages a clip sweep or
|
|
520
|
+
// rides anything — it just transitions the frame's height inline.
|
|
527
521
|
vi.useFakeTimers();
|
|
528
522
|
freezeRaf();
|
|
529
523
|
stubClip();
|
|
@@ -538,7 +532,7 @@ describe("HkModal rides its chrome and gates the slide window", () => {
|
|
|
538
532
|
h(HkModal, {
|
|
539
533
|
modelValue: open.value,
|
|
540
534
|
closable: true,
|
|
541
|
-
title: "
|
|
535
|
+
title: "morph-test",
|
|
542
536
|
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
543
537
|
}, {
|
|
544
538
|
default: () => h("div", { class: "swap-body" }, "step content"),
|
|
@@ -553,30 +547,27 @@ describe("HkModal rides its chrome and gates the slide window", () => {
|
|
|
553
547
|
await vi.advanceTimersByTimeAsync(600);
|
|
554
548
|
|
|
555
549
|
const frame = () => document.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
556
|
-
|
|
557
|
-
const bodyBlock = () => document.querySelector<HTMLElement>(".hk-modal-body")!;
|
|
558
|
-
|
|
559
|
-
// Resident promotion from the arm moment (round 15).
|
|
560
|
-
expect(header().style.willChange).toBe("transform");
|
|
561
|
-
expect(bodyBlock().style.willChange).toBe("transform");
|
|
550
|
+
expect(frame().style.height).toBe("596px");
|
|
562
551
|
|
|
563
|
-
// The morph
|
|
564
|
-
// sits at its pre-growth offset, instantly.
|
|
552
|
+
// The morph edge: the height transitions inline, no clip staging.
|
|
565
553
|
content = 700;
|
|
566
554
|
document
|
|
567
555
|
.querySelector<HTMLElement>(".hk-modal-body .swap-body")!
|
|
568
556
|
.dispatchEvent(
|
|
569
557
|
new CustomEvent(STEPFLOW_SWAP_EVENT, {
|
|
570
558
|
bubbles: true,
|
|
571
|
-
detail: { delta:
|
|
559
|
+
detail: { delta: 104, durationMs: 300, phase: "morph" },
|
|
572
560
|
}),
|
|
573
561
|
);
|
|
574
562
|
await nextTick();
|
|
575
|
-
expect(
|
|
576
|
-
expect(
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
//
|
|
580
|
-
|
|
563
|
+
expect(frame().style.height).toBe("796px");
|
|
564
|
+
expect(frame().style.transition).toContain("height");
|
|
565
|
+
expect(frame().style.transition).toContain("300ms");
|
|
566
|
+
expect(frame().style.clipPath).toBe("");
|
|
567
|
+
// No rider transforms — the chrome layers are untouched.
|
|
568
|
+
const header = document.querySelector<HTMLElement>(".hk-modal-header")!;
|
|
569
|
+
expect(header.style.transform).toBe("");
|
|
570
|
+
restoreBox();
|
|
581
571
|
});
|
|
572
|
+
|
|
582
573
|
});
|
|
@@ -328,15 +328,15 @@ export default defineComponent({
|
|
|
328
328
|
swapFreezeTimer = setTimeout(() => clearSwapFreeze(true), ms + 650);
|
|
329
329
|
return;
|
|
330
330
|
}
|
|
331
|
-
// Morph / instant: the content settled — unfreeze and
|
|
331
|
+
// Morph / instant: the content settled — unfreeze and animate the
|
|
332
|
+
// height with a plain CSS transition (round 18: the clip-based
|
|
333
|
+
// morph's staging/warmup/rider choreography kept raster-racing on
|
|
334
|
+
// phone GPUs; the height transition has none of those moving
|
|
335
|
+
// parts). Instant settlements (reduced motion) still snap.
|
|
332
336
|
clearSwapFreeze();
|
|
333
|
-
const frame = contentRef.value;
|
|
334
337
|
const ms = detail?.durationMs;
|
|
335
|
-
if (
|
|
336
|
-
|
|
337
|
-
morph.remeasure();
|
|
338
|
-
if (morphDurationTimer !== null) clearTimeout(morphDurationTimer);
|
|
339
|
-
morphDurationTimer = setTimeout(clearMorphDuration, ms + 350);
|
|
338
|
+
if (typeof ms === "number" && ms > 0) {
|
|
339
|
+
morph.heightMorph(ms);
|
|
340
340
|
} else {
|
|
341
341
|
morph.remeasure();
|
|
342
342
|
}
|
|
@@ -227,7 +227,7 @@ describe("HkStepFlow split-window swap (motion enabled)", () => {
|
|
|
227
227
|
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
228
228
|
expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("b-body");
|
|
229
229
|
expect(events).toHaveLength(2);
|
|
230
|
-
expect(events[1]!.detail).toMatchObject({ phase: "morph", durationMs:
|
|
230
|
+
expect(events[1]!.detail).toMatchObject({ phase: "morph", durationMs: 300 });
|
|
231
231
|
expect(typeof events[1]!.detail.delta).toBe("number");
|
|
232
232
|
});
|
|
233
233
|
|
|
@@ -78,13 +78,12 @@ export const STEPFLOW_SWAP_EVENT = "hk-stepflow-swap";
|
|
|
78
78
|
* the swap without a transitionend. */
|
|
79
79
|
const SWAP_WATCHDOG_GRACE_MS = 350;
|
|
80
80
|
|
|
81
|
-
/** The morph window's
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
const STEP_MORPH_SNAP_MS = 1;
|
|
81
|
+
/** The morph window's duration (round 18): a plain CSS height
|
|
82
|
+
* transition. The clip-based choreography this replaces kept
|
|
83
|
+
* raster-racing on phone GPUs through every variant (sweeps, snaps,
|
|
84
|
+
* riders); a simple height animation has no moving clip edge, no
|
|
85
|
+
* staged offsets and no warmup — the CSS engine owns every frame. */
|
|
86
|
+
const STEP_MORPH_MS = 300;
|
|
88
87
|
|
|
89
88
|
/** Fade level above which an entering body counts as the visible one. */
|
|
90
89
|
const FADED_IN = 0.05;
|
|
@@ -242,12 +241,21 @@ export default defineComponent({
|
|
|
242
241
|
const enteringEl = elFor(handle.enteringId);
|
|
243
242
|
const newH = enteringEl?.offsetHeight ?? handle.oldH;
|
|
244
243
|
bodies.value = bodies.value.filter((b) => b.id !== handle.leavingId);
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
244
|
+
// The morph event must fire AFTER the patch that removed the
|
|
245
|
+
// leaving body: heightMorph() reads the frame's natural height,
|
|
246
|
+
// and with the old body still in the DOM a SHRINK reads the old
|
|
247
|
+
// (taller) geometry, no-ops, and the observer then replays the
|
|
248
|
+
// old clip conceal — the round-18 rig caught exactly this on
|
|
249
|
+
// every conceal case (announce-before-patch = the leaving node
|
|
250
|
+
// still contributes to the cell).
|
|
251
|
+
void nextTick(() => {
|
|
252
|
+
swapPhase.value = "idle";
|
|
253
|
+
announce({
|
|
254
|
+
delta: newH - handle.oldH,
|
|
255
|
+
durationMs: STEP_MORPH_MS,
|
|
256
|
+
phase: "morph",
|
|
257
|
+
});
|
|
249
258
|
});
|
|
250
|
-
swapPhase.value = "idle";
|
|
251
259
|
}
|
|
252
260
|
|
|
253
261
|
/** Pre-empt the running swap for a new one, keeping whichever body
|
|
@@ -39,6 +39,7 @@ interface Harness {
|
|
|
39
39
|
stop(): void;
|
|
40
40
|
hold(): void;
|
|
41
41
|
remeasure(): void;
|
|
42
|
+
heightMorph(durationMs: number): void;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
function mountHarness(initialHeight: number, initialContentHeight = 0, options?: SizeMorphOptions): Harness {
|
|
@@ -101,6 +102,7 @@ function mountHarness(initialHeight: number, initialContentHeight = 0, options?:
|
|
|
101
102
|
stop: () => morph!.stop(),
|
|
102
103
|
hold: () => morph!.hold(),
|
|
103
104
|
remeasure: () => morph!.remeasure(),
|
|
105
|
+
heightMorph: (ms: number) => morph!.heightMorph(ms),
|
|
104
106
|
};
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -1165,3 +1167,47 @@ describe("useSizeMorph deferRemeasure gate (freeze protocol witness)", () => {
|
|
|
1165
1167
|
h.stop();
|
|
1166
1168
|
});
|
|
1167
1169
|
});
|
|
1170
|
+
|
|
1171
|
+
describe("useSizeMorph heightMorph (round 18)", () => {
|
|
1172
|
+
it("transitions the pin with an inline height transition and cleans up", async () => {
|
|
1173
|
+
const h = mountHarness(300, 300);
|
|
1174
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
1175
|
+
h.start();
|
|
1176
|
+
expect(h.frame.style.height).toBe("300px");
|
|
1177
|
+
expect(h.frame.style.willChange).toBe("clip-path");
|
|
1178
|
+
|
|
1179
|
+
h.setNatural(400);
|
|
1180
|
+
(h as unknown as { heightMorph: (ms: number) => void }).heightMorph(300);
|
|
1181
|
+
// The inline transition landed and the pin moved.
|
|
1182
|
+
expect(h.frame.style.height).toBe("400px");
|
|
1183
|
+
expect(h.frame.style.transition).toContain("height");
|
|
1184
|
+
expect(h.frame.style.transition).toContain("300ms");
|
|
1185
|
+
// No clip staging, no riders — this is a plain height animation.
|
|
1186
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
1187
|
+
// The resident layer promotion is LIFTED for the animation's
|
|
1188
|
+
// duration (round 19: the per-frame texture re-allocation of a
|
|
1189
|
+
// composited layer during height animation composites as
|
|
1190
|
+
// transparent on phone GPUs — the background flash-through).
|
|
1191
|
+
expect(h.frame.style.willChange).toBe("");
|
|
1192
|
+
|
|
1193
|
+
// The transitionend cleans the inline override AND restores the
|
|
1194
|
+
// resident promotion (the frame is at rest).
|
|
1195
|
+
const ev = new Event("transitionend");
|
|
1196
|
+
Object.defineProperty(ev, "propertyName", { value: "height" });
|
|
1197
|
+
h.frame.dispatchEvent(ev);
|
|
1198
|
+
expect(h.frame.style.transition).toBe("");
|
|
1199
|
+
expect(h.frame.style.willChange).toBe("clip-path");
|
|
1200
|
+
h.stop();
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
it("is a no-op for sub-threshold deltas", async () => {
|
|
1204
|
+
const h = mountHarness(300, 300);
|
|
1205
|
+
h.start();
|
|
1206
|
+
expect(h.frame.style.height).toBe("300px");
|
|
1207
|
+
h.setNatural(301);
|
|
1208
|
+
(h as unknown as { heightMorph: (ms: number) => void }).heightMorph(300);
|
|
1209
|
+
expect(h.frame.style.height).toBe("301px");
|
|
1210
|
+
expect(h.frame.style.transition).toBe("");
|
|
1211
|
+
h.stop();
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
@@ -56,6 +56,10 @@ export interface SizeMorph {
|
|
|
56
56
|
hold(): void;
|
|
57
57
|
/** Re-measure and pin now (resize events, open flows). */
|
|
58
58
|
remeasure(): void;
|
|
59
|
+
/** Plain CSS height morph (round 18): transition the frame's pin
|
|
60
|
+
* from its current value to the natural height under an inline
|
|
61
|
+
* height transition — no clip staging, no riders, no warmup. */
|
|
62
|
+
heightMorph(durationMs: number): void;
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
export interface RideEntry {
|
|
@@ -896,5 +900,84 @@ export function useSizeMorph(
|
|
|
896
900
|
clearResidentWill();
|
|
897
901
|
});
|
|
898
902
|
|
|
899
|
-
|
|
903
|
+
/** Plain CSS height morph (round 18): pin the old height, then
|
|
904
|
+
* transition to the natural height under an inline height transition
|
|
905
|
+
* — no clip staging, no riders, no warmup. This is what a stepflow's
|
|
906
|
+
* morph window wants: the content has already settled (the slide
|
|
907
|
+
* finished), the sheet is the only thing moving, and a simple height
|
|
908
|
+
* animation is both smooth and immune to the raster-race artifacts
|
|
909
|
+
* the clip-based choreography kept producing on phone GPUs.
|
|
910
|
+
* The pin bookkeeping stays in sync (pinned = target immediately),
|
|
911
|
+
* and the inline transition is removed on the animation's own
|
|
912
|
+
* transitionend or a watchdog — the stylesheet's transition list is
|
|
913
|
+
* untouched for every other morph. */
|
|
914
|
+
function heightMorph(durationMs: number): void {
|
|
915
|
+
const f = frame.value;
|
|
916
|
+
if (!f || !armed) return;
|
|
917
|
+
// 1. Measure the natural height with transitions disabled.
|
|
918
|
+
const inlineTransition = f.style.transition;
|
|
919
|
+
f.style.transition = "none";
|
|
920
|
+
stopReveal();
|
|
921
|
+
f.style.height = "";
|
|
922
|
+
const natural = f.offsetHeight;
|
|
923
|
+
if (natural <= 0) {
|
|
924
|
+
if (pinned > 0) f.style.height = `${pinned}px`;
|
|
925
|
+
f.style.transition = inlineTransition;
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const next = Math.round(natural);
|
|
929
|
+
const delta = next - pinned;
|
|
930
|
+
if (Math.abs(delta) < 2) {
|
|
931
|
+
f.style.height = `${next}px`;
|
|
932
|
+
f.style.transition = inlineTransition;
|
|
933
|
+
pinned = next;
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
// 2. Re-establish the OLD pin under no transition, flush it.
|
|
937
|
+
f.style.height = `${pinned}px`;
|
|
938
|
+
void f.offsetHeight;
|
|
939
|
+
// 3. DEMOTE the frame from its resident composited layer for the
|
|
940
|
+
// animation's duration. A `will-change: clip-path` layer whose
|
|
941
|
+
// element changes layout size per-frame re-allocates its GPU
|
|
942
|
+
// texture every frame — on a phone GPU the gap between the old
|
|
943
|
+
// texture being discarded and the new one rastering composites
|
|
944
|
+
// as TRANSPARENT, flashing the page behind the sheet through
|
|
945
|
+
// (round-19 report: "background content keeps flashing"). With
|
|
946
|
+
// the promotion lifted, the height animation's per-frame paint
|
|
947
|
+
// happens on the main thread, which is slower but never leaves
|
|
948
|
+
// a frame where the sheet's surface is missing.
|
|
949
|
+
const residentWill_ = f.style.willChange;
|
|
950
|
+
if (residentWill) {
|
|
951
|
+
f.style.willChange = "";
|
|
952
|
+
residentWill = false;
|
|
953
|
+
}
|
|
954
|
+
f.style.transition =
|
|
955
|
+
`height ${durationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
|
|
956
|
+
f.style.height = `${next}px`;
|
|
957
|
+
pinned = next;
|
|
958
|
+
// 4. Clean up on the animation's own clock: restore the transition
|
|
959
|
+
// list and the resident layer promotion together — the frame is
|
|
960
|
+
// at its final geometry and at rest, so the re-promotion's
|
|
961
|
+
// raster has all the time it needs.
|
|
962
|
+
const restore = (): void => {
|
|
963
|
+
f.removeEventListener("transitionend", onEnd);
|
|
964
|
+
f.style.transition = "";
|
|
965
|
+
if (residentWill_ === "clip-path") {
|
|
966
|
+
f.style.willChange = "clip-path";
|
|
967
|
+
residentWill = true;
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
const onEnd = (ev: TransitionEvent): void => {
|
|
971
|
+
if (ev.target === f && ev.propertyName === "height") {
|
|
972
|
+
restore();
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
f.addEventListener("transitionend", onEnd);
|
|
976
|
+
const watchdog = setTimeout(restore, durationMs + 350);
|
|
977
|
+
// If the element goes away mid-flight, don't leak the timer.
|
|
978
|
+
const origClear = watchdog;
|
|
979
|
+
void origClear; // timer fires restore() which is idempotent
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return { start, stop, hold, remeasure, heightMorph };
|
|
900
983
|
}
|