@celestia-island/hikari 0.55.310 → 0.55.312

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.
@@ -2,10 +2,6 @@ import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, s
2
2
 
3
3
  import { useI18n } from "../i18n/context";
4
4
  import "./HkModal.scss";
5
- // The ride GEOMETRY lives in the family sheet; the modal maps its phases onto
6
- // it, so the modal must ship it too (deep-import consumers of
7
- // `./components/HkModal` never touch the barrel). Idempotent with HkRideBox.
8
- import "./HkRideBox.scss";
9
5
  import HkFloatingLayer from "./HkFloatingLayer";
10
6
  import { focusFirst, trapFocus } from "../utils/dom";
11
7
  import { useOverlay } from "../runtime/useOverlay";
@@ -18,7 +14,6 @@ import { useSurfaceTransition } from "../composables/useSurfaceTransition";
18
14
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
19
15
  import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
20
16
  import { useSizeMorph, type RideEntry } from "../composables/useSizeMorph";
21
- import { useHeightRide } from "../composables/useHeightRide";
22
17
 
23
18
  import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
24
19
 
@@ -332,14 +327,8 @@ export default defineComponent({
332
327
  // finding). Riding the block keeps the scrollport's content
333
328
  // untransformed; the block's own poke below the frame lands
334
329
  // 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 && !desktopRide()) out.push({ el: bodyRef.value });
330
+ if (bodyRef.value) out.push({ el: bodyRef.value });
331
+ if (bodyRef.value) out.push({ el: bodyRef.value });
343
332
  return out;
344
333
  },
345
334
  // Republish the morph's real sweep span and its landing on the body
@@ -361,47 +350,11 @@ export default defineComponent({
361
350
  },
362
351
  });
363
352
 
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
353
  /** The window ride's own state, translated from the surface phases:
372
354
  * the open/close endpoints are the compressed end (`--from`) and the
373
355
  * natural end (`--to`). Between and after them no ride class is
374
356
  * present, so the frame keeps its laid-out height (or the morph's
375
357
  * 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
- // 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
-
405
358
  let previouslyFocused: HTMLElement | null = null;
406
359
  let unmounted = false;
407
360
 
@@ -620,10 +573,6 @@ export default defineComponent({
620
573
  ],
621
574
  onPhase: (from, to, event) => {
622
575
  if (to === "openingFrom") {
623
- // Measure BEFORE the enter classes land: the ride's `height:
624
- // calc()` needs the rest chrome/content split, and a frame that is
625
- // already compressed reports the compressed box.
626
- ride.refresh();
627
576
  // Open-request bookkeeping (was the modelValue watcher's open
628
577
  // arm): registration, overlay registry, back-guard, focus
629
578
  // capture.
@@ -653,71 +602,13 @@ export default defineComponent({
653
602
  void nextTick(() => {
654
603
  const p = machine.phase.value;
655
604
  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();
605
+ if (p === "openingFrom" || p === "openingTo") morph.start();
670
606
  });
671
607
  } else if (to === "open") {
672
608
  surfTrack.cancel();
673
- // Re-measure at the landed rest geometry so the NEXT ride starts
674
- // from this box (content may have changed while the surface opened).
675
- void nextTick(() => ride.refresh());
676
609
  onAfterEnter();
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
- }
610
+ // Idempotent re-arm for the fast paths (see openingFrom).
611
+ morph.start();
721
612
  // Flush the growth deferred through the unfold. One tick later
722
613
  // the transition classes are off the frame, so the base height
723
614
  // transition animates the pin change instead of the enter's
@@ -726,9 +617,6 @@ export default defineComponent({
726
617
  if (machine.phase.value === "open") morph.remeasure();
727
618
  });
728
619
  } else if (to === "closingFrom") {
729
- // Same rule on the way out: the frame is at its open (natural)
730
- // height right now, so this is the last honest measurement.
731
- ride.refresh();
732
620
  // Close-request bookkeeping (was the watcher's close arm): the
733
621
  // registries forget the surface at request time; the finalize
734
622
  // edge below completes the teardown at leave end.
@@ -1185,7 +1073,6 @@ export default defineComponent({
1185
1073
  <div
1186
1074
  ref={contentRef}
1187
1075
  class={["hk-modal-content", props.contentClass, ...machine.classesFor("hk-modal-content")]}
1188
- data-hk-ride-ready={ride.hasCache() ? "" : undefined}
1189
1076
  role="dialog"
1190
1077
  aria-modal="true"
1191
1078
  aria-label={resolvedSurfaceName.value}
@@ -1193,7 +1080,6 @@ export default defineComponent({
1193
1080
  style={{
1194
1081
  maxWidth: resolvedWidth.value,
1195
1082
  zIndex: contentZ.value,
1196
- ...ride.styleVars(),
1197
1083
 
1198
1084
  }}
1199
1085
  onClick={onContentClick}
@@ -127,15 +127,6 @@ describe("pane-swap contract", () => {
127
127
  // These are hosts that MUST render their slot keyed by state — they
128
128
  // are exactly the pane slots the rule is about, and each is either
129
129
  // wrapped by the standard above or justified here.
130
- // HkRideBox renders its two CHROME slots by name
131
- // (`slots["chrome-start"]`, `slots["chrome-end"]`). They are not pane
132
- // slots and never swap: they are the fixed-size bands bracketing the
133
- // content region, and the whole point of the box is that they stay
134
- // OUTSIDE the scaled region (2026-11-07 user report: a frame-level
135
- // scale squashed the title bar and the footer buttons). Routing them
136
- // through HkTransitionContainer would be wrong — they must render
137
- // exactly once, in place, in the same paint.
138
- "HkRideBox.tsx",
139
130
  "HkTabs.tsx", // wrapped by HkTransitionContainer (pinned above)
140
131
  "HkSettingsDialog.tsx", // wrapped by HkTransitionContainer (pinned above)
141
132
  // HkStepFlow is NOT a rogue pane slot — it is a sibling mechanism
package/src/index.ts CHANGED
@@ -86,7 +86,6 @@ export { default as HProgressBar } from "./components/HkProgressBar";
86
86
  export { default as HProgressDialog } from "./components/HkProgressDialog";
87
87
  export { default as HRadio } from "./components/HkRadio";
88
88
  export { default as HScrollContainer } from "./components/HkScrollContainer";
89
- export { default as HRideBox } from "./components/HkRideBox";
90
89
  export { default as HScreenIdentifyCard } from "./components/HkScreenIdentifyCard";
91
90
  export { default as HScreenIdentifyHost } from "./components/HkScreenIdentifyHost";
92
91
  export { default as HSearchInput } from "./components/HkSearchInput";
@@ -519,8 +518,6 @@ export type { HkScrollProgressHandle, HkScrollProgressRegistrar } from "./compon
519
518
  export type { OverlayScrollMapSegment, OverlayScrollbarMarker } from "./composables/useOverlayScrollbar";
520
519
  export { useDeferredTransition } from "./composables/useDeferredTransition";
521
520
  export { useSurfaceTransition } from "./composables/useSurfaceTransition";
522
- export { useHeightRide } from "./composables/useHeightRide";
523
- export type { HeightRide, HeightRideOptions } from "./composables/useHeightRide";
524
521
  export { useSizeMorph } from "./composables/useSizeMorph";
525
522
  export type { SizeMorph } from "./composables/useSizeMorph";
526
523
  export { useImage } from "./composables/useImage";
@@ -1,110 +0,0 @@
1
- // HkRideBox.scss
2
- // The family-level height-ride container (see useHeightRide for the WHY).
3
- //
4
- // Contract, in one place:
5
- // .hk-ride the frame whose LAYOUT height rides
6
- // .hk-ride--from compressed end (chrome + content × factor)
7
- // .hk-ride--to natural end (chrome + content)
8
- // .hk-ride-content the ONLY scaled region (origin top, so the
9
- // compressed paint lands exactly in the shorter slot
10
- // the frame leaves for it)
11
- // --hk-ride-chrome px, measured by the composable
12
- // --hk-ride-content px, measured by the composable
13
- // --hk-ride-factor the content fraction (default 0.9)
14
- // [data-hk-riding] present for the whole flight; scroll features that
15
- // must skip work read it, like [data-hk-morphing] on
16
- // the mobile sheet
17
- //
18
- // At rest neither state class is present, so the frame keeps whatever
19
- // height its own layout gives it (`auto`, or a consumer's px pin) and the
20
- // ride degrades to "no ride" rather than to a wrong box if a measurement
21
- // is missing.
22
-
23
- // NOTE: the shared class deliberately carries GEOMETRY only. Timing,
24
- // easing and `overflow` belong to the consuming surface (the modal already
25
- // runs its own enter/leave transition list, and the phone sheet has a
26
- // completely different choreography), so this sheet can never clobber one.
27
- .hk-ride {
28
- // Only meaningful while a ride class is present (the height resolves
29
- // from the measured variables); at rest the box keeps its own layout
30
- // height, or the consumer's px pin.
31
- }
32
-
33
- // The two ends of the ride. `!important` is deliberate and load-bearing: a
34
- // consumer's own size-morph pins `height` as an INLINE px value, and the
35
- // ride — not the pin — owns the box for the duration of the flight.
36
- .hk-ride-box {
37
- position: relative;
38
- }
39
-
40
- // The ride's geometry is DESKTOP-only on purpose: the ≤767px sheet has its
41
- // own clip/translateY choreography, and a shared height override there would
42
- // fight the sheet's `max-height ... !important` cap.
43
- @media (min-width: 768px) {
44
-
45
- .hk-ride[data-hk-ride-ready].hk-ride--from {
46
- height: calc(
47
- var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px) * var(--hk-ride-factor, 0.9)
48
- ) !important;
49
- }
50
-
51
- .hk-ride[data-hk-ride-ready].hk-ride--to {
52
- height: calc(var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px)) !important;
53
- }
54
-
55
- // The box's OWN root class carries the positioning context: the shared
56
- // `.hk-ride` class is also worn by surfaces that bring their own `position`
57
- // (`.hk-modal-content` is `position: fixed`), and a `position: relative`
58
- // here would tie with it at (0,1,0) and win or lose by bundle order.
59
-
60
-
61
- .hk-ride-content {
62
- transform-origin: 50% 0;
63
- transition: transform var(--hk-ride-duration, 0.42s) var(--hk-ride-ease, cubic-bezier(0.33, 1, 0.68, 1));
64
- }
65
-
66
- // THE load-bearing pair of the whole primitive: while riding, the content
67
- // region stops participating in the flex shrink and holds its NATURAL
68
- // height, so its layout box is stable (scroll geometry, pins and the
69
- // overlay scrollbar keep reading the same numbers) and the `scaleY` below
70
- // is the ONLY thing that compresses it. Without this the slot shrink and the
71
- // transform would compound (0.9 × 0.9 = 0.81) and leave a gap above the
72
- // pinned chrome-end band (2026-11-07 R3 finding).
73
- .hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-content,
74
- .hk-ride[data-hk-ride-ready].hk-ride--to > .hk-ride-content {
75
- flex: 0 0 auto;
76
- height: var(--hk-ride-content, auto);
77
- }
78
-
79
- .hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-chrome-start,
80
- .hk-ride[data-hk-ride-ready].hk-ride--to > .hk-ride-chrome-start {
81
- flex-shrink: 0;
82
- }
83
-
84
- .hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-chrome-end,
85
- .hk-ride[data-hk-ride-ready].hk-ride--to > .hk-ride-chrome-end {
86
- position: absolute;
87
- inset-inline: 0;
88
- bottom: 0;
89
- }
90
-
91
- .hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-content {
92
- transform: scaleY(var(--hk-ride-factor, 0.9));
93
- }
94
-
95
- }
96
-
97
- /* Reduced motion: the frame swaps instantly and the content is never
98
- * compressed. */
99
- @media (prefers-reduced-motion: reduce) {
100
- .hk-ride,
101
- .hk-ride-content {
102
- transition: none;
103
- }
104
- /* Repeated with the readiness gate so this flatten outranks the state
105
- * rule above (which is (0,4,0)); a bare `.hk-ride.hk-ride--from` here
106
- * would lose the cascade and the a11y contract would be a lie. */
107
- .hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-content {
108
- transform: none;
109
- }
110
- }
@@ -1,174 +0,0 @@
1
- /**
2
- * Contract for the family-level height-ride container (`HkRideBox` + the
3
- * `useHeightRide` measurement primitive).
4
- *
5
- * Why these assertions: the motion is a LAYOUT-height animation precisely so
6
- * that the chrome (title bar, footer action row) keeps its natural size — the
7
- * 2026-11-07 user report was that a frame-level `transform: scaleY()` squashed
8
- * the title text and the footer buttons along with everything else. The test
9
- * that matters is therefore structural, not decorative:
10
- *
11
- * 1. the chrome bands are SIBLINGS of the content region, so no transform
12
- * can ever reach them;
13
- * 2. the only scaled region is `.hk-ride-content`;
14
- * 3. the geometry is published as variables (chrome / content / factor) and
15
- * the two state classes are what the sheet interpolates between;
16
- * 4. an unmeasurable box (jsdom reports 0) publishes NOTHING, so the sheet
17
- * falls back to `auto` instead of collapsing the frame;
18
- * 5. a second ride never shrinks an already-compressed measurement.
19
- */
20
- import { describe, expect, it } from "vitest";
21
- import { createApp, defineComponent, h, nextTick, ref, type Component } from "vue";
22
-
23
- import HkRideBox from "./HkRideBox";
24
- import {
25
- RIDE_ATTR,
26
- RIDE_CLASS,
27
- RIDE_CONTENT_CLASS,
28
- RIDE_FROM_CLASS,
29
- RIDE_TO_CLASS,
30
- } from "../composables/useHeightRide";
31
-
32
- interface Harness {
33
- root: () => HTMLElement;
34
- setRiding: (next: boolean) => Promise<void>;
35
- unmount: () => void;
36
- }
37
-
38
- function mountBox(
39
- initial: { riding?: boolean; factor?: number } = {},
40
- slots?: Record<string, () => unknown>,
41
- ): Harness {
42
- const host = document.createElement("div");
43
- document.body.appendChild(host);
44
- const riding = ref(Boolean(initial.riding));
45
- const Host = defineComponent({
46
- setup() {
47
- return () =>
48
- h(
49
- HkRideBox as Component,
50
- { riding: riding.value, factor: initial.factor ?? 0.9 },
51
- slots ?? {
52
- "chrome-start": () => h("header", { class: "probe-chrome-start" }, "title"),
53
- default: () => h("div", { class: "probe-content" }, "body"),
54
- "chrome-end": () => h("footer", { class: "probe-chrome-end" }, "actions"),
55
- },
56
- );
57
- },
58
- });
59
- const app = createApp(Host);
60
- app.mount(host);
61
- return {
62
- root: () => host.firstElementChild as HTMLElement,
63
- setRiding: async (next: boolean) => {
64
- riding.value = next;
65
- await nextTick();
66
- await nextTick();
67
- },
68
- unmount: () => {
69
- app.unmount();
70
- host.remove();
71
- },
72
- };
73
- }
74
-
75
- /** jsdom reports 0 for every layout box. The measurement deliberately never
76
- * reads the FRAME's height (that is the number the ride computes), so the
77
- * stub feeds it the two honest sources: the content region's natural scroll
78
- * extent and the chrome bands' own offsets. */
79
- function stubBox(root: HTMLElement, content: number, chrome: number): void {
80
- const contentEl = root.querySelector(`.${RIDE_CONTENT_CLASS}`) as HTMLElement;
81
- Object.defineProperty(contentEl, "scrollHeight", { configurable: true, value: content });
82
- Object.defineProperty(contentEl, "offsetHeight", { configurable: true, value: content });
83
- const chromeEls = Array.from(root.children).filter((el) => el !== contentEl) as HTMLElement[];
84
- chromeEls.forEach((el, index) => {
85
- Object.defineProperty(el, "offsetHeight", {
86
- configurable: true,
87
- value: index === 0 ? chrome : 0,
88
- });
89
- });
90
- }
91
-
92
- describe("HkRideBox (family height-ride container)", () => {
93
- it("renders the ride contract and keeps the chrome outside the scaled region", () => {
94
- const h1 = mountBox();
95
- const root = h1.root();
96
- expect(root.classList.contains(RIDE_CLASS)).toBe(true);
97
- const content = root.querySelector(`.${RIDE_CONTENT_CLASS}`) as HTMLElement;
98
- expect(content).not.toBeNull();
99
- // Structural guarantee: no transform on the box can ever reach the
100
- // chrome, because the chrome is not inside the content region.
101
- expect(content.querySelector(".probe-chrome-start")).toBeNull();
102
- expect(content.querySelector(".probe-chrome-end")).toBeNull();
103
- expect(content.querySelector(".probe-content")).not.toBeNull();
104
- expect(root.querySelector(".probe-chrome-start")).not.toBeNull();
105
- expect(root.querySelector(".probe-chrome-end")).not.toBeNull();
106
- h1.unmount();
107
- });
108
-
109
- it("stamps the compressed end plus the riding flag while riding", async () => {
110
- const h2 = mountBox({ riding: true });
111
- await nextTick();
112
- const root = h2.root();
113
- expect(root.classList.contains(RIDE_FROM_CLASS)).toBe(true);
114
- expect(root.classList.contains(RIDE_TO_CLASS)).toBe(false);
115
- expect(root.hasAttribute(RIDE_ATTR)).toBe(true);
116
- h2.unmount();
117
- });
118
-
119
- it("swaps to the natural end and drops the riding flag at rest", async () => {
120
- const h3 = mountBox({ riding: true });
121
- await h3.setRiding(false);
122
- const root = h3.root();
123
- expect(root.classList.contains(RIDE_FROM_CLASS)).toBe(false);
124
- expect(root.classList.contains(RIDE_TO_CLASS)).toBe(true);
125
- expect(root.hasAttribute(RIDE_ATTR)).toBe(false);
126
- h3.unmount();
127
- });
128
-
129
- it("publishes the measured chrome/content split and the factor", async () => {
130
- const h4 = mountBox({ riding: false, factor: 0.9 });
131
- const root = h4.root();
132
- stubBox(root, 300, 100);
133
- await h4.setRiding(true);
134
-
135
- expect(root.style.getPropertyValue("--hk-ride-chrome")).toBe("100px");
136
- expect(root.style.getPropertyValue("--hk-ride-content")).toBe("300px");
137
- expect(root.style.getPropertyValue("--hk-ride-factor")).toBe("0.9");
138
- h4.unmount();
139
- });
140
-
141
- it("publishes nothing when the box cannot be measured", async () => {
142
- const h5 = mountBox({ riding: true });
143
- await nextTick();
144
- const root = h5.root();
145
- // Not "0px": an unmeasured ride must degrade to `auto` (no ride), never
146
- // to a collapsed frame.
147
- expect(root.style.getPropertyValue("--hk-ride-chrome")).toBe("");
148
- expect(root.style.getPropertyValue("--hk-ride-content")).toBe("");
149
- h5.unmount();
150
- });
151
-
152
- it("measures the same split whether the frame is at rest or compressed", async () => {
153
- // The measurement must never read the frame's height (that is the value
154
- // the ride computes): a compressed frame would otherwise report a short
155
- // box and every ride would shrink again on top of the last one.
156
- const h6 = mountBox({ riding: false });
157
- const root = h6.root();
158
- stubBox(root, 300, 60);
159
- await h6.setRiding(true);
160
- const atRest = {
161
- chrome: root.style.getPropertyValue("--hk-ride-chrome"),
162
- content: root.style.getPropertyValue("--hk-ride-content"),
163
- };
164
-
165
- // Squeeze the frame as the ride would, and re-arm.
166
- Object.defineProperty(root, "offsetHeight", { configurable: true, value: 2 });
167
- await h6.setRiding(false);
168
- await h6.setRiding(true);
169
-
170
- expect(root.style.getPropertyValue("--hk-ride-chrome")).toBe(atRest.chrome);
171
- expect(root.style.getPropertyValue("--hk-ride-content")).toBe(atRest.content);
172
- h6.unmount();
173
- });
174
- });
@@ -1,165 +0,0 @@
1
- /**
2
- * HkRideBox — the surface container for the family-level height ride.
3
- *
4
- * One job: give a surface a frame whose LAYOUT height rides between the
5
- * natural height and `chrome + content × factor`, while every chrome band
6
- * the caller pins keeps its natural size (see `useHeightRide` for why the
7
- * motion is done on layout height instead of `transform: scaleY()` — the
8
- * 2026-11-07 user report: a frame-level scale squashed the title bar and
9
- * the footer buttons).
10
- *
11
- * <HkRideBox :riding="isRiding" :factor="0.9">
12
- * <template #chrome-start><header/></template>
13
- * <Body/> <!-- the region that gets compressed -->
14
- * <template #chrome-end><footer/></template>
15
- * </HkRideBox>
16
- *
17
- * The chrome split is MEASURED, not declared (`chrome = boxHeight −
18
- * contentHeight`), so any number of chrome bands — header, sub-header,
19
- * breadcrumb strip, footer, a status row — works without the caller
20
- * listing them. The default slot is the only region ever scaled.
21
- *
22
- * Scroll-container compatibility: the content slot keeps its natural
23
- * layout box for the whole ride, so `HkScrollContainer` / `HkScrollPin` /
24
- * the overlay scrollbar read stable geometry — no per-frame re-measure, no
25
- * pins drifting. Surfaces that must skip work while a ride is in flight
26
- * read the `data-hk-riding` attribute this box stamps (the same contract
27
- * `data-hk-morphing` gives the mobile sheet).
28
- */
29
- import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
30
-
31
- import {
32
- RIDE_ATTR,
33
- RIDE_CLASS,
34
- RIDE_CONTENT_CLASS,
35
- RIDE_FROM_CLASS,
36
- RIDE_TO_CLASS,
37
- useHeightRide,
38
- } from "../composables/useHeightRide";
39
-
40
- /** Read the ride duration the sheet is using (ms), with a sane default. */
41
- function readRideMs(el: HTMLElement): number {
42
- const raw = getComputedStyle(el).transitionDuration || "";
43
- const ms = raw
44
- .split(",")
45
- .map((part) => part.trim())
46
- .map((part) => (part.endsWith("ms") ? parseFloat(part) : parseFloat(part) * 1000))
47
- .filter((n) => Number.isFinite(n) && n > 0);
48
- return ms.length > 0 ? Math.max(...ms) : 420;
49
- }
50
- import "./HkRideBox.scss";
51
-
52
- export default defineComponent({
53
- name: "HkRideBox",
54
- props: {
55
- /** True for the whole flight: the box is compressed from mount, and
56
- * drops back to natural when this goes false. */
57
- riding: { type: Boolean, default: false },
58
- /** Content height at the compressed extreme, as a fraction of natural. */
59
- factor: { type: Number, default: 0.9 },
60
- /** Root element tag. */
61
- as: { type: String, default: "div" },
62
- },
63
- emits: {
64
- /** The ride landed (its own transitionend, or immediately when there
65
- * was nothing to animate). */
66
- settle: () => true,
67
- },
68
- setup(props, { slots, emit }) {
69
- const rootRef = ref<HTMLElement | null>(null);
70
- const contentRef = ref<HTMLElement | null>(null);
71
- const ride = useHeightRide(rootRef, contentRef, { factor: props.factor });
72
-
73
- /** Stamps the state class for the current phase. Called after the
74
- * measurement lands so the first painted frame is already correct. */
75
- const apply = (riding: boolean): void => {
76
- const el = rootRef.value;
77
- if (!el) return;
78
- el.classList.toggle(RIDE_CLASS, true);
79
- if (riding) {
80
- el.classList.add(RIDE_FROM_CLASS);
81
- el.classList.remove(RIDE_TO_CLASS);
82
- el.setAttribute(RIDE_ATTR, "");
83
- } else if (el.classList.contains(RIDE_FROM_CLASS)) {
84
- // Coming out of a ride: land through the natural end, then drop the
85
- // classes (see land()) so the box returns to its own height.
86
- el.classList.remove(RIDE_FROM_CLASS);
87
- el.classList.add(RIDE_TO_CLASS);
88
- el.removeAttribute(RIDE_ATTR);
89
- }
90
- };
91
-
92
- let landTimer: ReturnType<typeof setTimeout> | null = null;
93
- /** Bumped on every arm: a stale async continuation (rapid
94
- * riding→rest→riding in one tick) must not land the NEW ride. */
95
- let generation = 0;
96
-
97
- const clearLand = (): void => {
98
- if (landTimer !== null) {
99
- clearTimeout(landTimer);
100
- landTimer = null;
101
- }
102
- };
103
-
104
- /** Drop every ride state so the box returns to its own layout height
105
- * (`auto`, or the consumer's pin) instead of freezing at the measured
106
- * px value. */
107
- const land = (): void => {
108
- const el = rootRef.value;
109
- if (!el) return;
110
- el.classList.remove(RIDE_FROM_CLASS, RIDE_TO_CLASS);
111
- el.removeAttribute(RIDE_ATTR);
112
- emit("settle");
113
- };
114
-
115
- const armRide = async (riding: boolean): Promise<void> => {
116
- const mine = ++generation;
117
- clearLand();
118
- // Measure at REST geometry first (a frame at the compressed end
119
- // reports a short box, which would make the next ride shrink twice).
120
- ride.refresh();
121
- const wasRiding = rootRef.value?.classList.contains(RIDE_FROM_CLASS) ?? false;
122
- apply(riding);
123
- await nextTick();
124
- if (mine !== generation) return;
125
- if (riding) return;
126
- if (!wasRiding) {
127
- emit("settle");
128
- return;
129
- }
130
- // Landing: the natural end is reached once the height transition has
131
- // run; then the classes come off so the box is `auto` again. A lost
132
- // transitionend (a WebView that skips it, a frame that never
133
- // animated) still lands through the watchdog.
134
- const el = rootRef.value;
135
- const duration = el ? readRideMs(el) : 420;
136
- landTimer = setTimeout(() => {
137
- landTimer = null;
138
- if (mine !== generation) return;
139
- land();
140
- }, duration + 120);
141
- };
142
-
143
- onMounted(() => void armRide(props.riding));
144
- onBeforeUnmount(() => {
145
- generation += 1;
146
- clearLand();
147
- ride.release();
148
- });
149
- watch(
150
- () => props.riding,
151
- (riding) => void armRide(riding),
152
- );
153
-
154
- return () =>
155
- h(
156
- props.as,
157
- { ref: rootRef, class: [RIDE_CLASS, "hk-ride-box"] },
158
- [
159
- h("div", { class: "hk-ride-chrome-start" }, slots["chrome-start"]?.()),
160
- h("div", { ref: contentRef, class: RIDE_CONTENT_CLASS }, slots.default?.()),
161
- h("div", { class: "hk-ride-chrome-end" }, slots["chrome-end"]?.()),
162
- ],
163
- );
164
- },
165
- });