@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 +1 -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 +124 -11
- package/src/composables/useSizeMorph.ts +127 -27
package/package.json
CHANGED
|
@@ -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);
|
|
@@ -295,7 +306,61 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
295
306
|
expect(h.frame.style.willChange).toBe("");
|
|
296
307
|
});
|
|
297
308
|
|
|
298
|
-
it("
|
|
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
|
|
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(
|
|
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("
|
|
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
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
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).
|
|
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
|
|
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 =
|
|
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,
|
|
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:
|
|
124
|
-
* the
|
|
125
|
-
*
|
|
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
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
358
|
-
raf =
|
|
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)
|
|
480
|
+
if (raf) raf.disconnect();
|
|
381
481
|
stopReveal();
|
|
382
482
|
});
|
|
383
483
|
|