@celestia-island/hikari 0.55.305 → 0.55.307

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.305",
3
+ "version": "0.55.307",
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",
@@ -81,7 +81,7 @@ describe("HkModal enter-class watchdog", () => {
81
81
  await vi.advanceTimersByTimeAsync(700);
82
82
  // Both layers snapped to their resting (class-less) state.
83
83
  expect(overlay!.className).toBe("hk-modal-overlay");
84
- expect(content!.className).toBe("hk-modal-content");
84
+ expect(content!.className).toBe("hk-modal-content hk-ride");
85
85
  // …and the surface is still open and intact.
86
86
  expect(document.querySelector(".hk-modal-content")).not.toBeNull();
87
87
  });
@@ -170,7 +170,7 @@ describe("HkModal enter-class watchdog across an interrupted leave", () => {
170
170
  // (fade from visible) instead of the full-opacity pop.
171
171
  await vi.advanceTimersByTimeAsync(700);
172
172
  expect(overlay.className).toBe("hk-modal-overlay");
173
- expect(content.className).toBe("hk-modal-content");
173
+ expect(content.className).toBe("hk-modal-content hk-ride");
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;
@@ -2,27 +2,23 @@
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 (supersedes the 2026-11-05 content-only ride
6
- * and the 2026-11-06 body scales): ONLY THE HEIGHT COMPRESSES — width is
7
- * never scaled. The frame settles `translate(…) scaleY(0.9) →
8
- * translate(…) scaleY(1)` about its center, so the box grows symmetrically
9
- * and anything centered in it (the content region above all) stays
10
- * horizontally AND vertically centered at every t; the translate prefix is
11
- * byte-identical in every state so the ride can never re-seat the frame.
12
- * `.hk-modal-body` carries NO transform rule of its own: its vertical ride
13
- * is entirely the frame's scaleY, so its width stays exactly 100% while its
14
- * height follows the 0.9 → 1 compression. The retired grammars, oldest
15
- * first: the 2026-11-04 whole-frame `scale(1, 0.9)`, the 2026-11-05
16
- * body-only uniform `scale(0.9)`, the 2026-11-06 `scale(1.1, 1)` overshoot
17
- * and its same-day `scale(0.95, 1)` pinch.
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`):
18
12
  *
19
- * Why compile: a source-text scanner can be dodged by a later duplicate
20
- * rule, a block comment, SCSS interpolation, `@extend` or an `@use`d
21
- * partial, and it cannot see the cascade. This contract compiles
22
- * `HkModal.scss` with the repo's own sass, parses the emitted CSS into
23
- * ordered rules with their media context, and then asks the real cascade
24
- * question for each moment of the ride: which declaration WINS for the
25
- * frame, the chrome and the content region?
13
+ * frame height : chrome + content × 0.9 ⇄ chrome + content
14
+ * content region: scaleY(0.9) ⇄ scaleY(1) (origin top)
15
+ *
16
+ * The retired grammars, oldest first: the 2026-11-04 whole-frame
17
+ * `scale(1, 0.9)`, the 2026-11-05 body-only uniform `scale(0.9)`, the
18
+ * 2026-11-06 `scale(1.1, 1)` overshoot, its same-day `scale(0.95, 1)`
19
+ * pinch, and the 2026-11-06/07 frame-level `scaleY(0.9)` ride (retired
20
+ * because a frame scale squashed the title glyphs and the footer buttons —
21
+ * the user's 2026-11-07 report on 0.55.305).
26
22
  */
27
23
  import { describe, expect, it } from "vitest";
28
24
  import { dirname, join } from "node:path";
@@ -337,10 +333,17 @@ const FRAME_TO = `${CENTERING} scaleY(1)`;
337
333
  * R2 finding 2026-11-06 (S1): prefix `toContain` let both survive. */
338
334
  const ENTER_TRANSITION =
339
335
  "opacity var(--hk-modal-duration, 0.42s) var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)), " +
340
- "transform 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))";
341
337
  const LEAVE_TRANSITION =
342
338
  "opacity var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)), " +
343
- "transform 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
+ /** The ride's own geometry lives in the shared container sheet; the modal
341
+ * frame maps its phases onto these two ends. */
342
+ const RIDE_SHEET = (() => {
343
+ const path = join(here, "HkRideBox.scss");
344
+ return compile(path, { loadPaths: [here, join(here, "..", "styles")], style: "expanded" }).css;
345
+ })();
346
+ const RIDE_RULES = parseRules(stripComments(RIDE_SHEET));
344
347
 
345
348
  describe("HkModal desktop open/close contract (compiled cascade, user direction 2026-11-07)", () => {
346
349
  it("compiles into a stylesheet with the ride rules present (known-positive)", () => {
@@ -352,30 +355,22 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
352
355
  expect(selectors.some((s) => s.includes("hk-modal-content-enter-from"))).toBe(true);
353
356
  });
354
357
 
355
- it("rides the frame height as scaleY appended to the byte-identical centering translate", () => {
356
- // 2026-11-06: the window's own height moves with the open/close. The
357
- // translate prefix must be byte-identical in EVERY state (rest too) so
358
- // the ride can never re-seat the frame or drop a dragged window's
359
- // shift; only the appended scaleY flips 0.9 → 1.
360
- expect(winner(frameWith(...RIDE[0].classes), "transform", DESKTOP), RIDE[0].name).toBe(FRAME_FROM);
361
- expect(winner(frameWith(...RIDE[1].classes), "transform", DESKTOP), RIDE[1].name).toBe(FRAME_TO);
362
- expect(winner(frameWith(...RIDE[2].classes), "transform", DESKTOP), RIDE[2].name).toBe(FRAME_TO);
363
- expect(winner(frameWith(...RIDE[3].classes), "transform", DESKTOP), RIDE[3].name).toBe(FRAME_FROM);
364
- // …and the same with the opt-in draggable flag on, where an
365
- // attribute-qualified frame rule could otherwise slip past the mocks
366
- // (R2 finding 2026-11-06 (S2): the body had this loop, the frame
367
- // didn't — the repo's own [data-draggable] block is the natural next
368
- // mutation site).
369
- for (const state of RIDE) {
370
- const expected = state.name === "enter-from" || state.name === "leave-to" ? FRAME_FROM : FRAME_TO;
371
- expect(
372
- winner(draggableFrameWith(...state.classes), "transform", DESKTOP),
373
- `draggable frame ${state.name}`,
374
- ).toBe(expected);
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.
363
+ for (const state of [...RIDE, { name: "rest", classes: [] as string[] }]) {
364
+ for (const [label, frame] of [
365
+ ["", frameWith(...state.classes)],
366
+ ["draggable ", draggableFrameWith(...state.classes)],
367
+ ] as const) {
368
+ expect(winner(frame, "transform", DESKTOP), `${label}${state.name}`).toBe(CENTERING);
369
+ for (const prop of ["scale", "zoom", "translate", "rotate", "transform-origin"]) {
370
+ expect(winner(frame, prop, DESKTOP), `${label}${state.name} ${prop}`).toBeUndefined();
371
+ }
372
+ }
375
373
  }
376
- // …and at rest, too: the settled frame carries the translate only.
377
- expect(winner(frameWith(), "transform", DESKTOP)).toBe(CENTERING);
378
- expect(winner(draggableFrameWith(), "transform", DESKTOP)).toBe(CENTERING);
379
374
  });
380
375
 
381
376
  it("never lets a transform longhand creep onto the frame", () => {
@@ -421,37 +416,58 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
421
416
  "will-change",
422
417
  DESKTOP,
423
418
  );
424
- expect(enterWill).toBe("opacity, transform");
419
+ expect(enterWill).toBe("opacity, height");
425
420
  // The close direction promotes the same pair of layers.
426
421
  const leaveWill = winner(
427
422
  frameWith("hk-modal-content-leave-to", "hk-modal-content-leave-active"),
428
423
  "will-change",
429
424
  DESKTOP,
430
425
  );
431
- expect(leaveWill).toBe("opacity, transform");
426
+ expect(leaveWill).toBe("opacity, height");
432
427
  });
433
428
 
434
- it("never scales the content region: width stays 100% at every t", () => {
435
- // 2026-11-07: only the height compresses. The body must carry no
436
- // transform of its own in ANY ride state — its vertical ride is the
437
- // frame's scaleY inherited down the tree, so a body transform here
438
- // would either re-scale the width or double the Y compression.
439
- const REST_OR_RIDE = [...RIDE, { name: "rest", classes: [] as string[] }];
440
- // The whole transform family, on BOTH branches (R2 finding 2026-11-07
441
- // (S2): the draggable branch used to pin `transform` only, so a
442
- // `[data-draggable] … { scale: … }` longhand stayed green).
443
- const PROPS = ["transform", "transform-origin", "scale", "translate", "rotate", "zoom", "perspective"];
444
- for (const state of REST_OR_RIDE) {
445
- for (const [label, frame] of [
446
- ["", frameWith(...state.classes)],
447
- ["draggable ", draggableFrameWith(...state.classes)],
448
- ] as const) {
449
- const body = child(frame, "hk-modal-body");
450
- for (const prop of PROPS) {
451
- expect(winner(body, prop, DESKTOP), `${label}${state.name} body ${prop}`).toBeUndefined();
452
- }
453
- }
454
- }
429
+ it("rides the HEIGHT through the shared container contract, chrome untouched", () => {
430
+ // The two ends of the ride are expressed as `height: calc(chrome +
431
+ // content × factor)` on the frame (`!important` beats the size morph's
432
+ // inline pin for the duration of the flight) and as a top-origin
433
+ // `scaleY` on the CONTENT REGION only — so the title bar and the footer
434
+ // keep their natural size, which is the 2026-11-07 user requirement.
435
+ // A selector may be declared by more than one rule (the ride's geometry
436
+ // pairs: the compressed height, the content's natural-height lock and the
437
+ // content's scale). Look the declaration up by property, not by "the
438
+ // first rule with this selector".
439
+ const rideRule = (selector: string, prop = "height"): CssRule | undefined =>
440
+ RIDE_RULES.find((r) => r.selector === selector && r.declarations.has(prop));
441
+
442
+ const from = rideRule(".hk-ride[data-hk-ride-ready].hk-ride--from");
443
+ expect(from, "compressed end rule").toBeTruthy();
444
+ expect(from!.declarations.get("height")).toBe(
445
+ "calc(var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px) * var(--hk-ride-factor, 0.9))",
446
+ );
447
+ expect(from!.important.has("height"), "the ride outranks the morph's inline pin").toBe(true);
448
+
449
+ const to = rideRule(".hk-ride[data-hk-ride-ready].hk-ride--to");
450
+ expect(to, "natural end rule").toBeTruthy();
451
+ expect(to!.declarations.get("height")).toBe(
452
+ "calc(var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px))",
453
+ );
454
+ expect(to!.important.has("height")).toBe(true);
455
+
456
+ // Only the content region is ever scaled — top origin, so the compressed
457
+ // paint lands exactly in the shorter slot the frame leaves for it.
458
+ const scaled = rideRule(".hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-content", "transform");
459
+ expect(scaled, "content compress rule").toBeTruthy();
460
+ expect(scaled!.declarations.get("transform")).toBe(
461
+ "scaleY(var(--hk-ride-factor, 0.9))",
462
+ );
463
+ const origin = rideRule(".hk-ride-content", "transform-origin");
464
+ expect(origin!.declarations.get("transform-origin")).toBe("50% 0");
465
+
466
+ // …and the CHROME is never named by any ride rule: the modal's header,
467
+ // sub-header and footer must not be reachable from the ride sheet.
468
+ const CHROME = /hk-modal-(header|subheader|footer|title|close)\b/;
469
+ const offenders = RIDE_RULES.filter((r) => CHROME.test(r.selector)).map((r) => r.selector);
470
+ expect(offenders, "the ride container must not target modal chrome").toEqual([]);
455
471
  });
456
472
 
457
473
  it("never transforms the chrome: title bar, sub-header, footer, close button, title glyph", () => {
@@ -534,6 +550,16 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
534
550
  ).toBeUndefined();
535
551
  });
536
552
 
553
+ it("maps the surface states onto the ride's two ends", () => {
554
+ // The modal's TSX adds `hk-ride--from` for openingFrom/closingTo and
555
+ // `hk-ride--to` for openingTo/closingFrom; the container sheet keys off
556
+ // exactly those class names. A rename on either side has to fail here.
557
+ const names = RIDE_RULES.map((r) => r.selector).join("\n");
558
+ expect(names).toContain("hk-ride--from");
559
+ expect(names).toContain("hk-ride--to");
560
+ expect(names).toContain("hk-ride-content");
561
+ });
562
+
537
563
  it("never lets ANY rule put a ride transform on the content region", () => {
538
564
  // Fail-closed sweep, selector-level and media-independent (R2 finding
539
565
  // 2026-11-07 (S2): the cascade mock cannot match `:not()`, a type
@@ -662,11 +688,11 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
662
688
  expect(winner(body, "translate", DESKTOP), `body ${state.name}`).toBeUndefined();
663
689
  expect(winner(body, "zoom", DESKTOP), `body ${state.name}`).toBeUndefined();
664
690
 
691
+ // The FRAME never carries a scale any more — the ride is height, and
692
+ // the only `scaleY` in the choreography lives on the content region's
693
+ // shared contract (pinned by the ride test above).
665
694
  const frameTransform = winner(frame, "transform", DESKTOP) ?? "";
666
- const centeringRe = CENTERING.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
667
- expect(frameTransform, `frame ${state.name}`).toMatch(
668
- new RegExp(`^${centeringRe} scaleY\\((0\\.9|1)\\)$`),
669
- );
695
+ expect(frameTransform, `frame ${state.name}`).toBe(CENTERING);
670
696
  expect(frameTransform, `frame ${state.name}`).not.toMatch(FRAME_ESCAPES);
671
697
  expect(winner(frame, "scale", DESKTOP), `frame ${state.name}`).toBeUndefined();
672
698
  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 PERMANENT transform prefix:
61
- // every state (rest, enter-from/to, leave-from/to) carries this exact
62
- // translate, and the open/close ride only ever APPENDS a `scaleY()` to
63
- // it (see the choreography block below) — so the ride can never re-seat
64
- // the frame and dragged windows keep their shift.
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.
65
65
  transform: translate(
66
66
  calc(-50% + var(--hk-modal-shift-x, 0px)),
67
67
  calc(-50% + var(--hk-modal-shift-y, 0px))
@@ -99,43 +99,41 @@
99
99
  // ------
100
100
  // Content enter/leave transitions (desktop ≥768px)
101
101
  //
102
- // Default window open/close (user direction 2026-11-06, superseding the
103
- // 2026-11-05 content-only ride): the window's own HEIGHT now rides the
104
- // open/close. The frame settles `scaleY(0.9) → scaleY(1)` about its
105
- // center (the default 50% 50% origin, composed AFTER the permanent
106
- // centering translate, which is byte-identical in every state — so the
107
- // box grows symmetrically up and down from its rest center and nothing
108
- // re-seats the frame mid-flight; dragged windows keep --hk-modal-shift-*
109
- // through the whole ride). Because a uniform map about the frame center
110
- // preserves relative geometry, anything sitting centered in the frame —
111
- // the content region above all — stays horizontally AND vertically
112
- // centered at every t. The chrome (title bar, sub-header, footer) has no
113
- // transform rule of its own; it inherits this single frame scale, which
114
- // IS the window-height motion the user asked for.
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:
115
105
  //
116
- // The content region does NOT ride on its own (user direction 2026-11-07,
117
- // superseding the body scale that shipped in 0.55.303 / 0.55.304): width
118
- // is never scaled, only height is. `.hk-modal-body` therefore carries NO
119
- // transform rule in any ride state — its vertical ride is entirely the
120
- // frame's `scaleY` above, so during the open/close its width stays exactly
121
- // 100% of the window while its height follows the 0.9 → 1 compression. Two
122
- // retired grammars before this one: the two-arg `scale(0.95, 1)` pinch and
123
- // the `scale(1.1, 1)` overshoot.
106
+ // frame height : chrome + content × 0.9 ⇄ chrome + content
107
+ // content region: scaleY(0.9) ⇄ scaleY(1) (origin top)
124
108
  //
125
- // Hosts retune the ride through the `--hk-modal-*` tokens (duration /
126
- // easing) or the body padding vars — NOT by setting `transform` on
127
- // `.hk-modal-body` or the frame: the frame's state rules pair a matched
128
- // function list (`translate + scaleY`) so both ends of the interpolation
129
- // ride the same shape, and a host's single-class transform would be
130
- // overridden mid-ride and again under reduced motion.
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.
131
120
  //
132
- // No clip-path and no Y offset ride the open/close: the visible box IS the
133
- // frame at every t, so nothing is ever sliced off (the 2026-09-14 clip
134
- // unveil — and the #508/#512/#524 lineage before it — stays retired). All
135
- // compositor-level: zero layout per frame. The centering translate lives
136
- // on the base .hk-modal-content rule, so the frame never re-seats
137
- // mid-flight. Scoped to ≥768px; ≤767px keeps the sheet's translateY
138
- // choreography below.
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.
139
137
  // ------
140
138
 
141
139
  @media (min-width: 768px) {
@@ -145,9 +143,9 @@
145
143
  transition:
146
144
  opacity var(--hk-modal-duration, 0.42s)
147
145
  var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
148
- transform var(--hk-modal-duration, 0.42s)
146
+ height var(--hk-modal-duration, 0.42s)
149
147
  var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
150
- will-change: opacity, transform;
148
+ will-change: opacity, height;
151
149
  }
152
150
 
153
151
  // Close: the reveal's TRUE reverse — same chrome fade, while the window
@@ -156,32 +154,58 @@
156
154
  transition:
157
155
  opacity var(--hk-modal-duration, 0.32s)
158
156
  var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
159
- transform var(--hk-modal-duration, 0.32s)
157
+ height var(--hk-modal-duration, 0.32s)
160
158
  var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
161
- will-change: opacity, transform;
159
+ will-change: opacity, height;
162
160
  }
163
161
 
164
- // The translate prefix is byte-identical to the base rule; only the
165
- // appended scaleY changes. scaleY(1) at the settled end — never `none`
166
- // — keeps both ends of the interpolation on the same function shape.
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
167
  .hk-modal-content-enter-from,
168
168
  .hk-modal-content-leave-to {
169
169
  opacity: 0;
170
- transform: translate(
171
- calc(-50% + var(--hk-modal-shift-x, 0px)),
172
- calc(-50% + var(--hk-modal-shift-y, 0px))
173
- )
174
- scaleY(0.9);
175
170
  }
176
171
 
177
172
  .hk-modal-content-enter-to,
178
173
  .hk-modal-content-leave-from {
179
174
  opacity: 1;
180
- transform: translate(
181
- calc(-50% + var(--hk-modal-shift-x, 0px)),
182
- calc(-50% + var(--hk-modal-shift-y, 0px))
183
- )
184
- scaleY(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;
185
209
  }
186
210
  }
187
211
 
@@ -671,10 +695,11 @@
671
695
  calc(-50% + var(--hk-modal-shift-y, 0px))
672
696
  );
673
697
  }
674
- // The content region carries no transform of its own any more (width is
675
- // never scaled — 2026-11-07), so only the frame needs flattening above.
676
- // The body lines stay as a fail-closed guard: if a future state rule ever
677
- // reintroduces a body transform, reduced motion still flattens it.
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.
678
703
  .hk-modal-content .hk-modal-body {
679
704
  transform: none;
680
705
  transition: none;
@@ -2,6 +2,10 @@ 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";
5
9
  import HkFloatingLayer from "./HkFloatingLayer";
6
10
  import { focusFirst, trapFocus } from "../utils/dom";
7
11
  import { useOverlay } from "../runtime/useOverlay";
@@ -14,6 +18,7 @@ import { useSurfaceTransition } from "../composables/useSurfaceTransition";
14
18
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
15
19
  import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
16
20
  import { useSizeMorph, type RideEntry } from "../composables/useSizeMorph";
21
+ import { useHeightRide } from "../composables/useHeightRide";
17
22
 
18
23
  import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
19
24
 
@@ -348,6 +353,51 @@ export default defineComponent({
348
353
  );
349
354
  },
350
355
  });
356
+
357
+ // Desktop open/close: the WINDOW's own height rides, the chrome
358
+ // (title bar, sub-header, footer) keeps its natural size, and only the
359
+ // content region is visually compressed. The measurement half lives in
360
+ // the family primitive (useHeightRide); this sheet maps its surface
361
+ // states onto the ride's two ends (see the choreography block below).
362
+ const ride = useHeightRide(contentRef, bodyRef, { factor: 0.9 });
363
+
364
+ /** The window ride's own state, translated from the surface phases:
365
+ * the open/close endpoints are the compressed end (`--from`) and the
366
+ * natural end (`--to`). Between and after them no ride class is
367
+ * present, so the frame keeps its laid-out height (or the morph's
368
+ * px pin) and the geometry contract never leaks into the rest state. */
369
+ // The body element mounts with the frame on every open, and the inline
370
+ // variables die with the previous frame. Republish the split cached from
371
+ // the last rest measurement FIRST (so the first painted frame is already
372
+ // correct), then — if this is the very first open — measure as soon as
373
+ // the element lands (the sheet keeps the frame at `auto` until then, so
374
+ // that measurement sees honest natural geometry).
375
+ watch(
376
+ bodyRef,
377
+ (el) => {
378
+ if (!el) return;
379
+ // ALWAYS measure: the component instance survives a close, so a
380
+ // cached split may belong to different content (this modal is reused
381
+ // across tabs/entries). Publishing the cache first made the second
382
+ // and later opens ride the PREVIOUS geometry, cropping new content
383
+ // and then snapping when the cache expired (R5 finding).
384
+ void nextTick(() => ride.refresh());
385
+ },
386
+ { flush: "post" },
387
+ );
388
+
389
+ const rideStateClass = (): string | null => {
390
+ switch (machine.phase.value) {
391
+ case "openingFrom":
392
+ case "closingTo":
393
+ return "hk-ride--from";
394
+ case "openingTo":
395
+ case "closingFrom":
396
+ return "hk-ride--to";
397
+ default:
398
+ return null;
399
+ }
400
+ };
351
401
  let previouslyFocused: HTMLElement | null = null;
352
402
  let unmounted = false;
353
403
 
@@ -566,6 +616,10 @@ export default defineComponent({
566
616
  ],
567
617
  onPhase: (from, to, event) => {
568
618
  if (to === "openingFrom") {
619
+ // Measure BEFORE the enter classes land: the ride's `height:
620
+ // calc()` needs the rest chrome/content split, and a frame that is
621
+ // already compressed reports the compressed box.
622
+ ride.refresh();
569
623
  // Open-request bookkeeping (was the modelValue watcher's open
570
624
  // arm): registration, overlay registry, back-guard, focus
571
625
  // capture.
@@ -598,6 +652,9 @@ export default defineComponent({
598
652
  });
599
653
  } else if (to === "open") {
600
654
  surfTrack.cancel();
655
+ // Re-measure at the landed rest geometry so the NEXT ride starts
656
+ // from this box (content may have changed while the surface opened).
657
+ void nextTick(() => ride.refresh());
601
658
  onAfterEnter();
602
659
  // Idempotent re-arm for the fast paths (see openingFrom); the
603
660
  // morph is usually armed already by the enter-edge tick.
@@ -610,6 +667,9 @@ export default defineComponent({
610
667
  if (machine.phase.value === "open") morph.remeasure();
611
668
  });
612
669
  } else if (to === "closingFrom") {
670
+ // Same rule on the way out: the frame is at its open (natural)
671
+ // height right now, so this is the last honest measurement.
672
+ ride.refresh();
613
673
  // Close-request bookkeeping (was the watcher's close arm): the
614
674
  // registries forget the surface at request time; the finalize
615
675
  // edge below completes the teardown at leave end.
@@ -1065,7 +1125,7 @@ export default defineComponent({
1065
1125
  />
1066
1126
  <div
1067
1127
  ref={contentRef}
1068
- class={["hk-modal-content", props.contentClass, ...machine.classesFor("hk-modal-content")]}
1128
+ class={["hk-modal-content", "hk-ride", rideStateClass(), props.contentClass, ...machine.classesFor("hk-modal-content")]}
1069
1129
  role="dialog"
1070
1130
  aria-modal="true"
1071
1131
  aria-label={resolvedSurfaceName.value}
@@ -1131,7 +1191,7 @@ export default defineComponent({
1131
1191
  )}
1132
1192
  </>
1133
1193
  ) : null,
1134
- <div ref={bodyRef} class="hk-modal-body">
1194
+ <div ref={bodyRef} class="hk-modal-body hk-ride-content">
1135
1195
  <div
1136
1196
  ref={scrollContainerRef}
1137
1197
  class="hk-modal-body-scroll hk-scroll-pin-host"
@@ -23,6 +23,12 @@
23
23
  // ONE label column for both surfaces: the overview paints through an
24
24
  // empty gutter of this width, so an x% bar sits at the same inline
25
25
  // position in the strip and in its lane (the strip locates segments).
26
+ // Wave (2026-11-06 raw-records five): the label column is JS-MEASURED —
27
+ // the component publishes the widest rendered label as a px value on the
28
+ // root (two-phase free/measure/pin). THIS fixed column is the no-JS /
29
+ // pre-measure fallback: a shared width keeps strip-lane x% alignment
30
+ // before the measurement lands (a bare max-content here would resolve
31
+ // 0px in the empty gutter and misalign everything).
26
32
  --hk-range-ribbon-label-col: minmax(48px, 84px);
27
33
 
28
34
  display: flex;
@@ -42,7 +48,9 @@
42
48
  min-width: 0;
43
49
  }
44
50
 
45
- // The empty first cell that lines the strip up with the lane tracks.
51
+ // The empty first cell that lines the strip up with the lane tracks. Its
52
+ // width comes from the shared var, which the component JS-measures and
53
+ // publishes on the root (two-phase free/measure/pin).
46
54
  .hk-range-ribbon-overview-gutter {
47
55
  display: block;
48
56
  min-width: 0;
@@ -55,6 +63,29 @@
55
63
  min-width: 0;
56
64
  }
57
65
 
66
+ .hk-range-ribbon[data-minimal] {
67
+ gap: 0;
68
+
69
+ /* Bare-bar layout: single column, gutter probe out of the way — the
70
+ strip spans the full width (its in-flow content is absolute, so
71
+ without this the max-content column would collapse to zero). */
72
+ .hk-range-ribbon-overview {
73
+ grid-template-columns: minmax(0, 1fr);
74
+ }
75
+
76
+ .hk-range-ribbon-overview-gutter {
77
+ display: none;
78
+ }
79
+ }
80
+
81
+ .hk-range-ribbon[data-minimal] .hk-range-ribbon-strip {
82
+ // Minimal mode (raw-records five): the track reads through muted-based
83
+ // tokens — the surface-based track read whitish inside dark phone
84
+ // modals. The inset hairline keeps the frame visible but quiet.
85
+ background: rgb(var(--color-muted) / 25%);
86
+ box-shadow: inset 0 0 0 1px rgb(var(--color-border) / 40%);
87
+ }
88
+
58
89
  .hk-range-ribbon-strip {
59
90
  position: relative;
60
91
  display: block;