@celestia-island/hikari 0.55.37 → 0.55.38

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.38",
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,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);
@@ -295,7 +306,61 @@ describe("useSizeMorph clip reveal", () => {
295
306
  expect(h.frame.style.willChange).toBe("");
296
307
  });
297
308
 
298
- it("starts the sweep from the old visual edge (delta inset)", () => {
309
+ it("holds the staged clip through a two-frame warmup before the sweep starts", async () => {
310
+ const h = mountHarness(300);
311
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
312
+ h.start();
313
+
314
+ h.setNatural(360);
315
+ 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).
321
+ expect(h.frame.style.height).toBe("360px");
322
+ 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
+
325
+ // The hold is TWO frames, not one: after the first frame the staged
326
+ // start inset must still be in place (a one-frame warmup would have
327
+ // already opened the clip).
328
+ await busFrames(1);
329
+ expect(h.frame.style.clipPath).toBe("inset(60px 0 0 0 round 0px 0px 0px 0px)");
330
+
331
+ await settle();
332
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
333
+ fireTransitionEnd(h.frame, "clip-path");
334
+ expect(h.frame.style.clipPath).toBe("");
335
+ expect(h.frame.style.willChange).toBe("");
336
+ });
337
+
338
+ it("reports the sweep to the animation bus only once it starts", async () => {
339
+ const h = mountHarness(300);
340
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
341
+ // Let any earlier test's report timer expire so the baseline is quiet.
342
+ await new Promise((resolve) => setTimeout(resolve, 220));
343
+ const transitions = (): number =>
344
+ Number(readHkRuntime("animationBus")?.transitions ?? 0);
345
+ const base = transitions();
346
+
347
+ h.start();
348
+ h.setNatural(360);
349
+ h.remeasure();
350
+ // Warmup pending: no CSS transition is running yet, nothing reported.
351
+ expect(transitions()).toBe(base);
352
+
353
+ // Two warmup frames start the sweep; its 150ms report is live now
354
+ // (well inside the report's window — do NOT use the 220ms settle,
355
+ // it outlives the report).
356
+ await busFrames(3);
357
+ expect(transitions()).toBe(base + 1);
358
+
359
+ fireTransitionEnd(h.frame, "clip-path");
360
+ expect(transitions()).toBe(base);
361
+ });
362
+
363
+ it("starts the sweep from the old visual edge (delta inset)", async () => {
299
364
  const h = mountHarness(300);
300
365
  h.frame.style.setProperty("--hk-sheet-morph", "clip");
301
366
  h.start();
@@ -324,6 +389,10 @@ describe("useSizeMorph clip reveal", () => {
324
389
  "inset(120px 0 0 0 round 0px 0px 0px 0px)",
325
390
  ]);
326
391
  expect(h.frame.style.height).toBe("420px");
392
+ // The staged start inset HOLDS through the warmup — the sweep is a
393
+ // bus one-shot now, never part of the staging task.
394
+ expect(h.frame.style.clipPath).toBe("inset(120px 0 0 0 round 0px 0px 0px 0px)");
395
+ await settle();
327
396
  expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
328
397
  });
329
398
 
@@ -366,18 +435,49 @@ describe("useSizeMorph clip reveal", () => {
366
435
 
367
436
  h.setNatural(380);
368
437
  h.remeasure();
438
+ // Staged: the sweep is still pending on the warmup.
439
+ expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
440
+ await settle();
369
441
  expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
370
442
 
371
- // A second growth lands before transitionend fired: the stale clip
372
- // must come off inside the new dance, then the new reveal stages.
443
+ // A second growth lands mid-sweep: the stale clip/listener/report
444
+ // must come off inside the new dance, then the new reveal stages
445
+ // from the NEW delta (450 − 380 = 70px).
373
446
  h.setNatural(450);
374
447
  h.remeasure();
375
448
  expect(h.frame.style.height).toBe("450px");
376
- expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
449
+ expect(h.frame.style.clipPath).toBe("inset(70px 0 0 0 round 0px 0px 0px 0px)");
377
450
  expect(h.frame.style.willChange).toBe("clip-path");
378
451
  });
379
452
 
380
- it("clears an interrupted reveal inside the next dance (no transitionend)", () => {
453
+ it("cancels a pending warmup when a second growth re-stages mid-warmup", async () => {
454
+ const h = mountHarness(300);
455
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
456
+ h.start();
457
+
458
+ h.setNatural(380);
459
+ h.remeasure();
460
+ expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
461
+
462
+ // Burn exactly one of the first warmup's two frames, then re-stage.
463
+ // A LEAKED first warmup would run its sweep on the next frame; the
464
+ // cancelled one (disconnect in stopReveal) never does.
465
+ await busFrames(1);
466
+ h.setNatural(450);
467
+ h.remeasure();
468
+ expect(h.frame.style.clipPath).toBe("inset(70px 0 0 0 round 0px 0px 0px 0px)");
469
+
470
+ // One frame later the leak would have flipped the clip open; the
471
+ // freshly staged start inset must still hold.
472
+ await busFrames(1);
473
+ expect(h.frame.style.clipPath).toBe("inset(70px 0 0 0 round 0px 0px 0px 0px)");
474
+
475
+ // The NEW warmup's second frame starts its own sweep.
476
+ await busFrames(1);
477
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
478
+ });
479
+
480
+ it("clears an interrupted reveal inside the next dance (no transitionend)", async () => {
381
481
  const h = mountHarness(300);
382
482
  h.frame.style.setProperty("--hk-sheet-morph", "clip");
383
483
  h.start();
@@ -386,17 +486,22 @@ describe("useSizeMorph clip reveal", () => {
386
486
  h.remeasure();
387
487
  expect(h.frame.style.clipPath).not.toBe("");
388
488
 
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).
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.
395
496
  h.setNatural(310);
396
497
  h.remeasure();
397
498
  expect(h.frame.style.height).toBe("310px");
398
499
  expect(h.frame.style.clipPath).toBe("");
399
500
  expect(h.frame.style.willChange).toBe("");
501
+
502
+ await settle();
503
+ expect(h.frame.style.clipPath).toBe("");
504
+ expect(h.frame.style.willChange).toBe("");
400
505
  });
401
506
 
402
507
  it("releases the clip state on stop so the leave animation owns the frame", async () => {
@@ -412,6 +517,12 @@ describe("useSizeMorph clip reveal", () => {
412
517
  expect(h.frame.style.height).toBe("");
413
518
  expect(h.frame.style.clipPath).toBe("");
414
519
  expect(h.frame.style.willChange).toBe("");
520
+
521
+ // A leaked warmup would fire the sweep AFTER the stop and re-add
522
+ // the clip/promotion — disconnect() in stopReveal is the fix.
523
+ await settle();
524
+ expect(h.frame.style.clipPath).toBe("");
525
+ expect(h.frame.style.willChange).toBe("");
415
526
  });
416
527
 
417
528
  it("ignores transitionend events for other properties", async () => {
@@ -421,6 +532,8 @@ describe("useSizeMorph clip reveal", () => {
421
532
 
422
533
  h.setNatural(360);
423
534
  h.remeasure();
535
+ // The listener only exists once the warmup started the sweep.
536
+ await settle();
424
537
  fireTransitionEnd(h.frame, "height");
425
538
  expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
426
539
  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
@@ -85,14 +103,28 @@ export interface SizeMorphOptions {
85
103
  * transition, same "content rides rigidly" grammar as the modal unveil,
86
104
  * but paint/compositor-level: no per-frame layout and no per-frame
87
105
  * backdrop-filter re-raster over the resizing fixed layer (the mobile
88
- * patchy-flicker source, 2026-09-15 chest report). Height-mode
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
109
+ * (2026-09-21 chest report — same-task starts revealed black tiles).
110
+ * Height-mode
89
111
  * surfaces and the select sheet keep the height transition for shrinks
90
112
  * (their stylesheets list it); the phone MODAL sheet narrowed its list
91
113
  * to clip-path-only (2026-09-21 step-change shrink report — 150ms of
92
114
  * 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
115
+ * its shrinks snap: the pin flip lands instantly when the
94
116
  * stylesheet no longer transitions height.
95
117
  *
118
+ * Scheduling rides the shared animation context
119
+ * (`runtime/animationBus`): the measurement hop and the reveal warmup
120
+ * are bus one-shots (`scheduleFrame`), and the CSS sweep is reported
121
+ * (`reportTransition`) so the bus keeps beating through it and the
122
+ * runtime registry sees the load. The settle debounce stays a
123
+ * real-time timer on purpose — it gates MEASUREMENT, not motion, and a
124
+ * parked (reduced-motion) bus must never freeze layout by stalling it;
125
+ * bus one-shots fire even parked, so every frame path still completes
126
+ * and the motion collapse itself stays CSS-owned.
127
+ *
96
128
  * Reduced motion / the global animation switch stay honored: the frame's
97
129
  * transition-duration collapses to one frame under
98
130
  * `html[data-css-animations="0"]`, so the pin updates snap.
@@ -103,7 +135,7 @@ export function useSizeMorph(
103
135
  options: SizeMorphOptions = {},
104
136
  ): SizeMorph {
105
137
  let ro: ResizeObserver | null = null;
106
- let raf = 0;
138
+ let raf: AnimationHandle | null = null;
107
139
  let settleTimer: ReturnType<typeof setTimeout> | null = null;
108
140
  let armed = false;
109
141
  /** Last pinned height (px) — the transition's "from" value. */
@@ -116,14 +148,26 @@ export function useSizeMorph(
116
148
  let chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
117
149
  /** In-flight clip reveal (clip-mode growth morph): the frame whose
118
150
  * inline clip-path/will-change must come off again once the sweep
119
- * lands, plus the listener that does it. */
151
+ * lands, the listener that does it, the warmup one-shot that starts
152
+ * the sweep, and the sweep's bus transition report. */
120
153
  let revealEl: HTMLElement | null = null;
121
154
  let revealEnd: ((ev: Event) => void) | null = null;
155
+ let revealWarmup: AnimationHandle | null = null;
156
+ let revealReport: AnimationHandle | null = null;
122
157
 
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). */
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). */
126
162
  function stopReveal(): void {
163
+ if (revealWarmup) {
164
+ revealWarmup.disconnect();
165
+ revealWarmup = null;
166
+ }
167
+ if (revealReport) {
168
+ revealReport.disconnect();
169
+ revealReport = null;
170
+ }
127
171
  if (revealEl && revealEnd) {
128
172
  revealEl.removeEventListener("transitionend", revealEnd);
129
173
  }
@@ -135,6 +179,49 @@ export function useSizeMorph(
135
179
  revealEnd = null;
136
180
  }
137
181
 
182
+ /** The frame's computed clip-transition duration, for the bus report
183
+ * at sweep start: the sweep is CSS-owned, so without a report the
184
+ * bus goes quiet for its duration and starves concurrent entries
185
+ * (and the runtime registry under-reports load). Max across the
186
+ * duration list; falls back to the --duration-fast default when the
187
+ * read fails (SSR) or carries no time token. */
188
+ function transitionDurationMs(f: HTMLElement): number {
189
+ let raw = "";
190
+ try {
191
+ raw = getComputedStyle(f).transitionDuration;
192
+ } catch {
193
+ raw = "";
194
+ }
195
+ let max = 0;
196
+ for (const m of raw.matchAll(/(\d+(?:\.\d+)?)(m?)s/g)) {
197
+ max = Math.max(max, parseFloat(m[1]!) * (m[2] ? 1 : 1000));
198
+ }
199
+ return max > 0 ? max : 150;
200
+ }
201
+
202
+ /** Begin the actual sweep: attach the end listener, report the CSS
203
+ * 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 {
208
+ const onEnd = (ev: Event): void => {
209
+ // transitionend bubbles: a descendant animating its own
210
+ // clip-path must not end the frame's reveal early.
211
+ if (
212
+ ev.target === f &&
213
+ (ev as TransitionEvent).propertyName === "clip-path"
214
+ ) {
215
+ stopReveal();
216
+ }
217
+ };
218
+ f.addEventListener("transitionend", onEnd);
219
+ revealEl = f;
220
+ revealEnd = onEnd;
221
+ revealReport = reportTransition(transitionDurationMs(f));
222
+ f.style.clipPath = `inset(0px 0 0 0 round ${radii})`;
223
+ }
224
+
138
225
  /** Clip-mode opt-in, owned by CSS: the modal's mobile media block
139
226
  * sets `--hk-sheet-morph: clip` inside its ≤767px query, and the
140
227
  * select sheet sets it on its class rule directly (that class only
@@ -212,8 +299,8 @@ export function useSizeMorph(
212
299
  // natural (CSS-capped) height in one layout flush.
213
300
  // 2. Clip-mode growth: pin the NEW height outright and stage the
214
301
  // 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.
302
+ // follows (after the warmup, started by the bus) sweeps a
303
+ // fully-laid-out box — layout happens once, here, never per frame.
217
304
  // Otherwise re-establish the OLD pin (still transition-disabled)
218
305
  // and flush it, so the style history is exactly "old height" when
219
306
  // the live CSS transition returns.
@@ -276,21 +363,29 @@ export function useSizeMorph(
276
363
  void f.offsetHeight;
277
364
  f.style.transition = inlineTransition;
278
365
  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);
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.
290
373
  revealEl = f;
291
- revealEnd = onEnd;
292
374
  f.style.willChange = "clip-path";
293
- f.style.clipPath = `inset(0px 0 0 0 round ${radii})`;
375
+ let framesLeft = REVEAL_WARMUP_FRAMES;
376
+ const armWarmup = (): void => {
377
+ revealWarmup = scheduleFrame(() => {
378
+ revealWarmup = null;
379
+ // Torn down mid-warmup (new dance / hold / stop / unmount).
380
+ if (revealEl !== f) return;
381
+ if (--framesLeft > 0) {
382
+ armWarmup();
383
+ return;
384
+ }
385
+ startRevealSweep(f, radii);
386
+ });
387
+ };
388
+ armWarmup();
294
389
  } else {
295
390
  f.style.height = `${next}px`;
296
391
  }
@@ -321,8 +416,13 @@ export function useSizeMorph(
321
416
  settleTimer = setTimeout(() => {
322
417
  settleTimer = null;
323
418
  if (raf) return;
324
- raf = requestAnimationFrame(() => {
325
- raf = 0;
419
+ // Frame work rides the shared bus; the settle debounce above
420
+ // deliberately stays a real-time timer — it gates MEASUREMENT,
421
+ // not motion, and a parked (reduced-motion) bus must never freeze
422
+ // the layout by stalling a bus-ridden interval. One-shots fire
423
+ // even parked, so this hop is safe in every motion state.
424
+ raf = scheduleFrame(() => {
425
+ raf = null;
326
426
  remeasure();
327
427
  });
328
428
  }, 150);
@@ -354,8 +454,8 @@ export function useSizeMorph(
354
454
  settleTimer = null;
355
455
  }
356
456
  if (raf) {
357
- cancelAnimationFrame(raf);
358
- raf = 0;
457
+ raf.disconnect();
458
+ raf = null;
359
459
  }
360
460
  // The next start() re-calibrates against whatever chrome that open
361
461
  // cycle carries.
@@ -377,7 +477,7 @@ export function useSizeMorph(
377
477
  onBeforeUnmount(() => {
378
478
  ro?.disconnect();
379
479
  if (settleTimer) clearTimeout(settleTimer);
380
- if (raf) cancelAnimationFrame(raf);
480
+ if (raf) raf.disconnect();
381
481
  stopReveal();
382
482
  });
383
483