@celestia-island/hikari 0.55.311 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.311",
3
+ "version": "0.55.312",
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",
@@ -2,25 +2,12 @@
2
2
  * Behavioral contract for the DESKTOP modal open/close choreography — run
3
3
  * against the COMPILED stylesheet, not the source text.
4
4
  *
5
- * User direction 2026-11-07 (third revision, supersedes the 2026-11-05
6
- * content-only ride and the 2026-11-06 body scales): ONLY THE HEIGHT MOVES,
7
- * and the CHROME KEEPS ITS NATURAL SIZE. The frame's transform is the plain
8
- * centering translate in EVERY state; the open/close is carried by LAYOUT
9
- * HEIGHT through the family primitive (`useHeightRide` publishes
10
- * `--hk-ride-chrome` / `--hk-ride-content` / `--hk-ride-factor`, and the
11
- * frame maps its surface phases onto `hk-ride--from` / `hk-ride--to`):
12
- *
13
- * frame height : chrome + content × 0.9 ⇄ chrome + content
14
- * frame : translateY(10px) scale(0.97) ⇄ translateY(0) scale(1)
15
- * content/body : never transformed (the whole window zooms)
16
- *
17
- * The retired grammars, oldest first: the 2026-11-04 whole-frame
18
- * `scale(1, 0.9)`, the 2026-11-05 body-only uniform `scale(0.9)`, the
19
- * 2026-11-06 `scale(1.1, 1)` overshoot, its same-day `scale(0.95, 1)`
20
- * pinch, and the 2026-11-06/07 frame-level `scaleY(0.9)` ride (retired
21
- * because a frame scale squashed the title glyphs and the footer buttons —
22
- * the user's 2026-11-07 report on 0.55.305).
23
- */
5
+ * User direction 2026-11-07 (fifth revision, chosen from a comparison
6
+ * page): the whole window zooms and rises — B3. The frame's transform is
7
+ * the permanent centering translate with a translateY+scale appended, a
8
+ * compositor-only motion; opacity rides the same clock. The body carries
9
+ * NO rule in this choreography (nothing inside is ever scaled on its own).
10
+ */
24
11
  import { describe, expect, it } from "vitest";
25
12
  import { dirname, join } from "node:path";
26
13
  import { fileURLToPath } from "node:url";
@@ -338,13 +325,6 @@ const ENTER_TRANSITION =
338
325
  const LEAVE_TRANSITION =
339
326
  "opacity var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)), " +
340
327
  "transform var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1))";
341
- /** The ride's own geometry lives in the shared container sheet; the modal
342
- * frame maps its phases onto these two ends. */
343
- const RIDE_SHEET = (() => {
344
- const path = join(here, "HkRideBox.scss");
345
- return compile(path, { loadPaths: [here, join(here, "..", "styles")], style: "expanded" }).css;
346
- })();
347
- const RIDE_RULES = parseRules(stripComments(RIDE_SHEET));
348
328
 
349
329
  describe("HkModal desktop open/close contract (compiled cascade, user direction 2026-11-07)", () => {
350
330
  it("compiles into a stylesheet with the ride rules present (known-positive)", () => {
@@ -434,49 +414,6 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
434
414
  expect(leaveWill).toBe("opacity, transform");
435
415
  });
436
416
 
437
- it("rides the HEIGHT through the shared container contract, chrome untouched", () => {
438
- // The two ends of the ride are expressed as `height: calc(chrome +
439
- // content × factor)` on the frame (`!important` beats the size morph's
440
- // inline pin for the duration of the flight) and as a top-origin
441
- // `scaleY` on the CONTENT REGION only — so the title bar and the footer
442
- // keep their natural size, which is the 2026-11-07 user requirement.
443
- // A selector may be declared by more than one rule (the ride's geometry
444
- // pairs: the compressed height, the content's natural-height lock and the
445
- // content's scale). Look the declaration up by property, not by "the
446
- // first rule with this selector".
447
- const rideRule = (selector: string, prop = "height"): CssRule | undefined =>
448
- RIDE_RULES.find((r) => r.selector === selector && r.declarations.has(prop));
449
-
450
- const from = rideRule(".hk-ride[data-hk-ride-ready].hk-ride--from");
451
- expect(from, "compressed end rule").toBeTruthy();
452
- expect(from!.declarations.get("height")).toBe(
453
- "calc(var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px) * var(--hk-ride-factor, 0.9))",
454
- );
455
- expect(from!.important.has("height"), "the ride outranks the morph's inline pin").toBe(true);
456
-
457
- const to = rideRule(".hk-ride[data-hk-ride-ready].hk-ride--to");
458
- expect(to, "natural end rule").toBeTruthy();
459
- expect(to!.declarations.get("height")).toBe(
460
- "calc(var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px))",
461
- );
462
- expect(to!.important.has("height")).toBe(true);
463
-
464
- // Only the content region is ever scaled — top origin, so the compressed
465
- // paint lands exactly in the shorter slot the frame leaves for it.
466
- const scaled = rideRule(".hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-content", "transform");
467
- expect(scaled, "content compress rule").toBeTruthy();
468
- expect(scaled!.declarations.get("transform")).toBe(
469
- "scaleY(var(--hk-ride-factor, 0.9))",
470
- );
471
- const origin = rideRule(".hk-ride-content", "transform-origin");
472
- expect(origin!.declarations.get("transform-origin")).toBe("50% 0");
473
-
474
- // …and the CHROME is never named by any ride rule: the modal's header,
475
- // sub-header and footer must not be reachable from the ride sheet.
476
- const CHROME = /hk-modal-(header|subheader|footer|title|close)\b/;
477
- const offenders = RIDE_RULES.filter((r) => CHROME.test(r.selector)).map((r) => r.selector);
478
- expect(offenders, "the ride container must not target modal chrome").toEqual([]);
479
- });
480
417
 
481
418
  it("never transforms the chrome: title bar, sub-header, footer, close button, title glyph", () => {
482
419
  for (const state of RIDE) {
@@ -558,15 +495,6 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
558
495
  ).toBeUndefined();
559
496
  });
560
497
 
561
- it("maps the surface states onto the ride's two ends", () => {
562
- // The modal's TSX adds `hk-ride--from` for openingFrom/closingTo and
563
- // `hk-ride--to` for openingTo/closingFrom; the container sheet keys off
564
- // exactly those class names. A rename on either side has to fail here.
565
- const names = RIDE_RULES.map((r) => r.selector).join("\n");
566
- expect(names).toContain("hk-ride--from");
567
- expect(names).toContain("hk-ride--to");
568
- expect(names).toContain("hk-ride-content");
569
- });
570
498
 
571
499
  it("never lets ANY rule put a ride transform on the content region", () => {
572
500
  // Fail-closed sweep, selector-level and media-independent (R2 finding
@@ -588,8 +516,6 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
588
516
  // width never does). Everything else — scaleX, a 2-arg scale, a
589
517
  // translate, a matrix — is a contract break.
590
518
  const LEGAL_BODY_TRANSFORM = new Set([
591
- "scaleY(var(--hk-ride-factor, 0.9))",
592
- "scaleY(1)",
593
519
  "none",
594
520
  ]);
595
521
  const offenders: string[] = [];
@@ -657,8 +583,6 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
657
583
  // The body carries no transform contract any more (2026-11-07): any
658
584
  // ride rule targeting it is an offender outright.
659
585
  const LEGAL_BODY = new Set<string>([
660
- "scaleY(var(--hk-ride-factor, 0.9))",
661
- "scaleY(1)",
662
586
  "none",
663
587
  ]);
664
588
  const offenders: string[] = [];
@@ -99,41 +99,19 @@
99
99
  // ------
100
100
  // Content enter/leave transitions (desktop ≥768px)
101
101
  //
102
- // Default window open/close (user direction 2026-11-07, third revision):
103
- // the WINDOW's own height rides, the CHROME keeps its natural size, and
104
- // only the content region is compressed. Concretely:
105
- //
106
- // frame height : chrome + content × 0.9 ⇄ chrome + content
107
- // content region: scaleY(0.9) ⇄ scaleY(1) (origin top)
108
- //
109
- // Why not `transform: scaleY()` on the frame (the 2026-11-05/06 grammar):
110
- // a frame-level scale squashes the title glyphs, the footer buttons and the
111
- // corner radii along with everything else — the user reported exactly that
112
- // ("顶部标题和底部按钮组也跟着压缩") on 0.55.305. The ride is therefore a
113
- // LAYOUT-height animation driven by the family primitive `useHeightRide`,
114
- // which publishes the measured chrome/content split as CSS variables; this
115
- // sheet maps the surface states onto the ride's two ends. Chrome lives
116
- // outside the transform, so its type renders at natural size for the whole
117
- // flight, and the content region keeps its natural LAYOUT box (only its
118
- // paint is compressed about its own top edge), so the compressed paint
119
- // lands exactly in the shorter slot the frame leaves for it.
120
- //
121
- // The centering translate stays the frame's whole transform in every state
122
- // (byte-identical between rest and both ride ends, `--hk-modal-shift-*`
123
- // included), so the ride never re-seats the frame, dragged windows keep
124
- // their shift, and a box that grows by Δ about its center moves its top
125
- // edge up by Δ/2 — the symmetric growth the user asked for.
126
- //
127
- // Scroll containers are untouched by construction: `HkScrollContainer`,
128
- // `HkScrollPin` and the overlay scrollbar measure layout boxes, which the
129
- // content region's transform never changes, and nothing inside the content
130
- // reflows per frame. Surfaces may read `data-hk-riding` to skip work while
131
- // a ride is in flight.
132
- //
133
- // The `!important` on the two height ends is load-bearing: the size morph
134
- // pins the frame's height as an inline px value, and the ride — not the pin
135
- // — owns the box for the duration of the flight. Reduced motion flattens
136
- // both the height transition and the content scale.
102
+ // Default window open/close (user direction 2026-11-07, fifth revision —
103
+ // chosen from a side-by-side comparison page): the WHOLE WINDOW zooms and
104
+ // rises while it fades. A compositor animation end to end — no layout
105
+ // property moves, nothing reflows per frame, the backdrop-filtered fixed
106
+ // layer never re-rasters. The 3% scale is imperceptible as glyph
107
+ // distortion; the 10px rise gives the motion direction. Chrome, content,
108
+ // sticky bars — everything inside keeps its exact shape at every t.
109
+ // (Retired grammars, oldest first: the 2026-11-04 whole-frame scale(1,0.9);
110
+ // the 2026-11-05 body-only uniform scale; the 0.95 pinch; the frame-level
111
+ // scaleY(0.9); and the 2026-11-07 layout-height ride of 0.55.307–310, whose
112
+ // family container — HkRideBox/useHeightRide — was retired with it: it
113
+ // fought the size morph for the same property and animated layout on a
114
+ // backdrop-filtered fixed layer.)
137
115
  // ------
138
116
 
139
117
  @media (min-width: 768px) {
@@ -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";
@@ -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
- });
@@ -1,198 +0,0 @@
1
- /**
2
- * useHeightRide — the family-level "height ride" measurement primitive.
3
- *
4
- * Several hikari surfaces want one specific open/close motion: the SURFACE
5
- * FRAME itself grows from a fraction of its natural height to the full
6
- * height, while the chrome (title bar, footer action row, any pinned band)
7
- * keeps its natural size and stays legible. Scaling the whole frame
8
- * (`transform: scaleY()`) is the wrong tool for that — it squashes the
9
- * title glyphs, the buttons and the corner radii along with everything
10
- * else (2026-11-07 user report on the modal ride), and it re-rasters text
11
- * for the entire ride.
12
- *
13
- * The ride is therefore a LAYOUT-height animation:
14
- *
15
- * frame height : chrome + content × factor ⇄ chrome + content
16
- * content region: scaleY(factor) ⇄ scaleY(1) (origin top)
17
- *
18
- * The content region keeps its natural layout box (so nothing inside it
19
- * reflows per frame) and is only visually compressed about its own TOP
20
- * edge, which makes the compressed paint land exactly in the shorter slot
21
- * the frame leaves for it: the frame's bottom edge and the content's
22
- * bottom edge coincide at every t. Chrome is never inside the transform,
23
- * so its type, icons and padding render at natural size throughout.
24
- *
25
- * This composable owns the MEASUREMENT half only: it publishes
26
- *
27
- * --hk-ride-chrome px the part of the frame the ride must not squeeze
28
- * --hk-ride-content px the region that does get compressed
29
- * --hk-ride-factor the fraction (default 0.9)
30
- *
31
- * on the frame, and leaves the interpolation to CSS. That split is what
32
- * keeps it composable with the surfaces' existing state machines: the
33
- * consumer keeps flipping its own enter/leave classes (HkModal's surface
34
- * machine, HkRideBox's `riding` prop) and the sheet resolves
35
- * `height: calc(chrome + content × factor)` from the published numbers.
36
- * No timers, no class ownership, no fight with a consumer's own height pin
37
- * — a state rule carrying `!important` legitimately outranks the inline
38
- * px the size-morph pins while a ride is in flight.
39
- *
40
- * Scroll-container compatibility (the reason this lives upstream rather
41
- * than inside HkModal): every hikari custom scroller (`HkScrollContainer`,
42
- * `HkScrollPin`, the overlay scrollbar, the windowed scroller) measures
43
- * LAYOUT boxes — `offsetHeight` / `clientHeight` / `scrollHeight` — which
44
- * a transform never touches. Because the ride animates the FRAME's height
45
- * and leaves the content box natural, those measurements, the scroll
46
- * offsets, the pins and the fade bands stay coherent for the whole ride;
47
- * nothing has to be re-measured per frame, and nothing drifts when the
48
- * ride is interrupted.
49
- */
50
- import { onBeforeUnmount, type Ref } from "vue";
51
-
52
- /** Class/attribute vocabulary shared by the sheet and the contract tests. */
53
- export const RIDE_CLASS = "hk-ride";
54
- export const RIDE_FROM_CLASS = "hk-ride--from";
55
- export const RIDE_TO_CLASS = "hk-ride--to";
56
- export const RIDE_CONTENT_CLASS = "hk-ride-content";
57
- export const RIDE_ATTR = "data-hk-riding";
58
- /** Present only once the chrome/content split has been measured. */
59
- export const RIDE_READY_ATTR = "data-hk-ride-ready";
60
-
61
- export interface HeightRideOptions {
62
- /** Content height at the ride's extreme, as a fraction of natural.
63
- * Default 0.9 (a 10% vertical reveal). */
64
- factor?: number;
65
- }
66
-
67
- export interface HeightRide {
68
- /** Measure the frame's chrome/content split and publish the variables.
69
- * Safe to call at any time OUTSIDE a ride (rest geometry is what the
70
- * ride needs); calling it mid-ride re-measures the live, compressed
71
- * box and is therefore a no-op unless `force` is passed. */
72
- refresh(force?: boolean): boolean;
73
- /** Inline style vars for the CURRENT cache, or null when nothing has
74
- * been measured yet. Consumers bind this in their render so the very
75
- * first painted frame of a remount already carries the ride (a post-
76
- * mount publish is one paint too late: the enter-from frame would show
77
- * a full-height frame with unscaled content — read as "clipping"). */
78
- styleVars(): Record<string, string> | null;
79
- /** True when a measured split exists (drives the ready attribute). */
80
- hasCache(): boolean;
81
- /** Re-apply the LAST measured split without touching the DOM boxes.
82
- * A surface that unmounts its frame on close loses the inline variables
83
- * with it; republishing on the next mount is what makes the very first
84
- * painted frame of the next ride already correct. */
85
- publish(): boolean;
86
- /** The chrome height (px) from the last successful measure. */
87
- chromeHeight(): number;
88
- /** The content height (px) from the last successful measure. */
89
- contentHeight(): number;
90
- /** Drop the published variables (the sheet then falls back to `auto`,
91
- * i.e. no ride — never to a wrong box). */
92
- release(): void;
93
- }
94
-
95
- /** Any ride class means the box is (or is heading) somewhere other than its
96
- * rest height, so a measurement taken then would poison the next ride. The
97
- * guard therefore covers BOTH ends; consumers measure at rest. */
98
- function isInFlight(frame: HTMLElement): boolean {
99
- return (
100
- frame.classList.contains(RIDE_FROM_CLASS) || frame.classList.contains(RIDE_TO_CLASS)
101
- );
102
- }
103
-
104
- /**
105
- * Wire a frame (whose height rides) to the content region it compresses.
106
- *
107
- * `frame` must carry `.hk-ride` (directly, or via `HkRideBox`); `content`
108
- * must carry `.hk-ride-content`. Both are applied by the sheet/component,
109
- * so consumers normally only supply the refs.
110
- */
111
- export function useHeightRide(
112
- frame: Ref<HTMLElement | null | undefined>,
113
- content: Ref<HTMLElement | null | undefined>,
114
- options: HeightRideOptions = {},
115
- ): HeightRide {
116
- // Read live: a consumer may change `factor` after mount.
117
- const factorOf = (): number => options.factor ?? 0.9;
118
- let chrome = 0;
119
- let contentH = 0;
120
-
121
- const release = (): void => {
122
- const f = frame.value;
123
- if (!f) return;
124
- f.style.removeProperty("--hk-ride-chrome");
125
- f.style.removeProperty("--hk-ride-content");
126
- f.style.removeProperty("--hk-ride-factor");
127
- f.removeAttribute(RIDE_READY_ATTR);
128
- };
129
-
130
- const refresh = (force = false): boolean => {
131
- const f = frame.value;
132
- const c = content.value;
133
- if (!f || !c) return false;
134
- // Once the split is known, re-measuring DURING a ride would read the
135
- // compressed boxes and shrink again on the next flight. The first
136
- // measurement is exempt: the sheet keeps the frame at `auto` until the
137
- // split is published, so that one is taken on honest natural geometry.
138
- if (!force && f.hasAttribute(RIDE_READY_ATTR) && isInFlight(f)) return false;
139
- // Neither side reads the FRAME's height (that is the number the ride
140
- // computes): the content region's own layout box, plus the frame's OTHER
141
- // element children (the pinned chrome bands, always `flex-shrink: 0`).
142
- // Phones/WebViews that report 0 publish nothing at all rather than a
143
- // wrong box.
144
- let chromeNatural = 0;
145
- for (const child of Array.from(f.children)) {
146
- if (child === c || !(child instanceof HTMLElement)) continue;
147
- chromeNatural += child.offsetHeight;
148
- }
149
- // The content region's own layout box is the ONLY honest source. With
150
- // no ride class the body is flex-natural; while a ride class is present
151
- // the box is locked to the published var (stable, no new information —
152
- // and the frame's height is the ride's own output, so deriving the
153
- // content from it would feed every early error forward forever).
154
- const measured = c.offsetHeight;
155
- if (measured <= 0) return false;
156
- contentH = measured;
157
- chrome = chromeNatural;
158
- f.style.setProperty("--hk-ride-chrome", `${chrome}px`);
159
- f.style.setProperty("--hk-ride-content", `${contentH}px`);
160
- f.style.setProperty("--hk-ride-factor", String(factorOf()));
161
- // Readiness flag: the sheet only overrides the frame's height once the
162
- // split is real, so an unmeasured ride degrades to a plain fade instead
163
- // of collapsing to `calc(0px + 0px)`.
164
- f.setAttribute(RIDE_READY_ATTR, "");
165
- return true;
166
- };
167
-
168
- onBeforeUnmount(() => release());
169
-
170
- const publish = (): boolean => {
171
- const f = frame.value;
172
- if (!f || contentH <= 0) return false;
173
- f.style.setProperty("--hk-ride-chrome", `${chrome}px`);
174
- f.style.setProperty("--hk-ride-content", `${contentH}px`);
175
- f.style.setProperty("--hk-ride-factor", String(factorOf()));
176
- f.setAttribute(RIDE_READY_ATTR, "");
177
- return true;
178
- };
179
-
180
- const styleVars = (): Record<string, string> | null =>
181
- contentH <= 0
182
- ? null
183
- : {
184
- "--hk-ride-chrome": `${chrome}px`,
185
- "--hk-ride-content": `${contentH}px`,
186
- "--hk-ride-factor": String(factorOf()),
187
- };
188
-
189
- return {
190
- refresh,
191
- styleVars,
192
- hasCache: () => contentH > 0,
193
- publish,
194
- chromeHeight: () => chrome,
195
- contentHeight: () => contentH,
196
- release,
197
- };
198
- }