@celestia-island/hikari 0.55.309 → 0.55.310
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/HkModal.contenthold.test.tsx +23 -0
- package/src/components/HkModal.enter-watchdog.test.tsx +2 -2
- package/src/components/HkModal.leavepin.test.tsx +23 -0
- package/src/components/HkModal.open-close-scale.test.ts +51 -11
- package/src/components/HkModal.scss +85 -34
- package/src/components/HkModal.stepflowswap.test.tsx +32 -0
- package/src/components/HkModal.tsx +71 -19
package/package.json
CHANGED
|
@@ -32,6 +32,27 @@ function freezeRaf(): void {
|
|
|
32
32
|
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Pin the viewport to the phone band (<768px): the desktop height ride
|
|
36
|
+
* blacks the morph out of the open/close edges, and these suites test the
|
|
37
|
+
* morph's own contracts. happy-dom's 1024px default would route them into
|
|
38
|
+
* the desktop branch. */
|
|
39
|
+
function stubPhoneViewport(): void {
|
|
40
|
+
vi.stubGlobal(
|
|
41
|
+
"matchMedia",
|
|
42
|
+
(query: string) =>
|
|
43
|
+
({
|
|
44
|
+
matches: false,
|
|
45
|
+
media: query,
|
|
46
|
+
addEventListener: () => {},
|
|
47
|
+
removeEventListener: () => {},
|
|
48
|
+
addListener: () => {},
|
|
49
|
+
removeListener: () => {},
|
|
50
|
+
onchange: null,
|
|
51
|
+
dispatchEvent: () => false,
|
|
52
|
+
}) as MediaQueryList,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
35
56
|
function stubDurations(): void {
|
|
36
57
|
const realGCS = window.getComputedStyle.bind(window);
|
|
37
58
|
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
@@ -103,6 +124,7 @@ describe("HkModal close-fold content hold", () => {
|
|
|
103
124
|
it("folds over the last live content even when the consumer clears state on close", async () => {
|
|
104
125
|
vi.useFakeTimers();
|
|
105
126
|
freezeRaf();
|
|
127
|
+
stubPhoneViewport();
|
|
106
128
|
stubDurations();
|
|
107
129
|
const rig = await mountRig(["alpha", "beta"]);
|
|
108
130
|
|
|
@@ -129,6 +151,7 @@ describe("HkModal close-fold content hold", () => {
|
|
|
129
151
|
it("a reopen interrupt drops the hold and serves live content again", async () => {
|
|
130
152
|
vi.useFakeTimers();
|
|
131
153
|
freezeRaf();
|
|
154
|
+
stubPhoneViewport();
|
|
132
155
|
stubDurations();
|
|
133
156
|
const rig = await mountRig(["alpha", "beta"]);
|
|
134
157
|
|
|
@@ -81,7 +81,7 @@ describe("HkModal enter-class watchdog", () => {
|
|
|
81
81
|
await vi.advanceTimersByTimeAsync(700);
|
|
82
82
|
// Both layers snapped to their resting (class-less) state.
|
|
83
83
|
expect(overlay!.className).toBe("hk-modal-overlay");
|
|
84
|
-
expect(content!.className).toBe("hk-modal-content
|
|
84
|
+
expect(content!.className).toBe("hk-modal-content");
|
|
85
85
|
// …and the surface is still open and intact.
|
|
86
86
|
expect(document.querySelector(".hk-modal-content")).not.toBeNull();
|
|
87
87
|
});
|
|
@@ -170,7 +170,7 @@ describe("HkModal enter-class watchdog across an interrupted leave", () => {
|
|
|
170
170
|
// (fade from visible) instead of the full-opacity pop.
|
|
171
171
|
await vi.advanceTimersByTimeAsync(700);
|
|
172
172
|
expect(overlay.className).toBe("hk-modal-overlay");
|
|
173
|
-
expect(content.className).toBe("hk-modal-content
|
|
173
|
+
expect(content.className).toBe("hk-modal-content");
|
|
174
174
|
// The surface is still open, and closing it afterwards runs a NORMAL
|
|
175
175
|
// leave (fade from visible) instead of the full-opacity pop.
|
|
176
176
|
open.value = false;
|
|
@@ -33,6 +33,27 @@ function freezeRaf(): void {
|
|
|
33
33
|
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Pin the viewport to the phone band (<768px): the desktop height ride
|
|
37
|
+
* blacks the morph out of the open/close edges, and these suites test the
|
|
38
|
+
* morph's own contracts. happy-dom's 1024px default would route them into
|
|
39
|
+
* the desktop branch. */
|
|
40
|
+
function stubPhoneViewport(): void {
|
|
41
|
+
vi.stubGlobal(
|
|
42
|
+
"matchMedia",
|
|
43
|
+
(query: string) =>
|
|
44
|
+
({
|
|
45
|
+
matches: false,
|
|
46
|
+
media: query,
|
|
47
|
+
addEventListener: () => {},
|
|
48
|
+
removeEventListener: () => {},
|
|
49
|
+
addListener: () => {},
|
|
50
|
+
removeListener: () => {},
|
|
51
|
+
onchange: null,
|
|
52
|
+
dispatchEvent: () => false,
|
|
53
|
+
}) as MediaQueryList,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
function stubDurations(): void {
|
|
37
58
|
const realGCS = window.getComputedStyle.bind(window);
|
|
38
59
|
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
@@ -101,6 +122,7 @@ describe("HkModal leave-window height pin", () => {
|
|
|
101
122
|
it("keeps the morph pin through the whole leave and releases at finalize", async () => {
|
|
102
123
|
vi.useFakeTimers();
|
|
103
124
|
freezeRaf();
|
|
125
|
+
stubPhoneViewport();
|
|
104
126
|
stubDurations();
|
|
105
127
|
stubLayout(500);
|
|
106
128
|
try {
|
|
@@ -138,6 +160,7 @@ describe("HkModal leave-window height pin", () => {
|
|
|
138
160
|
it("flushes growth deferred through the enter with an open-edge remeasure", async () => {
|
|
139
161
|
vi.useFakeTimers();
|
|
140
162
|
freezeRaf();
|
|
163
|
+
stubPhoneViewport();
|
|
141
164
|
stubDurations();
|
|
142
165
|
stubLayout(400);
|
|
143
166
|
try {
|
|
@@ -416,14 +416,14 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
416
416
|
"will-change",
|
|
417
417
|
DESKTOP,
|
|
418
418
|
);
|
|
419
|
-
expect(enterWill).toBe("opacity
|
|
419
|
+
expect(enterWill).toBe("opacity");
|
|
420
420
|
// The close direction promotes the same pair of layers.
|
|
421
421
|
const leaveWill = winner(
|
|
422
422
|
frameWith("hk-modal-content-leave-to", "hk-modal-content-leave-active"),
|
|
423
423
|
"will-change",
|
|
424
424
|
DESKTOP,
|
|
425
425
|
);
|
|
426
|
-
expect(leaveWill).toBe("opacity
|
|
426
|
+
expect(leaveWill).toBe("opacity");
|
|
427
427
|
});
|
|
428
428
|
|
|
429
429
|
it("rides the HEIGHT through the shared container contract, chrome untouched", () => {
|
|
@@ -574,19 +574,26 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
574
574
|
// through the first cut, as did `[class~="hk-modal-body"]` (R3 finding
|
|
575
575
|
// 2026-11-07 (S2)). The trailing guard keeps -scroll / -inner out.
|
|
576
576
|
const BODY_TARGET = /(^|[^\w-])hk-modal-body(?![\w-])/;
|
|
577
|
-
const TRANSFORM_FAMILY = /^(transform|
|
|
578
|
-
|
|
577
|
+
const TRANSFORM_FAMILY = /^(transform|translate|scale|rotate|zoom|perspective)$/;
|
|
578
|
+
// The content region's ONLY motion is a vertical compression about its
|
|
579
|
+
// top edge (2026-11-07 user direction: only the height compresses, the
|
|
580
|
+
// width never does). Everything else — scaleX, a 2-arg scale, a
|
|
581
|
+
// translate, a matrix — is a contract break.
|
|
582
|
+
const LEGAL_BODY_TRANSFORM = new Set([
|
|
583
|
+
"scaleY(var(--hk-ride-factor, 0.9))",
|
|
584
|
+
"scaleY(1)",
|
|
585
|
+
"none",
|
|
586
|
+
]);
|
|
579
587
|
const offenders: string[] = [];
|
|
580
588
|
for (const rule of RULES) {
|
|
581
589
|
if (!BODY_TARGET.test(rule.selector)) continue;
|
|
582
590
|
for (const prop of rule.declarations.keys()) {
|
|
583
591
|
if (!TRANSFORM_FAMILY.test(prop)) continue;
|
|
584
|
-
if (prop === "transform-origin") continue;
|
|
585
592
|
const value = (rule.declarations.get(prop) ?? "").replace(/\s+/g, " ").trim();
|
|
586
|
-
if (!
|
|
593
|
+
if (!LEGAL_BODY_TRANSFORM.has(value)) offenders.push(`${rule.selector} { ${prop}: ${value} }`);
|
|
587
594
|
}
|
|
588
595
|
}
|
|
589
|
-
expect(offenders, "the content region
|
|
596
|
+
expect(offenders, "the content region may only compress vertically").toEqual([]);
|
|
590
597
|
});
|
|
591
598
|
|
|
592
599
|
it("never lets ANY rule put a transform on a chrome surface", () => {
|
|
@@ -619,17 +626,44 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
619
626
|
"transition",
|
|
620
627
|
"will-change",
|
|
621
628
|
"opacity",
|
|
629
|
+
// The from-pair carries the ride's height end, and the -active pair
|
|
630
|
+
// locks the content region out of the flex shrink for the flight
|
|
631
|
+
// (the 0.81-compounding guard, 2026-11-07 R3 finding).
|
|
632
|
+
"height",
|
|
633
|
+
"flex",
|
|
634
|
+
// …and the -active pair pins the footer band to the frame's bottom
|
|
635
|
+
// edge for the flight (it would otherwise be pushed out by the
|
|
636
|
+
// content region's locked natural box).
|
|
637
|
+
"position",
|
|
638
|
+
"inset-inline",
|
|
639
|
+
"bottom",
|
|
622
640
|
]);
|
|
623
641
|
// Chrome ride states now also carry `transform` (the 2026-11-06 frame
|
|
624
642
|
// height ride) — but nothing else beyond opacity/transition/will-change.
|
|
625
|
-
const CHROME_ALLOWED = new Set([
|
|
643
|
+
const CHROME_ALLOWED = new Set([
|
|
644
|
+
"opacity",
|
|
645
|
+
"transition",
|
|
646
|
+
"will-change",
|
|
647
|
+
"transform",
|
|
648
|
+
"height",
|
|
649
|
+
// The -active pair pins the footer band to the frame's bottom edge
|
|
650
|
+
// for the flight (2026-11-07: the content region's locked natural
|
|
651
|
+
// box would otherwise push it past the shortened frame).
|
|
652
|
+
"position",
|
|
653
|
+
"inset-inline",
|
|
654
|
+
"bottom",
|
|
655
|
+
]);
|
|
626
656
|
// The contracted transform values a ride rule may carry. Frame: the
|
|
627
657
|
// three translate+scaleY states (reduced-motion flatten included);
|
|
628
658
|
// body: nothing (its width is never scaled — 2026-11-07).
|
|
629
659
|
const LEGAL_FRAME = new Set([CENTERING, FRAME_FROM, FRAME_TO]);
|
|
630
660
|
// The body carries no transform contract any more (2026-11-07): any
|
|
631
661
|
// ride rule targeting it is an offender outright.
|
|
632
|
-
const LEGAL_BODY = new Set<string>([
|
|
662
|
+
const LEGAL_BODY = new Set<string>([
|
|
663
|
+
"scaleY(var(--hk-ride-factor, 0.9))",
|
|
664
|
+
"scaleY(1)",
|
|
665
|
+
"none",
|
|
666
|
+
]);
|
|
633
667
|
const offenders: string[] = [];
|
|
634
668
|
// Media-independent on purpose: a ride selector declared at top level
|
|
635
669
|
// (no media query) applies at every width, so it must obey the same
|
|
@@ -682,8 +716,14 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
682
716
|
const frame = frameWith(...state.classes);
|
|
683
717
|
const body = child(frame, "hk-modal-body");
|
|
684
718
|
const bodyTransform = winner(body, "transform", DESKTOP) ?? "";
|
|
685
|
-
|
|
686
|
-
|
|
719
|
+
// The contracted body grammar: nothing but the vertical compression
|
|
720
|
+
// at the ride's two ends (and no transform at rest).
|
|
721
|
+
const expectCompressed = state.classes.some((c) =>
|
|
722
|
+
c.includes("enter-from") || c.includes("leave-to"));
|
|
723
|
+
const expected = expectCompressed
|
|
724
|
+
? "scaleY(var(--hk-ride-factor, 0.9))"
|
|
725
|
+
: "scaleY(1)";
|
|
726
|
+
expect(bodyTransform, `body ${state.name}`).toBe(expected);
|
|
687
727
|
expect(winner(body, "scale", DESKTOP), `body ${state.name}`).toBeUndefined();
|
|
688
728
|
expect(winner(body, "translate", DESKTOP), `body ${state.name}`).toBeUndefined();
|
|
689
729
|
expect(winner(body, "zoom", DESKTOP), `body ${state.name}`).toBeUndefined();
|
|
@@ -137,76 +137,127 @@
|
|
|
137
137
|
// ------
|
|
138
138
|
|
|
139
139
|
@media (min-width: 768px) {
|
|
140
|
-
//
|
|
141
|
-
//
|
|
140
|
+
// RESIDENT legs — the ride's continuity never depends on the transition
|
|
141
|
+
// classes' lifetime. The surface machine's deadline is advisory: it can
|
|
142
|
+
// flip the phase (and drop the classes) before the CSS lands. With the
|
|
143
|
+
// legs resident, that class drop only changes the TARGET (calc -> auto,
|
|
144
|
+
// scale -> none) and the browser finishes the motion on these legs
|
|
145
|
+
// instead of snapping (2026-11-07 user report: the open "replayed" — a
|
|
146
|
+
// partial ride, a snap, then a second animation). The height/max-height/
|
|
147
|
+
// clip-path legs stay at the base tokens for the size morph (its own
|
|
148
|
+
// heightMorph overrides the duration inline when it runs).
|
|
149
|
+
.hk-modal-content {
|
|
150
|
+
// Let the height leg interpolate to/from `auto` (Chromium 129+; older
|
|
151
|
+
// engines just snap that one handoff): when the transition classes come
|
|
152
|
+
// off mid-ride the calc end becomes the frame's own `auto` layout, and
|
|
153
|
+
// without this the motion would jump instead of finishing.
|
|
154
|
+
interpolate-size: allow-keywords;
|
|
155
|
+
transition:
|
|
156
|
+
opacity var(--hk-modal-duration, 0.42s)
|
|
157
|
+
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
|
|
158
|
+
height var(--hk-modal-duration, 0.42s)
|
|
159
|
+
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
|
|
160
|
+
max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
|
|
161
|
+
clip-path var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The content's compression leg is resident for the same reason — and it
|
|
165
|
+
// is what makes the ENTER start at exactly scaleY(0.9): a freshly
|
|
166
|
+
// inserted element's first style never transitions, so the from-pair
|
|
167
|
+
// paints at 0.9 and only ever animates from there.
|
|
168
|
+
.hk-modal-body {
|
|
169
|
+
transition: transform var(--hk-modal-duration, 0.42s)
|
|
170
|
+
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Open: the window's height rides while the chrome keeps its size.
|
|
142
174
|
.hk-modal-content-enter-active {
|
|
143
175
|
transition:
|
|
144
176
|
opacity var(--hk-modal-duration, 0.42s)
|
|
145
177
|
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
|
|
146
178
|
height var(--hk-modal-duration, 0.42s)
|
|
147
179
|
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
|
|
148
|
-
will-change: opacity
|
|
180
|
+
will-change: opacity;
|
|
149
181
|
}
|
|
150
182
|
|
|
151
|
-
// Close: the reveal's TRUE reverse — same
|
|
152
|
-
//
|
|
183
|
+
// Close: the reveal's TRUE reverse — same ride, the window settles back
|
|
184
|
+
// toward 90% height, width untouched in both directions.
|
|
153
185
|
.hk-modal-content-leave-active {
|
|
154
186
|
transition:
|
|
155
187
|
opacity var(--hk-modal-duration, 0.32s)
|
|
156
188
|
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
|
|
157
189
|
height var(--hk-modal-duration, 0.32s)
|
|
158
190
|
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
|
|
159
|
-
will-change: opacity
|
|
191
|
+
will-change: opacity;
|
|
160
192
|
}
|
|
161
193
|
|
|
162
|
-
// The
|
|
163
|
-
//
|
|
164
|
-
// the
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
.
|
|
168
|
-
.hk-modal-content-
|
|
194
|
+
// The ride's two ends. The from-pair ships with the FIRST paint (a
|
|
195
|
+
// freshly inserted element does not transition into its initial style),
|
|
196
|
+
// so the compressed height and the 0.9 content scale are both real at
|
|
197
|
+
// frame one — the phase-mapped classes that shipped one tick late are
|
|
198
|
+
// exactly what made the open look like clipping (2026-11-07 reports).
|
|
199
|
+
// `!important` outranks the size morph's inline pin for the flight.
|
|
200
|
+
.hk-modal-content[data-hk-ride-ready].hk-modal-content-enter-from,
|
|
201
|
+
.hk-modal-content[data-hk-ride-ready].hk-modal-content-leave-to {
|
|
169
202
|
opacity: 0;
|
|
203
|
+
height: calc(
|
|
204
|
+
var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px) * var(--hk-ride-factor, 0.9)
|
|
205
|
+
) !important;
|
|
170
206
|
}
|
|
171
207
|
|
|
208
|
+
// The natural end is an EXPLICIT calc too: the frame's base height is
|
|
209
|
+
// `auto`, and a transition cannot interpolate px -> auto. Without this
|
|
210
|
+
// leg the -active body lock made `auto` resolve to (almost) the
|
|
211
|
+
// compressed height, so the frame never grew while the content's scale
|
|
212
|
+
// unfolded underneath it — the "clipped open" the user reported. When the
|
|
213
|
+
// classes come off at rest, the morph's pin (the same px) catches the
|
|
214
|
+
// handoff with no visible change.
|
|
172
215
|
.hk-modal-content-enter-to,
|
|
173
216
|
.hk-modal-content-leave-from {
|
|
174
217
|
opacity: 1;
|
|
218
|
+
height: calc(var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px));
|
|
175
219
|
}
|
|
176
220
|
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// The content's compression has to ride the SAME per-direction token pair
|
|
185
|
-
// as the frame's height: the family sheet's default (0.42s) would leave a
|
|
186
|
-
// 0.1s tail on close (0.32s height) — a visible gap above the pinned
|
|
187
|
-
// footer — and would ignore a host's `--hk-modal-duration` retune.
|
|
188
|
-
.hk-modal-content-enter-active > .hk-modal-body {
|
|
189
|
-
transition: transform var(--hk-modal-duration, 0.42s)
|
|
190
|
-
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
|
|
221
|
+
// Only the content region is ever scaled — top origin, so the compressed
|
|
222
|
+
// paint lands exactly in the shorter slot the frame leaves for it.
|
|
223
|
+
.hk-modal-content-enter-from > .hk-modal-body,
|
|
224
|
+
.hk-modal-content-leave-to > .hk-modal-body {
|
|
225
|
+
transform: scaleY(var(--hk-ride-factor, 0.9));
|
|
226
|
+
transform-origin: 50% 0;
|
|
191
227
|
}
|
|
192
228
|
|
|
193
|
-
.hk-modal-content-
|
|
194
|
-
|
|
195
|
-
|
|
229
|
+
.hk-modal-content-enter-to > .hk-modal-body,
|
|
230
|
+
.hk-modal-content-leave-from > .hk-modal-body {
|
|
231
|
+
transform: scaleY(1);
|
|
232
|
+
transform-origin: 50% 0;
|
|
196
233
|
}
|
|
197
234
|
|
|
198
|
-
|
|
199
|
-
|
|
235
|
+
// The content region keeps its NATURAL layout height for the whole
|
|
236
|
+
// flight (or the slot shrink and the scale would compound to 0.81 and
|
|
237
|
+
// leave a gap above the footer), and the footer pins to the frame's
|
|
238
|
+
// bottom edge so it stays visible while the content's natural box
|
|
239
|
+
// overflows underneath it. The -active pair spans the flight window in
|
|
240
|
+
// both directions.
|
|
241
|
+
.hk-modal-content-enter-active > .hk-modal-body,
|
|
242
|
+
.hk-modal-content-leave-active > .hk-modal-body {
|
|
200
243
|
flex: 0 0 auto;
|
|
201
244
|
height: var(--hk-ride-content, auto);
|
|
202
245
|
}
|
|
203
246
|
|
|
204
|
-
.hk-modal-content
|
|
205
|
-
.hk-modal-content
|
|
247
|
+
.hk-modal-content-enter-active > .hk-modal-footer,
|
|
248
|
+
.hk-modal-content-leave-active > .hk-modal-footer {
|
|
206
249
|
position: absolute;
|
|
207
250
|
inset-inline: 0;
|
|
208
251
|
bottom: 0;
|
|
209
252
|
}
|
|
253
|
+
|
|
254
|
+
// Close-direction compression timing for the content (the frame's height
|
|
255
|
+
// leg above is 0.32s ease-in; a 0.42s content leg would trail it and open
|
|
256
|
+
// a gap above the pinned footer).
|
|
257
|
+
.hk-modal-content-leave-active > .hk-modal-body {
|
|
258
|
+
transition: transform var(--hk-modal-duration, 0.32s)
|
|
259
|
+
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
|
|
260
|
+
}
|
|
210
261
|
}
|
|
211
262
|
|
|
212
263
|
// ------
|
|
@@ -42,6 +42,27 @@ function freezeRaf(): void {
|
|
|
42
42
|
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** Pin the viewport to the phone band (<768px): the desktop height ride
|
|
46
|
+
* blacks the morph out of the open/close edges, and these suites test the
|
|
47
|
+
* morph's own contracts. happy-dom's 1024px default would route them into
|
|
48
|
+
* the desktop branch. */
|
|
49
|
+
function stubPhoneViewport(): void {
|
|
50
|
+
vi.stubGlobal(
|
|
51
|
+
"matchMedia",
|
|
52
|
+
(query: string) =>
|
|
53
|
+
({
|
|
54
|
+
matches: false,
|
|
55
|
+
media: query,
|
|
56
|
+
addEventListener: () => {},
|
|
57
|
+
removeEventListener: () => {},
|
|
58
|
+
addListener: () => {},
|
|
59
|
+
removeListener: () => {},
|
|
60
|
+
onchange: null,
|
|
61
|
+
dispatchEvent: () => false,
|
|
62
|
+
}) as MediaQueryList,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
function stubDurations(): void {
|
|
46
67
|
const realGCS = window.getComputedStyle.bind(window);
|
|
47
68
|
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
@@ -107,6 +128,7 @@ describe("HkModal stepflow swap handoff", () => {
|
|
|
107
128
|
it("morphs over the announced phase for a modal mounted CLOSED (the real consumer path)", async () => {
|
|
108
129
|
vi.useFakeTimers();
|
|
109
130
|
freezeRaf();
|
|
131
|
+
stubPhoneViewport();
|
|
110
132
|
stubDurations();
|
|
111
133
|
const rig = await mountRig();
|
|
112
134
|
|
|
@@ -128,6 +150,7 @@ describe("HkModal stepflow swap handoff", () => {
|
|
|
128
150
|
it("leaves the stylesheet default in place for an instant settlement", async () => {
|
|
129
151
|
vi.useFakeTimers();
|
|
130
152
|
freezeRaf();
|
|
153
|
+
stubPhoneViewport();
|
|
131
154
|
stubDurations();
|
|
132
155
|
const rig = await mountRig();
|
|
133
156
|
rig.open.value = true;
|
|
@@ -203,6 +226,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
203
226
|
it("republishes the morph's real fold span and its landing", async () => {
|
|
204
227
|
vi.useFakeTimers();
|
|
205
228
|
freezeRaf();
|
|
229
|
+
stubPhoneViewport();
|
|
206
230
|
stubClip();
|
|
207
231
|
// Content starts at 500 (frame 596) and the sheet is uncapped, so the
|
|
208
232
|
// morph can fold: shrinking the content must publish a conceal.
|
|
@@ -259,6 +283,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
259
283
|
it("publishes NOTHING when the sheet is capped and cannot fold", async () => {
|
|
260
284
|
vi.useFakeTimers();
|
|
261
285
|
freezeRaf();
|
|
286
|
+
stubPhoneViewport();
|
|
262
287
|
stubClip();
|
|
263
288
|
// Content that already overflows the cap: the natural height cannot
|
|
264
289
|
// change, so the morph stages no sweep at all — the flow must park
|
|
@@ -293,6 +318,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
293
318
|
// measurements) is unit-witnessed in useSizeMorph.test.ts.
|
|
294
319
|
vi.useFakeTimers();
|
|
295
320
|
freezeRaf();
|
|
321
|
+
stubPhoneViewport();
|
|
296
322
|
stubDurations();
|
|
297
323
|
const rig = await mountRig();
|
|
298
324
|
rig.open.value = true;
|
|
@@ -317,6 +343,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
317
343
|
it("ignores announcements while the surface is still unfolding", async () => {
|
|
318
344
|
vi.useFakeTimers();
|
|
319
345
|
freezeRaf();
|
|
346
|
+
stubPhoneViewport();
|
|
320
347
|
stubDurations();
|
|
321
348
|
const rig = await mountRig();
|
|
322
349
|
rig.open.value = true;
|
|
@@ -335,6 +362,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
335
362
|
it("drops the override when the modal unmounts mid-sweep", async () => {
|
|
336
363
|
vi.useFakeTimers();
|
|
337
364
|
freezeRaf();
|
|
365
|
+
stubPhoneViewport();
|
|
338
366
|
stubDurations();
|
|
339
367
|
const rig = await mountRig();
|
|
340
368
|
rig.open.value = true;
|
|
@@ -423,9 +451,11 @@ describe("HkModal rides its chrome and gates the slide window", () => {
|
|
|
423
451
|
// change).
|
|
424
452
|
vi.useFakeTimers();
|
|
425
453
|
freezeRaf();
|
|
454
|
+
stubPhoneViewport();
|
|
426
455
|
stubDurations();
|
|
427
456
|
stubClip();
|
|
428
457
|
stubBox(500, Number.POSITIVE_INFINITY);
|
|
458
|
+
stubPhoneViewport();
|
|
429
459
|
const roInstances: Array<{ callback: () => void }> = [];
|
|
430
460
|
vi.stubGlobal("ResizeObserver", class {
|
|
431
461
|
callback: () => void;
|
|
@@ -475,6 +505,7 @@ describe("HkModal rides its chrome and gates the slide window", () => {
|
|
|
475
505
|
stubDurations();
|
|
476
506
|
stubClip();
|
|
477
507
|
stubBox(500, Number.POSITIVE_INFINITY);
|
|
508
|
+
stubPhoneViewport();
|
|
478
509
|
const roInstances: Array<{ callback: () => void }> = [];
|
|
479
510
|
vi.stubGlobal("ResizeObserver", class {
|
|
480
511
|
callback: () => void;
|
|
@@ -520,6 +551,7 @@ describe("HkModal rides its chrome and gates the slide window", () => {
|
|
|
520
551
|
// rides anything — it just transitions the frame's height inline.
|
|
521
552
|
vi.useFakeTimers();
|
|
522
553
|
freezeRaf();
|
|
554
|
+
stubPhoneViewport();
|
|
523
555
|
stubClip();
|
|
524
556
|
stubBox(500, Number.POSITIVE_INFINITY);
|
|
525
557
|
const container = document.createElement("div");
|
|
@@ -339,7 +339,7 @@ export default defineComponent({
|
|
|
339
339
|
// report: the window folded "together with the chrome"). The body
|
|
340
340
|
// ride matters for the ≤767px sheet sweep, where the ride geometry
|
|
341
341
|
// is disabled — so the two never legitimately overlap.
|
|
342
|
-
if (bodyRef.value &&
|
|
342
|
+
if (bodyRef.value && !desktopRide()) out.push({ el: bodyRef.value });
|
|
343
343
|
return out;
|
|
344
344
|
},
|
|
345
345
|
// Republish the morph's real sweep span and its landing on the body
|
|
@@ -393,18 +393,15 @@ export default defineComponent({
|
|
|
393
393
|
{ flush: "post" },
|
|
394
394
|
);
|
|
395
395
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return null;
|
|
406
|
-
}
|
|
407
|
-
};
|
|
396
|
+
// The ride's geometry lives behind `@media (min-width: 768px)`, so the
|
|
397
|
+
// phase->class mapping alone cannot tell whether the ride actually owns
|
|
398
|
+
// the frame (on a phone the classes land but nothing happens). This
|
|
399
|
+
// predicate gates every "is the ride flying" decision below.
|
|
400
|
+
const desktopRide = (): boolean =>
|
|
401
|
+
typeof window !== "undefined" &&
|
|
402
|
+
window.matchMedia("(min-width: 768px)").matches;
|
|
403
|
+
|
|
404
|
+
|
|
408
405
|
let previouslyFocused: HTMLElement | null = null;
|
|
409
406
|
let unmounted = false;
|
|
410
407
|
|
|
@@ -655,7 +652,21 @@ export default defineComponent({
|
|
|
655
652
|
// open branch's own start() covers them.
|
|
656
653
|
void nextTick(() => {
|
|
657
654
|
const p = machine.phase.value;
|
|
658
|
-
if (p
|
|
655
|
+
if (p !== "openingFrom" && p !== "openingTo") return;
|
|
656
|
+
// DESKTOP RIDE FLIGHT = MORPH BLACKOUT. While the ride owns the
|
|
657
|
+
// frame, the morph must not write anything to it: arming installs
|
|
658
|
+
// a ResizeObserver on the body, and the ride's own class change
|
|
659
|
+
// (locking the body to its measured height) fires it — the
|
|
660
|
+
// remeasure then lands `transition: none` mid-flight, which kills
|
|
661
|
+
// the ride's running height/scaleY transitions and pins the
|
|
662
|
+
// COMPRESSED used height as if it were natural (2026-11-07 user
|
|
663
|
+
// report: the open "replays" — ride animates part-way, snaps to
|
|
664
|
+
// the bad pin, then re-animates to full height). The morph arms at
|
|
665
|
+
// the open edge instead, where the ride classes are already off
|
|
666
|
+
// and the pin equals the used height (no visible change). The
|
|
667
|
+
// phone keeps the enter-edge arm: its sheet choreography rides
|
|
668
|
+
// the morph's clip sweep, and the ride geometry is gated off.
|
|
669
|
+
if (!desktopRide()) morph.start();
|
|
659
670
|
});
|
|
660
671
|
} else if (to === "open") {
|
|
661
672
|
surfTrack.cancel();
|
|
@@ -663,9 +674,50 @@ export default defineComponent({
|
|
|
663
674
|
// from this box (content may have changed while the surface opened).
|
|
664
675
|
void nextTick(() => ride.refresh());
|
|
665
676
|
onAfterEnter();
|
|
666
|
-
// Idempotent re-arm for the fast paths (see openingFrom)
|
|
667
|
-
//
|
|
668
|
-
|
|
677
|
+
// Idempotent re-arm for the fast paths (see openingFrom) — but on
|
|
678
|
+
// desktop the ride may still be RUNNING when the machine flips to
|
|
679
|
+
// `open` (its deadline is advisory): arming the morph right now
|
|
680
|
+
// would land `transition: none` + a px pin on a frame whose height
|
|
681
|
+
// is mid-transition, snapping it to the end value (2026-11-07
|
|
682
|
+
// user report: the window "jumps" the last third of its growth).
|
|
683
|
+
// Wait for the height leg to actually land (watchdog: the budget
|
|
684
|
+
// plus slack), then arm — the pin then equals the used height and
|
|
685
|
+
// the handoff is invisible. The phone has no ride; arm at once.
|
|
686
|
+
if (desktopRide()) {
|
|
687
|
+
const f = contentRef.value;
|
|
688
|
+
const armNow = (): void => {
|
|
689
|
+
if (machine.phase.value !== "open" || unmounted) return;
|
|
690
|
+
morph.start();
|
|
691
|
+
};
|
|
692
|
+
// No live CSS duration (test DOMs, reduced motion, the global
|
|
693
|
+
// animation switch): nothing is animating, arm at once — the
|
|
694
|
+
// same zero-duration semantics the surface machine uses.
|
|
695
|
+
const rawDuration = f ? getComputedStyle(f).transitionDuration : "";
|
|
696
|
+
const durMs = rawDuration
|
|
697
|
+
.split(",")
|
|
698
|
+
.map((part) => parseFloat(part))
|
|
699
|
+
.filter((v) => Number.isFinite(v))
|
|
700
|
+
.reduce((a, b) => Math.max(a, b), 0);
|
|
701
|
+
if (!f || durMs === 0) {
|
|
702
|
+
armNow();
|
|
703
|
+
} else {
|
|
704
|
+
let done = false;
|
|
705
|
+
const finish = (): void => {
|
|
706
|
+
if (done) return;
|
|
707
|
+
done = true;
|
|
708
|
+
f.removeEventListener("transitionend", onHeightEnd);
|
|
709
|
+
clearTimeout(guard);
|
|
710
|
+
armNow();
|
|
711
|
+
};
|
|
712
|
+
const onHeightEnd = (e: TransitionEvent): void => {
|
|
713
|
+
if (e.target === f && e.propertyName === "height") finish();
|
|
714
|
+
};
|
|
715
|
+
const guard = setTimeout(finish, 600);
|
|
716
|
+
f.addEventListener("transitionend", onHeightEnd);
|
|
717
|
+
}
|
|
718
|
+
} else {
|
|
719
|
+
morph.start();
|
|
720
|
+
}
|
|
669
721
|
// Flush the growth deferred through the unfold. One tick later
|
|
670
722
|
// the transition classes are off the frame, so the base height
|
|
671
723
|
// transition animates the pin change instead of the enter's
|
|
@@ -1132,7 +1184,7 @@ export default defineComponent({
|
|
|
1132
1184
|
/>
|
|
1133
1185
|
<div
|
|
1134
1186
|
ref={contentRef}
|
|
1135
|
-
class={["hk-modal-content",
|
|
1187
|
+
class={["hk-modal-content", props.contentClass, ...machine.classesFor("hk-modal-content")]}
|
|
1136
1188
|
data-hk-ride-ready={ride.hasCache() ? "" : undefined}
|
|
1137
1189
|
role="dialog"
|
|
1138
1190
|
aria-modal="true"
|
|
@@ -1201,7 +1253,7 @@ export default defineComponent({
|
|
|
1201
1253
|
)}
|
|
1202
1254
|
</>
|
|
1203
1255
|
) : null,
|
|
1204
|
-
<div ref={bodyRef} class="hk-modal-body
|
|
1256
|
+
<div ref={bodyRef} class="hk-modal-body">
|
|
1205
1257
|
<div
|
|
1206
1258
|
ref={scrollContainerRef}
|
|
1207
1259
|
class="hk-modal-body-scroll hk-scroll-pin-host"
|