@celestia-island/hikari 0.55.44 → 0.55.46
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/animation/registerAnimations.ts +2 -0
- package/src/components/HkLoadingVeil.scss +64 -0
- package/src/components/HkLoadingVeil.test.tsx +89 -0
- package/src/components/HkLoadingVeil.tsx +41 -0
- package/src/components/HkModal.stepflowswap.test.tsx +329 -0
- package/src/components/HkModal.tsx +81 -11
- package/src/components/HkStepFlow.scss +72 -39
- package/src/components/HkStepFlow.slide.contract.test.ts +166 -49
- package/src/components/HkStepFlow.test.tsx +808 -59
- package/src/components/HkStepFlow.tsx +527 -116
- package/src/composables/useSizeMorph.test.ts +79 -0
- package/src/composables/useSizeMorph.ts +76 -2
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -106,6 +106,8 @@ registerCssAnimation("hk-placeholder-marquee-scroll", { infinite: true });
|
|
|
106
106
|
// styles/admin-tokens.scss — admin header emergency-stop chip while
|
|
107
107
|
// engaged (loops forever).
|
|
108
108
|
registerCssAnimation("s-admin-header-emergency-pulse", { infinite: true });
|
|
109
|
+
registerCssAnimation("hk-loading-veil-in");
|
|
110
|
+
registerCssAnimation("hk-loading-veil-spin", { infinite: true });
|
|
109
111
|
// The styles/theme/mixins.scss ambient/glow/entrance keyframes were
|
|
110
112
|
// retired with the legacy Gen-1 sheet vendoring (hikari #476) — their
|
|
111
113
|
// registrar entries went with them.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// HkLoadingVeil — fullscreen semi-transparent blocking overlay.
|
|
2
|
+
// The z-index sits above the toast band (--hk-z-toast) so the veil is
|
|
3
|
+
// the topmost painted surface while active; pointer-events: all on the
|
|
4
|
+
// root swallows every interaction beneath (the blocking contract).
|
|
5
|
+
.hk-loading-veil {
|
|
6
|
+
position: fixed;
|
|
7
|
+
inset: 0;
|
|
8
|
+
z-index: calc(var(--hk-z-toast, 4000) + 100);
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
justify-content: center;
|
|
12
|
+
// Semi-transparent backdrop with a slight blur: the page beneath
|
|
13
|
+
// stays readable (orientation) but clearly inactive.
|
|
14
|
+
background: rgb(var(--color-background, 214 236 240) / 0.55);
|
|
15
|
+
backdrop-filter: blur(6px);
|
|
16
|
+
-webkit-backdrop-filter: blur(6px);
|
|
17
|
+
pointer-events: all;
|
|
18
|
+
animation: hk-loading-veil-in 150ms ease-out;
|
|
19
|
+
|
|
20
|
+
// Both light and dark themes ride the --color-background token.
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.hk-loading-veil-body {
|
|
24
|
+
display: flex;
|
|
25
|
+
flex-direction: column;
|
|
26
|
+
align-items: center;
|
|
27
|
+
gap: 12px;
|
|
28
|
+
padding: 24px 32px;
|
|
29
|
+
border-radius: 12px;
|
|
30
|
+
background: rgb(var(--color-surface, 240 244 248) / 0.85);
|
|
31
|
+
box-shadow: 0 8px 32px rgb(0 0 0 / 0.12);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.hk-loading-veil-spinner {
|
|
35
|
+
display: inline-block;
|
|
36
|
+
width: 28px;
|
|
37
|
+
height: 28px;
|
|
38
|
+
border: 3px solid rgb(var(--color-border, 100 100 100) / 0.4);
|
|
39
|
+
border-top-color: rgb(var(--color-primary, 122 162 247));
|
|
40
|
+
border-radius: 50%;
|
|
41
|
+
animation: hk-loading-veil-spin 0.8s linear infinite;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.hk-loading-veil-label {
|
|
45
|
+
margin: 0;
|
|
46
|
+
font-size: 0.875rem;
|
|
47
|
+
color: rgb(var(--color-text, 30 30 30));
|
|
48
|
+
text-align: center;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@keyframes hk-loading-veil-spin {
|
|
52
|
+
to {
|
|
53
|
+
transform: rotate(360deg);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@keyframes hk-loading-veil-in {
|
|
58
|
+
from {
|
|
59
|
+
opacity: 0;
|
|
60
|
+
}
|
|
61
|
+
to {
|
|
62
|
+
opacity: 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkLoadingVeil from "./HkLoadingVeil";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkLoadingVeil contract tests:
|
|
8
|
+
* - nothing renders while inactive (zero DOM footprint)
|
|
9
|
+
* - active paints the veil root, the spinner, and the label
|
|
10
|
+
* - no label prop → no label node (spinner-only)
|
|
11
|
+
* - reactive: active toggles appearance/disappearance
|
|
12
|
+
* - the root carries role=status / aria-live=polite / the label as
|
|
13
|
+
* aria-label (screen readers announce the blocking state)
|
|
14
|
+
*
|
|
15
|
+
* (Repo test convention: raw createApp + document queries, no
|
|
16
|
+
* @vue/test-utils dependency.)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const mounts: Array<() => void> = [];
|
|
20
|
+
|
|
21
|
+
function mountVeil(props: { active?: boolean; label?: string } = {}) {
|
|
22
|
+
const container = document.createElement("div");
|
|
23
|
+
document.body.appendChild(container);
|
|
24
|
+
const active = ref(props.active ?? false);
|
|
25
|
+
const app = createApp({
|
|
26
|
+
setup() {
|
|
27
|
+
return () =>
|
|
28
|
+
h(HkLoadingVeil, {
|
|
29
|
+
active: active.value,
|
|
30
|
+
label: props.label ?? "",
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
app.mount(container);
|
|
35
|
+
mounts.push(() => {
|
|
36
|
+
app.unmount();
|
|
37
|
+
container.remove();
|
|
38
|
+
});
|
|
39
|
+
return { active, container };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
while (mounts.length) mounts.pop()?.();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("HkLoadingVeil", () => {
|
|
47
|
+
it("renders nothing while inactive", async () => {
|
|
48
|
+
const { container } = mountVeil({ active: false });
|
|
49
|
+
await nextTick();
|
|
50
|
+
expect(container.querySelector(".hk-loading-veil")).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders the veil, spinner and label while active", async () => {
|
|
54
|
+
const { container } = mountVeil({ active: true, label: "Opening…" });
|
|
55
|
+
await nextTick();
|
|
56
|
+
expect(container.querySelector(".hk-loading-veil")).not.toBeNull();
|
|
57
|
+
expect(container.querySelector(".hk-loading-veil-spinner")).not.toBeNull();
|
|
58
|
+
const label = container.querySelector(".hk-loading-veil-label");
|
|
59
|
+
expect(label?.textContent).toBe("Opening…");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("omits the label node when no label is given", async () => {
|
|
63
|
+
const { container } = mountVeil({ active: true });
|
|
64
|
+
await nextTick();
|
|
65
|
+
expect(container.querySelector(".hk-loading-veil-spinner")).not.toBeNull();
|
|
66
|
+
expect(container.querySelector(".hk-loading-veil-label")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("appears and disappears reactively with the active prop", async () => {
|
|
70
|
+
const { active, container } = mountVeil({ active: false });
|
|
71
|
+
await nextTick();
|
|
72
|
+
expect(container.querySelector(".hk-loading-veil")).toBeNull();
|
|
73
|
+
active.value = true;
|
|
74
|
+
await nextTick();
|
|
75
|
+
expect(container.querySelector(".hk-loading-veil")).not.toBeNull();
|
|
76
|
+
active.value = false;
|
|
77
|
+
await nextTick();
|
|
78
|
+
expect(container.querySelector(".hk-loading-veil")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("carries role=status and the label as aria-label", async () => {
|
|
82
|
+
const { container } = mountVeil({ active: true, label: "Loading backend" });
|
|
83
|
+
await nextTick();
|
|
84
|
+
const root = container.querySelector(".hk-loading-veil");
|
|
85
|
+
expect(root?.getAttribute("role")).toBe("status");
|
|
86
|
+
expect(root?.getAttribute("aria-label")).toBe("Loading backend");
|
|
87
|
+
expect(root?.getAttribute("aria-live")).toBe("polite");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HkLoadingVeil — a fullscreen semi-transparent blocking overlay for
|
|
3
|
+
* navigation-span loading (the "one tap, no dead window" pattern).
|
|
4
|
+
*
|
|
5
|
+
* While `active`, the veil paints above everything (z-index above the
|
|
6
|
+
* toast band): a dimmed backdrop with backdrop-blur, a centered spinner
|
|
7
|
+
* and a one-line label, and it swallows ALL pointer events — the user
|
|
8
|
+
* cannot double-tap, scroll, or interact with anything beneath. When
|
|
9
|
+
* `active` turns false the veil fades out (150 ms) and pointer events
|
|
10
|
+
* return.
|
|
11
|
+
*
|
|
12
|
+
* Use it when a click must IMMEDIATELY show progress across a lazy
|
|
13
|
+
* boundary (the frontend→backend console jump): the toast band is
|
|
14
|
+
* top-right and non-blocking — callers that need true blocking (one
|
|
15
|
+
* click, fullscreen feedback, zero interaction until ready) mount this
|
|
16
|
+
* instead. It is NOT a modal: no scroll lock, no focus trap, no
|
|
17
|
+
* Esc-close — the caller's flight owns the lifetime.
|
|
18
|
+
*/
|
|
19
|
+
import { defineComponent } from "vue";
|
|
20
|
+
import "./HkLoadingVeil.scss";
|
|
21
|
+
|
|
22
|
+
export default defineComponent({
|
|
23
|
+
name: "HkLoadingVeil",
|
|
24
|
+
props: {
|
|
25
|
+
/** Paint + block while true; fade out on false. */
|
|
26
|
+
active: { type: Boolean, default: false },
|
|
27
|
+
/** One-line label under the spinner (already-localized by caller). */
|
|
28
|
+
label: { type: String, default: "" },
|
|
29
|
+
},
|
|
30
|
+
setup(props) {
|
|
31
|
+
return () =>
|
|
32
|
+
props.active ? (
|
|
33
|
+
<div class="hk-loading-veil" role="status" aria-live="polite" aria-label={props.label || "loading"}>
|
|
34
|
+
<div class="hk-loading-veil-body">
|
|
35
|
+
<span class="hk-loading-veil-spinner" aria-hidden="true" />
|
|
36
|
+
{props.label ? <p class="hk-loading-veil-label">{props.label}</p> : null}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
) : null;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkModal from "./HkModal";
|
|
5
|
+
import {
|
|
6
|
+
SHEET_SWEEP_SETTLE_EVENT,
|
|
7
|
+
SHEET_SWEEP_STAGE_EVENT,
|
|
8
|
+
STEPFLOW_SWAP_EVENT,
|
|
9
|
+
} from "./HkStepFlow";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Behavioural contract for the stepflow → sheet morph handoff
|
|
13
|
+
* (2026-09-22 user directive, round 10): a HkStepFlow swap announces the
|
|
14
|
+
* phase that owns the height change (`{ delta, durationMs, phase }`) and
|
|
15
|
+
* the hosting modal must morph its clip over ONE phase, not the 0.3s
|
|
16
|
+
* family default.
|
|
17
|
+
*
|
|
18
|
+
* Round-1 verification (2026-09-22) found this wiring DEAD in the real
|
|
19
|
+
* consumer: the listener was attached in `onMounted` while the modal
|
|
20
|
+
* renders nothing until `machine.mounted` flips, so `bodyRef.value` was
|
|
21
|
+
* undefined at attach time and every mount-closed-then-open consumer
|
|
22
|
+
* (chest's wizards) silently got no handoff at all — the sheet fell back
|
|
23
|
+
* to the morph's 150ms settle debounce plus a 0.3s sweep. These tests
|
|
24
|
+
* therefore drive the REAL pattern (mount closed, open later) and assert
|
|
25
|
+
* the observable token, so the defect cannot return unnoticed.
|
|
26
|
+
*
|
|
27
|
+
* happy-dom has no transition engine: durations are stubbed and rAF is
|
|
28
|
+
* frozen (same harness as HkModal.contenthold.test.tsx), with fake timers
|
|
29
|
+
* driving the machine's phase edges.
|
|
30
|
+
*/
|
|
31
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
32
|
+
const containers: HTMLElement[] = [];
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
36
|
+
for (const el of containers.splice(0)) el.remove();
|
|
37
|
+
vi.unstubAllGlobals();
|
|
38
|
+
vi.useRealTimers();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function freezeRaf(): void {
|
|
42
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
43
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stubDurations(): void {
|
|
47
|
+
const realGCS = window.getComputedStyle.bind(window);
|
|
48
|
+
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
49
|
+
const style = realGCS(el as Element, ...(rest as []));
|
|
50
|
+
return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Rig {
|
|
55
|
+
open: { value: boolean };
|
|
56
|
+
bodyEl: () => HTMLElement | null;
|
|
57
|
+
frameEl: () => HTMLElement | null;
|
|
58
|
+
/** Fire the swap passthrough the way HkStepFlow does (bubbling from a
|
|
59
|
+
* descendant of the modal body). */
|
|
60
|
+
announceSwap: (detail: { delta: number; durationMs: number; phase: string }) => void;
|
|
61
|
+
morphToken: () => string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function mountRig(): Promise<Rig> {
|
|
65
|
+
const container = document.createElement("div");
|
|
66
|
+
document.body.appendChild(container);
|
|
67
|
+
containers.push(container);
|
|
68
|
+
|
|
69
|
+
const open = ref(false);
|
|
70
|
+
const Wrapper = defineComponent({
|
|
71
|
+
setup() {
|
|
72
|
+
return () =>
|
|
73
|
+
h(HkModal, {
|
|
74
|
+
modelValue: open.value,
|
|
75
|
+
closable: true,
|
|
76
|
+
title: "swap-test",
|
|
77
|
+
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
78
|
+
}, {
|
|
79
|
+
default: () => h("div", { class: "swap-body" }, "step content"),
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const app = createApp(Wrapper);
|
|
84
|
+
mounts.push(app);
|
|
85
|
+
app.mount(container);
|
|
86
|
+
await nextTick();
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
open,
|
|
90
|
+
bodyEl: () => document.querySelector<HTMLElement>(".hk-modal-body"),
|
|
91
|
+
frameEl: () => document.querySelector<HTMLElement>(".hk-modal-content"),
|
|
92
|
+
announceSwap: (detail) => {
|
|
93
|
+
const host = document.querySelector<HTMLElement>(".hk-modal-body .swap-body")
|
|
94
|
+
?? document.querySelector<HTMLElement>(".hk-modal-body");
|
|
95
|
+
if (!host) throw new Error("announceSwap: modal body missing");
|
|
96
|
+
host.dispatchEvent(
|
|
97
|
+
new CustomEvent(STEPFLOW_SWAP_EVENT, { bubbles: true, detail }),
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
morphToken: () =>
|
|
101
|
+
document
|
|
102
|
+
.querySelector<HTMLElement>(".hk-modal-content")
|
|
103
|
+
?.style.getPropertyValue("--hk-modal-morph-duration") ?? "",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("HkModal stepflow swap handoff", () => {
|
|
108
|
+
it("morphs over the announced phase for a modal mounted CLOSED (the real consumer path)", async () => {
|
|
109
|
+
vi.useFakeTimers();
|
|
110
|
+
freezeRaf();
|
|
111
|
+
stubDurations();
|
|
112
|
+
const rig = await mountRig();
|
|
113
|
+
|
|
114
|
+
// The premise of the round-1 defect: while closed there is no body to
|
|
115
|
+
// attach to, so an onMounted-time attach could never have worked.
|
|
116
|
+
expect(rig.bodyEl()).toBeNull();
|
|
117
|
+
|
|
118
|
+
rig.open.value = true;
|
|
119
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
120
|
+
expect(rig.bodyEl()).not.toBeNull();
|
|
121
|
+
expect(rig.morphToken()).toBe("");
|
|
122
|
+
|
|
123
|
+
// A swap that owns the height in its FIRST phase.
|
|
124
|
+
rig.announceSwap({ delta: 120, durationMs: 150, phase: "exit" });
|
|
125
|
+
await nextTick();
|
|
126
|
+
expect(rig.morphToken()).toBe("150ms");
|
|
127
|
+
|
|
128
|
+
// …and the override is scoped to that one sweep window.
|
|
129
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
130
|
+
expect(rig.morphToken()).toBe("");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("leaves the stylesheet default in place for an instant settlement", async () => {
|
|
134
|
+
vi.useFakeTimers();
|
|
135
|
+
freezeRaf();
|
|
136
|
+
stubDurations();
|
|
137
|
+
const rig = await mountRig();
|
|
138
|
+
rig.open.value = true;
|
|
139
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
140
|
+
|
|
141
|
+
// Reduced motion / stylesheet-less runtimes announce durationMs 0:
|
|
142
|
+
// there is no phase to match, so no override may be written.
|
|
143
|
+
rig.announceSwap({ delta: 0, durationMs: 0, phase: "instant" });
|
|
144
|
+
await nextTick();
|
|
145
|
+
expect(rig.morphToken()).toBe("");
|
|
146
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
147
|
+
expect(rig.morphToken()).toBe("");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("ignores announcements while the surface is still unfolding", async () => {
|
|
151
|
+
vi.useFakeTimers();
|
|
152
|
+
freezeRaf();
|
|
153
|
+
stubDurations();
|
|
154
|
+
const rig = await mountRig();
|
|
155
|
+
rig.open.value = true;
|
|
156
|
+
// The machine's phase is opening* here: the morph is not armed yet, so
|
|
157
|
+
// an override would be stranded on the frame.
|
|
158
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
159
|
+
if (rig.bodyEl()) {
|
|
160
|
+
rig.announceSwap({ delta: 60, durationMs: 150, phase: "exit" });
|
|
161
|
+
await nextTick();
|
|
162
|
+
}
|
|
163
|
+
expect(rig.morphToken()).toBe("");
|
|
164
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
165
|
+
expect(rig.morphToken()).toBe("");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("drops the override when the modal unmounts mid-sweep", async () => {
|
|
169
|
+
vi.useFakeTimers();
|
|
170
|
+
freezeRaf();
|
|
171
|
+
stubDurations();
|
|
172
|
+
const rig = await mountRig();
|
|
173
|
+
rig.open.value = true;
|
|
174
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
175
|
+
|
|
176
|
+
rig.announceSwap({ delta: 80, durationMs: 150, phase: "exit" });
|
|
177
|
+
await nextTick();
|
|
178
|
+
const frame = rig.frameEl();
|
|
179
|
+
expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("150ms");
|
|
180
|
+
|
|
181
|
+
// Unmount with the sweep still in flight: the timer must not outlive
|
|
182
|
+
// the component, and the token must not survive on the detached node.
|
|
183
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
184
|
+
await nextTick();
|
|
185
|
+
expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("HkModal publishes its own fold for the step flow", () => {
|
|
190
|
+
/** Box model: the frame hugs its content up to `cap`; the content probe
|
|
191
|
+
* reports `content` plus the chrome the calibration measures. */
|
|
192
|
+
let content = 0;
|
|
193
|
+
let cap = Number.POSITIVE_INFINITY;
|
|
194
|
+
const realOffsetHeight = Object.getOwnPropertyDescriptor(
|
|
195
|
+
HTMLElement.prototype,
|
|
196
|
+
"offsetHeight",
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
function stubBox(initialContent: number, capPx: number): void {
|
|
200
|
+
content = initialContent;
|
|
201
|
+
cap = capPx;
|
|
202
|
+
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
203
|
+
configurable: true,
|
|
204
|
+
get(this: HTMLElement) {
|
|
205
|
+
if (this.classList.contains("hk-modal-content")) {
|
|
206
|
+
return Math.min(content + 96, cap);
|
|
207
|
+
}
|
|
208
|
+
if (this.classList.contains("hk-modal-body-inner")) return content;
|
|
209
|
+
return 0;
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function restoreBox(): void {
|
|
215
|
+
if (realOffsetHeight) {
|
|
216
|
+
Object.defineProperty(
|
|
217
|
+
HTMLElement.prototype,
|
|
218
|
+
"offsetHeight",
|
|
219
|
+
realOffsetHeight,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function stubClip(): void {
|
|
225
|
+
const real = window.getComputedStyle.bind(window);
|
|
226
|
+
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
227
|
+
const style = real(el as Element, ...(rest as []));
|
|
228
|
+
if (!(el instanceof HTMLElement)) return style;
|
|
229
|
+
if (el.classList.contains("hk-modal-content")) {
|
|
230
|
+
return {
|
|
231
|
+
...style,
|
|
232
|
+
transitionDuration: "0.3s",
|
|
233
|
+
getPropertyValue: (name: string) =>
|
|
234
|
+
name === "--hk-sheet-morph" ? "clip" : style.getPropertyValue(name),
|
|
235
|
+
} as CSSStyleDeclaration;
|
|
236
|
+
}
|
|
237
|
+
return style;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
afterEach(() => {
|
|
242
|
+
restoreBox();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("republishes the morph's real fold span and its landing", async () => {
|
|
246
|
+
vi.useFakeTimers();
|
|
247
|
+
freezeRaf();
|
|
248
|
+
stubClip();
|
|
249
|
+
// Content starts at 500 (frame 596) and the sheet is uncapped, so the
|
|
250
|
+
// morph can fold: shrinking the content must publish a conceal.
|
|
251
|
+
stubBox(500, Number.POSITIVE_INFINITY);
|
|
252
|
+
const rig = await mountRig();
|
|
253
|
+
rig.open.value = true;
|
|
254
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
255
|
+
expect(rig.bodyEl()).not.toBeNull();
|
|
256
|
+
|
|
257
|
+
const stages: unknown[] = [];
|
|
258
|
+
let settled = 0;
|
|
259
|
+
rig.bodyEl()!.addEventListener(SHEET_SWEEP_STAGE_EVENT, (e) => {
|
|
260
|
+
stages.push((e as CustomEvent).detail);
|
|
261
|
+
});
|
|
262
|
+
rig.bodyEl()!.addEventListener(SHEET_SWEEP_SETTLE_EVENT, () => {
|
|
263
|
+
settled += 1;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// The sweep needs real frames: pump rAF from here on (a frozen frame
|
|
267
|
+
// loop would strand the morph's warmup, as its own docs warn).
|
|
268
|
+
const frames: FrameRequestCallback[] = [];
|
|
269
|
+
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
270
|
+
frames.push(cb);
|
|
271
|
+
return frames.length;
|
|
272
|
+
});
|
|
273
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
274
|
+
|
|
275
|
+
// The flow owns the new height and announces; the modal measures,
|
|
276
|
+
// stages its conceal, and publishes the span it will sweep.
|
|
277
|
+
content = 300;
|
|
278
|
+
rig.announceSwap({ delta: -200, durationMs: 150, phase: "enter" });
|
|
279
|
+
await nextTick();
|
|
280
|
+
expect(stages).toEqual([
|
|
281
|
+
{ direction: "conceal", from: 596, to: 396, sweep: expect.any(Number) },
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
// …and the landing is published when the sweep finishes, carrying the
|
|
285
|
+
// sweep's own identity (without it a consumer cannot tell its landing
|
|
286
|
+
// from another dance's teardown).
|
|
287
|
+
const landed: Array<{ sweep?: number }> = [];
|
|
288
|
+
rig.bodyEl()!.addEventListener(SHEET_SWEEP_SETTLE_EVENT, (e) => {
|
|
289
|
+
landed.push((e as CustomEvent<{ sweep?: number }>).detail ?? {});
|
|
290
|
+
});
|
|
291
|
+
for (let i = 0; i < 4; i += 1) {
|
|
292
|
+
for (const cb of frames.splice(0)) cb(i * 16);
|
|
293
|
+
await vi.advanceTimersByTimeAsync(60);
|
|
294
|
+
}
|
|
295
|
+
await vi.advanceTimersByTimeAsync(1200);
|
|
296
|
+
expect(settled).toBeGreaterThan(0);
|
|
297
|
+
expect(landed.length).toBeGreaterThan(0);
|
|
298
|
+
expect(typeof landed[0]?.sweep).toBe("number");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("publishes NOTHING when the sheet is capped and cannot fold", async () => {
|
|
302
|
+
vi.useFakeTimers();
|
|
303
|
+
freezeRaf();
|
|
304
|
+
stubClip();
|
|
305
|
+
// Content that already overflows the cap: the natural height cannot
|
|
306
|
+
// change, so the morph stages no sweep at all — the flow must park
|
|
307
|
+
// nothing (its span defaults to 0).
|
|
308
|
+
stubBox(900, 596);
|
|
309
|
+
const rig = await mountRig();
|
|
310
|
+
rig.open.value = true;
|
|
311
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
312
|
+
|
|
313
|
+
const stages: unknown[] = [];
|
|
314
|
+
let settled = 0;
|
|
315
|
+
rig.bodyEl()!.addEventListener(SHEET_SWEEP_STAGE_EVENT, (e) => {
|
|
316
|
+
stages.push((e as CustomEvent).detail);
|
|
317
|
+
});
|
|
318
|
+
rig.bodyEl()!.addEventListener(SHEET_SWEEP_SETTLE_EVENT, () => {
|
|
319
|
+
settled += 1;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
content = 850;
|
|
323
|
+
rig.announceSwap({ delta: -50, durationMs: 150, phase: "enter" });
|
|
324
|
+
await nextTick();
|
|
325
|
+
await vi.advanceTimersByTimeAsync(1200);
|
|
326
|
+
expect(stages).toEqual([]);
|
|
327
|
+
expect(settled).toBe(0);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -13,7 +13,11 @@ import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
|
13
13
|
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
14
14
|
import { useSizeMorph } from "../composables/useSizeMorph";
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
SHEET_SWEEP_SETTLE_EVENT,
|
|
18
|
+
SHEET_SWEEP_STAGE_EVENT,
|
|
19
|
+
STEPFLOW_SWAP_EVENT,
|
|
20
|
+
} from "./HkStepFlow";
|
|
17
21
|
import HButton from "./HkButton";
|
|
18
22
|
import HFab from "./HkFab";
|
|
19
23
|
import HSpinner from "./HkSpinner";
|
|
@@ -205,24 +209,70 @@ export default defineComponent({
|
|
|
205
209
|
deferRemeasure: () =>
|
|
206
210
|
machine.phase.value === "openingFrom" ||
|
|
207
211
|
machine.phase.value === "openingTo",
|
|
212
|
+
// Republish the morph's real sweep span and its landing on the body
|
|
213
|
+
// element (bubbling), so content choreography can park against the
|
|
214
|
+
// landing geometry and release exactly when it happens instead of
|
|
215
|
+
// guessing the cap and the warmup (2026-09-22 verification finding).
|
|
216
|
+
onSweepStage: (info) => {
|
|
217
|
+
bodyRef.value?.dispatchEvent(
|
|
218
|
+
new CustomEvent(SHEET_SWEEP_STAGE_EVENT, { bubbles: true, detail: info }),
|
|
219
|
+
);
|
|
220
|
+
},
|
|
221
|
+
onSweepSettle: (info) => {
|
|
222
|
+
bodyRef.value?.dispatchEvent(
|
|
223
|
+
new CustomEvent(SHEET_SWEEP_SETTLE_EVENT, {
|
|
224
|
+
bubbles: true,
|
|
225
|
+
detail: info,
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
},
|
|
208
229
|
});
|
|
209
230
|
let previouslyFocused: HTMLElement | null = null;
|
|
210
231
|
let unmounted = false;
|
|
211
232
|
|
|
212
233
|
// HkStepFlow swaps dispatch STEPFLOW_SWAP_EVENT (bubbling) from the
|
|
213
|
-
// flow root the
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
234
|
+
// flow root at the edge where the sheet must morph: the exit edge for
|
|
235
|
+
// a GROW (the new height is already in the flow, so the sheet finishes
|
|
236
|
+
// growing while the old body leaves) and the enter edge for a SHRINK
|
|
237
|
+
// (the flow held its old height until the old body finished playing).
|
|
238
|
+
// The morph's settle debounce is tuned for streaming bursts; a step
|
|
239
|
+
// swap is one clean change whose sweep must land INSIDE its 0.15s
|
|
240
|
+
// phase (2026-09-22 user directive, round 10) — so the event
|
|
241
|
+
// short-circuits straight into remeasure() and overrides the sheet's
|
|
242
|
+
// own sweep duration for this one morph (the stylesheet default stays
|
|
243
|
+
// the 0.3s family standard for every other morph). The override is
|
|
244
|
+
// cleared on the same duration+grace watchdog the sweep itself uses.
|
|
245
|
+
let morphDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
246
|
+
const clearMorphDuration = (): void => {
|
|
247
|
+
if (morphDurationTimer !== null) {
|
|
248
|
+
clearTimeout(morphDurationTimer);
|
|
249
|
+
morphDurationTimer = null;
|
|
250
|
+
}
|
|
251
|
+
contentRef.value?.style.removeProperty("--hk-modal-morph-duration");
|
|
252
|
+
};
|
|
253
|
+
const onStepflowSwap = (event: Event): void => {
|
|
254
|
+
if (machine.phase.value !== "open") {
|
|
255
|
+
// A swap announced while the surface is closing/opening: drop any
|
|
256
|
+
// override a previous morph left behind instead of letting it ride
|
|
257
|
+
// into the next open (it is otherwise only cleared by its own
|
|
258
|
+
// ms+350 timer).
|
|
259
|
+
clearMorphDuration();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const frame = contentRef.value;
|
|
263
|
+
const ms = (event as CustomEvent<{ durationMs?: number }>).detail?.durationMs;
|
|
264
|
+
if (frame && typeof ms === "number" && ms > 0) {
|
|
265
|
+
frame.style.setProperty("--hk-modal-morph-duration", `${ms}ms`);
|
|
266
|
+
morph.remeasure();
|
|
267
|
+
if (morphDurationTimer !== null) clearTimeout(morphDurationTimer);
|
|
268
|
+
morphDurationTimer = setTimeout(clearMorphDuration, ms + 350);
|
|
269
|
+
} else {
|
|
270
|
+
morph.remeasure();
|
|
271
|
+
}
|
|
220
272
|
};
|
|
221
|
-
onMounted(() => {
|
|
222
|
-
bodyRef.value?.addEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
223
|
-
});
|
|
224
273
|
onBeforeUnmount(() => {
|
|
225
274
|
bodyRef.value?.removeEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
275
|
+
clearMorphDuration();
|
|
226
276
|
});
|
|
227
277
|
|
|
228
278
|
const overlayZ = computed(() => handle.value?.zIndex ?? 0);
|
|
@@ -430,6 +480,26 @@ export default defineComponent({
|
|
|
430
480
|
},
|
|
431
481
|
});
|
|
432
482
|
|
|
483
|
+
// The swap listener must follow the ELEMENT, not the component
|
|
484
|
+
// lifecycle: the render returns null until `machine.mounted` flips, so
|
|
485
|
+
// an onMounted-time attach silently missed for every
|
|
486
|
+
// mount-closed-then-open consumer — i.e. every chest wizard
|
|
487
|
+
// (2026-09-22 R1 verification finding: this wiring was dead at
|
|
488
|
+
// runtime, which is why the sheet kept morphing on the observer's
|
|
489
|
+
// 150ms debounce and the 0.3s stylesheet default). Post-flush on the
|
|
490
|
+
// mount flag instead: the listener lands on the real node and comes
|
|
491
|
+
// off with it.
|
|
492
|
+
watch(
|
|
493
|
+
machine.mounted,
|
|
494
|
+
(isMounted) => {
|
|
495
|
+
const el = bodyRef.value;
|
|
496
|
+
if (!el) return;
|
|
497
|
+
if (isMounted) el.addEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
498
|
+
else el.removeEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
499
|
+
},
|
|
500
|
+
{ flush: "post", immediate: true },
|
|
501
|
+
);
|
|
502
|
+
|
|
433
503
|
// --- Windowed mode ---
|
|
434
504
|
|
|
435
505
|
function setupWindowed(container: HTMLElement) {
|