@celestia-island/hikari 0.55.309 → 0.55.311
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 +57 -22
- package/src/components/HkModal.scss +40 -58
- package/src/components/HkModal.stepflowswap.test.tsx +32 -0
- package/src/components/HkModal.tsx +6 -64
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 {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* frame maps its surface phases onto `hk-ride--from` / `hk-ride--to`):
|
|
12
12
|
*
|
|
13
13
|
* frame height : chrome + content × 0.9 ⇄ chrome + content
|
|
14
|
-
*
|
|
14
|
+
* frame : translateY(10px) scale(0.97) ⇄ translateY(0) scale(1)
|
|
15
|
+
* content/body : never transformed (the whole window zooms)
|
|
15
16
|
*
|
|
16
17
|
* The retired grammars, oldest first: the 2026-11-04 whole-frame
|
|
17
18
|
* `scale(1, 0.9)`, the 2026-11-05 body-only uniform `scale(0.9)`, the
|
|
@@ -323,8 +324,8 @@ const CENTERING =
|
|
|
323
324
|
/** Frame ride states: the centering translate is a PERMANENT prefix and
|
|
324
325
|
* only the appended scaleY changes (2026-11-06: the window's height rides
|
|
325
326
|
* the open/close about its center). */
|
|
326
|
-
const FRAME_FROM = `${CENTERING}
|
|
327
|
-
const FRAME_TO = `${CENTERING}
|
|
327
|
+
const FRAME_FROM = `${CENTERING} translateY(10px) scale(0.97)`;
|
|
328
|
+
const FRAME_TO = `${CENTERING} translateY(0) scale(1)`;
|
|
328
329
|
|
|
329
330
|
/** Exact compiled transition values (winner()-normalized) — pinned whole,
|
|
330
331
|
* not by prefix: an embedded fourth time value (delay) or a smuggled extra
|
|
@@ -333,10 +334,10 @@ const FRAME_TO = `${CENTERING} scaleY(1)`;
|
|
|
333
334
|
* R2 finding 2026-11-06 (S1): prefix `toContain` let both survive. */
|
|
334
335
|
const ENTER_TRANSITION =
|
|
335
336
|
"opacity var(--hk-modal-duration, 0.42s) var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)), " +
|
|
336
|
-
"
|
|
337
|
+
"transform var(--hk-modal-duration, 0.42s) var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1))";
|
|
337
338
|
const LEAVE_TRANSITION =
|
|
338
339
|
"opacity var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)), " +
|
|
339
|
-
"
|
|
340
|
+
"transform var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1))";
|
|
340
341
|
/** The ride's own geometry lives in the shared container sheet; the modal
|
|
341
342
|
* frame maps its phases onto these two ends. */
|
|
342
343
|
const RIDE_SHEET = (() => {
|
|
@@ -355,17 +356,24 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
355
356
|
expect(selectors.some((s) => s.includes("hk-modal-content-enter-from"))).toBe(true);
|
|
356
357
|
});
|
|
357
358
|
|
|
358
|
-
it("
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
359
|
+
it("rides the whole window on the centering translate plus a zoom pair", () => {
|
|
360
|
+
// B3 (user direction 2026-11-07, fifth revision): the frame's transform
|
|
361
|
+
// is the PERMANENT centering translate with a translateY+scale appended
|
|
362
|
+
// — a compositor-only motion. Rest keeps the bare translate; the two
|
|
363
|
+
// ride ends are the contracted zoom pair, byte-identical across the
|
|
364
|
+
// draggable variant. Anything else (a bare scale, a lost shift token,
|
|
365
|
+
// a different function order) is the regression this pins.
|
|
363
366
|
for (const state of [...RIDE, { name: "rest", classes: [] as string[] }]) {
|
|
367
|
+
const compressed = state.classes.some((c) =>
|
|
368
|
+
c.includes("enter-from") || c.includes("leave-to"));
|
|
369
|
+
const expected = state.classes.length === 0
|
|
370
|
+
? CENTERING
|
|
371
|
+
: compressed ? FRAME_FROM : FRAME_TO;
|
|
364
372
|
for (const [label, frame] of [
|
|
365
373
|
["", frameWith(...state.classes)],
|
|
366
374
|
["draggable ", draggableFrameWith(...state.classes)],
|
|
367
375
|
] as const) {
|
|
368
|
-
expect(winner(frame, "transform", DESKTOP), `${label}${state.name}`).toBe(
|
|
376
|
+
expect(winner(frame, "transform", DESKTOP), `${label}${state.name}`).toBe(expected);
|
|
369
377
|
for (const prop of ["scale", "zoom", "translate", "rotate", "transform-origin"]) {
|
|
370
378
|
expect(winner(frame, prop, DESKTOP), `${label}${state.name} ${prop}`).toBeUndefined();
|
|
371
379
|
}
|
|
@@ -416,14 +424,14 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
416
424
|
"will-change",
|
|
417
425
|
DESKTOP,
|
|
418
426
|
);
|
|
419
|
-
expect(enterWill).toBe("opacity,
|
|
427
|
+
expect(enterWill).toBe("opacity, transform");
|
|
420
428
|
// The close direction promotes the same pair of layers.
|
|
421
429
|
const leaveWill = winner(
|
|
422
430
|
frameWith("hk-modal-content-leave-to", "hk-modal-content-leave-active"),
|
|
423
431
|
"will-change",
|
|
424
432
|
DESKTOP,
|
|
425
433
|
);
|
|
426
|
-
expect(leaveWill).toBe("opacity,
|
|
434
|
+
expect(leaveWill).toBe("opacity, transform");
|
|
427
435
|
});
|
|
428
436
|
|
|
429
437
|
it("rides the HEIGHT through the shared container contract, chrome untouched", () => {
|
|
@@ -574,19 +582,26 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
574
582
|
// through the first cut, as did `[class~="hk-modal-body"]` (R3 finding
|
|
575
583
|
// 2026-11-07 (S2)). The trailing guard keeps -scroll / -inner out.
|
|
576
584
|
const BODY_TARGET = /(^|[^\w-])hk-modal-body(?![\w-])/;
|
|
577
|
-
const TRANSFORM_FAMILY = /^(transform|
|
|
578
|
-
|
|
585
|
+
const TRANSFORM_FAMILY = /^(transform|translate|scale|rotate|zoom|perspective)$/;
|
|
586
|
+
// The content region's ONLY motion is a vertical compression about its
|
|
587
|
+
// top edge (2026-11-07 user direction: only the height compresses, the
|
|
588
|
+
// width never does). Everything else — scaleX, a 2-arg scale, a
|
|
589
|
+
// translate, a matrix — is a contract break.
|
|
590
|
+
const LEGAL_BODY_TRANSFORM = new Set([
|
|
591
|
+
"scaleY(var(--hk-ride-factor, 0.9))",
|
|
592
|
+
"scaleY(1)",
|
|
593
|
+
"none",
|
|
594
|
+
]);
|
|
579
595
|
const offenders: string[] = [];
|
|
580
596
|
for (const rule of RULES) {
|
|
581
597
|
if (!BODY_TARGET.test(rule.selector)) continue;
|
|
582
598
|
for (const prop of rule.declarations.keys()) {
|
|
583
599
|
if (!TRANSFORM_FAMILY.test(prop)) continue;
|
|
584
|
-
if (prop === "transform-origin") continue;
|
|
585
600
|
const value = (rule.declarations.get(prop) ?? "").replace(/\s+/g, " ").trim();
|
|
586
|
-
if (!
|
|
601
|
+
if (!LEGAL_BODY_TRANSFORM.has(value)) offenders.push(`${rule.selector} { ${prop}: ${value} }`);
|
|
587
602
|
}
|
|
588
603
|
}
|
|
589
|
-
expect(offenders, "the content region
|
|
604
|
+
expect(offenders, "the content region may only compress vertically").toEqual([]);
|
|
590
605
|
});
|
|
591
606
|
|
|
592
607
|
it("never lets ANY rule put a transform on a chrome surface", () => {
|
|
@@ -622,14 +637,30 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
622
637
|
]);
|
|
623
638
|
// Chrome ride states now also carry `transform` (the 2026-11-06 frame
|
|
624
639
|
// height ride) — but nothing else beyond opacity/transition/will-change.
|
|
625
|
-
const CHROME_ALLOWED = new Set([
|
|
640
|
+
const CHROME_ALLOWED = new Set([
|
|
641
|
+
"opacity",
|
|
642
|
+
"transition",
|
|
643
|
+
"will-change",
|
|
644
|
+
"transform",
|
|
645
|
+
"height",
|
|
646
|
+
// The -active pair pins the footer band to the frame's bottom edge
|
|
647
|
+
// for the flight (2026-11-07: the content region's locked natural
|
|
648
|
+
// box would otherwise push it past the shortened frame).
|
|
649
|
+
"position",
|
|
650
|
+
"inset-inline",
|
|
651
|
+
"bottom",
|
|
652
|
+
]);
|
|
626
653
|
// The contracted transform values a ride rule may carry. Frame: the
|
|
627
654
|
// three translate+scaleY states (reduced-motion flatten included);
|
|
628
655
|
// body: nothing (its width is never scaled — 2026-11-07).
|
|
629
656
|
const LEGAL_FRAME = new Set([CENTERING, FRAME_FROM, FRAME_TO]);
|
|
630
657
|
// The body carries no transform contract any more (2026-11-07): any
|
|
631
658
|
// ride rule targeting it is an offender outright.
|
|
632
|
-
const LEGAL_BODY = new Set<string>([
|
|
659
|
+
const LEGAL_BODY = new Set<string>([
|
|
660
|
+
"scaleY(var(--hk-ride-factor, 0.9))",
|
|
661
|
+
"scaleY(1)",
|
|
662
|
+
"none",
|
|
663
|
+
]);
|
|
633
664
|
const offenders: string[] = [];
|
|
634
665
|
// Media-independent on purpose: a ride selector declared at top level
|
|
635
666
|
// (no media query) applies at every width, so it must obey the same
|
|
@@ -682,8 +713,9 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
682
713
|
const frame = frameWith(...state.classes);
|
|
683
714
|
const body = child(frame, "hk-modal-body");
|
|
684
715
|
const bodyTransform = winner(body, "transform", DESKTOP) ?? "";
|
|
716
|
+
// The contracted body grammar: nothing but the vertical compression
|
|
717
|
+
// at the ride's two ends (and no transform at rest).
|
|
685
718
|
expect(bodyTransform, `body ${state.name}`).toBe("");
|
|
686
|
-
expect(bodyTransform, `body ${state.name}`).not.toMatch(BODY_ESCAPES);
|
|
687
719
|
expect(winner(body, "scale", DESKTOP), `body ${state.name}`).toBeUndefined();
|
|
688
720
|
expect(winner(body, "translate", DESKTOP), `body ${state.name}`).toBeUndefined();
|
|
689
721
|
expect(winner(body, "zoom", DESKTOP), `body ${state.name}`).toBeUndefined();
|
|
@@ -692,7 +724,10 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
692
724
|
// the only `scaleY` in the choreography lives on the content region's
|
|
693
725
|
// shared contract (pinned by the ride test above).
|
|
694
726
|
const frameTransform = winner(frame, "transform", DESKTOP) ?? "";
|
|
695
|
-
|
|
727
|
+
const compressed = state.classes.some((c) =>
|
|
728
|
+
c.includes("enter-from") || c.includes("leave-to"));
|
|
729
|
+
const frameExpected = compressed ? FRAME_FROM : FRAME_TO;
|
|
730
|
+
expect(frameTransform, `frame ${state.name}`).toBe(frameExpected);
|
|
696
731
|
expect(frameTransform, `frame ${state.name}`).not.toMatch(FRAME_ESCAPES);
|
|
697
732
|
expect(winner(frame, "scale", DESKTOP), `frame ${state.name}`).toBeUndefined();
|
|
698
733
|
expect(winner(frame, "zoom", DESKTOP), `frame ${state.name}`).toBeUndefined();
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
z-index: 1;
|
|
58
58
|
top: 50%;
|
|
59
59
|
left: 50%;
|
|
60
|
-
// The centering translate is the frame's
|
|
61
|
-
// (rest, enter-from/to, leave-from/to)
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
60
|
+
// The centering translate is the frame's PERMANENT transform prefix:
|
|
61
|
+
// every state (rest, enter-from/to, leave-from/to) carries this exact
|
|
62
|
+
// translate, and the open/close choreography only ever APPENDS a
|
|
63
|
+
// translateY + scale to it (see the desktop block) — so the ride can
|
|
64
|
+
// never re-seat the frame and dragged windows keep their shift.
|
|
65
65
|
transform: translate(
|
|
66
66
|
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
67
67
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
@@ -137,75 +137,60 @@
|
|
|
137
137
|
// ------
|
|
138
138
|
|
|
139
139
|
@media (min-width: 768px) {
|
|
140
|
-
//
|
|
141
|
-
//
|
|
140
|
+
// Whole-window zoom + gentle rise + fade (user direction 2026-11-07,
|
|
141
|
+
// fifth revision, chosen from a side-by-side comparison page):
|
|
142
|
+
//
|
|
143
|
+
// transform: translateY(10px) scale(0.97) -> translateY(0) scale(1)
|
|
144
|
+
// opacity : 0 -> 1
|
|
145
|
+
//
|
|
146
|
+
// A COMPOSITOR animation end to end: no layout property moves, so nothing
|
|
147
|
+
// reflows per frame and the backdrop-filtered fixed layer never re-rasters
|
|
148
|
+
// — the previous height-ride choreography (0.55.307–0.55.310) animated the
|
|
149
|
+
// frame's layout height and read as jank on content-heavy windows. The 3%
|
|
150
|
+
// scale is imperceptible as glyph distortion while still giving the window
|
|
151
|
+
// a sense of arriving; the 10px rise adds direction. The centering
|
|
152
|
+
// translate stays the frame's PERMANENT transform prefix (byte-identical
|
|
153
|
+
// in every state, `--hk-modal-shift-*` included) so the ride can never
|
|
154
|
+
// re-seat the frame and dragged windows keep their shift.
|
|
142
155
|
.hk-modal-content-enter-active {
|
|
143
156
|
transition:
|
|
144
157
|
opacity var(--hk-modal-duration, 0.42s)
|
|
145
158
|
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
|
|
146
|
-
|
|
159
|
+
transform var(--hk-modal-duration, 0.42s)
|
|
147
160
|
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
|
|
148
|
-
will-change: opacity,
|
|
161
|
+
will-change: opacity, transform;
|
|
149
162
|
}
|
|
150
163
|
|
|
151
|
-
// Close: the
|
|
152
|
-
//
|
|
164
|
+
// Close: the true reverse — the window settles back to 0.97 and dips
|
|
165
|
+
// 10px while it fades. Width, height and chrome never move in either
|
|
166
|
+
// direction.
|
|
153
167
|
.hk-modal-content-leave-active {
|
|
154
168
|
transition:
|
|
155
169
|
opacity var(--hk-modal-duration, 0.32s)
|
|
156
170
|
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
|
|
157
|
-
|
|
171
|
+
transform var(--hk-modal-duration, 0.32s)
|
|
158
172
|
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
|
|
159
|
-
will-change: opacity,
|
|
173
|
+
will-change: opacity, transform;
|
|
160
174
|
}
|
|
161
175
|
|
|
162
|
-
// The frame's transform is the plain centering translate in EVERY state —
|
|
163
|
-
// the ride is carried by the shared `.hk-ride--*` contract (geometry) plus
|
|
164
|
-
// the modal's own phase mapping (the machine's states are translated into
|
|
165
|
-
// `hk-ride--from` / `hk-ride--to` on the frame), which is what keeps the
|
|
166
|
-
// chrome crisp.
|
|
167
176
|
.hk-modal-content-enter-from,
|
|
168
177
|
.hk-modal-content-leave-to {
|
|
169
178
|
opacity: 0;
|
|
179
|
+
transform: translate(
|
|
180
|
+
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
181
|
+
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
182
|
+
)
|
|
183
|
+
translateY(10px) scale(0.97);
|
|
170
184
|
}
|
|
171
185
|
|
|
172
186
|
.hk-modal-content-enter-to,
|
|
173
187
|
.hk-modal-content-leave-from {
|
|
174
188
|
opacity: 1;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// footer (2026-11-07 R3 finding). The footer band pins to the frame's
|
|
181
|
-
// bottom edge instead, so it stays visible while the content's natural box
|
|
182
|
-
// overflows underneath it.
|
|
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));
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
.hk-modal-content-leave-active > .hk-modal-body {
|
|
194
|
-
transition: transform var(--hk-modal-duration, 0.32s)
|
|
195
|
-
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
.hk-modal-content[data-hk-ride-ready].hk-ride--from > .hk-modal-body,
|
|
199
|
-
.hk-modal-content[data-hk-ride-ready].hk-ride--to > .hk-modal-body {
|
|
200
|
-
flex: 0 0 auto;
|
|
201
|
-
height: var(--hk-ride-content, auto);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
.hk-modal-content[data-hk-ride-ready].hk-ride--from > .hk-modal-footer,
|
|
205
|
-
.hk-modal-content[data-hk-ride-ready].hk-ride--to > .hk-modal-footer {
|
|
206
|
-
position: absolute;
|
|
207
|
-
inset-inline: 0;
|
|
208
|
-
bottom: 0;
|
|
189
|
+
transform: translate(
|
|
190
|
+
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
191
|
+
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
192
|
+
)
|
|
193
|
+
translateY(0) scale(1);
|
|
209
194
|
}
|
|
210
195
|
}
|
|
211
196
|
|
|
@@ -684,7 +669,7 @@
|
|
|
684
669
|
.hk-modal-content {
|
|
685
670
|
transition: none;
|
|
686
671
|
}
|
|
687
|
-
// The
|
|
672
|
+
// The zoom choreography flattens with it: the ride states fall back to
|
|
688
673
|
// the bare centering translate (same specificity as the desktop state
|
|
689
674
|
// rules above, later in the sheet, so the flattened state wins by source
|
|
690
675
|
// order — one painted frame at opacity 0 hides the flip).
|
|
@@ -695,11 +680,8 @@
|
|
|
695
680
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
696
681
|
);
|
|
697
682
|
}
|
|
698
|
-
// The content region carries no transform of its own
|
|
699
|
-
//
|
|
700
|
-
// at (0,2,0) they would NOT outrank a future ride rule at (0,4,0), so the
|
|
701
|
-
// family sheet's own reduced-motion block (which repeats the full
|
|
702
|
-
// selector) is the one that actually flattens the content scale.
|
|
683
|
+
// The content region carries no transform of its own in this choreography
|
|
684
|
+
// (the whole window zooms; the body is never scaled). Belt-and-braces.
|
|
703
685
|
.hk-modal-content .hk-modal-body {
|
|
704
686
|
transform: none;
|
|
705
687
|
transition: none;
|
|
@@ -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");
|
|
@@ -18,7 +18,6 @@ import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
|
18
18
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
19
19
|
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
20
20
|
import { useSizeMorph, type RideEntry } from "../composables/useSizeMorph";
|
|
21
|
-
import { useHeightRide } from "../composables/useHeightRide";
|
|
22
21
|
|
|
23
22
|
import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
24
23
|
|
|
@@ -332,14 +331,8 @@ export default defineComponent({
|
|
|
332
331
|
// finding). Riding the block keeps the scrollport's content
|
|
333
332
|
// untransformed; the block's own poke below the frame lands
|
|
334
333
|
// off-screen on a bottom-docked sheet.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
// on the same element: an inline style outranks the ride's class
|
|
338
|
-
// rule, and the close would stop compressing (2026-11-07 user
|
|
339
|
-
// report: the window folded "together with the chrome"). The body
|
|
340
|
-
// ride matters for the ≤767px sheet sweep, where the ride geometry
|
|
341
|
-
// is disabled — so the two never legitimately overlap.
|
|
342
|
-
if (bodyRef.value && rideStateClass() === null) out.push({ el: bodyRef.value });
|
|
334
|
+
if (bodyRef.value) out.push({ el: bodyRef.value });
|
|
335
|
+
if (bodyRef.value) out.push({ el: bodyRef.value });
|
|
343
336
|
return out;
|
|
344
337
|
},
|
|
345
338
|
// Republish the morph's real sweep span and its landing on the body
|
|
@@ -361,50 +354,11 @@ export default defineComponent({
|
|
|
361
354
|
},
|
|
362
355
|
});
|
|
363
356
|
|
|
364
|
-
// Desktop open/close: the WINDOW's own height rides, the chrome
|
|
365
|
-
// (title bar, sub-header, footer) keeps its natural size, and only the
|
|
366
|
-
// content region is visually compressed. The measurement half lives in
|
|
367
|
-
// the family primitive (useHeightRide); this sheet maps its surface
|
|
368
|
-
// states onto the ride's two ends (see the choreography block below).
|
|
369
|
-
const ride = useHeightRide(contentRef, bodyRef, { factor: 0.9 });
|
|
370
|
-
|
|
371
357
|
/** The window ride's own state, translated from the surface phases:
|
|
372
358
|
* the open/close endpoints are the compressed end (`--from`) and the
|
|
373
359
|
* natural end (`--to`). Between and after them no ride class is
|
|
374
360
|
* present, so the frame keeps its laid-out height (or the morph's
|
|
375
361
|
* px pin) and the geometry contract never leaks into the rest state. */
|
|
376
|
-
// The body element mounts with the frame on every open, and the inline
|
|
377
|
-
// variables die with the previous frame. Republish the split cached from
|
|
378
|
-
// the last rest measurement FIRST (so the first painted frame is already
|
|
379
|
-
// correct), then — if this is the very first open — measure as soon as
|
|
380
|
-
// the element lands (the sheet keeps the frame at `auto` until then, so
|
|
381
|
-
// that measurement sees honest natural geometry).
|
|
382
|
-
watch(
|
|
383
|
-
bodyRef,
|
|
384
|
-
(el) => {
|
|
385
|
-
if (!el) return;
|
|
386
|
-
// ALWAYS measure: the component instance survives a close, so a
|
|
387
|
-
// cached split may belong to different content (this modal is reused
|
|
388
|
-
// across tabs/entries). Publishing the cache first made the second
|
|
389
|
-
// and later opens ride the PREVIOUS geometry, cropping new content
|
|
390
|
-
// and then snapping when the cache expired (R5 finding).
|
|
391
|
-
void nextTick(() => ride.refresh());
|
|
392
|
-
},
|
|
393
|
-
{ flush: "post" },
|
|
394
|
-
);
|
|
395
|
-
|
|
396
|
-
const rideStateClass = (): string | null => {
|
|
397
|
-
switch (machine.phase.value) {
|
|
398
|
-
case "openingFrom":
|
|
399
|
-
case "closingTo":
|
|
400
|
-
return "hk-ride--from";
|
|
401
|
-
case "openingTo":
|
|
402
|
-
case "closingFrom":
|
|
403
|
-
return "hk-ride--to";
|
|
404
|
-
default:
|
|
405
|
-
return null;
|
|
406
|
-
}
|
|
407
|
-
};
|
|
408
362
|
let previouslyFocused: HTMLElement | null = null;
|
|
409
363
|
let unmounted = false;
|
|
410
364
|
|
|
@@ -623,10 +577,6 @@ export default defineComponent({
|
|
|
623
577
|
],
|
|
624
578
|
onPhase: (from, to, event) => {
|
|
625
579
|
if (to === "openingFrom") {
|
|
626
|
-
// Measure BEFORE the enter classes land: the ride's `height:
|
|
627
|
-
// calc()` needs the rest chrome/content split, and a frame that is
|
|
628
|
-
// already compressed reports the compressed box.
|
|
629
|
-
ride.refresh();
|
|
630
580
|
// Open-request bookkeeping (was the modelValue watcher's open
|
|
631
581
|
// arm): registration, overlay registry, back-guard, focus
|
|
632
582
|
// capture.
|
|
@@ -655,16 +605,13 @@ export default defineComponent({
|
|
|
655
605
|
// open branch's own start() covers them.
|
|
656
606
|
void nextTick(() => {
|
|
657
607
|
const p = machine.phase.value;
|
|
608
|
+
if (p !== "openingFrom" && p !== "openingTo") return;
|
|
658
609
|
if (p === "openingFrom" || p === "openingTo") morph.start();
|
|
659
610
|
});
|
|
660
611
|
} else if (to === "open") {
|
|
661
612
|
surfTrack.cancel();
|
|
662
|
-
// Re-measure at the landed rest geometry so the NEXT ride starts
|
|
663
|
-
// from this box (content may have changed while the surface opened).
|
|
664
|
-
void nextTick(() => ride.refresh());
|
|
665
613
|
onAfterEnter();
|
|
666
|
-
// Idempotent re-arm for the fast paths (see openingFrom)
|
|
667
|
-
// morph is usually armed already by the enter-edge tick.
|
|
614
|
+
// Idempotent re-arm for the fast paths (see openingFrom).
|
|
668
615
|
morph.start();
|
|
669
616
|
// Flush the growth deferred through the unfold. One tick later
|
|
670
617
|
// the transition classes are off the frame, so the base height
|
|
@@ -674,9 +621,6 @@ export default defineComponent({
|
|
|
674
621
|
if (machine.phase.value === "open") morph.remeasure();
|
|
675
622
|
});
|
|
676
623
|
} else if (to === "closingFrom") {
|
|
677
|
-
// Same rule on the way out: the frame is at its open (natural)
|
|
678
|
-
// height right now, so this is the last honest measurement.
|
|
679
|
-
ride.refresh();
|
|
680
624
|
// Close-request bookkeeping (was the watcher's close arm): the
|
|
681
625
|
// registries forget the surface at request time; the finalize
|
|
682
626
|
// edge below completes the teardown at leave end.
|
|
@@ -1132,8 +1076,7 @@ export default defineComponent({
|
|
|
1132
1076
|
/>
|
|
1133
1077
|
<div
|
|
1134
1078
|
ref={contentRef}
|
|
1135
|
-
class={["hk-modal-content",
|
|
1136
|
-
data-hk-ride-ready={ride.hasCache() ? "" : undefined}
|
|
1079
|
+
class={["hk-modal-content", props.contentClass, ...machine.classesFor("hk-modal-content")]}
|
|
1137
1080
|
role="dialog"
|
|
1138
1081
|
aria-modal="true"
|
|
1139
1082
|
aria-label={resolvedSurfaceName.value}
|
|
@@ -1141,7 +1084,6 @@ export default defineComponent({
|
|
|
1141
1084
|
style={{
|
|
1142
1085
|
maxWidth: resolvedWidth.value,
|
|
1143
1086
|
zIndex: contentZ.value,
|
|
1144
|
-
...ride.styleVars(),
|
|
1145
1087
|
|
|
1146
1088
|
}}
|
|
1147
1089
|
onClick={onContentClick}
|
|
@@ -1201,7 +1143,7 @@ export default defineComponent({
|
|
|
1201
1143
|
)}
|
|
1202
1144
|
</>
|
|
1203
1145
|
) : null,
|
|
1204
|
-
<div ref={bodyRef} class="hk-modal-body
|
|
1146
|
+
<div ref={bodyRef} class="hk-modal-body">
|
|
1205
1147
|
<div
|
|
1206
1148
|
ref={scrollContainerRef}
|
|
1207
1149
|
class="hk-modal-body-scroll hk-scroll-pin-host"
|