@celestia-island/hikari 0.55.0 → 0.55.1

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.0",
3
+ "version": "0.55.1",
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",
@@ -19,6 +19,7 @@ import { createBackGuard } from "../runtime/backStack";
19
19
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
20
20
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
21
21
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
22
+ import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
22
23
  import HIconButton from "./HkIconButton";
23
24
  import HIcon from "./HkIcon";
24
25
  import "./window-close.scss";
@@ -269,6 +270,8 @@ export default defineComponent({
269
270
  cleanup();
270
271
  });
271
272
 
273
+ const contentHold = useSurfaceContentHold(machine.phase);
274
+
272
275
  return () => {
273
276
  if (!machine.mounted.value) return null;
274
277
  const panelPrefix = `hk-drawer-${props.side}`;
@@ -295,7 +298,11 @@ export default defineComponent({
295
298
  else if (e.key === "Tab" && panelRef.value) trapFocus(panelRef.value, e);
296
299
  }}
297
300
  >
298
- {props.title || slots.header ? (
301
+ {/* Same close-fold content hold as HkModal: the sheet's
302
+ slide-away plays over the last live content even when
303
+ the consumer tears its state down on close. */}
304
+ {contentHold.hold(() => [
305
+ props.title || slots.header ? (
299
306
  <div class="hk-drawer-header">
300
307
  {slots.header ? (
301
308
  slots.header()
@@ -314,13 +321,14 @@ export default defineComponent({
314
321
  </HIconButton>
315
322
  ) : null}
316
323
  </div>
317
- ) : null}
324
+ ) : null,
318
325
  <div ref={bodyWrapRef} class="hk-drawer-body-wrap">
319
326
  <div ref={bodyRef} class="hk-drawer-body">{slots.default?.()}</div>
320
- </div>
321
- {slots.footer ? (
327
+ </div>,
328
+ slots.footer ? (
322
329
  <div class="hk-drawer-footer">{slots.footer()}</div>
323
- ) : null}
330
+ ) : null,
331
+ ])}
324
332
  </div>
325
333
  </Teleport>
326
334
  );
@@ -0,0 +1,156 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkModal from "./HkModal";
5
+
6
+ /**
7
+ * Source contract for the close-fold content hold (chest field report
8
+ * 2026-09-16): consumers routinely tear down the state that feeds the
9
+ * window's body in the same tick as the close (TodoLogModal's
10
+ * clear-on-close watcher emptied a 697px conversation window to a 200px
11
+ * stub BEFORE the fold started). While the machine is in a closing phase
12
+ * HkModal serves the last live-rendered children, so the fold always
13
+ * plays over the content the user was looking at.
14
+ *
15
+ * happy-dom has no transition engine, so the machine's duration probe
16
+ * reads a stubbed 0.3s (same harness trick as the surface-machine tests)
17
+ * and rAF is frozen — the flip timers drive the phase edges
18
+ * deterministically.
19
+ */
20
+ const mounts: ReturnType<typeof createApp>[] = [];
21
+ const containers: HTMLElement[] = [];
22
+
23
+ afterEach(async () => {
24
+ for (const app of mounts.splice(0)) app.unmount();
25
+ for (const el of containers.splice(0)) el.remove();
26
+ vi.unstubAllGlobals();
27
+ vi.useRealTimers();
28
+ });
29
+
30
+ function freezeRaf(): void {
31
+ vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
32
+ vi.stubGlobal("cancelAnimationFrame", () => {});
33
+ }
34
+
35
+ function stubDurations(): void {
36
+ const realGCS = window.getComputedStyle.bind(window);
37
+ vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
38
+ const style = realGCS(el as Element, ...(rest as []));
39
+ return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
40
+ });
41
+ }
42
+
43
+ interface Rig {
44
+ open: { value: boolean };
45
+ setLines: (lines: string[]) => void;
46
+ closeWithTeardown: () => void;
47
+ bodyText: () => string;
48
+ frameEl: () => HTMLElement | null;
49
+ }
50
+
51
+ /** Mount HkModal behind a wrapper that models the chest clear-on-close
52
+ * pattern: the body state clears in the SAME handler that flips the
53
+ * v-model false. */
54
+ async function mountRig(initialLines: string[]): Promise<Rig> {
55
+ const container = document.createElement("div");
56
+ document.body.appendChild(container);
57
+ containers.push(container);
58
+
59
+ const open = ref(false);
60
+ const lines = ref<string[]>(initialLines);
61
+
62
+ const Wrapper = defineComponent({
63
+ setup() {
64
+ return () =>
65
+ h(HkModal, {
66
+ modelValue: open.value,
67
+ closable: true,
68
+ title: "hold-test",
69
+ "onUpdate:modelValue": (v: boolean) => {
70
+ open.value = v;
71
+ // The reported chest pattern: destroy the content with the
72
+ // close request, before any leave animation runs.
73
+ if (!v) lines.value = [];
74
+ },
75
+ }, {
76
+ default: () => h("div", lines.value.map((l, i) => h("p", { key: i }, l))),
77
+ });
78
+ },
79
+ });
80
+ const app = createApp(Wrapper);
81
+ mounts.push(app);
82
+ app.mount(container);
83
+ await nextTick();
84
+
85
+ return {
86
+ open,
87
+ setLines: (next: string[]) => { lines.value = next; },
88
+ /** Faithful chest pattern: the store-watcher teardown runs in the SAME
89
+ * tick as the v-model flip, regardless of who flipped it — clearing
90
+ * only inside onUpdate:modelValue would miss programmatic closes and
91
+ * the test would pass vacuously (R1 finding: the hold was never
92
+ * exercised). */
93
+ closeWithTeardown: () => {
94
+ open.value = false;
95
+ lines.value = [];
96
+ },
97
+ bodyText: () => document.querySelector(".hk-modal-body-inner")?.textContent ?? "",
98
+ frameEl: () => document.querySelector<HTMLElement>(".hk-modal-content"),
99
+ };
100
+ }
101
+
102
+ describe("HkModal close-fold content hold", () => {
103
+ it("folds over the last live content even when the consumer clears state on close", async () => {
104
+ vi.useFakeTimers();
105
+ freezeRaf();
106
+ stubDurations();
107
+ const rig = await mountRig(["alpha", "beta"]);
108
+
109
+ // Open the surface to rest (flip timer 120ms + duration 300 + slack).
110
+ rig.open.value = true;
111
+ await vi.advanceTimersByTimeAsync(600);
112
+ expect(rig.bodyText()).toBe("alphabeta");
113
+
114
+ // Close with the SAME-TICK teardown — this is the exact moment the
115
+ // hold must earn its keep (without it the body blanks immediately).
116
+ rig.closeWithTeardown();
117
+ await vi.advanceTimersByTimeAsync(150); // mid-leave (past the flip)
118
+ expect(rig.frameEl()).not.toBeNull();
119
+ // THE contract: the fold plays over the ORIGINAL content — the
120
+ // same-tick teardown must not blank the window mid-fold.
121
+ expect(rig.bodyText()).toBe("alphabeta");
122
+
123
+ // The leave settles and the DOM goes away with it.
124
+ await vi.advanceTimersByTimeAsync(600);
125
+ expect(rig.frameEl()).toBeNull();
126
+ expect(rig.bodyText()).toBe("");
127
+ });
128
+
129
+ it("a reopen interrupt drops the hold and serves live content again", async () => {
130
+ vi.useFakeTimers();
131
+ freezeRaf();
132
+ stubDurations();
133
+ const rig = await mountRig(["alpha", "beta"]);
134
+
135
+ rig.open.value = true;
136
+ await vi.advanceTimersByTimeAsync(600);
137
+ const frameBeforeClose = rig.frameEl();
138
+
139
+ // Close with the same-tick teardown, then interrupt mid-leave.
140
+ rig.closeWithTeardown();
141
+ await vi.advanceTimersByTimeAsync(150);
142
+ expect(rig.bodyText()).toBe("alphabeta");
143
+
144
+ // Reopen with FRESH content — the hold releases with the phase.
145
+ rig.open.value = true;
146
+ rig.setLines(["gamma"]);
147
+ await vi.advanceTimersByTimeAsync(30);
148
+ expect(rig.bodyText()).toBe("gamma");
149
+
150
+ // …and the same element survives the reversal (no remount flash).
151
+ expect(rig.frameEl()).toBe(frameBeforeClose);
152
+ await vi.advanceTimersByTimeAsync(800);
153
+ expect(rig.bodyText()).toBe("gamma");
154
+ expect(rig.frameEl()).not.toBeNull();
155
+ });
156
+ });
@@ -0,0 +1,169 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkModal from "./HkModal";
5
+
6
+ /**
7
+ * Source contract for the leave-window height pin (chest field report
8
+ * 2026-09-16): the close fold owns the frame's geometry for the whole
9
+ * leave, so HkModal must KEEP the size morph's pin through closingFrom →
10
+ * closingTo (morph.hold()) and only release it on the finalize edge. The
11
+ * composable-level semantics are pinned by useSizeMorph.test.ts; this file
12
+ * pins the HkModal INTEGRATION — the wiring bug class (calling stop()
13
+ * instead of hold()) leaves the composable tests green while every real
14
+ * modal folds over a resizing frame.
15
+ *
16
+ * happy-dom has no layout engine, so the frame's offsetHeight is stubbed
17
+ * at the HTMLElement prototype level BEFORE mount — the morph's
18
+ * openingFrom-tick arm then measures 500px and pins it, and every
19
+ * mid-leave assertion reads the inline height string directly.
20
+ */
21
+ const mounts: ReturnType<typeof createApp>[] = [];
22
+ const containers: HTMLElement[] = [];
23
+
24
+ afterEach(async () => {
25
+ for (const app of mounts.splice(0)) app.unmount();
26
+ for (const el of containers.splice(0)) el.remove();
27
+ vi.unstubAllGlobals();
28
+ vi.useRealTimers();
29
+ });
30
+
31
+ function freezeRaf(): void {
32
+ vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
33
+ vi.stubGlobal("cancelAnimationFrame", () => {});
34
+ }
35
+
36
+ function stubDurations(): void {
37
+ const realGCS = window.getComputedStyle.bind(window);
38
+ vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
39
+ const style = realGCS(el as Element, ...(rest as []));
40
+ return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
41
+ });
42
+ }
43
+
44
+ /** Prototype-level layout stub: every element measures `layoutH` tall.
45
+ * Mutable so a test can simulate content growth mid-enter. */
46
+ let layoutH = 500;
47
+ function stubLayout(initial: number): void {
48
+ layoutH = initial;
49
+ Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
50
+ configurable: true,
51
+ get: () => layoutH,
52
+ });
53
+ }
54
+
55
+ function restoreLayout(): void {
56
+ delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight;
57
+ }
58
+
59
+ interface Rig {
60
+ open: { value: boolean };
61
+ pinHeight: () => string;
62
+ frameEl: () => HTMLElement | null;
63
+ clickClose: () => void;
64
+ }
65
+
66
+ async function mountRig(): Promise<Rig> {
67
+ const container = document.createElement("div");
68
+ document.body.appendChild(container);
69
+ containers.push(container);
70
+
71
+ const open = ref(false);
72
+
73
+ const Wrapper = defineComponent({
74
+ setup() {
75
+ return () =>
76
+ h(HkModal, {
77
+ modelValue: open.value,
78
+ closable: true,
79
+ title: "pin-test",
80
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
81
+ }, { default: () => h("div", "content") });
82
+ },
83
+ });
84
+ const app = createApp(Wrapper);
85
+ mounts.push(app);
86
+ app.mount(container);
87
+ await nextTick();
88
+
89
+ return {
90
+ open,
91
+ pinHeight: () => document.querySelector<HTMLElement>(".hk-modal-content")?.style.height ?? "",
92
+ frameEl: () => document.querySelector<HTMLElement>(".hk-modal-content"),
93
+ clickClose: () => {
94
+ const btn = document.querySelector<HTMLElement>(".hk-modal-close");
95
+ btn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
96
+ },
97
+ };
98
+ }
99
+
100
+ describe("HkModal leave-window height pin", () => {
101
+ it("keeps the morph pin through the whole leave and releases at finalize", async () => {
102
+ vi.useFakeTimers();
103
+ freezeRaf();
104
+ stubDurations();
105
+ stubLayout(500);
106
+ try {
107
+ const rig = await mountRig();
108
+
109
+ // Open to rest: the enter-edge arm measured 500px and pinned it.
110
+ rig.open.value = true;
111
+ await vi.advanceTimersByTimeAsync(600);
112
+ expect(rig.frameEl()).not.toBeNull();
113
+ expect(rig.pinHeight()).toBe("500px");
114
+
115
+ // Close through the modal's own close button (the real path).
116
+ rig.clickClose();
117
+ await vi.advanceTimersByTimeAsync(150); // mid-leave, past the flip
118
+ expect(rig.frameEl()).not.toBeNull();
119
+ // THE contract: the pin survives the whole closing window — a
120
+ // stop()-instead-of-hold() wiring drops it here and the fold plays
121
+ // over a frame handed back to `height: auto`.
122
+ expect(rig.pinHeight()).toBe("500px");
123
+
124
+ // Still pinned late in the leave.
125
+ await vi.advanceTimersByTimeAsync(250);
126
+ if (rig.frameEl()) {
127
+ expect(rig.pinHeight()).toBe("500px");
128
+ }
129
+
130
+ // Finalize: the surface unmounts (the release write is invisible).
131
+ await vi.advanceTimersByTimeAsync(600);
132
+ expect(rig.frameEl()).toBeNull();
133
+ } finally {
134
+ restoreLayout();
135
+ }
136
+ });
137
+
138
+ it("flushes growth deferred through the enter with an open-edge remeasure", async () => {
139
+ vi.useFakeTimers();
140
+ freezeRaf();
141
+ stubDurations();
142
+ stubLayout(400);
143
+ try {
144
+ const rig = await mountRig();
145
+
146
+ // Open: the enter-edge arm pins the enter-time height (400px).
147
+ rig.open.value = true;
148
+ await vi.advanceTimersByTimeAsync(150); // mid-enter, past the flip
149
+ expect(rig.pinHeight()).toBe("400px");
150
+
151
+ // Content grows mid-enter — with the enter's defer gate the pin
152
+ // must NOT chase it (the choreography's height-relative geometry
153
+ // stays put; in a real browser the RO fires and is deferred too).
154
+ layoutH = 700;
155
+ await vi.advanceTimersByTimeAsync(50);
156
+ expect(rig.pinHeight()).toBe("400px");
157
+
158
+ // The open edge flushes the deferred growth one tick after the
159
+ // transition classes clear — the pin lands at the grown height.
160
+ await vi.advanceTimersByTimeAsync(450);
161
+ expect(rig.pinHeight()).toBe("700px");
162
+
163
+ // Sanity: the surface really rested open before the flush assertion.
164
+ expect(rig.frameEl()).not.toBeNull();
165
+ } finally {
166
+ restoreLayout();
167
+ }
168
+ });
169
+ });
@@ -21,6 +21,7 @@ import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
21
21
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
22
22
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
23
23
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
24
+ import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
24
25
  import { useSizeMorph } from "../composables/useSizeMorph";
25
26
  import HButton from "./HkButton";
26
27
  import HFab from "./HkFab";
@@ -201,8 +202,19 @@ export default defineComponent({
201
202
  * useSizeMorph). */
202
203
  const innerRef = ref<HTMLElement>();
203
204
  // Content-driven size morphing: the frame follows content growth with
204
- // the height transition instead of snapping (see useSizeMorph).
205
- const morph = useSizeMorph(contentRef, innerRef);
205
+ // the height transition instead of snapping (see useSizeMorph). The
206
+ // gate freezes resize-driven re-measurement through the enter unfold:
207
+ // the choreography's geometry is height-relative (translateY 5% +
208
+ // bottom-10% clip of the frame height), so content streaming in
209
+ // mid-enter would otherwise recompute the pixel geometry under the
210
+ // running animation (2026-09-16 chest field report — the frame
211
+ // snapped 459→697px mid-unfold). The open phase edge flushes the
212
+ // deferred growth with an animated remeasure.
213
+ const morph = useSizeMorph(contentRef, innerRef, {
214
+ deferRemeasure: () =>
215
+ machine.phase.value === "openingFrom" ||
216
+ machine.phase.value === "openingTo",
217
+ });
206
218
  let previouslyFocused: HTMLElement | null = null;
207
219
  let unmounted = false;
208
220
 
@@ -347,13 +359,29 @@ export default defineComponent({
347
359
  backGuard.push();
348
360
  }
349
361
  surfTrack.run();
362
+ // Arm the size morph WITH the enter (one tick later, once the
363
+ // mounting patch landed): the pin locks the frame's height for
364
+ // the whole unfold so late-streaming content cannot move the
365
+ // choreography mid-flight (see the morph's gate above). The
366
+ // zero-duration fast paths reach `open` before the tick — the
367
+ // open branch's own start() covers them.
368
+ void nextTick(() => {
369
+ const p = machine.phase.value;
370
+ if (p === "openingFrom" || p === "openingTo") morph.start();
371
+ });
350
372
  } else if (to === "open") {
351
373
  surfTrack.cancel();
352
374
  onAfterEnter();
353
- // Size morphs arm once the open choreography finished —
354
- // pinning during enter would fight the reveal's transform
355
- // transition.
375
+ // Idempotent re-arm for the fast paths (see openingFrom); the
376
+ // morph is usually armed already by the enter-edge tick.
356
377
  morph.start();
378
+ // Flush the growth deferred through the unfold. One tick later
379
+ // the transition classes are off the frame, so the base height
380
+ // transition animates the pin change instead of the enter's
381
+ // transform/clip-only transition list swallowing it as a snap.
382
+ void nextTick(() => {
383
+ if (machine.phase.value === "open") morph.remeasure();
384
+ });
357
385
  } else if (to === "closingFrom") {
358
386
  // Close-request bookkeeping (was the watcher's close arm): the
359
387
  // registries forget the surface at request time; the finalize
@@ -361,8 +389,18 @@ export default defineComponent({
361
389
  surfTrack.run();
362
390
  overlay.close();
363
391
  backGuard.release();
364
- // Release the pinned height so the leave owns the frame.
365
- morph.stop();
392
+ // Keep the height PIN through the leave: the fold owns the
393
+ // frame's geometry and a mid-leave content change must not
394
+ // resize it. The pin releases on the finalize edge below.
395
+ morph.hold();
396
+ // The leave must not chase a live tail — the window folds over
397
+ // the final frame of content, not over a moving stream. A
398
+ // self-updating child inside the held content could still
399
+ // mutate the DOM mid-fold (the observer would re-pin the
400
+ // scroll), so auto-follow tears down with the close request;
401
+ // a reopen interrupt re-arms it at the open edge
402
+ // (onAfterEnter → setupAutoFollow).
403
+ teardownAutoFollow();
366
404
  } else if (
367
405
  to === "closed" &&
368
406
  (from === "closingFrom" || from === "closingTo") &&
@@ -373,6 +411,10 @@ export default defineComponent({
373
411
  event !== "UNMOUNT"
374
412
  ) {
375
413
  surfTrack.cancel();
414
+ // Bookkeeping reset for the NEXT open cycle: zeroes the morph's
415
+ // pin so the next enter starts clean (release() writes on the
416
+ // invisible, about-to-unmount frame — no visual effect).
417
+ morph.stop();
376
418
  onAfterLeaveFinalize();
377
419
  }
378
420
  // to === "closed" via UNMOUNT: teardown is owned by
@@ -743,6 +785,8 @@ export default defineComponent({
743
785
  return null;
744
786
  }
745
787
 
788
+ const contentHold = useSurfaceContentHold(machine.phase);
789
+
746
790
  return () => {
747
791
  if (!machine.mounted.value) return null;
748
792
 
@@ -773,7 +817,12 @@ export default defineComponent({
773
817
  onClick={onContentClick}
774
818
  tabindex={-1}
775
819
  >
776
- {headerShown && (
820
+ {/* Content hold: while the machine is in a closing
821
+ phase this serves the last live-rendered children,
822
+ so a consumer tearing down its state on close
823
+ cannot blank the window mid-fold. */}
824
+ {contentHold.hold(() => [
825
+ headerShown ? (
777
826
  <>
778
827
  <div
779
828
  class={[
@@ -807,7 +856,7 @@ export default defineComponent({
807
856
  <div class="hk-modal-subheader">{slots.header()}</div>
808
857
  )}
809
858
  </>
810
- )}
859
+ ) : null,
811
860
  <div ref={bodyRef} class="hk-modal-body">
812
861
  <div
813
862
  ref={scrollContainerRef}
@@ -851,8 +900,9 @@ export default defineComponent({
851
900
  )}
852
901
  </>
853
902
  )}
854
- </div>
855
- {renderFooter()}
903
+ </div>,
904
+ renderFooter(),
905
+ ])}
856
906
  </div>
857
907
  </div>
858
908
  </Teleport>
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, h, nextTick, ref } from "vue";
3
3
 
4
- import { useSizeMorph } from "./useSizeMorph";
4
+ import { useSizeMorph, type SizeMorphOptions } from "./useSizeMorph";
5
5
 
6
6
  /** Injectable ResizeObserver: captures the callback so tests can fire
7
7
  * content changes deterministically (happy-dom's own RO never fires —
@@ -35,10 +35,11 @@ interface Harness {
35
35
  setContentNatural(height: number): void;
36
36
  start(): void;
37
37
  stop(): void;
38
+ hold(): void;
38
39
  remeasure(): void;
39
40
  }
40
41
 
41
- function mountHarness(initialHeight: number, initialContentHeight = 0): Harness {
42
+ function mountHarness(initialHeight: number, initialContentHeight = 0, options?: SizeMorphOptions): Harness {
42
43
  const container = document.createElement("div");
43
44
  document.body.appendChild(container);
44
45
  let frameEl: HTMLElement | null = null;
@@ -50,7 +51,7 @@ function mountHarness(initialHeight: number, initialContentHeight = 0): Harness
50
51
  setup() {
51
52
  const frame = ref<HTMLElement | null>(null);
52
53
  const content = ref<HTMLElement | null>(null);
53
- morph = useSizeMorph(frame, content);
54
+ morph = useSizeMorph(frame, content, options);
54
55
  return () =>
55
56
  h("div", [
56
57
  h("div", {
@@ -96,6 +97,7 @@ function mountHarness(initialHeight: number, initialContentHeight = 0): Harness
96
97
  },
97
98
  start: () => morph!.start(),
98
99
  stop: () => morph!.stop(),
100
+ hold: () => morph!.hold(),
99
101
  remeasure: () => morph!.remeasure(),
100
102
  };
101
103
  }
@@ -427,3 +429,73 @@ describe("useSizeMorph clip reveal", () => {
427
429
  expect(h.frame.style.clipPath).toBe("");
428
430
  });
429
431
  });
432
+
433
+ // ── Leave-window hold + enter-window defer (2026-09-16 modal report) ──
434
+ // The modal's close fold owns the frame's geometry for the whole leave:
435
+ // hold() keeps the pin (a mid-leave content change must not resize the
436
+ // folding frame) and the reopen-interrupt path animates FROM that pin.
437
+ // The enter unfold is height-relative geometry (translateY 5% + bottom
438
+ // 10% clip of the frame height), so deferRemeasure freezes resize-driven
439
+ // re-pins through the enter and the open edge flushes them.
440
+
441
+ describe("useSizeMorph hold + deferRemeasure", () => {
442
+ it("hold keeps the pin and stops observing (leave-window stability)", () => {
443
+ const h = mountHarness(120, 100);
444
+ h.start();
445
+ expect(h.frame.style.height).toBe("120px");
446
+
447
+ h.hold();
448
+ // The pin STAYS (unlike stop's release to auto)…
449
+ expect(h.frame.style.height).toBe("120px");
450
+ // …and the observer is disarmed: no content change can re-pin.
451
+ expect(FakeResizeObserver.instances[0]!.disconnected).toBe(true);
452
+ });
453
+
454
+ it("stop after a hold still releases (full-close bookkeeping)", () => {
455
+ const h = mountHarness(120, 100);
456
+ h.start();
457
+ h.hold();
458
+ h.stop();
459
+ expect(h.frame.style.height).toBe("");
460
+ });
461
+
462
+ it("start after a hold re-arms and animates from the held pin", () => {
463
+ const h = mountHarness(120, 100);
464
+ h.start();
465
+ h.hold();
466
+ // Reopen interrupt: the natural height changed while held — the next
467
+ // arm re-pins to it (from the held 120px, per the dance's re-pin).
468
+ h.setNatural(200);
469
+ h.start();
470
+ expect(h.frame.style.height).toBe("200px");
471
+ // A FRESH observer owns the new cycle.
472
+ expect(FakeResizeObserver.instances.length).toBe(2);
473
+ expect(FakeResizeObserver.instances[1]!.disconnected).toBe(false);
474
+ });
475
+
476
+ it("deferRemeasure freezes RO-driven re-pins; explicit remeasure flushes", async () => {
477
+ let gated = true;
478
+ const h = mountHarness(120, 100, { deferRemeasure: () => gated });
479
+ h.start();
480
+ // The initial pin is NOT gated (arming must pin immediately).
481
+ expect(h.frame.style.height).toBe("120px");
482
+
483
+ // Content streams in mid-enter: the RO fires but the pin must not
484
+ // move — the unfold's height-relative geometry stays put.
485
+ h.setNatural(160);
486
+ FakeResizeObserver.instances[0]!.callback();
487
+ await settle();
488
+ expect(h.frame.style.height).toBe("120px");
489
+
490
+ // The open edge flushes the deferred growth.
491
+ h.remeasure();
492
+ expect(h.frame.style.height).toBe("160px");
493
+
494
+ // Gate off: RO-driven updates flow again.
495
+ gated = false;
496
+ h.setNatural(200);
497
+ FakeResizeObserver.instances[0]!.callback();
498
+ await settle();
499
+ expect(h.frame.style.height).toBe("200px");
500
+ });
501
+ });
@@ -17,16 +17,41 @@ export interface SizeMorph {
17
17
  /** Arm the morph: observe the content and pin the frame's natural
18
18
  * height on every change. Call once the surface finished its open
19
19
  * enter transition — pinning during enter would override the
20
- * choreography's own height animation. */
20
+ * choreography's own height animation. (Surfaces whose enter is
21
+ * height-INDEPENDENT — HkModal's clip+transform unfold — may arm
22
+ * earlier with a `deferRemeasure` gate, see below.) */
21
23
  start(): void;
22
24
  /** Disarm the morph and release the frame to `height: auto` — call
23
25
  * before a surface's leave/close so the exit animation owns the
24
26
  * height again. */
25
27
  stop(): void;
28
+ /** Leave-window hold: disarm the observer/timers and cancel any
29
+ * mid-flight reveal like stop(), but KEEP the height pin — the close
30
+ * choreography owns the frame's geometry through the whole leave and
31
+ * the pin keeps it stable (a mid-leave content change must not resize
32
+ * the folding frame). The next start() re-arms and, because the pin
33
+ * was kept, a re-pin to the SAME natural height is a no-op (the
34
+ * typical reopen-interrupt case — held content is unchanged, delta
35
+ * zero; a re-pin to a CHANGED natural while an enter's transition
36
+ * classes own the frame lands without a height animation, i.e. snaps
37
+ * — acceptable, the enter's own choreography owns that moment). */
38
+ hold(): void;
26
39
  /** Re-measure and pin now (resize events, open flows). */
27
40
  remeasure(): void;
28
41
  }
29
42
 
43
+ export interface SizeMorphOptions {
44
+ /** While this returns true, resize-driven re-measurements are deferred
45
+ * (the current pin stays). HkModal freezes the frame's height during
46
+ * the enter unfold: the choreography's translateY(5%) + bottom-10%
47
+ * clip are fractions of the frame height, so late-streaming content
48
+ * resizing the frame mid-enter would recompute the geometry under the
49
+ * running animation (2026-09-16 chest field report — the frame snapped
50
+ * 459→697px mid-unfold). The caller flushes the deferred growth with
51
+ * an explicit remeasure() at the open edge. */
52
+ deferRemeasure?: () => boolean;
53
+ }
54
+
30
55
  /**
31
56
  * useSizeMorph — smooth size morphing for content-hugging surfaces.
32
57
  *
@@ -71,6 +96,7 @@ export interface SizeMorph {
71
96
  export function useSizeMorph(
72
97
  frame: Ref<HTMLElement | null | undefined>,
73
98
  content: Ref<HTMLElement | null | undefined>,
99
+ options: SizeMorphOptions = {},
74
100
  ): SizeMorph {
75
101
  let ro: ResizeObserver | null = null;
76
102
  let raf = 0;
@@ -155,7 +181,13 @@ export function useSizeMorph(
155
181
  * finished) frame — the only moment guaranteed free of transition-class
156
182
  * flex rules. Never calibrate from a remeasure sample: the first
157
183
  * remeasure can itself be the contaminated one (a frozen enter leaves
158
- * the flex rules behind when the surface is mid-repair). */
184
+ * the flex rules behind when the surface is mid-repair). HkModal's
185
+ * reopen-interrupt arm violates the precondition on purpose (it
186
+ * calibrates a pinned, enter-classed frame): benign today because no
187
+ * current enter/leave class carries height/flex rules (the inline pin
188
+ * beats the mobile sheet's static height:auto), and the contamination
189
+ * guard below degrades any future SCSS regression to a dropped pin
190
+ * rather than a corrupted one. */
159
191
  function calibrate(): void {
160
192
  const f = frame.value;
161
193
  const c = content.value;
@@ -271,6 +303,10 @@ export function useSizeMorph(
271
303
  }
272
304
 
273
305
  function onResize(): void {
306
+ // Frozen window (e.g. HkModal's enter unfold): keep the current pin;
307
+ // the caller flushes the accumulated change with an explicit
308
+ // remeasure() once the choreography hands the height back.
309
+ if (options.deferRemeasure?.()) return;
274
310
  // Debounce the choreography: content can change in a burst (a list
275
311
  // transition shrinking rows over several frames, a textarea growing
276
312
  // per keystroke). Dancing to every intermediate would restart the
@@ -304,7 +340,7 @@ export function useSizeMorph(
304
340
  remeasure();
305
341
  }
306
342
 
307
- function stop(): void {
343
+ function hold(): void {
308
344
  if (!armed) return;
309
345
  armed = false;
310
346
  ro?.disconnect();
@@ -320,6 +356,17 @@ export function useSizeMorph(
320
356
  // The next start() re-calibrates against whatever chrome that open
321
357
  // cycle carries.
322
358
  chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
359
+ stopReveal();
360
+ // Deliberately no release(): the pin stays on the frame so the close
361
+ // fold plays on a stable box, and a reopen interrupt animates from it.
362
+ }
363
+
364
+ function stop(): void {
365
+ // Disarm only when armed — after hold() the morph is already
366
+ // disarmed and only the release is owed. release() is internally
367
+ // guarded (no pin / no frame → no-op), so a never-armed stop()
368
+ // stays the no-op it always was.
369
+ if (armed) hold();
323
370
  release();
324
371
  }
325
372
 
@@ -330,5 +377,5 @@ export function useSizeMorph(
330
377
  stopReveal();
331
378
  });
332
379
 
333
- return { start, stop, remeasure };
380
+ return { start, stop, hold, remeasure };
334
381
  }
@@ -0,0 +1,58 @@
1
+ import { computed, watch, type ComputedRef } from "vue";
2
+
3
+ import type { SurfacePhase } from "../runtime/surfaceMachine";
4
+
5
+ /**
6
+ * useSurfaceContentHold — keep a surface's last live content on screen
7
+ * for the whole close fold.
8
+ *
9
+ * Consumers routinely tear down the very state that feeds a window's body
10
+ * the moment its v-model flips false (clear-on-close watchers, store
11
+ * resets, socket disconnects). Without a hold, the leave transition then
12
+ * plays on an emptied shell: the frame collapses to its chrome BEFORE the
13
+ * fold starts (chest TodoLogModal, 2026-09-16 field report — the 697px
14
+ * conversation window became a 200px stub in the same tick as the close).
15
+ *
16
+ * While the machine is in a closing phase this serves the most recent
17
+ * live-rendered children instead of re-invoking the slots, so the fold
18
+ * always plays over the content the user was actually looking at. Vue's
19
+ * patch skips identical vnode references, so the held DOM is never
20
+ * re-patched during the close; child components with their own store
21
+ * subscriptions may still self-update (harmless — the failure mode under
22
+ * repair is content VANISHING, not content ticking).
23
+ *
24
+ * The hold releases the moment the surface leaves a closing phase: a
25
+ * reopen interrupt (closing → openingFrom) serves live slots again, and
26
+ * `closed` drops the cache so a closed surface retains nothing.
27
+ */
28
+ export function useSurfaceContentHold(phase: ComputedRef<SurfacePhase>) {
29
+ const closing = computed(
30
+ () => phase.value === "closingFrom" || phase.value === "closingTo",
31
+ );
32
+
33
+ let held: unknown = null;
34
+
35
+ // A closed surface renders nothing — the cache would only pin a dead
36
+ // vnode tree. Drop it on the closed edge.
37
+ watch(phase, (p) => {
38
+ if (p === "closed") held = null;
39
+ });
40
+
41
+ /**
42
+ * Wrap one render-region producer: live while opening/open, frozen
43
+ * while closing. The first closing render falls through to live
44
+ * production when no snapshot exists (the surface opened directly into
45
+ * a close is not a real path, but the guard keeps the contract total).
46
+ */
47
+ function hold<T>(produce: () => T): T {
48
+ if (closing.value) {
49
+ if (held !== null) return held as T;
50
+ return produce();
51
+ }
52
+ const next = produce();
53
+ held = next;
54
+ return next;
55
+ }
56
+
57
+ return { hold, closing };
58
+ }
@@ -240,6 +240,56 @@ describe("useSurfaceMachine driver", () => {
240
240
  expect(rig.edges.length).toBe(edgesAtRest);
241
241
  });
242
242
 
243
+ it("transitionstart re-arms the deadline once so a late-latched transition completes", async () => {
244
+ vi.useFakeTimers();
245
+ vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
246
+ vi.stubGlobal("cancelAnimationFrame", () => {});
247
+ const realGCS = window.getComputedStyle.bind(window);
248
+ vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
249
+ const style = realGCS(el as Element, ...(rest as []));
250
+ return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
251
+ });
252
+ const rig = mountRig(true);
253
+ rig.send("OPEN");
254
+ await nextTick();
255
+ await vi.advanceTimersByTimeAsync(130); // flip timer forced the FLIP
256
+ expect(rig.phase()).toBe("openingTo");
257
+ // Flip-relative deadline: 300 + 80 → settles at t≈500 unless the
258
+ // browser reports the transition ACTUALLY started late (frame
259
+ // starvation latches the transition on the first post-flip frame).
260
+ const panel = document.querySelector<HTMLElement>(".panel")!;
261
+
262
+ // t=330: the transition latches 200ms after the flip.
263
+ await vi.advanceTimersByTimeAsync(200);
264
+ panel.dispatchEvent(new Event("transitionstart", { bubbles: true }));
265
+ // → deadline re-armed to 330 + 300 + 80 = 710.
266
+
267
+ // t=390: a bubbled report from a NON-layer descendant (target filter)
268
+ // and a duplicate report on the panel (one re-arm per phase) are
269
+ // both ignored — either would push the deadline to 770.
270
+ await vi.advanceTimersByTimeAsync(60);
271
+ const child = document.createElement("span");
272
+ panel.appendChild(child);
273
+ child.dispatchEvent(new Event("transitionstart", { bubbles: true }));
274
+ panel.dispatchEvent(new Event("transitionstart", { bubbles: true }));
275
+
276
+ // The flip-relative deadline (t=500) passes without settling — the
277
+ // re-arm is real…
278
+ await vi.advanceTimersByTimeAsync(300); // t=690
279
+ expect(rig.phase()).toBe("openingTo");
280
+ // …and the latch-relative deadline completes the open (t=710), ahead
281
+ // of where a second re-arm would have put it (t=770).
282
+ await vi.advanceTimersByTimeAsync(30); // t=720
283
+ expect(rig.phase()).toBe("open");
284
+ await nextTick();
285
+ expect(scrimClasses(rig)).toEqual([]);
286
+ // No late timers fire afterwards.
287
+ const edgesAtRest = rig.edges.length;
288
+ await vi.advanceTimersByTimeAsync(5000);
289
+ expect(rig.phase()).toBe("open");
290
+ expect(rig.edges.length).toBe(edgesAtRest);
291
+ });
292
+
243
293
  it("UNMOUNT from any phase clears every clock and walks to closed", async () => {
244
294
  vi.useFakeTimers();
245
295
  const rig = mountRig();
@@ -58,7 +58,10 @@ export interface SurfaceMachine {
58
58
  * - The ONLY correctness clock is setTimeout: every animation phase arms
59
59
  * exactly one deadline timer; the frame flip is double-rAF (smooth)
60
60
  * with a flip timer as the starved fallback. Duplicate FLIPs are
61
- * absorbed by the table (idempotent self-loops).
61
+ * absorbed by the table (idempotent self-loops). transitionend (TEND)
62
+ * settles a phase early; transitionstart re-arms the deadline once so
63
+ * a frame-starved, late-latched transition still completes — both are
64
+ * advisory optimizations, never the correctness clock.
62
65
  * - Phase changes clear every pending timer/rAF before arming the next
63
66
  * phase's — at most one flip + one deadline exist at any moment, so
64
67
  * timers cannot leak or fire stale (the machine has no "late
@@ -257,6 +260,7 @@ export function useSurfaceMachine(options: SurfaceMachineOptions): SurfaceMachin
257
260
  if (measured > 0) {
258
261
  rearmDeadline(measured + slack);
259
262
  armTend(measured);
263
+ armStart(measured);
260
264
  } else {
261
265
  completeNow();
262
266
  }
@@ -264,6 +268,36 @@ export function useSurfaceMachine(options: SurfaceMachineOptions): SurfaceMachin
264
268
  }
265
269
  }
266
270
 
271
+ /** transitionstart re-arm: under frame starvation the CSS transition
272
+ * only LATCHES when the first frame after the flip is produced, while
273
+ * this phase's deadline was armed at the flip — a transition starting
274
+ * 300ms late would be cut mid-flight by its own deadline, snapping the
275
+ * surface to rest (2026-09-16 chest field report: the desktop modal
276
+ * enter freezes at its low/transparent from-pair, then jumps). When
277
+ * the browser reports the transition actually started, re-arm the
278
+ * deadline from that moment so a late-started transition still runs
279
+ * to completion. A never-started transition (frames never come) fires
280
+ * nothing — the flip-relative deadline stays the bound (A2), so this
281
+ * changes settle latency only when the animation genuinely runs.
282
+ * One re-arm per layer per phase: every animated property fires its
283
+ * own transitionstart on the same frame. */
284
+ function armStart(expectedMs: number): void {
285
+ const seen = new Set<HTMLElement>();
286
+ for (const layer of options.layers) {
287
+ const el = layer.el?.();
288
+ if (!el || !el.isConnected || seen.has(el)) continue;
289
+ seen.add(el);
290
+ let rearmed = false;
291
+ const onStart = (e: TransitionEvent) => {
292
+ if (e.target !== el || rearmed) return;
293
+ rearmed = true;
294
+ rearmDeadline(expectedMs + slack);
295
+ };
296
+ el.addEventListener("transitionstart", onStart);
297
+ tendCleanups.push(() => el.removeEventListener("transitionstart", onStart));
298
+ }
299
+ }
300
+
267
301
  function send(event: SurfaceEventType): void {
268
302
  const from = phase.value;
269
303
  const to = surfaceTransition(from, event);