@celestia-island/hikari 0.55.0 → 0.55.2

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.
@@ -218,16 +218,20 @@ describe("HkModalBreadcrumb label budget", () => {
218
218
  expect(labels()).toEqual(["abcdefghijklmnopqrst", "abcdefghijklmnopqr…"]);
219
219
  });
220
220
 
221
- it("keeps the whole layer name for assistive tech when it cuts", async () => {
221
+ it("keeps the whole layer name on a cut label", async () => {
222
222
  manager.register("modal", true, LONG);
223
223
  manager.register("modal", true, "细节");
224
224
  await mountStrip();
225
225
  const nav = strip()!;
226
- const first = nav.querySelector<HTMLElement>(
227
- ":scope > .hk-modal-breadcrumb-crumb .hk-modal-breadcrumb-item",
226
+ const first = nav.querySelector<HTMLButtonElement>(
227
+ ":scope > .hk-modal-breadcrumb-crumb button.hk-modal-breadcrumb-item",
228
228
  )!;
229
- expect(first.querySelector('[aria-hidden="true"]')!.textContent).toBe(LONG_TAIL);
230
- expect(first.querySelector(".hk-modal-breadcrumb-sr-only")!.textContent).toBe(LONG);
229
+ // Rendered cut, announced whole: a truncated string is not a name.
230
+ expect(first.textContent).toBe(LONG_TAIL);
231
+ expect(first.getAttribute("aria-label")).toBe(LONG);
232
+ expect(first.getAttribute("aria-expanded")).toBe("false");
233
+ // An uncut label stays plain text — no new interaction surface.
234
+ expect(nav.querySelectorAll("button.hk-modal-breadcrumb-item")).toHaveLength(1);
231
235
  // The measurement clone must never join the a11y tree.
232
236
  expect(
233
237
  nav.querySelector(".hk-modal-breadcrumb-measure")!.getAttribute("aria-hidden"),
@@ -252,6 +256,251 @@ describe("HkModalBreadcrumb label budget", () => {
252
256
  }
253
257
  });
254
258
 
259
+ it("reveals a cut label on the same crumb's popover", async () => {
260
+ setViewport(1200);
261
+ manager.register("modal", true, LONG);
262
+ manager.register("modal", true, "细节");
263
+ await mountStrip();
264
+ const cut = () =>
265
+ strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!;
266
+ expect(document.body.querySelector(".hk-popover-panel")).toBeNull();
267
+
268
+ cut().click();
269
+ await nextTick();
270
+ await nextTick();
271
+ const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
272
+ expect(panel).toBeTruthy();
273
+ // Desktop: an anchored popover carrying the WHOLE name, wrapping.
274
+ expect(panel.classList.contains("hk-is-sheet")).toBe(false);
275
+ expect(panel.querySelector(".hk-modal-breadcrumb-reveal")!.textContent).toBe(LONG);
276
+ expect(cut().getAttribute("aria-expanded")).toBe("true");
277
+
278
+ // Tapping the same crumb again puts it away.
279
+ cut().click();
280
+ await nextTick();
281
+ await nextTick();
282
+ expect(cut().getAttribute("aria-expanded")).toBe("false");
283
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
284
+ });
285
+
286
+ it("docks the revealed name as a bottom sheet on mobile", async () => {
287
+ setViewport(360);
288
+ manager.register("modal", true, LONG);
289
+ manager.register("modal", true, "细节");
290
+ await mountStrip();
291
+ strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
292
+ // Settle before asserting: the sheet's own registration re-folds the
293
+ // strip, and the surface must outlive that (see the phone test above).
294
+ await until(() => document.body.querySelector(".hk-modal-breadcrumb-reveal") !== null);
295
+ await until(() => false, 150);
296
+ const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
297
+ expect(panel.classList.contains("hk-is-sheet")).toBe(true);
298
+ expect(panel.querySelector(".hk-modal-breadcrumb-reveal")!.textContent).toBe(LONG);
299
+ // It registers as a window layer like any other blocking sheet, and the
300
+ // name it reveals is the name it carries.
301
+ const blocking = [...manager.registry.value.values()].filter((entry) => entry.blocking);
302
+ expect(blocking.map((entry) => entry.title)).toContain(LONG);
303
+ });
304
+
305
+ it("keeps the revealed name open on a phone, where its own sheet moves the fold", async () => {
306
+ // Opening the reveal docks a blocking sheet, the sheet joins the stack
307
+ // the strip lists, and the tail is re-decided underneath: the tapped
308
+ // crumb can end up behind the trigger in the same frame. The surface
309
+ // belongs to the LAYER, not to the fold — closing it there made the
310
+ // reveal cancel itself on every phone (2026-09-16 review).
311
+ setViewport(360);
312
+ manager.register("modal", true, LONG); // cut, and visible before the tap
313
+ manager.register("modal", true, "第二层");
314
+ manager.register("modal", true, "第三层");
315
+ await mountStrip();
316
+ const crumb = strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!;
317
+ expect(crumb.getAttribute("aria-label")).toBe(LONG);
318
+
319
+ crumb.click();
320
+ await until(() => document.body.querySelector(".hk-modal-breadcrumb-reveal") !== null);
321
+ // Let every deferred consequence land (the sheet's registration, the
322
+ // re-measure, the fold) and assert the surface is STILL there.
323
+ await until(() => false, 200);
324
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")!.textContent).toBe(LONG);
325
+ expect(document.body.querySelector<HTMLElement>(".hk-popover-panel")!.classList.contains("hk-is-sheet")).toBe(true);
326
+ });
327
+
328
+ it("closes the reveal when its layer stops being cut (retitle)", async () => {
329
+ setViewport(1200);
330
+ const layer = manager.register("modal", true, LONG);
331
+ manager.register("modal", true, "细节");
332
+ await mountStrip();
333
+ strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
334
+ await nextTick();
335
+ await nextTick();
336
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeTruthy();
337
+
338
+ // The owner renames the window to something that fits: the crumb is
339
+ // plain text again, so an open panel would be anchored to nothing while
340
+ // showing a name that no longer exists.
341
+ manager.setTitle(layer.id, "短标题");
342
+ await nextTick();
343
+ await nextTick();
344
+ expect(strip()!.querySelectorAll("button.hk-modal-breadcrumb-item")).toHaveLength(0);
345
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
346
+ });
347
+
348
+ it("follows a retitle that is still cut", async () => {
349
+ setViewport(1200);
350
+ const layer = manager.register("modal", true, LONG);
351
+ manager.register("modal", true, "细节");
352
+ await mountStrip();
353
+ strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
354
+ await nextTick();
355
+ await nextTick();
356
+ const longer = "另一个非常长的层级标题占位一二三四五";
357
+ manager.setTitle(layer.id, longer);
358
+ await nextTick();
359
+ await nextTick();
360
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")!.textContent).toBe(longer);
361
+ });
362
+
363
+ it("does not reopen the reveal after the strip comes back", async () => {
364
+ // The strip unmounts when the stack drops to one layer: the reveal goes
365
+ // with it (its surface is rendered inside), and a stale `revealed` would
366
+ // otherwise spring back — anchored to the element that no longer exists
367
+ // — the moment another window opens (review round two).
368
+ setViewport(1200);
369
+ manager.register("modal", true, LONG);
370
+ const other = manager.register("modal", true, "细节");
371
+ await mountStrip();
372
+ strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
373
+ await nextTick();
374
+ await nextTick();
375
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeTruthy();
376
+
377
+ manager.unregister(other.id); // one layer left: the strip goes away
378
+ await nextTick();
379
+ expect(strip()).toBeNull();
380
+ manager.register("modal", true, "新来的层级");
381
+ await nextTick();
382
+ await nextTick();
383
+ expect(strip()).not.toBeNull();
384
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
385
+ });
386
+
387
+ it("closes the anchored reveal when its crumb folds behind the trigger", async () => {
388
+ // A fold that unmounts the anchor would leave the anchored panel
389
+ // measuring a detached 0×0 rect on its next reposition, which parks it
390
+ // in the viewport corner (review round two). The sheet form needs no
391
+ // anchor and keeps the name (see the phone test above).
392
+ setViewport(1200);
393
+ manager.register("modal", true, LONG);
394
+ manager.register("modal", true, "第二个很长的层级标题占位一二三");
395
+ manager.register("modal", true, "第三个很长的层级标题占位一二三");
396
+ manager.register("modal", true, "细节");
397
+ await mountStrip();
398
+ expect(more()).toBeNull();
399
+ strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
400
+ await nextTick();
401
+ await nextTick();
402
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeTruthy();
403
+
404
+ // More windows push the tapped crumb behind the trigger (the fold is
405
+ // re-decided a tick after the labels land, then rendered).
406
+ STACK_TITLES.forEach((title) => manager.register("modal", true, title));
407
+ await until(() => more() !== null, 300);
408
+ expect(more()).not.toBeNull();
409
+ expect(strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")).not.toBeNull();
410
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
411
+ });
412
+
413
+ it("closes an open reveal when the folded-layers menu opens", async () => {
414
+ // On a phone the strip paints above the sheet's scrim, so its trigger
415
+ // stays tappable while a reveal is docked (review round two). The menu
416
+ // must take over from the reveal, not stack on top of it: one Escape,
417
+ // one surface.
418
+ setViewport(360);
419
+ [LONG, LONG, LONG, LONG].forEach((title, i) =>
420
+ manager.register("modal", true, `${i}:${title}`),
421
+ );
422
+ await mountStrip();
423
+ expect(more()).not.toBeNull();
424
+ strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
425
+ await until(() => document.body.querySelector(".hk-modal-breadcrumb-reveal") !== null);
426
+ expect(more()!.getAttribute("aria-expanded")).toBe("false");
427
+
428
+ more()!.click();
429
+ await nextTick();
430
+ await nextTick();
431
+ expect(more()!.getAttribute("aria-expanded")).toBe("true");
432
+ // The reveal's own sheet plays its leave out before the DOM drops it —
433
+ // and with nothing closing it, it never does (this assertion is what
434
+ // pins the symmetric close).
435
+ await until(() => document.body.querySelector(".hk-modal-breadcrumb-reveal") === null, 900);
436
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
437
+ });
438
+
439
+ it("closes whichever surface is open on Escape", async () => {
440
+ setViewport(1200);
441
+ STACK_TITLES.forEach((title) => manager.register("modal", true, title));
442
+ manager.register("modal", true, "细节");
443
+ await mountStrip();
444
+
445
+ // The folded-layers menu (anchored: its panel takes no focus, so the
446
+ // strip owns the key).
447
+ more()!.click();
448
+ await nextTick();
449
+ await nextTick();
450
+ expect(more()!.getAttribute("aria-expanded")).toBe("true");
451
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
452
+ await nextTick();
453
+ expect(more()!.getAttribute("aria-expanded")).toBe("false");
454
+
455
+ // The revealed name.
456
+ strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
457
+ await nextTick();
458
+ await nextTick();
459
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeTruthy();
460
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
461
+ await nextTick();
462
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
463
+ });
464
+
465
+ it("closes the reveal when its layer leaves the stack", async () => {
466
+ setViewport(1200);
467
+ const layer = manager.register("modal", true, LONG);
468
+ manager.register("modal", true, "细节");
469
+ await mountStrip();
470
+ strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
471
+ await nextTick();
472
+ await nextTick();
473
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeTruthy();
474
+
475
+ // The window closes under the open panel: there is no name left to
476
+ // reveal.
477
+ manager.unregister(layer.id);
478
+ await nextTick();
479
+ await nextTick();
480
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
481
+ });
482
+
483
+ it("opens the reveal instead of the hidden-layers menu", async () => {
484
+ // The two surfaces are never open together: tapping a crumb closes the
485
+ // menu it was opened from (its leave plays out, so the logical state is
486
+ // what the assertion reads).
487
+ setViewport(800);
488
+ STACK_TITLES.forEach((title) => manager.register("modal", true, title));
489
+ manager.register("modal", true, "细节");
490
+ await mountStrip();
491
+ more()!.click();
492
+ await nextTick();
493
+ await nextTick();
494
+ expect(menuRows().length).toBeGreaterThan(0);
495
+ expect(more()!.getAttribute("aria-expanded")).toBe("true");
496
+
497
+ strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
498
+ await nextTick();
499
+ await nextTick();
500
+ expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeTruthy();
501
+ expect(more()!.getAttribute("aria-expanded")).toBe("false");
502
+ });
503
+
255
504
  it("honours a narrower budget from the host", async () => {
256
505
  manager.register("modal", true, "自动化测试流水线冒烟");
257
506
  manager.register("modal", true, "细节");
@@ -14,6 +14,7 @@ import { useReportedTransition } from "../composables/useReportedTransition";
14
14
  import { scheduleEvery } from "../runtime/animationBus";
15
15
  import { ancestorZoom } from "../runtime/cssZoom";
16
16
  import { viewportGutterPx } from "../runtime/viewportGutter";
17
+ import { useBreakpoint } from "../runtime/useBreakpoint";
17
18
  import { clampToDisplayWidth, displayWidthUnits, ELLIPSIS } from "../runtime/displayWidth";
18
19
  import HkPopover from "./HkPopover";
19
20
  import HkMenuPanel from "./HkMenuPanel";
@@ -69,6 +70,7 @@ export default defineComponent({
69
70
  setup(props) {
70
71
  const manager = usePopupManager();
71
72
  const { t } = useI18n();
73
+ const { isMobile } = useBreakpoint();
72
74
 
73
75
  /** Which entries the strip navigates. Windows (modal/drawer) always;
74
76
  * dropdown-kind surfaces only while they BLOCK like a window — the
@@ -274,6 +276,12 @@ export default defineComponent({
274
276
 
275
277
  function openMenu(): void {
276
278
  if (!hidden.value.length) return;
279
+ // The two surfaces are never open together — and the symmetric close
280
+ // matters on a phone: the strip paints above the sheet's scrim, so
281
+ // its trigger stays tappable while a reveal is docked (review round
282
+ // two), and a menu opening underneath it would leave the pair
283
+ // fighting over one Escape.
284
+ revealed.value = null;
277
285
  menuItems.value = hidden.value.map(({ id, label }) => ({ id, label }));
278
286
  menuOpen.value = true;
279
287
  }
@@ -294,6 +302,79 @@ export default defineComponent({
294
302
  if (!stillFolded) menuOpen.value = false;
295
303
  });
296
304
 
305
+ // ── Truncated-name reveal ─────────────────────────────────────────
306
+ // A cut label travels whole to assistive tech, and the folded layers
307
+ // get the menu above — but a layer that is VISIBLE and still cut had no
308
+ // way to read it. Tapping such a crumb opens the same popover family
309
+ // (anchored under the crumb on desktop, bottom-up sheet on mobile) with
310
+ // the whole name, because the strip's own bar is not a place long text
311
+ // can be read from.
312
+ const revealed = ref<{ id: string; label: string } | null>(null);
313
+ const revealAnchor = ref<HTMLElement | null>(null);
314
+ const crumbEls = new Map<string, HTMLElement>();
315
+
316
+ function setCrumbEl(id: string, el: Element | null): void {
317
+ if (el) crumbEls.set(id, el as HTMLElement);
318
+ else crumbEls.delete(id);
319
+ }
320
+
321
+ function toggleReveal(crumb: Crumb): void {
322
+ if (revealed.value?.id === crumb.id) {
323
+ revealed.value = null;
324
+ return;
325
+ }
326
+ if (!crumbEls.has(crumb.id)) return;
327
+ menuOpen.value = false;
328
+ revealAnchor.value = crumbEls.get(crumb.id) ?? null;
329
+ revealed.value = { id: crumb.id, label: crumb.label };
330
+ }
331
+
332
+ // What keeps the reveal alive is the LAYER, never the fold: opening it
333
+ // on a phone docks a blocking sheet, which joins the stack the strip
334
+ // lists and re-decides the tail underneath — a fold that carried the
335
+ // tapped crumb behind the trigger used to close the surface in the same
336
+ // frame it opened (2026-09-16 review). It goes away when the layer
337
+ // itself does, and when the layer stops being cut in the first place
338
+ // (a retitle makes its crumb plain text again — an open panel would
339
+ // then be anchored to nothing while showing a name that no longer
340
+ // exists); a retitle that keeps cutting follows the live name.
341
+ watch([crumbs, tail], ([list, visibleTail]) => {
342
+ const open = revealed.value;
343
+ if (!open) return;
344
+ const crumb = list.find((entry) => entry.id === open.id);
345
+ if (!crumb || !crumb.truncated) {
346
+ revealed.value = null;
347
+ return;
348
+ }
349
+ // An ANCHORED panel whose crumb folded behind the trigger would keep
350
+ // a detached element as its anchor, and the next reposition (a
351
+ // resize, a panel resize) would measure a 0×0 rect and fly the panel
352
+ // into the viewport corner (review round two). The mobile sheet
353
+ // needs no anchor and keeps showing the name.
354
+ if (!isMobile.value && !visibleTail.some((entry) => entry.id === open.id)) {
355
+ revealed.value = null;
356
+ return;
357
+ }
358
+ if (crumb.label !== open.label) {
359
+ revealed.value = { id: crumb.id, label: crumb.label };
360
+ }
361
+ });
362
+
363
+ // Escape closes whichever strip surface is open. HkPopover owns Escape
364
+ // for its SHEET form (the panel takes focus there); the anchored form
365
+ // has no focusable panel, so a document listener covers both.
366
+ function onSurfaceKeydown(e: KeyboardEvent) {
367
+ if (e.key !== "Escape") return;
368
+ if (revealed.value) revealed.value = null;
369
+ else if (menuOpen.value) menuOpen.value = false;
370
+ }
371
+ const surfaceOpen = computed(() => revealed.value !== null || menuOpen.value);
372
+ watch(surfaceOpen, (open) => {
373
+ if (open) document.addEventListener("keydown", onSurfaceKeydown);
374
+ else document.removeEventListener("keydown", onSurfaceKeydown);
375
+ });
376
+ onBeforeUnmount(() => document.removeEventListener("keydown", onSurfaceKeydown));
377
+
297
378
  const topPx = ref(24);
298
379
  function resyncTop() {
299
380
  const app = document.getElementById(props.appRootId);
@@ -394,6 +475,7 @@ export default defineComponent({
394
475
  window.removeEventListener("resize", onViewportChange);
395
476
  releaseClone();
396
477
  menuOpen.value = false;
478
+ revealed.value = null;
397
479
  hiddenCount.value = 0;
398
480
  }
399
481
  },
@@ -502,16 +584,23 @@ export default defineComponent({
502
584
  {/* A chevron separates two items — the FIRST rendered item
503
585
  carries none, whether or not the trigger precedes it. */}
504
586
  {(triggerShown.value || i > 0) && separator()}
505
- <span class={itemClass(crumb)}>
506
- {crumb.truncated ? (
507
- <>
508
- <span aria-hidden="true">{crumb.text}</span>
509
- <span class="hk-modal-breadcrumb-sr-only">{crumb.label}</span>
510
- </>
511
- ) : (
512
- crumb.text
513
- )}
514
- </span>
587
+ {crumb.truncated ? (
588
+ // Cut label: tappable, and the full name is the button's
589
+ // accessible name (a cut string is not a name).
590
+ <button
591
+ type="button"
592
+ ref={(el) => setCrumbEl(crumb.id, el as Element | null)}
593
+ class={`${itemClass(crumb)} hk-modal-breadcrumb-item-reveal`}
594
+ aria-haspopup="dialog"
595
+ aria-expanded={revealed.value?.id === crumb.id}
596
+ aria-label={crumb.label}
597
+ onClick={() => toggleReveal(crumb)}
598
+ >
599
+ {crumb.text}
600
+ </button>
601
+ ) : (
602
+ <span class={itemClass(crumb)}>{crumb.text}</span>
603
+ )}
515
604
  </span>
516
605
  ))}
517
606
  </nav>
@@ -542,6 +631,26 @@ export default defineComponent({
542
631
  ))}
543
632
  </HkMenuPanel>
544
633
  </HkPopover>
634
+ <HkPopover
635
+ modelValue={revealed.value !== null}
636
+ onUpdate:modelValue={(v: boolean) => {
637
+ if (!v) revealed.value = null;
638
+ }}
639
+ anchorRef={revealAnchor.value}
640
+ placement="bottom-start"
641
+ // Same clearance as the menu: the crumb sits inside the strip's
642
+ // own padding box, which paints above the anchored band.
643
+ offset={16}
644
+ sheetOnMobile
645
+ // The name is the surface's reason to exist, so it names it.
646
+ // The sheet heading ellipsises (it is chrome) — the panel body
647
+ // below carries the whole name and wraps.
648
+ title={revealed.value?.label ?? ""}
649
+ >
650
+ {revealed.value && (
651
+ <p class="hk-modal-breadcrumb-reveal">{revealed.value.label}</p>
652
+ )}
653
+ </HkPopover>
545
654
  </Teleport>
546
655
  ) : null;
547
656
  },
@@ -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
  }