@celestia-island/hikari 0.55.53 → 0.55.54

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.53",
3
+ "version": "0.55.54",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -84,6 +84,13 @@ registerCssAnimation("hk-image-preview-shimmer", { infinite: true });
84
84
  registerCssAnimation("hk-progress-indeterminate", { infinite: true });
85
85
  // components/HkPasswordSurface.scss — caps-lock warning flash (plays once).
86
86
  registerCssAnimation("hk-pwd-flash");
87
+
88
+ // Timeline navigation wipes (HkTimeline, 2026-09-23 round 16): the
89
+ // window-mode cells slide in from the navigation side — one-shot.
90
+ registerCssAnimation("hk-timeline-wipe");
91
+ registerCssAnimation("hk-timeline-wipe-back");
92
+ registerCssAnimation("hk-timeline-wipe-v");
93
+ registerCssAnimation("hk-timeline-wipe-back-v");
87
94
  // components/HkPasswordSurface.scss — breathing hint glow (loops forever).
88
95
  registerCssAnimation("hk-pwd-breathe", { infinite: true });
89
96
  // components/HkPickerPane.scss — date/time picker view drill forward
@@ -3,7 +3,6 @@ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
3
 
4
4
  import HkModal from "./HkModal";
5
5
  import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
6
- import { useSheetRide } from "../runtime/sheetRide";
7
6
 
8
7
  const SHEET_SWEEP_STAGE_EVENT = "hk-sheet-sweep-stage";
9
8
  const SHEET_SWEEP_SETTLE_EVENT = "hk-sheet-sweep-settle";
@@ -120,8 +119,8 @@ describe("HkModal stepflow swap handoff", () => {
120
119
  expect(rig.bodyEl()).not.toBeNull();
121
120
  expect(rig.morphToken()).toBe("");
122
121
 
123
- // A swap that owns the height in its FIRST phase.
124
- rig.announceSwap({ delta: 120, durationMs: 150, phase: "exit" });
122
+ // The morph window (after the slide settles) owns the height.
123
+ rig.announceSwap({ delta: 120, durationMs: 150, phase: "morph" });
125
124
  await nextTick();
126
125
  expect(rig.morphToken()).toBe("150ms");
127
126
 
@@ -147,43 +146,6 @@ describe("HkModal stepflow swap handoff", () => {
147
146
  expect(rig.morphToken()).toBe("");
148
147
  });
149
148
 
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
149
  });
188
150
 
189
151
  describe("HkModal publishes its own fold for the step flow", () => {
@@ -327,28 +289,244 @@ describe("HkModal publishes its own fold for the step flow", () => {
327
289
  expect(settled).toBe(0);
328
290
  });
329
291
 
330
- it("rides its chrome and registered descendants with the staged fold", async () => {
331
- // Round 14: the fold's clip edge must never slice the title. When a
332
- // sweep stages, the modal's chrome and content probe ride the edge,
333
- // and a descendant that registered through the sheet-ride registry
334
- // (the way HkStepFlow registers its entering body) rides as a
335
- // reveal COUNTER — staged at the mirrored negative offset.
292
+ it("freezes the sheet for the slide window and unfreezes at the morph edge", async () => {
293
+ // Round 16 two-window protocol: the "swap" phase holds the sheet's
294
+ // pin (observer-driven remeasures must not chase the flow's cell
295
+ // mid-slide) until the "morph" phase lifts the freeze and measures.
296
+ // The freeze-flag plumbing itself (gate and release of background
297
+ // measurements) is unit-witnessed in useSizeMorph.test.ts.
298
+ vi.useFakeTimers();
299
+ freezeRaf();
300
+ stubDurations();
301
+ const rig = await mountRig();
302
+ rig.open.value = true;
303
+ await vi.advanceTimersByTimeAsync(600);
304
+ expect(rig.morphToken()).toBe("");
305
+
306
+ // The slide window: freeze — no morph token, no measurement.
307
+ rig.announceSwap({ delta: 0, durationMs: 300, phase: "swap" });
308
+ await nextTick();
309
+ expect(rig.morphToken()).toBe("");
310
+
311
+ // The morph window: the freeze lifts and the override lands.
312
+ rig.announceSwap({ delta: 120, durationMs: 300, phase: "morph" });
313
+ await nextTick();
314
+ expect(rig.morphToken()).toBe("300ms");
315
+ await vi.advanceTimersByTimeAsync(700);
316
+ expect(rig.morphToken()).toBe("");
317
+ });
318
+
319
+ it("ignores announcements while the surface is still unfolding", async () => {
320
+ vi.useFakeTimers();
321
+ freezeRaf();
322
+ stubDurations();
323
+ const rig = await mountRig();
324
+ rig.open.value = true;
325
+ // The machine's phase is opening* here: the morph is not armed yet, so
326
+ // an override would be stranded on the frame.
327
+ await vi.advanceTimersByTimeAsync(10);
328
+ if (rig.bodyEl()) {
329
+ rig.announceSwap({ delta: 60, durationMs: 150, phase: "exit" });
330
+ await nextTick();
331
+ }
332
+ expect(rig.morphToken()).toBe("");
333
+ await vi.advanceTimersByTimeAsync(600);
334
+ expect(rig.morphToken()).toBe("");
335
+ });
336
+
337
+ it("drops the override when the modal unmounts mid-sweep", async () => {
336
338
  vi.useFakeTimers();
337
339
  freezeRaf();
340
+ stubDurations();
341
+ const rig = await mountRig();
342
+ rig.open.value = true;
343
+ await vi.advanceTimersByTimeAsync(600);
344
+
345
+ rig.announceSwap({ delta: 80, durationMs: 150, phase: "exit" });
346
+ await nextTick();
347
+ const frame = rig.frameEl()!;
348
+ expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("150ms");
349
+
350
+ // Unmount with the sweep still in flight: the timer must not outlive
351
+ // the component, and the token must not survive on the detached node.
352
+ for (const app of mounts.splice(0)) app.unmount();
353
+ await nextTick();
354
+ expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("");
355
+ });
356
+ });
357
+
358
+ describe("HkModal rides its chrome and gates the slide window", () => {
359
+ /** Box model: the frame hugs its content up to `cap`; the content probe
360
+ * reports `content` plus the chrome the calibration measures. */
361
+ let content = 0;
362
+ let cap = Number.POSITIVE_INFINITY;
363
+ const realOffsetHeight = Object.getOwnPropertyDescriptor(
364
+ HTMLElement.prototype,
365
+ "offsetHeight",
366
+ );
367
+
368
+ function stubBox(initialContent: number, capPx: number): void {
369
+ content = initialContent;
370
+ cap = capPx;
371
+ Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
372
+ configurable: true,
373
+ get(this: HTMLElement) {
374
+ if (this.classList.contains("hk-modal-content")) {
375
+ return Math.min(content + 96, cap);
376
+ }
377
+ if (this.classList.contains("hk-modal-body-inner")) return content;
378
+ return 0;
379
+ },
380
+ });
381
+ }
382
+
383
+ function restoreBox(): void {
384
+ if (realOffsetHeight) {
385
+ Object.defineProperty(
386
+ HTMLElement.prototype,
387
+ "offsetHeight",
388
+ realOffsetHeight,
389
+ );
390
+ }
391
+ }
392
+
393
+ function stubClip(): void {
394
+ const real = window.getComputedStyle.bind(window);
395
+ vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
396
+ const style = real(el as Element, ...(rest as []));
397
+ if (!(el instanceof HTMLElement)) return style;
398
+ if (el.classList.contains("hk-modal-content")) {
399
+ return {
400
+ ...style,
401
+ transitionDuration: "0.3s",
402
+ getPropertyValue: (name: string) =>
403
+ name === "--hk-sheet-morph" ? "clip" : style.getPropertyValue(name),
404
+ } as CSSStyleDeclaration;
405
+ }
406
+ return style;
407
+ });
408
+ }
409
+
410
+ afterEach(() => {
411
+ restoreBox();
412
+ });
413
+
414
+
415
+
416
+
417
+
418
+
419
+
420
+ it("unfreezes via the safety timer when the settle never arrives", async () => {
421
+ // A flow unmounted mid-slide never sends the morph event — the
422
+ // safety unfreeze must not only drop the flag but FLUSH the
423
+ // measurement the freeze dropped (adversarial finding: without the
424
+ // flush the sheet sits at its old height until the next content
425
+ // change).
426
+ vi.useFakeTimers();
427
+ freezeRaf();
428
+ stubDurations();
338
429
  stubClip();
339
430
  stubBox(500, Number.POSITIVE_INFINITY);
431
+ const roInstances: Array<{ callback: () => void }> = [];
432
+ vi.stubGlobal("ResizeObserver", class {
433
+ callback: () => void;
434
+ constructor(cb: () => void) {
435
+ this.callback = cb;
436
+ roInstances.push(this);
437
+ }
438
+ observe() {}
439
+ disconnect() {}
440
+ });
441
+ const rig = await mountRig();
442
+ rig.open.value = true;
443
+ await vi.advanceTimersByTimeAsync(700);
444
+ const frame = rig.frameEl()!;
445
+ expect(frame.style.height).toBe("596px");
340
446
 
341
- // The rider models the stepflow's ENTERING body: registered
342
- // MID-CYCLE (never at arm time), so it stays on the per-swap
343
- // promotion path — resident promotion is the chrome's alone.
344
- let lateRegistry: ReturnType<typeof useSheetRide> = null;
345
- const Rider = defineComponent({
346
- setup() {
347
- lateRegistry = useSheetRide();
348
- return () => h("div", { class: "rider-el" }, "rider");
349
- },
447
+ // The slide window freezes; the content grew and the observer event
448
+ // was dropped (gated); no morph event will ever come.
449
+ rig.announceSwap({ delta: 0, durationMs: 300, phase: "swap" });
450
+ await nextTick();
451
+ content = 560;
452
+ roInstances[0]!.callback();
453
+ await vi.advanceTimersByTimeAsync(400);
454
+ expect(frame.style.height).toBe("596px");
455
+
456
+ // Past ms + 650 the safety unfreeze runs AND flushes: the pin
457
+ // reflects the growth without any event.
458
+ await vi.advanceTimersByTimeAsync(700);
459
+ expect(frame.style.height).toBe("656px");
460
+ // …and a later morph event still works (no stranded freeze).
461
+ rig.announceSwap({ delta: -40, durationMs: 300, phase: "morph" });
462
+ await nextTick();
463
+ expect(rig.morphToken()).toBe("300ms");
464
+ await vi.advanceTimersByTimeAsync(700);
465
+ expect(rig.morphToken()).toBe("");
466
+ restoreBox();
467
+ });
468
+ it("gates the observer while the slide window freezes the sheet", async () => {
469
+ // Round 16 wiring witness (the adversarial round's MuD survived
470
+ // against everything else): the "swap" event must route into the
471
+ // morph's deferRemeasure gate — an observer-driven growth mid-slide
472
+ // leaves the pin untouched, and the very same observer path measures
473
+ // again once the morph edge unfroze it (the last clause also proves
474
+ // the observer path is ALIVE in this harness, so the hold clause is
475
+ // not vacuously green).
476
+ vi.useFakeTimers();
477
+ stubDurations();
478
+ stubClip();
479
+ stubBox(500, Number.POSITIVE_INFINITY);
480
+ const roInstances: Array<{ callback: () => void }> = [];
481
+ vi.stubGlobal("ResizeObserver", class {
482
+ callback: () => void;
483
+ constructor(cb: () => void) {
484
+ this.callback = cb;
485
+ roInstances.push(this);
486
+ }
487
+ observe() {}
488
+ disconnect() {}
350
489
  });
490
+ // No rAF stub: happy-dom maps rAF onto its timer queue, so the
491
+ // debounce AND the frame hops all run under the fake clock.
492
+ const rig = await mountRig();
493
+ rig.open.value = true;
494
+ await vi.advanceTimersByTimeAsync(700);
495
+ const frame = rig.frameEl()!;
496
+ expect(frame.style.height).toBe("596px");
497
+
498
+ // The slide window: frozen. The observer fires; the pin holds.
499
+ rig.announceSwap({ delta: 0, durationMs: 300, phase: "swap" });
500
+ await nextTick();
501
+ content = 560;
502
+ roInstances[0]!.callback();
503
+ await vi.advanceTimersByTimeAsync(700);
504
+ expect(frame.style.height).toBe("596px");
351
505
 
506
+ // The morph edge unfreezes and measures the accumulated growth.
507
+ rig.announceSwap({ delta: 24, durationMs: 300, phase: "morph" });
508
+ await nextTick();
509
+ expect(frame.style.height).not.toBe("596px");
510
+
511
+ // Harness validity + unfreeze proof: the SAME observer path now
512
+ // moves the pin without any event.
513
+ content = 700;
514
+ roInstances[0]!.callback();
515
+ await vi.advanceTimersByTimeAsync(700);
516
+ expect(frame.style.height).toBe("796px");
517
+ restoreBox();
518
+ });
519
+
520
+ it("rides its chrome with the staged fold", async () => {
521
+ // Round 16: only the host chrome rides (header/subheader/body
522
+ // block) — the per-swap registry is retired with the concurrent
523
+ // choreography. This pins the modal-level collection + the resident
524
+ // promotion (round 15); the ride's flip/settle lifecycle is
525
+ // unit-witnessed in useSizeMorph.test.ts.
526
+ vi.useFakeTimers();
527
+ freezeRaf();
528
+ stubClip();
529
+ stubBox(500, Number.POSITIVE_INFINITY);
352
530
  const container = document.createElement("div");
353
531
  document.body.appendChild(container);
354
532
  containers.push(container);
@@ -362,7 +540,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
362
540
  title: "ride-test",
363
541
  "onUpdate:modelValue": (v: boolean) => { open.value = v; },
364
542
  }, {
365
- default: () => h("div", { class: "swap-body" }, [h(Rider)]),
543
+ default: () => h("div", { class: "swap-body" }, "step content"),
366
544
  });
367
545
  },
368
546
  });
@@ -376,60 +554,28 @@ describe("HkModal publishes its own fold for the step flow", () => {
376
554
  const frame = () => document.querySelector<HTMLElement>(".hk-modal-content")!;
377
555
  const header = () => document.querySelector<HTMLElement>(".hk-modal-header")!;
378
556
  const bodyBlock = () => document.querySelector<HTMLElement>(".hk-modal-body")!;
379
- const riderEl = () => document.querySelector<HTMLElement>(".rider-el")!;
380
- expect(lateRegistry).not.toBeNull();
381
- // Register only AFTER the surface armed — the per-swap timing.
382
- lateRegistry!.register({ el: riderEl(), counter: true });
383
557
 
384
- // The sweep needs real frames from here on.
385
- const frames: FrameRequestCallback[] = [];
386
- vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
387
- frames.push(cb);
388
- return frames.length;
389
- });
390
- vi.stubGlobal("cancelAnimationFrame", () => {});
558
+ // Resident promotion from the arm moment (round 15).
559
+ expect(header().style.willChange).toBe("transform");
560
+ expect(bodyBlock().style.willChange).toBe("transform");
391
561
 
392
- // A GROW announced at the flow's exit edge: content 500 -> 700, so
393
- // the frame folds +200. The chrome rides at the pre-growth offset,
394
- // the registered counter at the mirrored negative.
562
+ // The morph window (round 16 protocol) stages the fold: the chrome
563
+ // sits at its pre-growth offset, instantly.
395
564
  content = 700;
396
565
  document
397
566
  .querySelector<HTMLElement>(".hk-modal-body .swap-body")!
398
567
  .dispatchEvent(
399
568
  new CustomEvent(STEPFLOW_SWAP_EVENT, {
400
569
  bubbles: true,
401
- detail: { delta: 200, durationMs: 150, phase: "exit" },
570
+ detail: { delta: 200, durationMs: 300, phase: "morph" },
402
571
  }),
403
572
  );
404
573
  await nextTick();
405
574
  expect(header().style.transform).toBe("translateY(200px)");
406
575
  expect(bodyBlock().style.transform).toBe("translateY(200px)");
407
- expect(riderEl().style.transform).toBe("translateY(-200px)");
408
- expect(header().style.willChange).toBe("transform");
409
576
 
410
- // Two warmup frames, then the sweep: everything flips to its target
411
- // under the mirrored transform transition.
412
- for (let i = 0; i < 2; i += 1) {
413
- for (const cb of frames.splice(0)) cb(i * 16);
414
- await vi.advanceTimersByTimeAsync(16);
415
- }
416
- expect(header().style.transform).toBe("translateY(0px)");
417
- expect(riderEl().style.transform).toBe("translateY(0px)");
418
- expect(header().style.transition).toMatch(/^transform 0\.3s /);
419
-
420
- // The landing releases the rides with the frame — but the CHROME
421
- // keeps its resident promotion (round 15: demoting at the settle
422
- // put the phone-GPU raster race back at every landing = black
423
- // blocks). Only the per-swap registration (the Rider div, not in
424
- // the frame at arm time) releases its layer.
425
- const ev = new Event("transitionend");
426
- Object.defineProperty(ev, "propertyName", { value: "clip-path" });
427
- frame().dispatchEvent(ev);
428
- expect(header().style.transform).toBe("");
429
- expect(bodyBlock().style.transform).toBe("");
430
- expect(riderEl().style.transform).toBe("");
431
- expect(header().style.willChange).toBe("transform");
432
- expect(bodyBlock().style.willChange).toBe("transform");
433
- expect(riderEl().style.willChange).toBe("");
577
+ // (The landing's release + resident survival are unit-witnessed in
578
+ // useSizeMorph.test.ts — this rig's frozen rAF never starts the
579
+ // sweep, so there is no listener to land here.)
434
580
  });
435
581
  });
@@ -12,7 +12,6 @@ import { useSurfaceTransition } from "../composables/useSurfaceTransition";
12
12
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
13
13
  import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
14
14
  import { useSizeMorph, type RideEntry } from "../composables/useSizeMorph";
15
- import { provideSheetRide } from "../runtime/sheetRide";
16
15
 
17
16
  import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
18
17
 
@@ -211,12 +210,15 @@ export default defineComponent({
211
210
  // running animation (2026-09-16 chest field report — the frame
212
211
  // snapped 459→697px mid-unfold). The open phase edge flushes the
213
212
  // deferred growth with an animated remeasure.
214
- // Fold-ride registry (round 14): descendants register elements the
215
- // fold must not slice or drag (the stepflow's entering body rides as
216
- // a counter); merged into collectRide below.
217
- const sheetRide = provideSheetRide();
213
+ // The freeze flag (round 16): while a stepflow SLIDE is in flight the
214
+ // sheet's height must not chase the flow's changing cell — the gate
215
+ // below also returns true then, and the morph window's own event
216
+ // flushes the frozen change with an explicit remeasure.
217
+ let swapFrozen = false;
218
+ let swapFreezeTimer: ReturnType<typeof setTimeout> | null = null;
218
219
  const morph = useSizeMorph(contentRef, innerRef, {
219
220
  deferRemeasure: () =>
221
+ swapFrozen ||
220
222
  machine.phase.value === "openingFrom" ||
221
223
  machine.phase.value === "openingTo",
222
224
  // The sheet's chrome and content block ride the fold edge in
@@ -245,7 +247,6 @@ export default defineComponent({
245
247
  // untransformed; the block's own poke below the frame lands
246
248
  // off-screen on a bottom-docked sheet.
247
249
  if (bodyRef.value) out.push({ el: bodyRef.value });
248
- out.push(...sheetRide.snapshot());
249
250
  return out;
250
251
  },
251
252
  // Republish the morph's real sweep span and its landing on the body
@@ -269,18 +270,19 @@ export default defineComponent({
269
270
  let previouslyFocused: HTMLElement | null = null;
270
271
  let unmounted = false;
271
272
 
272
- // HkStepFlow swaps dispatch STEPFLOW_SWAP_EVENT (bubbling) from the
273
- // flow root at the edge where the sheet must morph: the exit edge for
274
- // a GROW (the new height is already in the flow, so the sheet finishes
275
- // growing while the old body leaves) and the enter edge for a SHRINK
276
- // (the flow held its old height until the old body finished playing).
277
- // The morph's settle debounce is tuned for streaming bursts; a step
278
- // swap is one clean change whose sweep must land INSIDE its 0.15s
279
- // phase (2026-09-22 user directive, round 10) — so the event
280
- // short-circuits straight into remeasure() and overrides the sheet's
281
- // own sweep duration for this one morph (the stylesheet default stays
282
- // the 0.3s family standard for every other morph). The override is
283
- // cleared on the same duration+grace watchdog the sweep itself uses.
273
+ // HkStepFlow swaps dispatch STEPFLOW_SWAP_EVENT (bubbling) with the
274
+ // two-window protocol (2026-09-23 user directive, round 16): the
275
+ // slide window announces `{phase: "swap"}` — the sheet FREEZES its
276
+ // height (observer-driven remeasures gated; a safety timer unfreezes
277
+ // if the settle never arrives, e.g. the flow unmounts mid-slide) —
278
+ // and the morph window that follows announces `{phase: "morph",
279
+ // delta, durationMs}` once the content settled: the freeze lifts and
280
+ // the remeasure short-circuits the settle debounce, overriding the
281
+ // sheet's own sweep duration for this one morph (the stylesheet
282
+ // default stays the 0.3s family standard for every other morph; the
283
+ // override is cleared on the same duration+grace watchdog the sweep
284
+ // itself uses). `{phase: "instant"}` (reduced motion, no-transition
285
+ // runtimes) skips the freeze entirely and just pokes the sheet.
284
286
  let morphDurationTimer: ReturnType<typeof setTimeout> | null = null;
285
287
  const clearMorphDuration = (): void => {
286
288
  if (morphDurationTimer !== null) {
@@ -289,17 +291,47 @@ export default defineComponent({
289
291
  }
290
292
  contentRef.value?.style.removeProperty("--hk-modal-morph-duration");
291
293
  };
294
+ const clearSwapFreeze = (flush = false): void => {
295
+ const timed = swapFreezeTimer !== null;
296
+ swapFrozen = false;
297
+ if (swapFreezeTimer !== null) {
298
+ clearTimeout(swapFreezeTimer);
299
+ swapFreezeTimer = null;
300
+ }
301
+ // The safety unfreeze (no morph event ever arrived — e.g. the flow
302
+ // unmounted mid-slide) must not just drop the flag: the slide-time
303
+ // content change was DROPPED, not queued, so without a flush here
304
+ // the sheet would sit at its old height until the next content
305
+ // change (adversarial round finding).
306
+ if (flush && timed) morph.remeasure();
307
+ };
292
308
  const onStepflowSwap = (event: Event): void => {
293
309
  if (machine.phase.value !== "open") {
294
310
  // A swap announced while the surface is closing/opening: drop any
295
- // override a previous morph left behind instead of letting it ride
296
- // into the next open (it is otherwise only cleared by its own
297
- // ms+350 timer).
311
+ // override or freeze a previous window left behind instead of
312
+ // letting it ride into the next open.
313
+ clearSwapFreeze();
298
314
  clearMorphDuration();
299
315
  return;
300
316
  }
317
+ const detail = (event as CustomEvent<{
318
+ phase?: string;
319
+ durationMs?: number;
320
+ }>).detail;
321
+ if (detail?.phase === "swap") {
322
+ // The slide window: hold the pin no matter what the flow's cell
323
+ // does, and arm the safety unfreeze (the morph window's own
324
+ // event clears it earlier on the happy path).
325
+ swapFrozen = true;
326
+ if (swapFreezeTimer !== null) clearTimeout(swapFreezeTimer);
327
+ const ms = typeof detail.durationMs === "number" ? detail.durationMs : 300;
328
+ swapFreezeTimer = setTimeout(() => clearSwapFreeze(true), ms + 650);
329
+ return;
330
+ }
331
+ // Morph / instant: the content settled — unfreeze and measure now.
332
+ clearSwapFreeze();
301
333
  const frame = contentRef.value;
302
- const ms = (event as CustomEvent<{ durationMs?: number }>).detail?.durationMs;
334
+ const ms = detail?.durationMs;
303
335
  if (frame && typeof ms === "number" && ms > 0) {
304
336
  frame.style.setProperty("--hk-modal-morph-duration", `${ms}ms`);
305
337
  morph.remeasure();
@@ -311,6 +343,7 @@ export default defineComponent({
311
343
  };
312
344
  onBeforeUnmount(() => {
313
345
  bodyRef.value?.removeEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
346
+ clearSwapFreeze();
314
347
  clearMorphDuration();
315
348
  });
316
349