@celestia-island/hikari 0.55.37 → 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.37",
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
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Source contract for the phone step-swap snap (2026-09-21 chest field
3
+ * report, AddProviderWizard Prev/Next "content blinks once").
4
+ *
5
+ * On ≤767px the step body's slide+fade must be inert: inside the mobile
6
+ * bottom sheet the frame's own size morph already carries the spatial
7
+ * feedback, and the slide's transient layer promotion/demotion costs a
8
+ * one-frame raster gap at both ends on phone GPUs. Desktop keeps the
9
+ * directional slide; reduced motion already snaps globally.
10
+ *
11
+ * Pinned here so a refactor cannot silently restore the phone slide —
12
+ * the class names are the same, so only the media-scoped rule proves it.
13
+ */
14
+ import { describe, expect, it } from "vitest";
15
+ import { readFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const src = readFileSync(join(here, "HkStepFlow.scss"), "utf-8");
21
+
22
+ /** Brace-aware `@media <query>` block extractor: a naive slice at the
23
+ * first `}` would cut at the first nested rule's closing brace. */
24
+ function mediaBlocks(source: string, query: string): string[] {
25
+ const blocks: string[] = [];
26
+ let from = 0;
27
+ for (;;) {
28
+ const at = source.indexOf(`@media ${query}`, from);
29
+ if (at < 0) break;
30
+ const open = source.indexOf("{", at);
31
+ let depth = 0;
32
+ let i = open;
33
+ for (; i < source.length; i++) {
34
+ if (source[i] === "{") depth++;
35
+ else if (source[i] === "}") {
36
+ depth--;
37
+ if (depth === 0) break;
38
+ }
39
+ }
40
+ blocks.push(source.slice(at, i + 1));
41
+ from = i + 1;
42
+ }
43
+ return blocks;
44
+ }
45
+
46
+ const PHONE_QUERY = "(max-width: 767px)";
47
+ /** The four transition-class selectors Vue's <Transition> toggles. */
48
+ const ACTIVE_SELECTORS = [
49
+ ".hk-stepflow-fwd-enter-active",
50
+ ".hk-stepflow-back-enter-active",
51
+ ".hk-stepflow-fwd-leave-active",
52
+ ".hk-stepflow-back-leave-active",
53
+ ];
54
+
55
+ /** The single ≤767px block, asserted present IN the test that needs it
56
+ * (a beforeAll assertion would downgrade a missing block to "skipped"
57
+ * instead of a hard failure). */
58
+ function phoneBlock(): string {
59
+ const blocks = mediaBlocks(src, PHONE_QUERY);
60
+ expect(blocks.length).toBe(1);
61
+ return blocks[0]!;
62
+ }
63
+
64
+ describe("HkStepFlow phone snap contract", () => {
65
+ it("disables the slide/fade transition on ≤767px", () => {
66
+ const block = phoneBlock();
67
+ for (const selector of ACTIVE_SELECTORS) {
68
+ expect(block).toContain(selector);
69
+ }
70
+ // transition:none on every active class — the whole point: no
71
+ // property animates, so no promotion window exists to blink.
72
+ expect(block).toMatch(/transition:\s*none\s*;/);
73
+ // …and it must be the ONLY transition declaration in the block
74
+ // (a stray `transition: opacity …` would re-open the blink).
75
+ const declarations = block.match(/transition:\s*[^;]+;/g) ?? [];
76
+ expect(declarations).toHaveLength(1);
77
+ });
78
+
79
+ it("keeps the desktop slide outside the phone block", () => {
80
+ // Mutation guard: the phone rule must be ADDITIVE. The base
81
+ // (desktop) enter/leave rules still carry the opacity+transform
82
+ // transition, so deleting them fails here instead of passing
83
+ // vacuously.
84
+ const baseEnter = src.match(
85
+ /\.hk-stepflow-fwd-enter-active\s*,\s*\n\.hk-stepflow-back-enter-active\s*\{[^}]*\}/,
86
+ );
87
+ expect(baseEnter).not.toBeNull();
88
+ expect(baseEnter![0]).toContain("transition:");
89
+ expect(baseEnter![0]).toContain("opacity");
90
+ expect(baseEnter![0]).toContain("transform");
91
+
92
+ const baseLeave = src.match(
93
+ /\.hk-stepflow-fwd-leave-active\s*,\s*\n\.hk-stepflow-back-leave-active\s*\{[^}]*\}/,
94
+ );
95
+ expect(baseLeave).not.toBeNull();
96
+ expect(baseLeave![0]).toContain("opacity");
97
+ expect(baseLeave![0]).toContain("transform");
98
+ });
99
+
100
+ it("keeps the reduce-motion snap block (house pattern) intact", () => {
101
+ const blocks = mediaBlocks(src, "(prefers-reduced-motion: reduce)");
102
+ expect(blocks.length).toBe(1);
103
+ expect(blocks[0]).toMatch(/transition:\s*none\s*;/);
104
+ });
105
+ });
@@ -89,6 +89,22 @@
89
89
  transform: translateX(24px);
90
90
  }
91
91
 
92
+ /* Phone (≤767px): the body snaps between steps instead of sliding. On
93
+ * the mobile sheet the frame's own size morph already carries the
94
+ * spatial feedback, and the slide's transient layer promotion/demotion
95
+ * costs a one-frame raster gap at both ends on phone GPUs — the
96
+ * "content blinks once on every step" report (2026-09-21 chest field
97
+ * report, AddProviderWizard Prev/Next). Same shape as the
98
+ * reduced-motion block below; desktop keeps the directional slide. */
99
+ @media (max-width: 767px) {
100
+ .hk-stepflow-fwd-enter-active,
101
+ .hk-stepflow-back-enter-active,
102
+ .hk-stepflow-fwd-leave-active,
103
+ .hk-stepflow-back-leave-active {
104
+ transition: none;
105
+ }
106
+ }
107
+
92
108
  /* Reduced motion: the body snaps between steps instead of sliding
93
109
  * (house pattern: HkExpansionPanel.scss / HkAffixPicker.scss). */
94
110
  @media (prefers-reduced-motion: reduce) {
@@ -115,6 +115,15 @@
115
115
  border-radius: 8px 0 0 0;
116
116
  }
117
117
 
118
+ /* A maximized frame has no rounded window corner to follow: Windows 11
119
+ squares decorated windows itself and borderless hosts square the DWM
120
+ corners manually, so keeping the radius would round the hover fill
121
+ away from the (square) frame corner. The (0,3,0) compound out-ranks
122
+ both the base rule and the RTL mirror above. */
123
+ .hk-titlebar[data-maximized] .hk-titlebar-btn-close {
124
+ border-radius: 0;
125
+ }
126
+
118
127
  .hk-titlebar-btn-close:hover {
119
128
  background: var(--hk-tb-close-hover);
120
129
  color: var(--hi-color-text-on-danger, #ffffff);
@@ -0,0 +1,127 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { compile } from "sass";
6
+ import { afterEach, describe, expect, it } from "vitest";
7
+ import { createApp, h, nextTick, ref } from "vue";
8
+
9
+ import HkTitleBar from "./HkTitleBar";
10
+
11
+ /**
12
+ * HkTitleBar maximized-state contract tests.
13
+ *
14
+ * The `maximized` prop drives three things; all are contract-tested here
15
+ * because the failure mode is silent (a wrong glyph, a hover fill that
16
+ * rounds away from a square frame corner):
17
+ *
18
+ * 1. the root carries `data-maximized` only while maximized — the
19
+ * stylesheet keys the close-button radius override off it,
20
+ * 2. the maximize button swaps its glyph/title with the same prop, and
21
+ * 3. the compiled sheet squares the close corner under `[data-maximized]`.
22
+ *
23
+ * The compiled-stylesheet guard exists because vitest stubs CSS, so a
24
+ * runtime test can never notice the rule going missing (the HkAboutModal
25
+ * styles test hit exactly that class of regression).
26
+ *
27
+ * (Repo test convention: raw createApp + container queries, no
28
+ * @vue/test-utils dependency.)
29
+ */
30
+
31
+ const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
32
+
33
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
34
+
35
+ function mount(node: ReturnType<typeof h>) {
36
+ const container = document.createElement("div");
37
+ document.body.appendChild(container);
38
+ const app = createApp({ render: () => node });
39
+ app.mount(container);
40
+ mounts.push({ app, container });
41
+ return container;
42
+ }
43
+
44
+ afterEach(() => {
45
+ for (const { app, container } of mounts.splice(0)) {
46
+ app.unmount();
47
+ container.remove();
48
+ }
49
+ });
50
+
51
+ describe("HkTitleBar", () => {
52
+ it("marks the root data-maximized only while maximized", () => {
53
+ const windowed = mount(h(HkTitleBar, { title: "App", maximized: false }));
54
+ expect(windowed.querySelector(".hk-titlebar")?.hasAttribute("data-maximized")).toBe(false);
55
+
56
+ const maximized = mount(h(HkTitleBar, { title: "App", maximized: true }));
57
+ expect(maximized.querySelector(".hk-titlebar")?.hasAttribute("data-maximized")).toBe(true);
58
+ });
59
+
60
+ it("follows the prop reactively, adding and removing the marker", async () => {
61
+ // Hosts toggle `maximized` live (resize listener → ref); the marker
62
+ // must come and go with it, not just latch on mount.
63
+ const maximized = ref(false);
64
+ const host = mount(
65
+ h(() => h(HkTitleBar, { title: "App", maximized: maximized.value })),
66
+ );
67
+ const bar = () => host.querySelector<HTMLElement>(".hk-titlebar")!;
68
+ expect(bar().hasAttribute("data-maximized")).toBe(false);
69
+
70
+ maximized.value = true;
71
+ await nextTick();
72
+ expect(bar().hasAttribute("data-maximized")).toBe(true);
73
+
74
+ maximized.value = false;
75
+ await nextTick();
76
+ expect(bar().hasAttribute("data-maximized")).toBe(false);
77
+ });
78
+
79
+ it("swaps the maximize glyph with the same prop", () => {
80
+ const windowed = mount(h(HkTitleBar, { title: "App", maximized: false }));
81
+ expect(windowed.querySelector('[title="Maximize"]')).toBeTruthy();
82
+
83
+ const maximized = mount(h(HkTitleBar, { title: "App", maximized: true }));
84
+ expect(maximized.querySelector('[title="Restore"]')).toBeTruthy();
85
+ });
86
+ });
87
+
88
+ describe("HkTitleBar stylesheet contract", () => {
89
+ const css = compile(resolve(componentDir, "HkTitleBar.scss"), {
90
+ style: "expanded",
91
+ }).css;
92
+
93
+ /**
94
+ * Body of one compiled rule, by exact selector (Sass drops the quotes
95
+ * around an attribute value: `[data-maximized]`).
96
+ *
97
+ * The selector is anchored to its own line: expanded Sass puts every
98
+ * selector of a rule (comma lists included) on its own line, so this
99
+ * skips compound rules that merely END with the selector (e.g. the
100
+ * `[data-maximized]` override) while still requiring exactly one match —
101
+ * a second copy, say inside a media query, would stay invisible to a
102
+ * looser scan while still winning the cascade.
103
+ */
104
+ function ruleBody(sheet: string, selector: string): string {
105
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
106
+ const matches = [
107
+ ...sheet.matchAll(new RegExp(`(?:^|\\n)\\s*${escaped}\\s*\\{([^}]*)\\}`, "g")),
108
+ ];
109
+ expect(matches, `${selector} compiles exactly once`).toHaveLength(1);
110
+ return matches[0]?.[1] ?? "";
111
+ }
112
+
113
+ it("squares the close button corner while maximized, both directions", () => {
114
+ // The (0,3,0) compound must survive compilation and out-rank the base
115
+ // radius rule and its RTL mirror; reading the compiled declarations
116
+ // (not the SCSS source) means a rule that failed to compile fails here.
117
+ const body = ruleBody(css, ".hk-titlebar[data-maximized] .hk-titlebar-btn-close");
118
+ expect(body).toContain("border-radius: 0");
119
+ });
120
+
121
+ it("keeps the windowed radius var wired to the close button", () => {
122
+ const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
123
+ expect(scss).toContain("--hk-tb-radius-close:");
124
+ const body = ruleBody(css, ".hk-titlebar-btn-close");
125
+ expect(body).toContain("border-radius: var(--hk-tb-radius-close)");
126
+ });
127
+ });
@@ -23,7 +23,9 @@ import "./HkTitleBar.scss";
23
23
  * to the LEFT of minimize, each emitting `action` with its id.
24
24
  *
25
25
  * The maximized state is data-driven: the host passes `maximized` and the
26
- * component swaps the maximize/restore glyph — no shell probing inside.
26
+ * component swaps the maximize/restore glyph and squares the close button's
27
+ * window corner radius (a maximized frame has no rounded corner to follow)
28
+ * — no shell probing inside.
27
29
  */
28
30
  export default defineComponent({
29
31
  name: "HkTitleBar",
@@ -51,7 +53,11 @@ export default defineComponent({
51
53
  },
52
54
  setup(props, { emit, slots }) {
53
55
  return () => (
54
- <div class="hk-titlebar" data-drag-region>
56
+ <div
57
+ class="hk-titlebar"
58
+ data-drag-region
59
+ data-maximized={props.maximized || undefined}
60
+ >
55
61
  {slots.left?.() ?? (
56
62
  <span class="hk-titlebar-title">
57
63
  {props.icon && <img class="hk-titlebar-icon" src={props.icon} alt="" />}
@@ -1,6 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, h, nextTick, ref } from "vue";
3
3
 
4
+ import { readHkRuntime } from "../runtime/registry";
5
+
4
6
  import { useSizeMorph, type SizeMorphOptions } from "./useSizeMorph";
5
7
 
6
8
  /** Injectable ResizeObserver: captures the callback so tests can fire
@@ -123,6 +125,15 @@ async function settle(): Promise<void> {
123
125
  await nextTick();
124
126
  }
125
127
 
128
+ /** Await exactly N animation frames. The bus's one-shot pump is itself
129
+ * rAF-driven and FIFO-ordered with these, so an awaited frame resolves
130
+ * in the same tick the pump that ran before it did. */
131
+ async function busFrames(n: number): Promise<void> {
132
+ for (let i = 0; i < n; i++) {
133
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
134
+ }
135
+ }
136
+
126
137
  describe("useSizeMorph", () => {
127
138
  it("pins the frame to its natural height on start", () => {
128
139
  const h = mountHarness(120);
@@ -279,23 +290,82 @@ describe("useSizeMorph clip reveal", () => {
279
290
  h.frame.style.setProperty("--hk-sheet-morph", "clip");
280
291
  h.start();
281
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");
282
297
 
283
298
  h.setNatural(360);
284
299
  FakeResizeObserver.instances[0]!.callback();
285
300
  await settle();
286
301
  // The pin landed at the new height with no height animation staged.
287
302
  expect(h.frame.style.height).toBe("360px");
288
- // The sweep runs: end-state clip + layer promotion in flight.
303
+ // The sweep runs: end-state clip (promotion stays resident).
289
304
  expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
290
305
  expect(h.frame.style.willChange).toBe("clip-path");
291
306
  expect(h.frame.style.transition).toBe("");
292
307
 
293
308
  fireTransitionEnd(h.frame, "clip-path");
294
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();
295
313
  expect(h.frame.style.willChange).toBe("");
296
314
  });
297
315
 
298
- it("starts the sweep from the old visual edge (delta inset)", () => {
316
+ it("holds the staged clip through a two-frame warmup before the sweep starts", async () => {
317
+ const h = mountHarness(300);
318
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
319
+ h.start();
320
+
321
+ h.setNatural(360);
322
+ h.remeasure();
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).
327
+ expect(h.frame.style.height).toBe("360px");
328
+ expect(h.frame.style.clipPath).toBe("inset(60px 0 0 0 round 0px 0px 0px 0px)");
329
+
330
+ // The hold is TWO frames, not one: after the first frame the staged
331
+ // start inset must still be in place (a one-frame warmup would have
332
+ // already opened the clip).
333
+ await busFrames(1);
334
+ expect(h.frame.style.clipPath).toBe("inset(60px 0 0 0 round 0px 0px 0px 0px)");
335
+
336
+ await settle();
337
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
338
+ fireTransitionEnd(h.frame, "clip-path");
339
+ expect(h.frame.style.clipPath).toBe("");
340
+ expect(h.frame.style.willChange).toBe("clip-path");
341
+ });
342
+
343
+ it("reports the sweep to the animation bus only once it starts", async () => {
344
+ const h = mountHarness(300);
345
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
346
+ // Let any earlier test's report timer expire so the baseline is quiet.
347
+ await new Promise((resolve) => setTimeout(resolve, 220));
348
+ const transitions = (): number =>
349
+ Number(readHkRuntime("animationBus")?.transitions ?? 0);
350
+ const base = transitions();
351
+
352
+ h.start();
353
+ h.setNatural(360);
354
+ h.remeasure();
355
+ // Warmup pending: no CSS transition is running yet, nothing reported.
356
+ expect(transitions()).toBe(base);
357
+
358
+ // Two warmup frames start the sweep; its 150ms report is live now
359
+ // (well inside the report's window — do NOT use the 220ms settle,
360
+ // it outlives the report).
361
+ await busFrames(3);
362
+ expect(transitions()).toBe(base + 1);
363
+
364
+ fireTransitionEnd(h.frame, "clip-path");
365
+ expect(transitions()).toBe(base);
366
+ });
367
+
368
+ it("starts the sweep from the old visual edge (delta inset)", async () => {
299
369
  const h = mountHarness(300);
300
370
  h.frame.style.setProperty("--hk-sheet-morph", "clip");
301
371
  h.start();
@@ -324,32 +394,75 @@ describe("useSizeMorph clip reveal", () => {
324
394
  "inset(120px 0 0 0 round 0px 0px 0px 0px)",
325
395
  ]);
326
396
  expect(h.frame.style.height).toBe("420px");
397
+ // The staged start inset HOLDS through the warmup — the sweep is a
398
+ // bus one-shot now, never part of the staging task.
399
+ expect(h.frame.style.clipPath).toBe("inset(120px 0 0 0 round 0px 0px 0px 0px)");
400
+ await settle();
327
401
  expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
328
402
  });
329
403
 
330
- 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 () => {
331
405
  const h = mountHarness(400);
332
406
  h.frame.style.setProperty("--hk-sheet-morph", "clip");
333
407
  h.start();
334
408
 
335
- // 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.
336
412
  h.setNatural(320);
337
413
  FakeResizeObserver.instances[0]!.callback();
338
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");
339
422
  expect(h.frame.style.height).toBe("320px");
340
423
  expect(h.frame.style.clipPath).toBe("");
341
- expect(h.frame.style.willChange).toBe("");
342
424
 
343
425
  // Sub-threshold growth (2px < REVEAL_MIN_PX): snaps, no reveal.
344
426
  h.setNatural(322);
345
427
  h.remeasure();
346
428
  expect(h.frame.style.height).toBe("322px");
347
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)");
348
459
  });
349
460
 
350
461
  it("never clips without the mode flag (desktop height morph intact)", async () => {
351
462
  const h = mountHarness(300);
352
463
  h.start();
464
+ // No resident promotion either — the desktop surface never promotes.
465
+ expect(h.frame.style.willChange).toBe("");
353
466
 
354
467
  h.setNatural(400);
355
468
  FakeResizeObserver.instances[0]!.callback();
@@ -357,6 +470,13 @@ describe("useSizeMorph clip reveal", () => {
357
470
  expect(h.frame.style.height).toBe("400px");
358
471
  expect(h.frame.style.clipPath).toBe("");
359
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("");
360
480
  });
361
481
 
362
482
  it("clears an in-flight reveal when a new dance starts", async () => {
@@ -366,18 +486,49 @@ describe("useSizeMorph clip reveal", () => {
366
486
 
367
487
  h.setNatural(380);
368
488
  h.remeasure();
489
+ // Staged: the sweep is still pending on the warmup.
490
+ expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
491
+ await settle();
369
492
  expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
370
493
 
371
- // A second growth lands before transitionend fired: the stale clip
372
- // must come off inside the new dance, then the new reveal stages.
494
+ // A second growth lands mid-sweep: the stale clip/listener/report
495
+ // must come off inside the new dance, then the new reveal stages
496
+ // from the NEW delta (450 − 380 = 70px).
373
497
  h.setNatural(450);
374
498
  h.remeasure();
375
499
  expect(h.frame.style.height).toBe("450px");
376
- expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
500
+ expect(h.frame.style.clipPath).toBe("inset(70px 0 0 0 round 0px 0px 0px 0px)");
377
501
  expect(h.frame.style.willChange).toBe("clip-path");
378
502
  });
379
503
 
380
- it("clears an interrupted reveal inside the next dance (no transitionend)", () => {
504
+ it("cancels a pending warmup when a second growth re-stages mid-warmup", async () => {
505
+ const h = mountHarness(300);
506
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
507
+ h.start();
508
+
509
+ h.setNatural(380);
510
+ h.remeasure();
511
+ expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
512
+
513
+ // Burn exactly one of the first warmup's two frames, then re-stage.
514
+ // A LEAKED first warmup would run its sweep on the next frame; the
515
+ // cancelled one (disconnect in stopReveal) never does.
516
+ await busFrames(1);
517
+ h.setNatural(450);
518
+ h.remeasure();
519
+ expect(h.frame.style.clipPath).toBe("inset(70px 0 0 0 round 0px 0px 0px 0px)");
520
+
521
+ // One frame later the leak would have flipped the clip open; the
522
+ // freshly staged start inset must still hold.
523
+ await busFrames(1);
524
+ expect(h.frame.style.clipPath).toBe("inset(70px 0 0 0 round 0px 0px 0px 0px)");
525
+
526
+ // The NEW warmup's second frame starts its own sweep.
527
+ await busFrames(1);
528
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
529
+ });
530
+
531
+ it("clears an interrupted reveal inside the next dance (no transitionend)", async () => {
381
532
  const h = mountHarness(300);
382
533
  h.frame.style.setProperty("--hk-sheet-morph", "clip");
383
534
  h.start();
@@ -386,17 +537,22 @@ describe("useSizeMorph clip reveal", () => {
386
537
  h.remeasure();
387
538
  expect(h.frame.style.clipPath).not.toBe("");
388
539
 
389
- // A SHRINK lands before the reveal's transitionend fired: unlike a
390
- // follow-up growth (which restages its own clip), the height-morph
391
- // branch writes no clip at all — the dance-start teardown is the
392
- // only thing that returns the frame to CSS ownership (R1 mutation
393
- // M1 evidence: without it the stale inset(0px) + will-change ride
394
- // the shrink and linger at rest).
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.)
395
547
  h.setNatural(310);
396
548
  h.remeasure();
549
+ expect(h.frame.style.height).toBe("380px");
550
+ expect(h.frame.style.clipPath).toBe("");
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");
397
554
  expect(h.frame.style.height).toBe("310px");
398
555
  expect(h.frame.style.clipPath).toBe("");
399
- expect(h.frame.style.willChange).toBe("");
400
556
  });
401
557
 
402
558
  it("releases the clip state on stop so the leave animation owns the frame", async () => {
@@ -412,6 +568,12 @@ describe("useSizeMorph clip reveal", () => {
412
568
  expect(h.frame.style.height).toBe("");
413
569
  expect(h.frame.style.clipPath).toBe("");
414
570
  expect(h.frame.style.willChange).toBe("");
571
+
572
+ // A leaked warmup would fire the sweep AFTER the stop and re-add
573
+ // the clip/promotion — disconnect() in stopReveal is the fix.
574
+ await settle();
575
+ expect(h.frame.style.clipPath).toBe("");
576
+ expect(h.frame.style.willChange).toBe("");
415
577
  });
416
578
 
417
579
  it("ignores transitionend events for other properties", async () => {
@@ -421,6 +583,8 @@ describe("useSizeMorph clip reveal", () => {
421
583
 
422
584
  h.setNatural(360);
423
585
  h.remeasure();
586
+ // The listener only exists once the warmup started the sweep.
587
+ await settle();
424
588
  fireTransitionEnd(h.frame, "height");
425
589
  expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
426
590
  fireTransitionEnd(h.frame, "opacity");
@@ -1,5 +1,11 @@
1
1
  import { onBeforeUnmount, type Ref } from "vue";
2
2
 
3
+ import {
4
+ reportTransition,
5
+ scheduleFrame,
6
+ type AnimationHandle,
7
+ } from "../runtime/animationBus";
8
+
3
9
  /** Chrome-allowance calibration constants (px): the floor covers a
4
10
  * standard header+footer+borders stack (and bodies that overflow at
5
11
  * arm time, where the resting delta goes negative and says nothing
@@ -13,6 +19,18 @@ const CHROME_ALLOWANCE_SLACK = 32;
13
19
  * layer for. */
14
20
  const REVEAL_MIN_PX = 3;
15
21
 
22
+ /** Reveal warmup (bus frames): how long the staged clip holds before
23
+ * the sweep starts. The pin + clip start + `will-change` land in one
24
+ * task, so the promoted layer starts re-rastering the whole resized
25
+ * box immediately; holding the sweep for two frames lets that raster
26
+ * land before the moving edge reveals it. Starting the sweep in the
27
+ * staging task outran the raster thread on phone GPUs and the
28
+ * just-revealed band composited as black tiles (2026-09-21 chest
29
+ * field report, AddProviderWizard step growth). The staged clip keeps
30
+ * the new band hidden through the hold, so the visible geometry is
31
+ * the pre-growth sheet while warming — the wait itself is invisible. */
32
+ const REVEAL_WARMUP_FRAMES = 2;
33
+
16
34
  export interface SizeMorph {
17
35
  /** Arm the morph: observe the content and pin the frame's natural
18
36
  * height on every change. Call once the surface finished its open
@@ -78,20 +96,37 @@ export interface SizeMorphOptions {
78
96
  * pin chases the target with the CSS transition — bounded, one-shot
79
97
  * choreography, not an infinite per-frame animation.
80
98
  *
81
- * Growth morphs on clip-mode surfaces (the mobile sheet docking, flagged
82
- * `--hk-sheet-morph: clip` in CSS) reveal instead of animating height:
83
- * the new pin lands instantly and the box's top edge sweeps up through
84
- * `clip-path: inset()` — same duration/ease tokens as the height
85
- * transition, same "content rides rigidly" grammar as the modal unveil,
86
- * 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
87
104
  * backdrop-filter re-raster over the resizing fixed layer (the mobile
88
- * patchy-flicker source, 2026-09-15 chest report). Height-mode
89
- * surfaces and the select sheet keep the height transition for shrinks
90
- * (their stylesheets list it); the phone MODAL sheet narrowed its list
91
- * to clip-path-only (2026-09-21 step-change shrink report — 150ms of
92
- * per-frame layout on the fixed layer re-rastered the moving edge), so
93
- * its shrinks snap: the L291-style pin flip lands instantly when the
94
- * stylesheet no longer transitions height.
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
+ * (2026-09-21 chest report — same-task starts revealed black tiles).
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.
120
+ *
121
+ * Scheduling rides the shared animation context
122
+ * (`runtime/animationBus`): the measurement hop and the reveal warmup
123
+ * are bus one-shots (`scheduleFrame`), and the CSS sweep is reported
124
+ * (`reportTransition`) so the bus keeps beating through it and the
125
+ * runtime registry sees the load. The settle debounce stays a
126
+ * real-time timer on purpose — it gates MEASUREMENT, not motion, and a
127
+ * parked (reduced-motion) bus must never freeze layout by stalling it;
128
+ * bus one-shots fire even parked, so every frame path still completes
129
+ * and the motion collapse itself stays CSS-owned.
95
130
  *
96
131
  * Reduced motion / the global animation switch stay honored: the frame's
97
132
  * transition-duration collapses to one frame under
@@ -103,7 +138,7 @@ export function useSizeMorph(
103
138
  options: SizeMorphOptions = {},
104
139
  ): SizeMorph {
105
140
  let ro: ResizeObserver | null = null;
106
- let raf = 0;
141
+ let raf: AnimationHandle | null = null;
107
142
  let settleTimer: ReturnType<typeof setTimeout> | null = null;
108
143
  let armed = false;
109
144
  /** Last pinned height (px) — the transition's "from" value. */
@@ -114,25 +149,140 @@ export function useSizeMorph(
114
149
  * bodies that overflow at rest, plus subpixel slack. See the guard in
115
150
  * remeasure(). */
116
151
  let chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
117
- /** In-flight clip reveal (clip-mode growth morph): the frame whose
118
- * inline clip-path/will-change must come off again once the sweep
119
- * lands, plus the listener that does it. */
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. */
120
157
  let revealEl: HTMLElement | null = null;
121
158
  let revealEnd: ((ev: Event) => void) | null = null;
159
+ let revealWarmup: AnimationHandle | null = null;
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;
172
+
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
+ }
122
190
 
123
- /** Tear down an in-flight clip reveal: drop the listener and return
124
- * the inline clip/will-change to CSS ownership. Safe to call when no
125
- * reveal is running (every dance start, stop, and unmount). */
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). */
126
199
  function stopReveal(): void {
200
+ if (revealWarmup) {
201
+ revealWarmup.disconnect();
202
+ revealWarmup = null;
203
+ }
204
+ if (revealReport) {
205
+ revealReport.disconnect();
206
+ revealReport = null;
207
+ }
127
208
  if (revealEl && revealEnd) {
128
209
  revealEl.removeEventListener("transitionend", revealEnd);
129
210
  }
130
211
  if (revealEl) {
131
- revealEl.style.clipPath = "";
132
- revealEl.style.willChange = "";
212
+ if (revealDir === "conceal" && concealTo != null) {
213
+ finishConceal(revealEl);
214
+ } else {
215
+ revealEl.style.clipPath = "";
216
+ }
133
217
  }
134
218
  revealEl = null;
135
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 = "";
230
+ }
231
+
232
+ /** The frame's computed clip-transition duration, for the bus report
233
+ * at sweep start: the sweep is CSS-owned, so without a report the
234
+ * bus goes quiet for its duration and starves concurrent entries
235
+ * (and the runtime registry under-reports load). Max across the
236
+ * duration list; falls back to the --duration-fast default when the
237
+ * read fails (SSR) or carries no time token. */
238
+ function transitionDurationMs(f: HTMLElement): number {
239
+ let raw = "";
240
+ try {
241
+ raw = getComputedStyle(f).transitionDuration;
242
+ } catch {
243
+ raw = "";
244
+ }
245
+ let max = 0;
246
+ for (const m of raw.matchAll(/(\d+(?:\.\d+)?)(m?)s/g)) {
247
+ max = Math.max(max, parseFloat(m[1]!) * (m[2] ? 1 : 1000));
248
+ }
249
+ return max > 0 ? max : 150;
250
+ }
251
+
252
+ /** Begin the actual sweep: attach the end listener, report the CSS
253
+ * transition to the bus so it keeps beating for the duration, and
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 {
267
+ const onEnd = (ev: Event): void => {
268
+ // transitionend bubbles: a descendant animating its own
269
+ // clip-path must not end the frame's morph early.
270
+ if (
271
+ ev.target === f &&
272
+ (ev as TransitionEvent).propertyName === "clip-path"
273
+ ) {
274
+ stopReveal();
275
+ }
276
+ };
277
+ f.addEventListener("transitionend", onEnd);
278
+ revealEl = f;
279
+ revealEnd = onEnd;
280
+ revealDir = dir;
281
+ revealReport = reportTransition(transitionDurationMs(f));
282
+ f.style.clipPath =
283
+ dir === "reveal"
284
+ ? `inset(0px 0 0 0 round ${radii})`
285
+ : `inset(${insetPx}px 0 0 0 round ${radii})`;
136
286
  }
137
287
 
138
288
  /** Clip-mode opt-in, owned by CSS: the modal's mobile media block
@@ -178,6 +328,7 @@ export function useSizeMorph(
178
328
  const f = frame.value;
179
329
  if (f) f.style.height = "";
180
330
  stopReveal();
331
+ clearResidentWill();
181
332
  pinned = 0;
182
333
  }
183
334
 
@@ -212,8 +363,8 @@ export function useSizeMorph(
212
363
  // natural (CSS-capped) height in one layout flush.
213
364
  // 2. Clip-mode growth: pin the NEW height outright and stage the
214
365
  // clip start (still transition-disabled), so the reveal that
215
- // follows sweeps a fully-laid-out box — layout happens once,
216
- // here, never per frame.
366
+ // follows (after the warmup, started by the bus) sweeps a
367
+ // fully-laid-out box — layout happens once, here, never per frame.
217
368
  // Otherwise re-establish the OLD pin (still transition-disabled)
218
369
  // and flush it, so the style history is exactly "old height" when
219
370
  // the live CSS transition returns.
@@ -251,22 +402,31 @@ export function useSizeMorph(
251
402
  return;
252
403
  }
253
404
  const next = Math.round(natural);
254
- const growth = next - pinned;
255
- // Clip reveal (see the composable doc): the pin lands instantly and
256
- // the top edge sweeps up through paint-only clip-path, with the
257
- // box's own corner radii riding the moving edge. The frame's
258
- // stylesheet owns the clip-path transition (duration/ease tokens
259
- // shared with the height transition), so reduced-motion and the
260
- // global animation switch collapse it exactly like the height morph
261
- // they already govern. Everything else — shrink, first pin,
262
- // sub-threshold growth, height-mode surfaces — keeps the height
263
- // morph below (desktop stays exactly as it was).
264
- 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);
265
419
  let radii = "";
266
420
  if (reveal) {
267
421
  radii = cornerRadii(f);
268
422
  f.style.height = `${next}px`;
269
- 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`;
270
430
  } else if (pinned > 0) {
271
431
  f.style.height = `${pinned}px`;
272
432
  }
@@ -275,26 +435,47 @@ export function useSizeMorph(
275
435
  // old visual edge / the height transition starts from the old pin.
276
436
  void f.offsetHeight;
277
437
  f.style.transition = inlineTransition;
278
- if (reveal) {
279
- const onEnd = (ev: Event): void => {
280
- // transitionend bubbles: a descendant animating its own
281
- // clip-path must not end the frame's reveal early.
282
- if (
283
- ev.target === f &&
284
- (ev as TransitionEvent).propertyName === "clip-path"
285
- ) {
286
- stopReveal();
287
- }
288
- };
289
- f.addEventListener("transitionend", onEnd);
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.
290
449
  revealEl = f;
291
- revealEnd = onEnd;
292
- f.style.willChange = "clip-path";
293
- f.style.clipPath = `inset(0px 0 0 0 round ${radii})`;
450
+ revealDir = reveal ? "reveal" : "conceal";
451
+ concealTo = conceal ? next : null;
452
+ const dir = revealDir;
453
+ const insetPx = Math.abs(delta);
454
+ let framesLeft = REVEAL_WARMUP_FRAMES;
455
+ const armWarmup = (): void => {
456
+ revealWarmup = scheduleFrame(() => {
457
+ revealWarmup = null;
458
+ // Torn down mid-warmup (new dance / hold / stop / unmount).
459
+ if (revealEl !== f || revealDir !== dir) return;
460
+ if (--framesLeft > 0) {
461
+ armWarmup();
462
+ return;
463
+ }
464
+ startRevealSweep(f, radii, dir, insetPx);
465
+ });
466
+ };
467
+ armWarmup();
294
468
  } else {
295
469
  f.style.height = `${next}px`;
296
470
  }
297
- 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
+ }
298
479
  // Self-heal the allowance on every VALIDATED pin: chrome that grew
299
480
  // after calibration (an async footer, a header slot mounting
300
481
  // mid-open) updates the baseline instead of tripping the guard on
@@ -321,8 +502,13 @@ export function useSizeMorph(
321
502
  settleTimer = setTimeout(() => {
322
503
  settleTimer = null;
323
504
  if (raf) return;
324
- raf = requestAnimationFrame(() => {
325
- raf = 0;
505
+ // Frame work rides the shared bus; the settle debounce above
506
+ // deliberately stays a real-time timer — it gates MEASUREMENT,
507
+ // not motion, and a parked (reduced-motion) bus must never freeze
508
+ // the layout by stalling a bus-ridden interval. One-shots fire
509
+ // even parked, so this hop is safe in every motion state.
510
+ raf = scheduleFrame(() => {
511
+ raf = null;
326
512
  remeasure();
327
513
  });
328
514
  }, 150);
@@ -335,6 +521,15 @@ export function useSizeMorph(
335
521
  // its enter (callers arm in after-enter) and sits at rest, so the
336
522
  // frame-vs-content delta is pure chrome.
337
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
+ }
338
533
  if (typeof ResizeObserver === "undefined" || !content.value) {
339
534
  remeasure();
340
535
  return;
@@ -354,13 +549,16 @@ export function useSizeMorph(
354
549
  settleTimer = null;
355
550
  }
356
551
  if (raf) {
357
- cancelAnimationFrame(raf);
358
- raf = 0;
552
+ raf.disconnect();
553
+ raf = null;
359
554
  }
360
555
  // The next start() re-calibrates against whatever chrome that open
361
556
  // cycle carries.
362
557
  chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
363
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();
364
562
  // Deliberately no release(): the pin stays on the frame so the close
365
563
  // fold plays on a stable box, and a reopen interrupt animates from it.
366
564
  }
@@ -377,8 +575,9 @@ export function useSizeMorph(
377
575
  onBeforeUnmount(() => {
378
576
  ro?.disconnect();
379
577
  if (settleTimer) clearTimeout(settleTimer);
380
- if (raf) cancelAnimationFrame(raf);
578
+ if (raf) raf.disconnect();
381
579
  stopReveal();
580
+ clearResidentWill();
382
581
  });
383
582
 
384
583
  return { start, stop, hold, remeasure };