@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.
@@ -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 but transparent (`hk-stepflow-enter-pending` =
120
- * `opacity: 0` + `pointer-events: none`, deliberately NOT
121
- * `visibility: hidden` — that flip re-rasters the layer on the phone
122
- * GPU, which was the flash the user kept seeing).
123
- * - ENTER phase (second half): the old node is recycled; the new body
124
- * fades in place.
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 plus the
201
- * duration+grace watchdog, both routed to the same `advance`. */
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) advance();
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: recycle the old node and reveal the new one. */
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
- // A shrink hands the sheet its new geometry at THIS edge. The
285
- // announce runs AFTER the flush — the old node was recycled in the
286
- // filter above, and the sheet must measure the COLLAPSED cell (the
287
- // announcing-before-flush variant read the old content and skipped
288
- // the fold, leaving it to the observer's 150ms debounce, which
289
- // wiped the fading-in content and jumped at the landing).
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
- entry.id === swap?.enteringId && swapPhase.value === "exit"
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
+ });