@celestia-island/hikari 0.55.49 → 0.55.50
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 +106 -0
- package/src/components/HkModal.tsx +35 -1
- package/src/components/HkStepFlow.scss +30 -15
- package/src/components/HkStepFlow.slide.contract.test.ts +35 -9
- package/src/components/HkStepFlow.test.tsx +191 -6
- package/src/components/HkStepFlow.tsx +154 -20
- package/src/composables/useSizeMorph.test.ts +240 -0
- package/src/composables/useSizeMorph.ts +179 -0
- package/src/runtime/sheetRide.ts +62 -0
|
@@ -12,6 +12,7 @@ import HkTimeline from "./HkTimeline";
|
|
|
12
12
|
import type { TimelineCollapse, TimelineStep } from "./HkTimeline";
|
|
13
13
|
import { SCROLL_HOST_CLASS } from "./HkScrollPin";
|
|
14
14
|
import { reportTransition, type AnimationHandle } from "../runtime/animationBus";
|
|
15
|
+
import { useSheetRide } from "../runtime/sheetRide";
|
|
15
16
|
|
|
16
17
|
// Sticky header mode rides the shared scroll-pin contract (class + data
|
|
17
18
|
// attributes on the timeline root); the pin stylesheet ships those
|
|
@@ -46,7 +47,19 @@ interface BodyEntry {
|
|
|
46
47
|
* is recycled at the phase boundary — no visibility flips, no absolute
|
|
47
48
|
* positioning, no height pins, no measured offsets (2026-09-22 user
|
|
48
49
|
* directive, round 12: those all changed the render tree mid-animation
|
|
49
|
-
* and read as a flash on the phone GPU).
|
|
50
|
+
* and read as a flash on the phone GPU). The enter EDGE fires early, at
|
|
51
|
+
* ENTER_EDGE_SHARE of the exit phase, and flips the opacity-hiding
|
|
52
|
+
* class off the entering element imperatively — the fade starts while
|
|
53
|
+
* the old body's fading tail still covers the stage, and the old node
|
|
54
|
+
* is recycled RECYCLE_SHARE in, in the same patch that drops the
|
|
55
|
+
* entering body's end anchoring (round 14: the strictly sequential edge
|
|
56
|
+
* painted a ~33ms blank body plus the old negative-delay pop — the
|
|
57
|
+
* "the model list always flickers once" report). Inside a fold-riding
|
|
58
|
+
* sheet the entering body also registers
|
|
59
|
+
* as a COUNTER rider: the sheet's content block glides with the fold
|
|
60
|
+
* edge, and the new content must not glide with it (round-7 rejection)
|
|
61
|
+
* — it fades in at its final position while the block settles around
|
|
62
|
+
* it. */
|
|
50
63
|
interface RunningSwap {
|
|
51
64
|
leavingId: number;
|
|
52
65
|
enteringId: number;
|
|
@@ -61,6 +74,12 @@ interface RunningSwap {
|
|
|
61
74
|
report: AnimationHandle | null;
|
|
62
75
|
/** Watchdog: advance the phase even if transitionend never arrives. */
|
|
63
76
|
timer: ReturnType<typeof setTimeout> | null;
|
|
77
|
+
/** Overlap edge: advance to the enter phase before the exit transition
|
|
78
|
+
* fully ends, so the new fade starts while the old tail still covers
|
|
79
|
+
* the stage (round 14 — the sequential edge painted a blank window). */
|
|
80
|
+
earlyTimer: ReturnType<typeof setTimeout> | null;
|
|
81
|
+
/** Unregister the entering body's fold-ride (see useSheetRide). */
|
|
82
|
+
unregisterRide: (() => void) | null;
|
|
64
83
|
listenEl: HTMLElement | null;
|
|
65
84
|
onEnded: ((event: TransitionEvent) => void) | null;
|
|
66
85
|
}
|
|
@@ -71,6 +90,22 @@ export const STEPFLOW_SWAP_EVENT = "hk-stepflow-swap";
|
|
|
71
90
|
* settles a phase without a transitionend. */
|
|
72
91
|
const SWAP_WATCHDOG_GRACE_MS = 350;
|
|
73
92
|
|
|
93
|
+
/** Where in the exit phase the enter edge fires (0–1). 0.65 starts the
|
|
94
|
+
* entering fade while the outgoing body is still ~0.35 — the old tail
|
|
95
|
+
* then covers the new fade's sub-threshold ramp, so the sequential
|
|
96
|
+
* grammar never paints a readable-empty frame (the round-14 R1 rig
|
|
97
|
+
* measured a 17–34ms sub-perceptual valley at 0.8; at 0.65 the crossing
|
|
98
|
+
* lands at the perceptual threshold itself). The two bodies are never
|
|
99
|
+
* both READABLE — the newcomer stays under ~0.15 through the overlap. */
|
|
100
|
+
const ENTER_EDGE_SHARE = 0.65;
|
|
101
|
+
|
|
102
|
+
/** Where in the exit phase the old node is finally recycled (0–1): late
|
|
103
|
+
* enough that the leaving fade is at ~0.07 (its removal is
|
|
104
|
+
* imperceptible), early enough to stay inside the same overall window.
|
|
105
|
+
* The recycle patch also drops the entering body's end anchoring, so
|
|
106
|
+
* the cell's collapse and the stretch land as one flush. */
|
|
107
|
+
const RECYCLE_SHARE = 0.9;
|
|
108
|
+
|
|
74
109
|
/** Fade level at or below which an outgoing body counts as gone. */
|
|
75
110
|
const FADED_OUT = 0.05;
|
|
76
111
|
|
|
@@ -116,12 +151,22 @@ function opacityOf(el: HTMLElement | null): number {
|
|
|
116
151
|
* as the swap starts for a grow, and keeps the old geometry until the
|
|
117
152
|
* old node is recycled for a shrink, with no pin.
|
|
118
153
|
* - EXIT phase (first half): the old body slides out and fades; the new
|
|
119
|
-
* body is mounted
|
|
120
|
-
* `
|
|
121
|
-
* `visibility: hidden` — that flip re-rasters the
|
|
122
|
-
* GPU, which was the flash the user kept seeing).
|
|
123
|
-
* - ENTER phase (second half): the
|
|
124
|
-
*
|
|
154
|
+
* body is mounted staged — end-anchored and hidden
|
|
155
|
+
* (`hk-stepflow-enter-pending` + `hk-stepflow-enter-hidden`,
|
|
156
|
+
* deliberately NOT `visibility: hidden` — that flip re-rasters the
|
|
157
|
+
* layer on the phone GPU, which was the flash the user kept seeing).
|
|
158
|
+
* - ENTER phase (second half): the enter EDGE (65% of the exit phase,
|
|
159
|
+
* round 14, 2026-09-23) imperatively releases the entering body's
|
|
160
|
+
* opacity while the old tail is still ~0.35 — the old covers the new
|
|
161
|
+
* fade's sub-threshold ramp, so no readable-empty frame ever paints
|
|
162
|
+
* between the phases. The old node is recycled at 90% (≈0.07 opacity,
|
|
163
|
+
* imperceptible) in the same patch that drops the end anchoring, so
|
|
164
|
+
* the cell's collapse and the stretch land as one flush.
|
|
165
|
+
* - Inside a fold-riding sheet (HkModal's clip morph), the entering body
|
|
166
|
+
* registers as a COUNTER rider (see runtime/sheetRide): the sheet's
|
|
167
|
+
* content block glides with the fold edge — the title is never sliced
|
|
168
|
+
* — while the new content fades in at its FINAL position (vertical
|
|
169
|
+
* travel of the new body was the round-7 rejection).
|
|
125
170
|
* - The height handoff to the hosting sheet rides `hk-stepflow-swap`
|
|
126
171
|
* (bubbles, detail `{ delta, durationMs, phase }`), whose listener
|
|
127
172
|
* re-measures immediately and morphs over one phase.
|
|
@@ -175,7 +220,15 @@ export default defineComponent({
|
|
|
175
220
|
]);
|
|
176
221
|
let swap: RunningSwap | null = null;
|
|
177
222
|
const swapPhase = ref<"idle" | "exit" | "enter">("idle");
|
|
223
|
+
/** True once the leaving body was recycled and the entering body's
|
|
224
|
+
* end anchoring dropped with it (see recycleLeaving) — the render
|
|
225
|
+
* keys the anchoring class off it so the vdom agrees with the
|
|
226
|
+
* imperative edge flip. */
|
|
227
|
+
const swapRecycled = ref(false);
|
|
178
228
|
const flowRef = ref<HTMLDivElement | null>(null);
|
|
229
|
+
// The hosting sheet's fold-ride registry (HkModal provides it); null
|
|
230
|
+
// standalone or on desktop-style hosts — no registration, no ride.
|
|
231
|
+
const sheetRide = useSheetRide();
|
|
179
232
|
|
|
180
233
|
/** The element rendering one body entry. */
|
|
181
234
|
function elFor(id: number | undefined): HTMLElement | null {
|
|
@@ -190,6 +243,8 @@ export default defineComponent({
|
|
|
190
243
|
function disarmPhase(handle: RunningSwap): void {
|
|
191
244
|
if (handle.timer !== null) clearTimeout(handle.timer);
|
|
192
245
|
handle.timer = null;
|
|
246
|
+
if (handle.earlyTimer !== null) clearTimeout(handle.earlyTimer);
|
|
247
|
+
handle.earlyTimer = null;
|
|
193
248
|
if (handle.listenEl && handle.onEnded) {
|
|
194
249
|
handle.listenEl.removeEventListener("transitionend", handle.onEnded);
|
|
195
250
|
}
|
|
@@ -197,15 +252,21 @@ export default defineComponent({
|
|
|
197
252
|
handle.onEnded = null;
|
|
198
253
|
}
|
|
199
254
|
|
|
200
|
-
/** Arm a phase's closer: the acting body's own transitionend
|
|
201
|
-
* duration+grace watchdog, both routed to the same
|
|
255
|
+
/** Arm a phase's closer: the acting body's own opacity transitionend
|
|
256
|
+
* plus the duration+grace watchdog, both routed to the same
|
|
257
|
+
* `advance`. The property guard matters once the body rides a sheet
|
|
258
|
+
* fold: the ride's transform leg lives on the SAME element and its
|
|
259
|
+
* transitionend (the fold's landing) must not settle the swap while
|
|
260
|
+
* the fade is still running. */
|
|
202
261
|
function armPhase(
|
|
203
262
|
handle: RunningSwap,
|
|
204
263
|
el: HTMLElement,
|
|
205
264
|
advance: () => void,
|
|
206
265
|
): void {
|
|
207
266
|
const onEnded = (event: TransitionEvent) => {
|
|
208
|
-
if (event.target === el)
|
|
267
|
+
if (event.target === el && event.propertyName === "opacity") {
|
|
268
|
+
advance();
|
|
269
|
+
}
|
|
209
270
|
};
|
|
210
271
|
el.addEventListener("transitionend", onEnded);
|
|
211
272
|
handle.listenEl = el;
|
|
@@ -234,6 +295,8 @@ export default defineComponent({
|
|
|
234
295
|
swapPhase.value = "idle";
|
|
235
296
|
disarmPhase(handle);
|
|
236
297
|
handle.report?.disconnect();
|
|
298
|
+
handle.unregisterRide?.();
|
|
299
|
+
handle.unregisterRide = null;
|
|
237
300
|
const id = handle.leavingId;
|
|
238
301
|
bodies.value = bodies.value.filter((b) => b.id !== id);
|
|
239
302
|
}
|
|
@@ -251,6 +314,8 @@ export default defineComponent({
|
|
|
251
314
|
}
|
|
252
315
|
const goneEl = elFor(handle.leavingId);
|
|
253
316
|
const faded = opacityOf(goneEl) <= FADED_OUT;
|
|
317
|
+
handle.unregisterRide?.();
|
|
318
|
+
handle.unregisterRide = null;
|
|
254
319
|
bodies.value = bodies.value.filter((b) => b.id !== handle.enteringId);
|
|
255
320
|
const survivor = bodies.value.find((b) => b.id === handle.leavingId);
|
|
256
321
|
if (!survivor) {
|
|
@@ -268,31 +333,70 @@ export default defineComponent({
|
|
|
268
333
|
return faded;
|
|
269
334
|
}
|
|
270
335
|
|
|
271
|
-
/** Exit → enter edge:
|
|
336
|
+
/** Exit → enter edge: release the new body's fade and schedule the
|
|
337
|
+
* old node's recycling. The edge fires at ENTER_EDGE_SHARE of the
|
|
338
|
+
* exit phase (early timer) or at the leaving body's transitionend,
|
|
339
|
+
* whichever comes first — the phase guard below makes double
|
|
340
|
+
* arrival a no-op. The old node is NOT recycled here: its fading
|
|
341
|
+
* tail stays on stage covering the new fade's first sub-threshold
|
|
342
|
+
* frames, and the recycle lands RECYCLE_SHARE later (still inside
|
|
343
|
+
* the leaving fade, at ~0.07 opacity) in the same patch that drops
|
|
344
|
+
* the entering body's end anchoring — so the cell's collapse and
|
|
345
|
+
* the stretch never paint as separate frames. */
|
|
272
346
|
function startEnterPhase(): void {
|
|
273
347
|
const handle = swap;
|
|
274
348
|
if (!handle || handle.phase !== "exit") return;
|
|
275
349
|
disarmPhase(handle);
|
|
276
350
|
handle.phase = "enter";
|
|
277
351
|
swapPhase.value = "enter";
|
|
278
|
-
bodies.value = bodies.value.filter((b) => b.id !== handle.leavingId);
|
|
279
352
|
const entering = elFor(handle.enteringId);
|
|
280
353
|
if (!entering) {
|
|
354
|
+
bodies.value = bodies.value.filter((b) => b.id !== handle.leavingId);
|
|
281
355
|
endSwap();
|
|
282
356
|
return;
|
|
283
357
|
}
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
// the
|
|
289
|
-
//
|
|
358
|
+
// Release the entering body's OPACITY imperatively — the fade
|
|
359
|
+
// starts at the next style flush instead of after Vue's async
|
|
360
|
+
// patch, so the recycle→patch→fade pipeline never paints an empty
|
|
361
|
+
// body (round 14 — that gap, on top of the old negative delay, was
|
|
362
|
+
// the "the new list always flickers once" report). Only the
|
|
363
|
+
// opacity-hiding class comes off; the END ANCHORING
|
|
364
|
+
// (`hk-stepflow-enter-pending`) must survive until the cell
|
|
365
|
+
// collapses — without it the body would stretch to the still-tall
|
|
366
|
+
// cell the instant it becomes visible and jump a full delta.
|
|
367
|
+
entering.classList.remove("hk-stepflow-enter-hidden");
|
|
368
|
+
// Recycle the old node (and drop the anchoring with it) one share
|
|
369
|
+
// later — see recycleLeaving. Both shares are positions on the
|
|
370
|
+
// PHASE clock (swap start), so the gap between the edges is their
|
|
371
|
+
// difference.
|
|
372
|
+
handle.earlyTimer = setTimeout(
|
|
373
|
+
() => recycleLeaving(handle),
|
|
374
|
+
Math.max(
|
|
375
|
+
0,
|
|
376
|
+
Math.round(handle.phaseMs * (RECYCLE_SHARE - ENTER_EDGE_SHARE)),
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
armPhase(handle, entering, endSwap);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Recycle the leaving body at the scheduled moment: the same patch
|
|
383
|
+
* removes the old node AND the entering body's end anchoring, so the
|
|
384
|
+
* cell's collapse and the stretch land in one flush. A shrink hands
|
|
385
|
+
* the sheet its new geometry HERE (after the flush — the sheet must
|
|
386
|
+
* measure the COLLAPSED cell; announcing before the flush read the
|
|
387
|
+
* old content and skipped the fold, leaving it to the observer's
|
|
388
|
+
* 150ms debounce, which wiped the fading-in content and jumped at
|
|
389
|
+
* the landing). */
|
|
390
|
+
function recycleLeaving(handle: RunningSwap): void {
|
|
391
|
+
if (swap !== handle || handle.phase !== "enter") return;
|
|
392
|
+
handle.earlyTimer = null;
|
|
393
|
+
swapRecycled.value = true;
|
|
394
|
+
bodies.value = bodies.value.filter((b) => b.id !== handle.leavingId);
|
|
290
395
|
if (handle.delta < 0) {
|
|
291
396
|
void nextTick(() => {
|
|
292
397
|
if (swap === handle) announce(handle);
|
|
293
398
|
});
|
|
294
399
|
}
|
|
295
|
-
armPhase(handle, entering, endSwap);
|
|
296
400
|
}
|
|
297
401
|
|
|
298
402
|
// Sticky-header whitespace strategy (2026-09-14): see HkScrollPin.
|
|
@@ -339,12 +443,15 @@ export default defineComponent({
|
|
|
339
443
|
delta: 0,
|
|
340
444
|
report: null,
|
|
341
445
|
timer: null,
|
|
446
|
+
earlyTimer: null,
|
|
447
|
+
unregisterRide: null,
|
|
342
448
|
listenEl: null,
|
|
343
449
|
onEnded: null,
|
|
344
450
|
};
|
|
345
451
|
swap = handle;
|
|
346
452
|
bodies.value = [...bodies.value, entering];
|
|
347
453
|
swapPhase.value = "exit";
|
|
454
|
+
swapRecycled.value = false;
|
|
348
455
|
await nextTick();
|
|
349
456
|
if (swap !== handle) return;
|
|
350
457
|
const newEl = elFor(entering.id);
|
|
@@ -357,12 +464,22 @@ export default defineComponent({
|
|
|
357
464
|
return;
|
|
358
465
|
}
|
|
359
466
|
handle.delta = newEl.offsetHeight - oldH;
|
|
467
|
+
// Fold-ride (round 14): inside a sheet whose fold rides its
|
|
468
|
+
// content, the entering body registers as a COUNTER rider so it
|
|
469
|
+
// holds its final position while the block glides — BEFORE the
|
|
470
|
+
// grow announce below, because the sheet's remeasure collects
|
|
471
|
+
// riders synchronously as it stages the sweep.
|
|
472
|
+
handle.unregisterRide = sheetRide
|
|
473
|
+
? sheetRide.register({ el: newEl, counter: true })
|
|
474
|
+
: null;
|
|
360
475
|
if (skipExit) {
|
|
361
476
|
// The faded body is gone: this edge IS the enter edge. Tell the
|
|
362
477
|
// sheet and let the new body fade in from this moment.
|
|
363
478
|
bodies.value = bodies.value.filter((b) => b.id !== handle.leavingId);
|
|
364
479
|
handle.phase = "enter";
|
|
365
480
|
swapPhase.value = "enter";
|
|
481
|
+
swapRecycled.value = true;
|
|
482
|
+
newEl.classList.remove("hk-stepflow-enter-hidden");
|
|
366
483
|
announce(handle);
|
|
367
484
|
handle.report = reportTransition(phaseMs);
|
|
368
485
|
handle.timer = setTimeout(endSwap, handle.phaseMs + SWAP_WATCHDOG_GRACE_MS);
|
|
@@ -374,6 +491,14 @@ export default defineComponent({
|
|
|
374
491
|
announce(handle);
|
|
375
492
|
}
|
|
376
493
|
handle.report = reportTransition(phaseMs * 2);
|
|
494
|
+
// Overlap edge: fire the enter phase at ENTER_EDGE_SHARE of the
|
|
495
|
+
// exit duration — the outgoing body is still visibly fading, and
|
|
496
|
+
// its tail covers the new fade's sub-threshold ramp so no blank
|
|
497
|
+
// body ever paints between the phases.
|
|
498
|
+
handle.earlyTimer = setTimeout(
|
|
499
|
+
() => startEnterPhase(),
|
|
500
|
+
Math.max(0, Math.round(handle.phaseMs * ENTER_EDGE_SHARE)),
|
|
501
|
+
);
|
|
377
502
|
armPhase(handle, goneEl, startEnterPhase);
|
|
378
503
|
},
|
|
379
504
|
{ flush: "post" },
|
|
@@ -413,9 +538,18 @@ export default defineComponent({
|
|
|
413
538
|
class={[
|
|
414
539
|
"hk-stepflow-body",
|
|
415
540
|
entry.phase,
|
|
416
|
-
|
|
541
|
+
// End anchoring: from mount until the RECYCLE patch
|
|
542
|
+
// collapses the cell (see recycleLeaving) — never
|
|
543
|
+
// dropped by the fade release, or the body would
|
|
544
|
+
// stretch to the still-tall cell and jump.
|
|
545
|
+
entry.id === swap?.enteringId && !swapRecycled.value
|
|
417
546
|
? "hk-stepflow-enter-pending"
|
|
418
547
|
: null,
|
|
548
|
+
// Opacity hiding: only until the enter edge releases
|
|
549
|
+
// the fade (imperatively there, reactively here).
|
|
550
|
+
entry.id === swap?.enteringId && swapPhase.value === "exit"
|
|
551
|
+
? "hk-stepflow-enter-hidden"
|
|
552
|
+
: null,
|
|
419
553
|
entry.id === swap?.leavingId
|
|
420
554
|
? "hk-stepflow-leave-to"
|
|
421
555
|
: null,
|
|
@@ -111,6 +111,7 @@ beforeEach(() => {
|
|
|
111
111
|
|
|
112
112
|
afterEach(() => {
|
|
113
113
|
globalThis.ResizeObserver = originalRO;
|
|
114
|
+
vi.restoreAllMocks();
|
|
114
115
|
vi.useRealTimers();
|
|
115
116
|
for (const m of mounts.splice(0)) {
|
|
116
117
|
m.app.unmount();
|
|
@@ -777,3 +778,242 @@ describe("useSizeMorph hold + deferRemeasure", () => {
|
|
|
777
778
|
expect(h.frame.style.height).toBe("200px");
|
|
778
779
|
});
|
|
779
780
|
});
|
|
781
|
+
|
|
782
|
+
// ── Fold riders (collectRide, 2026-09-23 round 14) ─────────────────
|
|
783
|
+
// The clip sweep's edge must not slice the content it passes over: the
|
|
784
|
+
// host's chrome and content block ride the edge in lockstep (same
|
|
785
|
+
// duration/easing, flipped in the same task), staged in the clip's own
|
|
786
|
+
// transition-disabled task so the morph's first painted frame is
|
|
787
|
+
// pixel-identical to the last pre-morph one.
|
|
788
|
+
|
|
789
|
+
describe("useSizeMorph fold riders", () => {
|
|
790
|
+
it("stages riders with the clip and flips them with the sweep (reveal)", async () => {
|
|
791
|
+
const rider = document.createElement("div");
|
|
792
|
+
const counter = document.createElement("div");
|
|
793
|
+
const h = mountHarness(300, 300, {
|
|
794
|
+
collectRide: () => [{ el: rider }, { el: counter, counter: true }],
|
|
795
|
+
});
|
|
796
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
797
|
+
h.start();
|
|
798
|
+
|
|
799
|
+
h.setNatural(360);
|
|
800
|
+
h.remeasure();
|
|
801
|
+
// Staged in the clip's transition-disabled task: the block sits at
|
|
802
|
+
// its pre-growth offset, the counter at the mirrored negative, and
|
|
803
|
+
// the promotion is already up (the warmup is the raster window). The
|
|
804
|
+
// transform leg is staged at a zero clock — instant — so nothing of
|
|
805
|
+
// the ride animates before the sweep starts.
|
|
806
|
+
expect(rider.style.transform).toBe("translateY(60px)");
|
|
807
|
+
expect(counter.style.transform).toBe("translateY(-60px)");
|
|
808
|
+
expect(rider.style.transition).toBe("transform 0s");
|
|
809
|
+
expect(rider.style.willChange).toBe("transform");
|
|
810
|
+
|
|
811
|
+
// Two warmup frames, then the sweep: riders flip to their targets
|
|
812
|
+
// under a transition that mirrors the frame's own clip transition.
|
|
813
|
+
await busFrames(2);
|
|
814
|
+
expect(rider.style.transform).toBe("translateY(0px)");
|
|
815
|
+
expect(counter.style.transform).toBe("translateY(0px)");
|
|
816
|
+
expect(rider.style.transition).toMatch(/^transform 150ms /);
|
|
817
|
+
expect(rider.style.willChange).toBe("transform");
|
|
818
|
+
|
|
819
|
+
// The landing clears the rides with the frame.
|
|
820
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
821
|
+
expect(rider.style.transform).toBe("");
|
|
822
|
+
expect(rider.style.transition).toBe("");
|
|
823
|
+
expect(rider.style.willChange).toBe("");
|
|
824
|
+
expect(counter.style.transform).toBe("");
|
|
825
|
+
h.stop();
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it("rides a conceal downward with the block and lands atomically", async () => {
|
|
829
|
+
const rider = document.createElement("div");
|
|
830
|
+
const counter = document.createElement("div");
|
|
831
|
+
const h = mountHarness(360, 360, {
|
|
832
|
+
collectRide: () => [{ el: rider }, { el: counter, counter: true }],
|
|
833
|
+
});
|
|
834
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
835
|
+
h.start();
|
|
836
|
+
expect(h.frame.style.height).toBe("360px");
|
|
837
|
+
|
|
838
|
+
h.setNatural(300);
|
|
839
|
+
h.remeasure();
|
|
840
|
+
// Conceal start: the block rides from zero; the COUNTER stages at
|
|
841
|
+
// +|delta| — the collapsed layout sits that far above its final
|
|
842
|
+
// position, so the counter's viewport position is already final
|
|
843
|
+
// (R1 rig finding: a one-sided counter rode the block AND inherited
|
|
844
|
+
// it — a 2×delta sink with a delta jump at the landing).
|
|
845
|
+
expect(rider.style.transform).toBe("translateY(0px)");
|
|
846
|
+
expect(counter.style.transform).toBe("translateY(60px)");
|
|
847
|
+
|
|
848
|
+
await busFrames(2);
|
|
849
|
+
// The sweep folds the edge down: the block glides WITH it, the
|
|
850
|
+
// counter glides to zero — static at final the whole way.
|
|
851
|
+
expect(rider.style.transform).toBe("translateY(60px)");
|
|
852
|
+
expect(counter.style.transform).toBe("translateY(0px)");
|
|
853
|
+
|
|
854
|
+
// Landing: the re-pin and the ride's release land in one task — the
|
|
855
|
+
// layout drops delta while the delta offset disappears.
|
|
856
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
857
|
+
expect(rider.style.transform).toBe("");
|
|
858
|
+
expect(counter.style.transform).toBe("");
|
|
859
|
+
expect(h.frame.style.height).toBe("300px");
|
|
860
|
+
h.stop();
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it("clears riders when an interrupting dance re-stages mid-sweep", async () => {
|
|
864
|
+
const rider = document.createElement("div");
|
|
865
|
+
let collected = 0;
|
|
866
|
+
const h = mountHarness(300, 300, {
|
|
867
|
+
collectRide: () => {
|
|
868
|
+
collected += 1;
|
|
869
|
+
return [{ el: rider }];
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
873
|
+
h.start();
|
|
874
|
+
h.setNatural(360);
|
|
875
|
+
h.remeasure();
|
|
876
|
+
await busFrames(2);
|
|
877
|
+
expect(collected).toBe(1);
|
|
878
|
+
// A second growth mid-sweep: the old ride is cleared (atomically
|
|
879
|
+
// with the interrupted sweep's landing) and re-collected for the new
|
|
880
|
+
// dance's own stage.
|
|
881
|
+
h.setNatural(420);
|
|
882
|
+
h.remeasure();
|
|
883
|
+
expect(rider.style.transform).toBe("translateY(60px)");
|
|
884
|
+
expect(collected).toBe(2);
|
|
885
|
+
// The successor sweep runs and lands on its own terms.
|
|
886
|
+
await busFrames(2);
|
|
887
|
+
expect(rider.style.transform).toBe("translateY(0px)");
|
|
888
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
889
|
+
expect(rider.style.transform).toBe("");
|
|
890
|
+
h.stop();
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it("never collects riders for non-sweep morphs", async () => {
|
|
894
|
+
const rider = document.createElement("div");
|
|
895
|
+
let collected = 0;
|
|
896
|
+
const h = mountHarness(300, 300, {
|
|
897
|
+
collectRide: () => {
|
|
898
|
+
collected += 1;
|
|
899
|
+
return [{ el: rider }];
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
h.start();
|
|
903
|
+
// Desktop height mode (no clip flag): a growth morphs the height,
|
|
904
|
+
// never stages a sweep, never collects a ride.
|
|
905
|
+
h.setNatural(360);
|
|
906
|
+
h.remeasure();
|
|
907
|
+
expect(collected).toBe(0);
|
|
908
|
+
expect(rider.style.transform).toBe("");
|
|
909
|
+
// Sub-threshold deltas on clip mode stay snaps — still no ride.
|
|
910
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
911
|
+
h.setNatural(362);
|
|
912
|
+
h.remeasure();
|
|
913
|
+
expect(collected).toBe(0);
|
|
914
|
+
expect(rider.style.transform).toBe("");
|
|
915
|
+
h.stop();
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("keeps a rider's own transition legs live through the ride", async () => {
|
|
919
|
+
// The entering step body is MID-FADE when a shrink stages its ride;
|
|
920
|
+
// an inline transition override would cancel the running opacity
|
|
921
|
+
// transition and snap the body to full opacity — the very flash this
|
|
922
|
+
// round removes. The ride's transform leg must be APPENDED to the
|
|
923
|
+
// element's own computed transition, and the settle must hand back
|
|
924
|
+
// exactly the stylesheet state.
|
|
925
|
+
const rider = document.createElement("div");
|
|
926
|
+
const OWN = "opacity 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s";
|
|
927
|
+
const real = window.getComputedStyle.bind(window);
|
|
928
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation(
|
|
929
|
+
(el: Element, pseudoElt?: string | null): CSSStyleDeclaration => {
|
|
930
|
+
const cs = real(el, pseudoElt ?? undefined);
|
|
931
|
+
if (el === rider) {
|
|
932
|
+
return Object.create(cs, {
|
|
933
|
+
transition: { get: () => OWN },
|
|
934
|
+
transitionProperty: { get: () => "opacity" },
|
|
935
|
+
transitionDuration: { get: () => "150ms" },
|
|
936
|
+
transitionTimingFunction: { get: () => "cubic-bezier(0.4, 0, 0.2, 1)" },
|
|
937
|
+
}) as CSSStyleDeclaration;
|
|
938
|
+
}
|
|
939
|
+
return cs;
|
|
940
|
+
},
|
|
941
|
+
);
|
|
942
|
+
const h = mountHarness(300, 300, {
|
|
943
|
+
collectRide: () => [{ el: rider }],
|
|
944
|
+
});
|
|
945
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
946
|
+
h.start();
|
|
947
|
+
h.setNatural(360);
|
|
948
|
+
h.remeasure();
|
|
949
|
+
// Stage: the own legs lead, the zero-clock transform leg appends.
|
|
950
|
+
expect(rider.style.transition).toBe(`${OWN}, transform 0s`);
|
|
951
|
+
await busFrames(2);
|
|
952
|
+
// Flip: the transform leg rides the mirrored clock, own legs intact.
|
|
953
|
+
expect(rider.style.transition).toBe(
|
|
954
|
+
`${OWN}, transform 150ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
|
955
|
+
);
|
|
956
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
957
|
+
// Settle: inline fully released — the stylesheet (the own legs) owns
|
|
958
|
+
// the element again.
|
|
959
|
+
expect(rider.style.transition).toBe("");
|
|
960
|
+
expect(rider.style.transform).toBe("");
|
|
961
|
+
h.stop();
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
describe("useSizeMorph fold rider settle handoff", () => {
|
|
966
|
+
it("hands the rider back its own transition legs at the settle flush", async () => {
|
|
967
|
+
// R2 finding 1: clearRiders' first pass must WRITE the element's own
|
|
968
|
+
// computed transition (not "none") before the flush — an override
|
|
969
|
+
// there cancels a mid-flight opacity transition on a real engine,
|
|
970
|
+
// which is exactly the snap this round removes. happy-dom cannot run
|
|
971
|
+
// transitions, so the witness is the inline value CAPTURED AT THE
|
|
972
|
+
// FLUSH the settle itself performs (the frame's offsetHeight read).
|
|
973
|
+
const rider = document.createElement("div");
|
|
974
|
+
const OWN = "opacity 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s";
|
|
975
|
+
const real = window.getComputedStyle.bind(window);
|
|
976
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation(
|
|
977
|
+
(el: Element, pseudoElt?: string | null): CSSStyleDeclaration => {
|
|
978
|
+
const cs = real(el, pseudoElt ?? undefined);
|
|
979
|
+
if (el === rider) {
|
|
980
|
+
return Object.create(cs, {
|
|
981
|
+
transition: { get: () => OWN },
|
|
982
|
+
transitionProperty: { get: () => "opacity" },
|
|
983
|
+
transitionDuration: { get: () => "150ms" },
|
|
984
|
+
transitionTimingFunction: { get: () => "cubic-bezier(0.4, 0, 0.2, 1)" },
|
|
985
|
+
}) as CSSStyleDeclaration;
|
|
986
|
+
}
|
|
987
|
+
return cs;
|
|
988
|
+
},
|
|
989
|
+
);
|
|
990
|
+
const h = mountHarness(300, 300, {
|
|
991
|
+
collectRide: () => [{ el: rider }],
|
|
992
|
+
});
|
|
993
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
994
|
+
h.start();
|
|
995
|
+
h.setNatural(360);
|
|
996
|
+
h.remeasure();
|
|
997
|
+
await busFrames(2);
|
|
998
|
+
|
|
999
|
+
// Capture the rider's inline transition at every layout flush the
|
|
1000
|
+
// settle performs — the handoff must be visible there.
|
|
1001
|
+
const atFlush: string[] = [];
|
|
1002
|
+
Object.defineProperty(h.frame, "offsetHeight", {
|
|
1003
|
+
configurable: true,
|
|
1004
|
+
get() {
|
|
1005
|
+
atFlush.push(rider.style.transition);
|
|
1006
|
+
return 360;
|
|
1007
|
+
},
|
|
1008
|
+
});
|
|
1009
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
1010
|
+
expect(
|
|
1011
|
+
atFlush,
|
|
1012
|
+
"the settle's own flush must observe the rider's own legs",
|
|
1013
|
+
).toContain(OWN);
|
|
1014
|
+
// …and the inline override is fully released afterwards.
|
|
1015
|
+
expect(rider.style.transition).toBe("");
|
|
1016
|
+
expect(rider.style.transform).toBe("");
|
|
1017
|
+
h.stop();
|
|
1018
|
+
});
|
|
1019
|
+
});
|