@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.309",
3
+ "version": "0.55.311",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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 hk-ride");
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 hk-ride");
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
- * content region: scaleY(0.9) ⇄ scaleY(1) (origin top)
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} scaleY(0.9)`;
327
- const FRAME_TO = `${CENTERING} scaleY(1)`;
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
- "height var(--hk-modal-duration, 0.42s) var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1))";
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
- "height var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1))";
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("keeps the frame's transform at the plain centering translate in every state", () => {
359
- // The ride is layout height, not a frame transform — that is the whole
360
- // point (a frame scale squashed the chrome). So the transform must be
361
- // the SAME `translate(...)` in rest and at both ride ends, and adding a
362
- // scale to it in any state is the regression this pins.
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(CENTERING);
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, height");
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, height");
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|transform-origin|translate|scale|rotate|zoom|perspective)$/;
578
- const INERT = new Set(["none", "auto"]);
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 (!INERT.has(value)) offenders.push(`${rule.selector} { ${prop}: ${value} }`);
601
+ if (!LEGAL_BODY_TRANSFORM.has(value)) offenders.push(`${rule.selector} { ${prop}: ${value} }`);
587
602
  }
588
603
  }
589
- expect(offenders, "the content region must never be scaled").toEqual([]);
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(["opacity", "transition", "will-change", "transform"]);
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
- expect(frameTransform, `frame ${state.name}`).toBe(CENTERING);
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 WHOLE transform, in every state
61
- // (rest, enter-from/to, leave-from/to). The open/close ride is carried by
62
- // the height instead (see the choreography block below), so the frame can
63
- // never re-seat mid-flight, dragged windows keep their shift, and the
64
- // chrome is never scaled.
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
- // Chrome: opacity fade + the frame's height ride, on one shared token
141
- // pair so the window's growth lands exactly with its fade.
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
- height var(--hk-modal-duration, 0.42s)
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, height;
161
+ will-change: opacity, transform;
149
162
  }
150
163
 
151
- // Close: the reveal's TRUE reverse — same chrome fade, while the window
152
- // settles back toward 90% height, width untouched in both directions.
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
- height var(--hk-modal-duration, 0.32s)
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, height;
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
- // The content region keeps its NATURAL layout height for the whole ride: it
178
- // must not join the frame's flex shrink, or the slot compression and the
179
- // `.hk-ride-content` scale would compound to 0.81 and leave a gap above the
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 frame's height ride flattens with it: the ride states fall back to
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 any more (the ride is
699
- // height-only — 2026-11-07). These lines are a belt-and-braces guard only:
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
- // While the desktop height ride owns the body's paint (its scaleY
336
- // IS the choreography), the morph must not write an inline transform
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); the
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", "hk-ride", rideStateClass(), props.contentClass, ...machine.classesFor("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 hk-ride-content">
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"