@celestia-island/hikari 0.55.38 → 0.55.39

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.39",
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,68 @@ 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
413
  FakeResizeObserver.instances[0]!.callback();
407
414
  await settle();
415
+ // Warmup + sweep in flight: clip folding toward inset(80px), height
416
+ // still the old pin.
417
+ expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
418
+ expect(h.frame.style.height).toBe("400px");
419
+ // The sweep ends: atomic re-pin — height jumps to the target with
420
+ // the clip cleared in one transition-off task (visually a no-op).
421
+ fireTransitionEnd(h.frame, "clip-path");
408
422
  expect(h.frame.style.height).toBe("320px");
409
423
  expect(h.frame.style.clipPath).toBe("");
410
- expect(h.frame.style.willChange).toBe("");
411
424
 
412
425
  // Sub-threshold growth (2px < REVEAL_MIN_PX): snaps, no reveal.
413
426
  h.setNatural(322);
414
427
  h.remeasure();
415
428
  expect(h.frame.style.height).toBe("322px");
416
429
  expect(h.frame.style.clipPath).toBe("");
430
+
431
+ // Sub-threshold shrink (−2px): snaps too, no conceal.
432
+ h.setNatural(320);
433
+ h.remeasure();
434
+ expect(h.frame.style.height).toBe("320px");
435
+ expect(h.frame.style.clipPath).toBe("");
436
+ });
437
+
438
+ it("lands an interrupted conceal atomically on the next dance", async () => {
439
+ const h = mountHarness(400);
440
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
441
+ h.start();
442
+
443
+ h.setNatural(320);
444
+ h.remeasure();
445
+ await settle();
446
+ expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
447
+ expect(h.frame.style.height).toBe("400px");
448
+
449
+ // A growth lands mid-conceal: the teardown must land the target pin
450
+ // ATOMICALLY (clearing the clip alone would pop the box back to
451
+ // full height for a frame), then the new reveal stages from the
452
+ // CONCEALED height (360 − 320 = 40px).
453
+ h.setNatural(360);
454
+ h.remeasure();
455
+ expect(h.frame.style.height).toBe("360px");
456
+ expect(h.frame.style.clipPath).toBe("inset(40px 0 0 0 round 0px 0px 0px 0px)");
457
+ await settle();
458
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
417
459
  });
418
460
 
419
461
  it("never clips without the mode flag (desktop height morph intact)", async () => {
420
462
  const h = mountHarness(300);
421
463
  h.start();
464
+ // No resident promotion either — the desktop surface never promotes.
465
+ expect(h.frame.style.willChange).toBe("");
422
466
 
423
467
  h.setNatural(400);
424
468
  FakeResizeObserver.instances[0]!.callback();
@@ -426,6 +470,13 @@ describe("useSizeMorph clip reveal", () => {
426
470
  expect(h.frame.style.height).toBe("400px");
427
471
  expect(h.frame.style.clipPath).toBe("");
428
472
  expect(h.frame.style.willChange).toBe("");
473
+
474
+ // Desktop shrink keeps the plain height morph (no conceal).
475
+ h.setNatural(240);
476
+ FakeResizeObserver.instances[0]!.callback();
477
+ await settle();
478
+ expect(h.frame.style.height).toBe("240px");
479
+ expect(h.frame.style.clipPath).toBe("");
429
480
  });
430
481
 
431
482
  it("clears an in-flight reveal when a new dance starts", async () => {
@@ -486,22 +537,22 @@ describe("useSizeMorph clip reveal", () => {
486
537
  h.remeasure();
487
538
  expect(h.frame.style.clipPath).not.toBe("");
488
539
 
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.
540
+ // A SHRINK lands before the reveal's warmup fired: the teardown
541
+ // drops the staged reveal (clip off — the reveal branch's teardown
542
+ // never pops the box, it already sits at its pin) and the new dance
543
+ // CONCEALS toward 310: the box keeps the 380 pin while the edge
544
+ // folds, so no height change is visible until the atomic re-pin.
545
+ // (R1 mutation M1 lineage: without the dance-start teardown a stale
546
+ // clip rode the next morph and lingered at rest.)
496
547
  h.setNatural(310);
497
548
  h.remeasure();
498
- expect(h.frame.style.height).toBe("310px");
549
+ expect(h.frame.style.height).toBe("380px");
499
550
  expect(h.frame.style.clipPath).toBe("");
500
- expect(h.frame.style.willChange).toBe("");
501
-
502
551
  await settle();
552
+ expect(h.frame.style.clipPath).toBe("inset(70px 0 0 0 round 0px 0px 0px 0px)");
553
+ fireTransitionEnd(h.frame, "clip-path");
554
+ expect(h.frame.style.height).toBe("310px");
503
555
  expect(h.frame.style.clipPath).toBe("");
504
- expect(h.frame.style.willChange).toBe("");
505
556
  });
506
557
 
507
558
  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,53 @@ 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
+ /** Resident layer promotion on clip-mode surfaces: applied at arm time
164
+ * and cleared on hold/release. Promoting at the STEP-change moment
165
+ * (0.55.38's per-sweep will-change) destroyed the old layer one frame
166
+ * before the new one had rastered — on the phone GPU the sheet read
167
+ * as a one-frame see-through flash exactly when the raster race had
168
+ * just been fixed (2026-09-21 chest report, round 5). Promoting once
169
+ * at open and KEEPING it means step morphs never cross a layer
170
+ * boundary at all. */
171
+ let residentWill = false;
157
172
 
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). */
173
+ /** Land a conceal atomically: pin the target height and clear the clip
174
+ * in one transition-disabled task. Called from the sweep's end, from
175
+ * stopReveal() when a conceal is interrupted, and from hold/release.
176
+ * Visually a no-op: the box top is exactly where the clip edge sits,
177
+ * so the pin swap paints nothing. */
178
+ function finishConceal(f: HTMLElement): void {
179
+ const inlineTransition = f.style.transition;
180
+ f.style.transition = "none";
181
+ if (concealTo != null) {
182
+ f.style.height = `${concealTo}px`;
183
+ pinned = concealTo;
184
+ }
185
+ f.style.clipPath = "";
186
+ void f.offsetHeight;
187
+ f.style.transition = inlineTransition;
188
+ concealTo = null;
189
+ }
190
+
191
+ /** Tear down an in-flight clip morph: cancel the pending warmup and
192
+ * the bus report, drop the listener, and land the frame in its rest
193
+ * state — an interrupted conceal re-pins its target atomically
194
+ * (clearing the clip alone would pop the box back to full height for
195
+ * one frame); a reveal just clears the clip. The resident
196
+ * will-change is NOT touched here (it belongs to the arm cycle, see
197
+ * residentWill). Safe to call when nothing is running (every dance
198
+ * start, stop, and unmount). */
162
199
  function stopReveal(): void {
163
200
  if (revealWarmup) {
164
201
  revealWarmup.disconnect();
@@ -172,11 +209,24 @@ export function useSizeMorph(
172
209
  revealEl.removeEventListener("transitionend", revealEnd);
173
210
  }
174
211
  if (revealEl) {
175
- revealEl.style.clipPath = "";
176
- revealEl.style.willChange = "";
212
+ if (revealDir === "conceal" && concealTo != null) {
213
+ finishConceal(revealEl);
214
+ } else {
215
+ revealEl.style.clipPath = "";
216
+ }
177
217
  }
178
218
  revealEl = null;
179
219
  revealEnd = null;
220
+ revealDir = null;
221
+ concealTo = null;
222
+ }
223
+
224
+ /** Drop the resident layer promotion (hold / release paths). */
225
+ function clearResidentWill(): void {
226
+ if (!residentWill) return;
227
+ residentWill = false;
228
+ const f = frame.value;
229
+ if (f) f.style.willChange = "";
180
230
  }
181
231
 
182
232
  /** The frame's computed clip-transition duration, for the bus report
@@ -201,13 +251,22 @@ export function useSizeMorph(
201
251
 
202
252
  /** Begin the actual sweep: attach the end listener, report the CSS
203
253
  * 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 {
254
+ * flip the clip to the sweep's END state under the live transition.
255
+ * A reveal opens the clip (inset(delta)→inset(0), the top edge
256
+ * sweeping up); a conceal closes it (inset(0)→inset(delta), the top
257
+ * edge folding down while the box itself stays pinned at the OLD
258
+ * height — the atomic re-pin lands in finishConceal when the sweep
259
+ * ends). Only ever called from the warmup's last frame — never
260
+ * synchronously from the dance (see REVEAL_WARMUP_FRAMES). */
261
+ function startRevealSweep(
262
+ f: HTMLElement,
263
+ radii: string,
264
+ dir: "reveal" | "conceal",
265
+ insetPx: number,
266
+ ): void {
208
267
  const onEnd = (ev: Event): void => {
209
268
  // transitionend bubbles: a descendant animating its own
210
- // clip-path must not end the frame's reveal early.
269
+ // clip-path must not end the frame's morph early.
211
270
  if (
212
271
  ev.target === f &&
213
272
  (ev as TransitionEvent).propertyName === "clip-path"
@@ -218,8 +277,12 @@ export function useSizeMorph(
218
277
  f.addEventListener("transitionend", onEnd);
219
278
  revealEl = f;
220
279
  revealEnd = onEnd;
280
+ revealDir = dir;
221
281
  revealReport = reportTransition(transitionDurationMs(f));
222
- f.style.clipPath = `inset(0px 0 0 0 round ${radii})`;
282
+ f.style.clipPath =
283
+ dir === "reveal"
284
+ ? `inset(0px 0 0 0 round ${radii})`
285
+ : `inset(${insetPx}px 0 0 0 round ${radii})`;
223
286
  }
224
287
 
225
288
  /** Clip-mode opt-in, owned by CSS: the modal's mobile media block
@@ -265,6 +328,7 @@ export function useSizeMorph(
265
328
  const f = frame.value;
266
329
  if (f) f.style.height = "";
267
330
  stopReveal();
331
+ clearResidentWill();
268
332
  pinned = 0;
269
333
  }
270
334
 
@@ -338,22 +402,31 @@ export function useSizeMorph(
338
402
  return;
339
403
  }
340
404
  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);
405
+ const delta = next - pinned;
406
+ // Clip morphs (see the composable doc): on clip-mode surfaces BOTH
407
+ // directions ride paint-only clip-path — growth REVEALS (pin the new
408
+ // height outright, then sweep the top edge up through the staged
409
+ // inset) and shrink CONCEALS (keep the old pin, then fold the top
410
+ // edge down; the atomic re-pin lands when the sweep ends). No
411
+ // per-frame layout in either direction; the frame's stylesheet owns
412
+ // the clip-path transition (duration/ease tokens shared with the
413
+ // height transition), so reduced-motion and the global animation
414
+ // switch collapse both exactly like the height morph they govern.
415
+ // First pin, sub-threshold deltas and height-mode surfaces keep the
416
+ // height morph below (desktop stays exactly as it was).
417
+ const reveal = pinned > 0 && delta >= REVEAL_MIN_PX && clipMode(f);
418
+ const conceal = pinned > 0 && delta <= -REVEAL_MIN_PX && clipMode(f);
352
419
  let radii = "";
353
420
  if (reveal) {
354
421
  radii = cornerRadii(f);
355
422
  f.style.height = `${next}px`;
356
- f.style.clipPath = `inset(${growth}px 0 0 0 round ${radii})`;
423
+ f.style.clipPath = `inset(${delta}px 0 0 0 round ${radii})`;
424
+ } else if (conceal) {
425
+ radii = cornerRadii(f);
426
+ // The box stays pinned at the OLD height (the visible start state
427
+ // needs no staging — the dance-start teardown already cleared any
428
+ // clip, so the box paints its full current height).
429
+ f.style.height = `${pinned}px`;
357
430
  } else if (pinned > 0) {
358
431
  f.style.height = `${pinned}px`;
359
432
  }
@@ -362,34 +435,47 @@ export function useSizeMorph(
362
435
  // old visual edge / the height transition starts from the old pin.
363
436
  void f.offsetHeight;
364
437
  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.
438
+ if (reveal || conceal) {
439
+ // Warmup (see REVEAL_WARMUP_FRAMES): hold the staged state for two
440
+ // bus frames so the layer's raster lands before the edge moves;
441
+ // the sweep itself starts from the bus. The layer is ALREADY
442
+ // promoted (resident will-change since start()), so the warmup
443
+ // costs no layer churn. Bus one-shots fire even while the bus is
444
+ // parked for reduced motion — they are scheduling primitives, not
445
+ // motion; the motion collapse stays CSS-owned
446
+ // (transition-duration → one frame), so a parked bus still lands
447
+ // the sweep instantly and transitionend cleans up exactly as
448
+ // before.
373
449
  revealEl = f;
374
- f.style.willChange = "clip-path";
450
+ revealDir = reveal ? "reveal" : "conceal";
451
+ concealTo = conceal ? next : null;
452
+ const dir = revealDir;
453
+ const insetPx = Math.abs(delta);
375
454
  let framesLeft = REVEAL_WARMUP_FRAMES;
376
455
  const armWarmup = (): void => {
377
456
  revealWarmup = scheduleFrame(() => {
378
457
  revealWarmup = null;
379
458
  // Torn down mid-warmup (new dance / hold / stop / unmount).
380
- if (revealEl !== f) return;
459
+ if (revealEl !== f || revealDir !== dir) return;
381
460
  if (--framesLeft > 0) {
382
461
  armWarmup();
383
462
  return;
384
463
  }
385
- startRevealSweep(f, radii);
464
+ startRevealSweep(f, radii, dir, insetPx);
386
465
  });
387
466
  };
388
467
  armWarmup();
389
468
  } else {
390
469
  f.style.height = `${next}px`;
391
470
  }
392
- pinned = next;
471
+ if (!conceal) {
472
+ // The pin bookkeeping is immediate for reveals (the box already
473
+ // sits at the new height) and snaps (nothing animates). A conceal
474
+ // keeps the OLD pin until finishConceal() lands the target — a
475
+ // mid-flight remeasure must see the still-visual height as its
476
+ // "from".
477
+ pinned = next;
478
+ }
393
479
  // Self-heal the allowance on every VALIDATED pin: chrome that grew
394
480
  // after calibration (an async footer, a header slot mounting
395
481
  // mid-open) updates the baseline instead of tripping the guard on
@@ -435,6 +521,15 @@ export function useSizeMorph(
435
521
  // its enter (callers arm in after-enter) and sits at rest, so the
436
522
  // frame-vs-content delta is pure chrome.
437
523
  calibrate();
524
+ // Resident promotion on clip-mode surfaces (see residentWill): the
525
+ // layer crosses no boundary during later step morphs. Applied here,
526
+ // at the open edge — one promotion per open cycle instead of one
527
+ // per resize.
528
+ const f0 = frame.value;
529
+ if (f0 && clipMode(f0)) {
530
+ f0.style.willChange = "clip-path";
531
+ residentWill = true;
532
+ }
438
533
  if (typeof ResizeObserver === "undefined" || !content.value) {
439
534
  remeasure();
440
535
  return;
@@ -461,6 +556,9 @@ export function useSizeMorph(
461
556
  // cycle carries.
462
557
  chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
463
558
  stopReveal();
559
+ // The leave fold no longer clips; drop the resident promotion with
560
+ // it (start() re-applies on the next open).
561
+ clearResidentWill();
464
562
  // Deliberately no release(): the pin stays on the frame so the close
465
563
  // fold plays on a stable box, and a reopen interrupt animates from it.
466
564
  }
@@ -479,6 +577,7 @@ export function useSizeMorph(
479
577
  if (settleTimer) clearTimeout(settleTimer);
480
578
  if (raf) raf.disconnect();
481
579
  stopReveal();
580
+ clearResidentWill();
482
581
  });
483
582
 
484
583
  return { start, stop, hold, remeasure };