@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 +1 -1
- package/src/components/HkModalBreadcrumb.blur.test.ts +61 -0
- package/src/components/HkModalBreadcrumb.scss +17 -1
- package/src/components/HkStepFlow.phonesnap.test.ts +105 -0
- package/src/components/HkStepFlow.scss +16 -0
- package/src/components/HkTitleBar.scss +9 -0
- package/src/components/HkTitleBar.test.tsx +127 -0
- package/src/components/HkTitleBar.tsx +8 -2
- package/src/composables/useSizeMorph.test.ts +180 -16
- package/src/composables/useSizeMorph.ts +256 -57
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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("
|
|
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
|
|
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
|
|
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(
|
|
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("
|
|
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
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
// the
|
|
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
|
-
*
|
|
82
|
-
* `--hk-sheet-morph: clip` in CSS)
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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).
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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 =
|
|
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
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
124
|
-
* the
|
|
125
|
-
*
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
255
|
-
// Clip
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
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(${
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
358
|
-
raf =
|
|
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)
|
|
578
|
+
if (raf) raf.disconnect();
|
|
381
579
|
stopReveal();
|
|
580
|
+
clearResidentWill();
|
|
382
581
|
});
|
|
383
582
|
|
|
384
583
|
return { start, stop, hold, remeasure };
|