@celestia-island/hikari 0.55.38 → 0.55.40

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.38",
3
+ "version": "0.55.40",
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",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Source contract for the breadcrumb strip's blur stand-down (2026-09-21
3
+ * chest field report, round 5 — the "content blinks once" residual).
4
+ *
5
+ * The strip floats over every popup band, so its backdrop-filter
6
+ * re-samples the whole blurred band each time the sheet behind it
7
+ * resizes or swaps content; on the phone GPU that re-filter read as a
8
+ * one-shot flash per wizard step. The family pattern (HkModal /
9
+ * HkDrawer / HkPopover) is a host-tunable blur knob that defaults to
10
+ * NONE on ≤767px — the strip was the last surface without it.
11
+ */
12
+ import { describe, expect, it } from "vitest";
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const src = readFileSync(join(here, "HkModalBreadcrumb.scss"), "utf-8");
19
+
20
+ /** Brace-aware `@media <query>` block extractor. */
21
+ function mediaBlocks(source: string, query: string): string[] {
22
+ const blocks: string[] = [];
23
+ let from = 0;
24
+ for (;;) {
25
+ const at = source.indexOf(`@media ${query}`, from);
26
+ if (at < 0) break;
27
+ const open = source.indexOf("{", at);
28
+ let depth = 0;
29
+ let i = open;
30
+ for (; i < source.length; i++) {
31
+ if (source[i] === "{") depth++;
32
+ else if (source[i] === "}") {
33
+ depth--;
34
+ if (depth === 0) break;
35
+ }
36
+ }
37
+ blocks.push(source.slice(at, i + 1));
38
+ from = i + 1;
39
+ }
40
+ return blocks;
41
+ }
42
+
43
+ describe("HkModalBreadcrumb blur contract", () => {
44
+ it("keeps the base blur host-tunable through the family knob", () => {
45
+ const rule = src.match(/\.hk-modal-breadcrumb\s*\{[^}]*\}/)![0]!;
46
+ expect(rule).toContain(
47
+ "backdrop-filter: var(--hk-modal-breadcrumb-blur, blur(var(--blur-md)));",
48
+ );
49
+ // No naked blur declaration left on the strip itself.
50
+ expect(rule).not.toMatch(/backdrop-filter:\s*blur\(/);
51
+ });
52
+
53
+ it("stands the blur down on ≤767px by default", () => {
54
+ const blocks = mediaBlocks(src, "(max-width: 767px)");
55
+ expect(blocks.length).toBe(1);
56
+ expect(blocks[0]).toContain(".hk-modal-breadcrumb");
57
+ expect(blocks[0]).toContain(
58
+ "backdrop-filter: var(--hk-modal-breadcrumb-blur-mobile, none);",
59
+ );
60
+ });
61
+ });
@@ -17,7 +17,13 @@
17
17
  border: 1px solid var(--hk-breadcrumb-border, rgba(0, 0, 0, 0.06));
18
18
  border-radius: var(--radius-md, 0.5rem);
19
19
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
20
- backdrop-filter: blur(var(--blur-md));
20
+ /* Family blur pattern (HkModal/HkDrawer/HkPopover): host-tunable, and
21
+ stood down on phones — the strip floats over every popup, so its
22
+ backdrop-filter re-samples the whole blurred band each time the sheet
23
+ behind it resizes or swaps content. On the phone GPU that re-filter
24
+ read as a one-shot flash on every wizard step change (2026-09-21
25
+ chest report, round 5). */
26
+ backdrop-filter: var(--hk-modal-breadcrumb-blur, blur(var(--blur-md)));
21
27
  pointer-events: none;
22
28
  user-select: none;
23
29
  animation: hk-modal-breadcrumb-in var(--duration-fast, 0.15s) cubic-bezier(0.16, 1, 0.3, 1);
@@ -228,3 +234,13 @@
228
234
  animation: none;
229
235
  }
230
236
  }
237
+
238
+ /* Phone stand-down for the strip's blur (see the base rule): the default
239
+ * is NONE on ≤767px; a host that explicitly wants the finish back can set
240
+ * --hk-modal-breadcrumb-blur-mobile (the same knob family the modal,
241
+ * drawer and popover sheets already expose). */
242
+ @media (max-width: 767px) {
243
+ .hk-modal-breadcrumb {
244
+ backdrop-filter: var(--hk-modal-breadcrumb-blur-mobile, none);
245
+ }
246
+ }
@@ -290,19 +290,26 @@ describe("useSizeMorph clip reveal", () => {
290
290
  h.frame.style.setProperty("--hk-sheet-morph", "clip");
291
291
  h.start();
292
292
  expect(h.frame.style.height).toBe("300px");
293
+ // Resident promotion: clip-mode frames carry will-change from the
294
+ // arm moment, not per sweep (2026-09-21 round-5 report: a per-sweep
295
+ // promotion read as a one-frame see-through).
296
+ expect(h.frame.style.willChange).toBe("clip-path");
293
297
 
294
298
  h.setNatural(360);
295
299
  FakeResizeObserver.instances[0]!.callback();
296
300
  await settle();
297
301
  // The pin landed at the new height with no height animation staged.
298
302
  expect(h.frame.style.height).toBe("360px");
299
- // The sweep runs: end-state clip + layer promotion in flight.
303
+ // The sweep runs: end-state clip (promotion stays resident).
300
304
  expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
301
305
  expect(h.frame.style.willChange).toBe("clip-path");
302
306
  expect(h.frame.style.transition).toBe("");
303
307
 
304
308
  fireTransitionEnd(h.frame, "clip-path");
305
309
  expect(h.frame.style.clipPath).toBe("");
310
+ // Still resident until stop/hold.
311
+ expect(h.frame.style.willChange).toBe("clip-path");
312
+ h.stop();
306
313
  expect(h.frame.style.willChange).toBe("");
307
314
  });
308
315
 
@@ -313,14 +320,12 @@ describe("useSizeMorph clip reveal", () => {
313
320
 
314
321
  h.setNatural(360);
315
322
  h.remeasure();
316
- // Staged synchronously — new pin, start inset, layer promotion —
317
- // but NO sweep yet: the reveal must let the promoted layer raster
318
- // the resized box first (2026-09-21 chest report — a same-task
319
- // sweep outran the raster thread and the revealed band composited
320
- // as black tiles).
323
+ // Staged synchronously — new pin, start inset — but NO sweep yet:
324
+ // the reveal must let the resident layer raster the resized box
325
+ // first (2026-09-21 chest report — a same-task sweep outran the
326
+ // raster thread and the revealed band composited as black tiles).
321
327
  expect(h.frame.style.height).toBe("360px");
322
328
  expect(h.frame.style.clipPath).toBe("inset(60px 0 0 0 round 0px 0px 0px 0px)");
323
- expect(h.frame.style.willChange).toBe("clip-path");
324
329
 
325
330
  // The hold is TWO frames, not one: after the first frame the staged
326
331
  // start inset must still be in place (a one-frame warmup would have
@@ -332,7 +337,7 @@ describe("useSizeMorph clip reveal", () => {
332
337
  expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
333
338
  fireTransitionEnd(h.frame, "clip-path");
334
339
  expect(h.frame.style.clipPath).toBe("");
335
- expect(h.frame.style.willChange).toBe("");
340
+ expect(h.frame.style.willChange).toBe("clip-path");
336
341
  });
337
342
 
338
343
  it("reports the sweep to the animation bus only once it starts", async () => {
@@ -396,29 +401,100 @@ describe("useSizeMorph clip reveal", () => {
396
401
  expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
397
402
  });
398
403
 
399
- it("keeps the height morph for shrink and sub-threshold growth", async () => {
404
+ it("conceals shrink through clip-path and re-pins atomically at the end", async () => {
400
405
  const h = mountHarness(400);
401
406
  h.frame.style.setProperty("--hk-sheet-morph", "clip");
402
407
  h.start();
403
408
 
404
- // Shrink: no clip state, the pin flips under the height transition.
409
+ // Shrink CONCEALS (round-5 report: the snap read as "no animation"):
410
+ // the box keeps the OLD pin while the top edge folds down through
411
+ // the closing inset — the pin swap lands only at the sweep's end.
405
412
  h.setNatural(320);
406
- FakeResizeObserver.instances[0]!.callback();
413
+ h.remeasure();
414
+ // Mid-warmup: the explicit inset(0) start holds (regression
415
+ // contract — never a cleared/none start).
416
+ await busFrames(1);
417
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
418
+ expect(h.frame.style.height).toBe("400px");
407
419
  await settle();
420
+ // Sweep in flight: clip folding toward inset(80px), height still
421
+ // the old pin.
422
+ expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
423
+ expect(h.frame.style.height).toBe("400px");
424
+ // The sweep ends: atomic re-pin — height jumps to the target with
425
+ // the clip cleared in one transition-off task (visually a no-op).
426
+ fireTransitionEnd(h.frame, "clip-path");
408
427
  expect(h.frame.style.height).toBe("320px");
409
428
  expect(h.frame.style.clipPath).toBe("");
410
- expect(h.frame.style.willChange).toBe("");
411
429
 
412
430
  // Sub-threshold growth (2px < REVEAL_MIN_PX): snaps, no reveal.
413
431
  h.setNatural(322);
414
432
  h.remeasure();
415
433
  expect(h.frame.style.height).toBe("322px");
416
434
  expect(h.frame.style.clipPath).toBe("");
435
+
436
+ // Sub-threshold shrink (−2px): snaps too, no conceal.
437
+ h.setNatural(320);
438
+ h.remeasure();
439
+ expect(h.frame.style.height).toBe("320px");
440
+ expect(h.frame.style.clipPath).toBe("");
441
+ });
442
+
443
+ it("lands an interrupted conceal atomically on the next dance", async () => {
444
+ const h = mountHarness(400);
445
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
446
+ h.start();
447
+
448
+ h.setNatural(320);
449
+ h.remeasure();
450
+ await settle();
451
+ expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
452
+ expect(h.frame.style.height).toBe("400px");
453
+
454
+ // A growth lands mid-conceal: the teardown must land the target pin
455
+ // ATOMICALLY (clearing the clip alone would pop the box back to
456
+ // full height for a frame), then the new reveal stages from the
457
+ // CONCEALED height (360 − 320 = 40px).
458
+ h.setNatural(360);
459
+ h.remeasure();
460
+ expect(h.frame.style.height).toBe("360px");
461
+ expect(h.frame.style.clipPath).toBe("inset(40px 0 0 0 round 0px 0px 0px 0px)");
462
+ await settle();
463
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
464
+ });
465
+
466
+ it("lands the sweep via the watchdog when transitionend never fires", async () => {
467
+ const h = mountHarness(400);
468
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
469
+ h.start();
470
+
471
+ h.setNatural(320);
472
+ FakeResizeObserver.instances[0]!.callback();
473
+ await settle();
474
+ expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
475
+ expect(h.frame.style.height).toBe("400px");
476
+
477
+ // Round-6 regression: a WebView that never delivers clip-path
478
+ // transitionend used to freeze the frame here forever. The watchdog
479
+ // (duration + 350ms grace; happy-dom resolves no computed duration,
480
+ // so the 150ms fallback applies -> ~500ms) must land the same
481
+ // atomic re-pin.
482
+ await new Promise((resolve) => setTimeout(resolve, 620));
483
+ expect(h.frame.style.height).toBe("320px");
484
+ expect(h.frame.style.clipPath).toBe("");
485
+ expect(h.frame.style.willChange).toBe("clip-path");
486
+
487
+ // The watchdog is disarmed on the normal close: a later unrelated
488
+ // transitionend must be a no-op (no double landing, no throw).
489
+ fireTransitionEnd(h.frame, "clip-path");
490
+ expect(h.frame.style.height).toBe("320px");
417
491
  });
418
492
 
419
493
  it("never clips without the mode flag (desktop height morph intact)", async () => {
420
494
  const h = mountHarness(300);
421
495
  h.start();
496
+ // No resident promotion either — the desktop surface never promotes.
497
+ expect(h.frame.style.willChange).toBe("");
422
498
 
423
499
  h.setNatural(400);
424
500
  FakeResizeObserver.instances[0]!.callback();
@@ -426,6 +502,13 @@ describe("useSizeMorph clip reveal", () => {
426
502
  expect(h.frame.style.height).toBe("400px");
427
503
  expect(h.frame.style.clipPath).toBe("");
428
504
  expect(h.frame.style.willChange).toBe("");
505
+
506
+ // Desktop shrink keeps the plain height morph (no conceal).
507
+ h.setNatural(240);
508
+ FakeResizeObserver.instances[0]!.callback();
509
+ await settle();
510
+ expect(h.frame.style.height).toBe("240px");
511
+ expect(h.frame.style.clipPath).toBe("");
429
512
  });
430
513
 
431
514
  it("clears an in-flight reveal when a new dance starts", async () => {
@@ -486,22 +569,25 @@ describe("useSizeMorph clip reveal", () => {
486
569
  h.remeasure();
487
570
  expect(h.frame.style.clipPath).not.toBe("");
488
571
 
489
- // A SHRINK lands before the reveal finished: unlike a follow-up
490
- // growth (which restages its own clip), the height-morph branch
491
- // writes no clip at all — the dance-start teardown is the only
492
- // thing that returns the frame to CSS ownership (R1 mutation M1
493
- // evidence: without it the stale inset + will-change ride the
494
- // shrink and linger at rest). It must also cancel the pending
495
- // warmup, or a stale sweep lands after the shrink.
572
+ // A SHRINK lands before the reveal's warmup fired: the teardown
573
+ // drops the staged reveal (clip off — the reveal branch's teardown
574
+ // never pops the box, it already sits at its pin) and the new dance
575
+ // CONCEALS toward 310: the box keeps the 380 pin while the edge
576
+ // folds, so no height change is visible until the atomic re-pin.
577
+ // (R1 mutation M1 lineage: without the dance-start teardown a stale
578
+ // clip rode the next morph and lingered at rest.)
496
579
  h.setNatural(310);
497
580
  h.remeasure();
498
- expect(h.frame.style.height).toBe("310px");
499
- expect(h.frame.style.clipPath).toBe("");
500
- expect(h.frame.style.willChange).toBe("");
501
-
581
+ expect(h.frame.style.height).toBe("380px");
582
+ // Explicit inset(0) start — the round-6 regression contract: a
583
+ // cleared (none) start point made Chromium skip the clip transition
584
+ // AND its transitionend, freezing the sheet clipped at its old pin.
585
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
502
586
  await settle();
587
+ expect(h.frame.style.clipPath).toBe("inset(70px 0 0 0 round 0px 0px 0px 0px)");
588
+ fireTransitionEnd(h.frame, "clip-path");
589
+ expect(h.frame.style.height).toBe("310px");
503
590
  expect(h.frame.style.clipPath).toBe("");
504
- expect(h.frame.style.willChange).toBe("");
505
591
  });
506
592
 
507
593
  it("releases the clip state on stop so the leave animation owns the frame", async () => {
@@ -96,24 +96,27 @@ export interface SizeMorphOptions {
96
96
  * pin chases the target with the CSS transition — bounded, one-shot
97
97
  * choreography, not an infinite per-frame animation.
98
98
  *
99
- * Growth morphs on clip-mode surfaces (the mobile sheet docking, flagged
100
- * `--hk-sheet-morph: clip` in CSS) reveal instead of animating height:
101
- * the new pin lands instantly and the box's top edge sweeps up through
102
- * `clip-path: inset()` — same duration/ease tokens as the height
103
- * transition, same "content rides rigidly" grammar as the modal unveil,
104
- * but paint/compositor-level: no per-frame layout and no per-frame
99
+ * Size morphs on clip-mode surfaces (the mobile sheet docking, flagged
100
+ * `--hk-sheet-morph: clip` in CSS) ride paint-only clip-path in BOTH
101
+ * directions — same duration/ease tokens as the height transition, same
102
+ * "content rides rigidly" grammar as the modal unveil, but
103
+ * compositor-level: no per-frame layout and no per-frame
105
104
  * backdrop-filter re-raster over the resizing fixed layer (the mobile
106
- * patchy-flicker source, 2026-09-15 chest report). The sweep never
107
- * starts in the staging task: a two-frame warmup (REVEAL_WARMUP_FRAMES)
108
- * lets the promoted layer's raster land before the edge moves
105
+ * patchy-flicker source, 2026-09-15 chest report). Growth REVEALS: the
106
+ * new pin lands instantly and the top edge sweeps up through the staged
107
+ * inset — never in the staging task, a two-frame warmup
108
+ * (REVEAL_WARMUP_FRAMES) lets the layer's raster land first
109
109
  * (2026-09-21 chest report — same-task starts revealed black tiles).
110
- * Height-mode
111
- * surfaces and the select sheet keep the height transition for shrinks
112
- * (their stylesheets list it); the phone MODAL sheet narrowed its list
113
- * to clip-path-only (2026-09-21 step-change shrink report — 150ms of
114
- * per-frame layout on the fixed layer re-rastered the moving edge), so
115
- * its shrinks snap: the pin flip lands instantly when the
116
- * stylesheet no longer transitions height.
110
+ * Shrink CONCEALS: the box keeps its old pin while the top edge folds
111
+ * down through the closing inset, and an atomic re-pin (transition-off
112
+ * height swap) lands the target when the sweep ends — restoring the
113
+ * shrink animation the clip-path-only transition list had lost
114
+ * (2026-09-21 chest report, round 5) without reintroducing a frame of
115
+ * per-frame layout. The layer promotion is RESIDENT for the whole arm
116
+ * cycle (applied at start(), cleared on hold/release): promoting at the
117
+ * step-change moment cost a one-frame see-through as the old layer died
118
+ * before the new one rastered (same report).
119
+ * Height-mode surfaces (desktop) keep the height morph unchanged.
117
120
  *
118
121
  * Scheduling rides the shared animation context
119
122
  * (`runtime/animationBus`): the measurement hop and the reveal warmup
@@ -146,19 +149,61 @@ export function useSizeMorph(
146
149
  * bodies that overflow at rest, plus subpixel slack. See the guard in
147
150
  * remeasure(). */
148
151
  let chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
149
- /** In-flight clip reveal (clip-mode growth morph): the frame whose
150
- * inline clip-path/will-change must come off again once the sweep
151
- * lands, the listener that does it, the warmup one-shot that starts
152
- * the sweep, and the sweep's bus transition report. */
152
+ /** In-flight clip morph (clip-mode size change): the frame whose inline
153
+ * clip-path must come off again once the sweep lands, the listener
154
+ * that does it, the warmup one-shot that starts the sweep, the sweep's
155
+ * bus transition report — and, for a CONCEAL (shrink), the target
156
+ * height the atomic re-pin lands when the sweep ends. */
153
157
  let revealEl: HTMLElement | null = null;
154
158
  let revealEnd: ((ev: Event) => void) | null = null;
155
159
  let revealWarmup: AnimationHandle | null = null;
156
160
  let revealReport: AnimationHandle | null = null;
161
+ let revealDir: "reveal" | "conceal" | null = null;
162
+ let concealTo: number | null = null;
163
+ /** Sweep watchdog: transitionend is the normal closer, but a lost
164
+ * event (a WebView that skips clip-path end events entirely — the
165
+ * 2026-09-21 round-6 regression: a none→inset() start point made
166
+ * Chromium drop the transition AND its events, freezing the sheet
167
+ * clipped at its old pin) must not freeze the frame forever. The
168
+ * timer fires the exact same landing path and is disarmed on every
169
+ * normal close. */
170
+ let revealWatchdog: ReturnType<typeof setTimeout> | null = null;
171
+ /** Resident layer promotion on clip-mode surfaces: applied at arm time
172
+ * and cleared on hold/release. Promoting at the STEP-change moment
173
+ * (0.55.38's per-sweep will-change) destroyed the old layer one frame
174
+ * before the new one had rastered — on the phone GPU the sheet read
175
+ * as a one-frame see-through flash exactly when the raster race had
176
+ * just been fixed (2026-09-21 chest report, round 5). Promoting once
177
+ * at open and KEEPING it means step morphs never cross a layer
178
+ * boundary at all. */
179
+ let residentWill = false;
157
180
 
158
- /** Tear down an in-flight clip reveal: cancel the pending warmup and
159
- * the bus report, drop the listener, and return the inline
160
- * clip/will-change to CSS ownership. Safe to call when no reveal is
161
- * running (every dance start, stop, and unmount). */
181
+ /** Land a conceal atomically: pin the target height and clear the clip
182
+ * in one transition-disabled task. Called from the sweep's end, from
183
+ * stopReveal() when a conceal is interrupted, and from hold/release.
184
+ * Visually a no-op: the box top is exactly where the clip edge sits,
185
+ * so the pin swap paints nothing. */
186
+ function finishConceal(f: HTMLElement): void {
187
+ const inlineTransition = f.style.transition;
188
+ f.style.transition = "none";
189
+ if (concealTo != null) {
190
+ f.style.height = `${concealTo}px`;
191
+ pinned = concealTo;
192
+ }
193
+ f.style.clipPath = "";
194
+ void f.offsetHeight;
195
+ f.style.transition = inlineTransition;
196
+ concealTo = null;
197
+ }
198
+
199
+ /** Tear down an in-flight clip morph: cancel the pending warmup and
200
+ * the bus report, drop the listener, and land the frame in its rest
201
+ * state — an interrupted conceal re-pins its target atomically
202
+ * (clearing the clip alone would pop the box back to full height for
203
+ * one frame); a reveal just clears the clip. The resident
204
+ * will-change is NOT touched here (it belongs to the arm cycle, see
205
+ * residentWill). Safe to call when nothing is running (every dance
206
+ * start, stop, and unmount). */
162
207
  function stopReveal(): void {
163
208
  if (revealWarmup) {
164
209
  revealWarmup.disconnect();
@@ -168,15 +213,32 @@ export function useSizeMorph(
168
213
  revealReport.disconnect();
169
214
  revealReport = null;
170
215
  }
216
+ if (revealWatchdog) {
217
+ clearTimeout(revealWatchdog);
218
+ revealWatchdog = null;
219
+ }
171
220
  if (revealEl && revealEnd) {
172
221
  revealEl.removeEventListener("transitionend", revealEnd);
173
222
  }
174
223
  if (revealEl) {
175
- revealEl.style.clipPath = "";
176
- revealEl.style.willChange = "";
224
+ if (revealDir === "conceal" && concealTo != null) {
225
+ finishConceal(revealEl);
226
+ } else {
227
+ revealEl.style.clipPath = "";
228
+ }
177
229
  }
178
230
  revealEl = null;
179
231
  revealEnd = null;
232
+ revealDir = null;
233
+ concealTo = null;
234
+ }
235
+
236
+ /** Drop the resident layer promotion (hold / release paths). */
237
+ function clearResidentWill(): void {
238
+ if (!residentWill) return;
239
+ residentWill = false;
240
+ const f = frame.value;
241
+ if (f) f.style.willChange = "";
180
242
  }
181
243
 
182
244
  /** The frame's computed clip-transition duration, for the bus report
@@ -201,13 +263,22 @@ export function useSizeMorph(
201
263
 
202
264
  /** Begin the actual sweep: attach the end listener, report the CSS
203
265
  * transition to the bus so it keeps beating for the duration, and
204
- * flip the clip to the open state under the live transition. Only
205
- * ever called from the warmup's last frame — never synchronously
206
- * from the dance (see REVEAL_WARMUP_FRAMES). */
207
- function startRevealSweep(f: HTMLElement, radii: string): void {
266
+ * flip the clip to the sweep's END state under the live transition.
267
+ * A reveal opens the clip (inset(delta)→inset(0), the top edge
268
+ * sweeping up); a conceal closes it (inset(0)→inset(delta), the top
269
+ * edge folding down while the box itself stays pinned at the OLD
270
+ * height — the atomic re-pin lands in finishConceal when the sweep
271
+ * ends). Only ever called from the warmup's last frame — never
272
+ * synchronously from the dance (see REVEAL_WARMUP_FRAMES). */
273
+ function startRevealSweep(
274
+ f: HTMLElement,
275
+ radii: string,
276
+ dir: "reveal" | "conceal",
277
+ insetPx: number,
278
+ ): void {
208
279
  const onEnd = (ev: Event): void => {
209
280
  // transitionend bubbles: a descendant animating its own
210
- // clip-path must not end the frame's reveal early.
281
+ // clip-path must not end the frame's morph early.
211
282
  if (
212
283
  ev.target === f &&
213
284
  (ev as TransitionEvent).propertyName === "clip-path"
@@ -218,8 +289,18 @@ export function useSizeMorph(
218
289
  f.addEventListener("transitionend", onEnd);
219
290
  revealEl = f;
220
291
  revealEnd = onEnd;
221
- revealReport = reportTransition(transitionDurationMs(f));
222
- f.style.clipPath = `inset(0px 0 0 0 round ${radii})`;
292
+ revealDir = dir;
293
+ const durationMs = transitionDurationMs(f);
294
+ revealReport = reportTransition(durationMs);
295
+ // Watchdog (see revealWatchdog): same landing, forced, if the
296
+ // transitionend never arrives.
297
+ revealWatchdog = setTimeout(() => {
298
+ if (revealEl === f && revealDir === dir) stopReveal();
299
+ }, durationMs + 350);
300
+ f.style.clipPath =
301
+ dir === "reveal"
302
+ ? `inset(0px 0 0 0 round ${radii})`
303
+ : `inset(${insetPx}px 0 0 0 round ${radii})`;
223
304
  }
224
305
 
225
306
  /** Clip-mode opt-in, owned by CSS: the modal's mobile media block
@@ -265,6 +346,7 @@ export function useSizeMorph(
265
346
  const f = frame.value;
266
347
  if (f) f.style.height = "";
267
348
  stopReveal();
349
+ clearResidentWill();
268
350
  pinned = 0;
269
351
  }
270
352
 
@@ -338,22 +420,36 @@ export function useSizeMorph(
338
420
  return;
339
421
  }
340
422
  const next = Math.round(natural);
341
- const growth = next - pinned;
342
- // Clip reveal (see the composable doc): the pin lands instantly and
343
- // the top edge sweeps up through paint-only clip-path, with the
344
- // box's own corner radii riding the moving edge. The frame's
345
- // stylesheet owns the clip-path transition (duration/ease tokens
346
- // shared with the height transition), so reduced-motion and the
347
- // global animation switch collapse it exactly like the height morph
348
- // they already govern. Everything else — shrink, first pin,
349
- // sub-threshold growth, height-mode surfaces — keeps the height
350
- // morph below (desktop stays exactly as it was).
351
- const reveal = pinned > 0 && growth >= REVEAL_MIN_PX && clipMode(f);
423
+ const delta = next - pinned;
424
+ // Clip morphs (see the composable doc): on clip-mode surfaces BOTH
425
+ // directions ride paint-only clip-path — growth REVEALS (pin the new
426
+ // height outright, then sweep the top edge up through the staged
427
+ // inset) and shrink CONCEALS (keep the old pin, then fold the top
428
+ // edge down; the atomic re-pin lands when the sweep ends). No
429
+ // per-frame layout in either direction; the frame's stylesheet owns
430
+ // the clip-path transition (duration/ease tokens shared with the
431
+ // height transition), so reduced-motion and the global animation
432
+ // switch collapse both exactly like the height morph they govern.
433
+ // First pin, sub-threshold deltas and height-mode surfaces keep the
434
+ // height morph below (desktop stays exactly as it was).
435
+ const reveal = pinned > 0 && delta >= REVEAL_MIN_PX && clipMode(f);
436
+ const conceal = pinned > 0 && delta <= -REVEAL_MIN_PX && clipMode(f);
352
437
  let radii = "";
353
438
  if (reveal) {
354
439
  radii = cornerRadii(f);
355
440
  f.style.height = `${next}px`;
356
- f.style.clipPath = `inset(${growth}px 0 0 0 round ${radii})`;
441
+ f.style.clipPath = `inset(${delta}px 0 0 0 round ${radii})`;
442
+ } else if (conceal) {
443
+ radii = cornerRadii(f);
444
+ // The box stays pinned at the OLD height, and the clip start is an
445
+ // EXPLICIT inset(0) — not a cleared inline clip. Chromium does not
446
+ // interpolate clip-path between none and inset(), so a none start
447
+ // made the sweep jump to its end state and never fire
448
+ // transitionend, freezing the sheet clipped at its old pin
449
+ // (2026-09-21 round-6 regression report: "the top half is just cut
450
+ // off and then it stays there"). inset(0)→inset(N) interpolates.
451
+ f.style.height = `${pinned}px`;
452
+ f.style.clipPath = `inset(0px 0 0 0 round ${radii})`;
357
453
  } else if (pinned > 0) {
358
454
  f.style.height = `${pinned}px`;
359
455
  }
@@ -362,34 +458,47 @@ export function useSizeMorph(
362
458
  // old visual edge / the height transition starts from the old pin.
363
459
  void f.offsetHeight;
364
460
  f.style.transition = inlineTransition;
365
- if (reveal) {
366
- // Warmup (see REVEAL_WARMUP_FRAMES): promote the layer now and
367
- // hold the staged clip; the sweep itself starts from the bus.
368
- // Bus one-shots fire even while the bus is parked for reduced
369
- // motion — they are scheduling primitives, not motion; the motion
370
- // collapse stays CSS-owned (transition-duration → one frame), so
371
- // a parked bus still lands the sweep instantly and transitionend
372
- // cleans up exactly as before.
461
+ if (reveal || conceal) {
462
+ // Warmup (see REVEAL_WARMUP_FRAMES): hold the staged state for two
463
+ // bus frames so the layer's raster lands before the edge moves;
464
+ // the sweep itself starts from the bus. The layer is ALREADY
465
+ // promoted (resident will-change since start()), so the warmup
466
+ // costs no layer churn. Bus one-shots fire even while the bus is
467
+ // parked for reduced motion — they are scheduling primitives, not
468
+ // motion; the motion collapse stays CSS-owned
469
+ // (transition-duration → one frame), so a parked bus still lands
470
+ // the sweep instantly and transitionend cleans up exactly as
471
+ // before.
373
472
  revealEl = f;
374
- f.style.willChange = "clip-path";
473
+ revealDir = reveal ? "reveal" : "conceal";
474
+ concealTo = conceal ? next : null;
475
+ const dir = revealDir;
476
+ const insetPx = Math.abs(delta);
375
477
  let framesLeft = REVEAL_WARMUP_FRAMES;
376
478
  const armWarmup = (): void => {
377
479
  revealWarmup = scheduleFrame(() => {
378
480
  revealWarmup = null;
379
481
  // Torn down mid-warmup (new dance / hold / stop / unmount).
380
- if (revealEl !== f) return;
482
+ if (revealEl !== f || revealDir !== dir) return;
381
483
  if (--framesLeft > 0) {
382
484
  armWarmup();
383
485
  return;
384
486
  }
385
- startRevealSweep(f, radii);
487
+ startRevealSweep(f, radii, dir, insetPx);
386
488
  });
387
489
  };
388
490
  armWarmup();
389
491
  } else {
390
492
  f.style.height = `${next}px`;
391
493
  }
392
- pinned = next;
494
+ if (!conceal) {
495
+ // The pin bookkeeping is immediate for reveals (the box already
496
+ // sits at the new height) and snaps (nothing animates). A conceal
497
+ // keeps the OLD pin until finishConceal() lands the target — a
498
+ // mid-flight remeasure must see the still-visual height as its
499
+ // "from".
500
+ pinned = next;
501
+ }
393
502
  // Self-heal the allowance on every VALIDATED pin: chrome that grew
394
503
  // after calibration (an async footer, a header slot mounting
395
504
  // mid-open) updates the baseline instead of tripping the guard on
@@ -435,6 +544,15 @@ export function useSizeMorph(
435
544
  // its enter (callers arm in after-enter) and sits at rest, so the
436
545
  // frame-vs-content delta is pure chrome.
437
546
  calibrate();
547
+ // Resident promotion on clip-mode surfaces (see residentWill): the
548
+ // layer crosses no boundary during later step morphs. Applied here,
549
+ // at the open edge — one promotion per open cycle instead of one
550
+ // per resize.
551
+ const f0 = frame.value;
552
+ if (f0 && clipMode(f0)) {
553
+ f0.style.willChange = "clip-path";
554
+ residentWill = true;
555
+ }
438
556
  if (typeof ResizeObserver === "undefined" || !content.value) {
439
557
  remeasure();
440
558
  return;
@@ -461,6 +579,9 @@ export function useSizeMorph(
461
579
  // cycle carries.
462
580
  chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
463
581
  stopReveal();
582
+ // The leave fold no longer clips; drop the resident promotion with
583
+ // it (start() re-applies on the next open).
584
+ clearResidentWill();
464
585
  // Deliberately no release(): the pin stays on the frame so the close
465
586
  // fold plays on a stable box, and a reopen interrupt animates from it.
466
587
  }
@@ -479,6 +600,7 @@ export function useSizeMorph(
479
600
  if (settleTimer) clearTimeout(settleTimer);
480
601
  if (raf) raf.disconnect();
481
602
  stopReveal();
603
+ clearResidentWill();
482
604
  });
483
605
 
484
606
  return { start, stop, hold, remeasure };