@celestia-island/hikari 0.55.308 → 0.55.310

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.308",
3
+ "version": "0.55.310",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -32,6 +32,27 @@ function freezeRaf(): void {
32
32
  vi.stubGlobal("cancelAnimationFrame", () => {});
33
33
  }
34
34
 
35
+ /** Pin the viewport to the phone band (<768px): the desktop height ride
36
+ * blacks the morph out of the open/close edges, and these suites test the
37
+ * morph's own contracts. happy-dom's 1024px default would route them into
38
+ * the desktop branch. */
39
+ function stubPhoneViewport(): void {
40
+ vi.stubGlobal(
41
+ "matchMedia",
42
+ (query: string) =>
43
+ ({
44
+ matches: false,
45
+ media: query,
46
+ addEventListener: () => {},
47
+ removeEventListener: () => {},
48
+ addListener: () => {},
49
+ removeListener: () => {},
50
+ onchange: null,
51
+ dispatchEvent: () => false,
52
+ }) as MediaQueryList,
53
+ );
54
+ }
55
+
35
56
  function stubDurations(): void {
36
57
  const realGCS = window.getComputedStyle.bind(window);
37
58
  vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
@@ -103,6 +124,7 @@ describe("HkModal close-fold content hold", () => {
103
124
  it("folds over the last live content even when the consumer clears state on close", async () => {
104
125
  vi.useFakeTimers();
105
126
  freezeRaf();
127
+ stubPhoneViewport();
106
128
  stubDurations();
107
129
  const rig = await mountRig(["alpha", "beta"]);
108
130
 
@@ -129,6 +151,7 @@ describe("HkModal close-fold content hold", () => {
129
151
  it("a reopen interrupt drops the hold and serves live content again", async () => {
130
152
  vi.useFakeTimers();
131
153
  freezeRaf();
154
+ stubPhoneViewport();
132
155
  stubDurations();
133
156
  const rig = await mountRig(["alpha", "beta"]);
134
157
 
@@ -81,7 +81,7 @@ describe("HkModal enter-class watchdog", () => {
81
81
  await vi.advanceTimersByTimeAsync(700);
82
82
  // Both layers snapped to their resting (class-less) state.
83
83
  expect(overlay!.className).toBe("hk-modal-overlay");
84
- expect(content!.className).toBe("hk-modal-content hk-ride");
84
+ expect(content!.className).toBe("hk-modal-content");
85
85
  // …and the surface is still open and intact.
86
86
  expect(document.querySelector(".hk-modal-content")).not.toBeNull();
87
87
  });
@@ -170,7 +170,7 @@ describe("HkModal enter-class watchdog across an interrupted leave", () => {
170
170
  // (fade from visible) instead of the full-opacity pop.
171
171
  await vi.advanceTimersByTimeAsync(700);
172
172
  expect(overlay.className).toBe("hk-modal-overlay");
173
- expect(content.className).toBe("hk-modal-content hk-ride");
173
+ expect(content.className).toBe("hk-modal-content");
174
174
  // The surface is still open, and closing it afterwards runs a NORMAL
175
175
  // leave (fade from visible) instead of the full-opacity pop.
176
176
  open.value = false;
@@ -33,6 +33,27 @@ function freezeRaf(): void {
33
33
  vi.stubGlobal("cancelAnimationFrame", () => {});
34
34
  }
35
35
 
36
+ /** Pin the viewport to the phone band (<768px): the desktop height ride
37
+ * blacks the morph out of the open/close edges, and these suites test the
38
+ * morph's own contracts. happy-dom's 1024px default would route them into
39
+ * the desktop branch. */
40
+ function stubPhoneViewport(): void {
41
+ vi.stubGlobal(
42
+ "matchMedia",
43
+ (query: string) =>
44
+ ({
45
+ matches: false,
46
+ media: query,
47
+ addEventListener: () => {},
48
+ removeEventListener: () => {},
49
+ addListener: () => {},
50
+ removeListener: () => {},
51
+ onchange: null,
52
+ dispatchEvent: () => false,
53
+ }) as MediaQueryList,
54
+ );
55
+ }
56
+
36
57
  function stubDurations(): void {
37
58
  const realGCS = window.getComputedStyle.bind(window);
38
59
  vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
@@ -101,6 +122,7 @@ describe("HkModal leave-window height pin", () => {
101
122
  it("keeps the morph pin through the whole leave and releases at finalize", async () => {
102
123
  vi.useFakeTimers();
103
124
  freezeRaf();
125
+ stubPhoneViewport();
104
126
  stubDurations();
105
127
  stubLayout(500);
106
128
  try {
@@ -138,6 +160,7 @@ describe("HkModal leave-window height pin", () => {
138
160
  it("flushes growth deferred through the enter with an open-edge remeasure", async () => {
139
161
  vi.useFakeTimers();
140
162
  freezeRaf();
163
+ stubPhoneViewport();
141
164
  stubDurations();
142
165
  stubLayout(400);
143
166
  try {
@@ -416,14 +416,14 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
416
416
  "will-change",
417
417
  DESKTOP,
418
418
  );
419
- expect(enterWill).toBe("opacity, height");
419
+ expect(enterWill).toBe("opacity");
420
420
  // The close direction promotes the same pair of layers.
421
421
  const leaveWill = winner(
422
422
  frameWith("hk-modal-content-leave-to", "hk-modal-content-leave-active"),
423
423
  "will-change",
424
424
  DESKTOP,
425
425
  );
426
- expect(leaveWill).toBe("opacity, height");
426
+ expect(leaveWill).toBe("opacity");
427
427
  });
428
428
 
429
429
  it("rides the HEIGHT through the shared container contract, chrome untouched", () => {
@@ -574,19 +574,26 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
574
574
  // through the first cut, as did `[class~="hk-modal-body"]` (R3 finding
575
575
  // 2026-11-07 (S2)). The trailing guard keeps -scroll / -inner out.
576
576
  const BODY_TARGET = /(^|[^\w-])hk-modal-body(?![\w-])/;
577
- const TRANSFORM_FAMILY = /^(transform|transform-origin|translate|scale|rotate|zoom|perspective)$/;
578
- const INERT = new Set(["none", "auto"]);
577
+ const TRANSFORM_FAMILY = /^(transform|translate|scale|rotate|zoom|perspective)$/;
578
+ // The content region's ONLY motion is a vertical compression about its
579
+ // top edge (2026-11-07 user direction: only the height compresses, the
580
+ // width never does). Everything else — scaleX, a 2-arg scale, a
581
+ // translate, a matrix — is a contract break.
582
+ const LEGAL_BODY_TRANSFORM = new Set([
583
+ "scaleY(var(--hk-ride-factor, 0.9))",
584
+ "scaleY(1)",
585
+ "none",
586
+ ]);
579
587
  const offenders: string[] = [];
580
588
  for (const rule of RULES) {
581
589
  if (!BODY_TARGET.test(rule.selector)) continue;
582
590
  for (const prop of rule.declarations.keys()) {
583
591
  if (!TRANSFORM_FAMILY.test(prop)) continue;
584
- if (prop === "transform-origin") continue;
585
592
  const value = (rule.declarations.get(prop) ?? "").replace(/\s+/g, " ").trim();
586
- if (!INERT.has(value)) offenders.push(`${rule.selector} { ${prop}: ${value} }`);
593
+ if (!LEGAL_BODY_TRANSFORM.has(value)) offenders.push(`${rule.selector} { ${prop}: ${value} }`);
587
594
  }
588
595
  }
589
- expect(offenders, "the content region must never be scaled").toEqual([]);
596
+ expect(offenders, "the content region may only compress vertically").toEqual([]);
590
597
  });
591
598
 
592
599
  it("never lets ANY rule put a transform on a chrome surface", () => {
@@ -619,17 +626,44 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
619
626
  "transition",
620
627
  "will-change",
621
628
  "opacity",
629
+ // The from-pair carries the ride's height end, and the -active pair
630
+ // locks the content region out of the flex shrink for the flight
631
+ // (the 0.81-compounding guard, 2026-11-07 R3 finding).
632
+ "height",
633
+ "flex",
634
+ // …and the -active pair pins the footer band to the frame's bottom
635
+ // edge for the flight (it would otherwise be pushed out by the
636
+ // content region's locked natural box).
637
+ "position",
638
+ "inset-inline",
639
+ "bottom",
622
640
  ]);
623
641
  // Chrome ride states now also carry `transform` (the 2026-11-06 frame
624
642
  // height ride) — but nothing else beyond opacity/transition/will-change.
625
- const CHROME_ALLOWED = new Set(["opacity", "transition", "will-change", "transform"]);
643
+ const CHROME_ALLOWED = new Set([
644
+ "opacity",
645
+ "transition",
646
+ "will-change",
647
+ "transform",
648
+ "height",
649
+ // The -active pair pins the footer band to the frame's bottom edge
650
+ // for the flight (2026-11-07: the content region's locked natural
651
+ // box would otherwise push it past the shortened frame).
652
+ "position",
653
+ "inset-inline",
654
+ "bottom",
655
+ ]);
626
656
  // The contracted transform values a ride rule may carry. Frame: the
627
657
  // three translate+scaleY states (reduced-motion flatten included);
628
658
  // body: nothing (its width is never scaled — 2026-11-07).
629
659
  const LEGAL_FRAME = new Set([CENTERING, FRAME_FROM, FRAME_TO]);
630
660
  // The body carries no transform contract any more (2026-11-07): any
631
661
  // ride rule targeting it is an offender outright.
632
- const LEGAL_BODY = new Set<string>([]);
662
+ const LEGAL_BODY = new Set<string>([
663
+ "scaleY(var(--hk-ride-factor, 0.9))",
664
+ "scaleY(1)",
665
+ "none",
666
+ ]);
633
667
  const offenders: string[] = [];
634
668
  // Media-independent on purpose: a ride selector declared at top level
635
669
  // (no media query) applies at every width, so it must obey the same
@@ -682,8 +716,14 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
682
716
  const frame = frameWith(...state.classes);
683
717
  const body = child(frame, "hk-modal-body");
684
718
  const bodyTransform = winner(body, "transform", DESKTOP) ?? "";
685
- expect(bodyTransform, `body ${state.name}`).toBe("");
686
- expect(bodyTransform, `body ${state.name}`).not.toMatch(BODY_ESCAPES);
719
+ // The contracted body grammar: nothing but the vertical compression
720
+ // at the ride's two ends (and no transform at rest).
721
+ const expectCompressed = state.classes.some((c) =>
722
+ c.includes("enter-from") || c.includes("leave-to"));
723
+ const expected = expectCompressed
724
+ ? "scaleY(var(--hk-ride-factor, 0.9))"
725
+ : "scaleY(1)";
726
+ expect(bodyTransform, `body ${state.name}`).toBe(expected);
687
727
  expect(winner(body, "scale", DESKTOP), `body ${state.name}`).toBeUndefined();
688
728
  expect(winner(body, "translate", DESKTOP), `body ${state.name}`).toBeUndefined();
689
729
  expect(winner(body, "zoom", DESKTOP), `body ${state.name}`).toBeUndefined();
@@ -137,76 +137,127 @@
137
137
  // ------
138
138
 
139
139
  @media (min-width: 768px) {
140
- // Chrome: opacity fade + the frame's height ride, on one shared token
141
- // pair so the window's growth lands exactly with its fade.
140
+ // RESIDENT legs — the ride's continuity never depends on the transition
141
+ // classes' lifetime. The surface machine's deadline is advisory: it can
142
+ // flip the phase (and drop the classes) before the CSS lands. With the
143
+ // legs resident, that class drop only changes the TARGET (calc -> auto,
144
+ // scale -> none) and the browser finishes the motion on these legs
145
+ // instead of snapping (2026-11-07 user report: the open "replayed" — a
146
+ // partial ride, a snap, then a second animation). The height/max-height/
147
+ // clip-path legs stay at the base tokens for the size morph (its own
148
+ // heightMorph overrides the duration inline when it runs).
149
+ .hk-modal-content {
150
+ // Let the height leg interpolate to/from `auto` (Chromium 129+; older
151
+ // engines just snap that one handoff): when the transition classes come
152
+ // off mid-ride the calc end becomes the frame's own `auto` layout, and
153
+ // without this the motion would jump instead of finishing.
154
+ interpolate-size: allow-keywords;
155
+ transition:
156
+ opacity var(--hk-modal-duration, 0.42s)
157
+ var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
158
+ height var(--hk-modal-duration, 0.42s)
159
+ var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
160
+ max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
161
+ clip-path var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
162
+ }
163
+
164
+ // The content's compression leg is resident for the same reason — and it
165
+ // is what makes the ENTER start at exactly scaleY(0.9): a freshly
166
+ // inserted element's first style never transitions, so the from-pair
167
+ // paints at 0.9 and only ever animates from there.
168
+ .hk-modal-body {
169
+ transition: transform var(--hk-modal-duration, 0.42s)
170
+ var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
171
+ }
172
+
173
+ // Open: the window's height rides while the chrome keeps its size.
142
174
  .hk-modal-content-enter-active {
143
175
  transition:
144
176
  opacity var(--hk-modal-duration, 0.42s)
145
177
  var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
146
178
  height var(--hk-modal-duration, 0.42s)
147
179
  var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
148
- will-change: opacity, height;
180
+ will-change: opacity;
149
181
  }
150
182
 
151
- // Close: the reveal's TRUE reverse — same chrome fade, while the window
152
- // settles back toward 90% height, width untouched in both directions.
183
+ // Close: the reveal's TRUE reverse — same ride, the window settles back
184
+ // toward 90% height, width untouched in both directions.
153
185
  .hk-modal-content-leave-active {
154
186
  transition:
155
187
  opacity var(--hk-modal-duration, 0.32s)
156
188
  var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
157
189
  height var(--hk-modal-duration, 0.32s)
158
190
  var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
159
- will-change: opacity, height;
191
+ will-change: opacity;
160
192
  }
161
193
 
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
- .hk-modal-content-enter-from,
168
- .hk-modal-content-leave-to {
194
+ // The ride's two ends. The from-pair ships with the FIRST paint (a
195
+ // freshly inserted element does not transition into its initial style),
196
+ // so the compressed height and the 0.9 content scale are both real at
197
+ // frame one — the phase-mapped classes that shipped one tick late are
198
+ // exactly what made the open look like clipping (2026-11-07 reports).
199
+ // `!important` outranks the size morph's inline pin for the flight.
200
+ .hk-modal-content[data-hk-ride-ready].hk-modal-content-enter-from,
201
+ .hk-modal-content[data-hk-ride-ready].hk-modal-content-leave-to {
169
202
  opacity: 0;
203
+ height: calc(
204
+ var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px) * var(--hk-ride-factor, 0.9)
205
+ ) !important;
170
206
  }
171
207
 
208
+ // The natural end is an EXPLICIT calc too: the frame's base height is
209
+ // `auto`, and a transition cannot interpolate px -> auto. Without this
210
+ // leg the -active body lock made `auto` resolve to (almost) the
211
+ // compressed height, so the frame never grew while the content's scale
212
+ // unfolded underneath it — the "clipped open" the user reported. When the
213
+ // classes come off at rest, the morph's pin (the same px) catches the
214
+ // handoff with no visible change.
172
215
  .hk-modal-content-enter-to,
173
216
  .hk-modal-content-leave-from {
174
217
  opacity: 1;
218
+ height: calc(var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px));
175
219
  }
176
220
 
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));
221
+ // Only the content region is ever scaled — top origin, so the compressed
222
+ // paint lands exactly in the shorter slot the frame leaves for it.
223
+ .hk-modal-content-enter-from > .hk-modal-body,
224
+ .hk-modal-content-leave-to > .hk-modal-body {
225
+ transform: scaleY(var(--hk-ride-factor, 0.9));
226
+ transform-origin: 50% 0;
191
227
  }
192
228
 
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));
229
+ .hk-modal-content-enter-to > .hk-modal-body,
230
+ .hk-modal-content-leave-from > .hk-modal-body {
231
+ transform: scaleY(1);
232
+ transform-origin: 50% 0;
196
233
  }
197
234
 
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 {
235
+ // The content region keeps its NATURAL layout height for the whole
236
+ // flight (or the slot shrink and the scale would compound to 0.81 and
237
+ // leave a gap above the footer), and the footer pins to the frame's
238
+ // bottom edge so it stays visible while the content's natural box
239
+ // overflows underneath it. The -active pair spans the flight window in
240
+ // both directions.
241
+ .hk-modal-content-enter-active > .hk-modal-body,
242
+ .hk-modal-content-leave-active > .hk-modal-body {
200
243
  flex: 0 0 auto;
201
244
  height: var(--hk-ride-content, auto);
202
245
  }
203
246
 
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 {
247
+ .hk-modal-content-enter-active > .hk-modal-footer,
248
+ .hk-modal-content-leave-active > .hk-modal-footer {
206
249
  position: absolute;
207
250
  inset-inline: 0;
208
251
  bottom: 0;
209
252
  }
253
+
254
+ // Close-direction compression timing for the content (the frame's height
255
+ // leg above is 0.32s ease-in; a 0.42s content leg would trail it and open
256
+ // a gap above the pinned footer).
257
+ .hk-modal-content-leave-active > .hk-modal-body {
258
+ transition: transform var(--hk-modal-duration, 0.32s)
259
+ var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
260
+ }
210
261
  }
211
262
 
212
263
  // ------
@@ -42,6 +42,27 @@ function freezeRaf(): void {
42
42
  vi.stubGlobal("cancelAnimationFrame", () => {});
43
43
  }
44
44
 
45
+ /** Pin the viewport to the phone band (<768px): the desktop height ride
46
+ * blacks the morph out of the open/close edges, and these suites test the
47
+ * morph's own contracts. happy-dom's 1024px default would route them into
48
+ * the desktop branch. */
49
+ function stubPhoneViewport(): void {
50
+ vi.stubGlobal(
51
+ "matchMedia",
52
+ (query: string) =>
53
+ ({
54
+ matches: false,
55
+ media: query,
56
+ addEventListener: () => {},
57
+ removeEventListener: () => {},
58
+ addListener: () => {},
59
+ removeListener: () => {},
60
+ onchange: null,
61
+ dispatchEvent: () => false,
62
+ }) as MediaQueryList,
63
+ );
64
+ }
65
+
45
66
  function stubDurations(): void {
46
67
  const realGCS = window.getComputedStyle.bind(window);
47
68
  vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
@@ -107,6 +128,7 @@ describe("HkModal stepflow swap handoff", () => {
107
128
  it("morphs over the announced phase for a modal mounted CLOSED (the real consumer path)", async () => {
108
129
  vi.useFakeTimers();
109
130
  freezeRaf();
131
+ stubPhoneViewport();
110
132
  stubDurations();
111
133
  const rig = await mountRig();
112
134
 
@@ -128,6 +150,7 @@ describe("HkModal stepflow swap handoff", () => {
128
150
  it("leaves the stylesheet default in place for an instant settlement", async () => {
129
151
  vi.useFakeTimers();
130
152
  freezeRaf();
153
+ stubPhoneViewport();
131
154
  stubDurations();
132
155
  const rig = await mountRig();
133
156
  rig.open.value = true;
@@ -203,6 +226,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
203
226
  it("republishes the morph's real fold span and its landing", async () => {
204
227
  vi.useFakeTimers();
205
228
  freezeRaf();
229
+ stubPhoneViewport();
206
230
  stubClip();
207
231
  // Content starts at 500 (frame 596) and the sheet is uncapped, so the
208
232
  // morph can fold: shrinking the content must publish a conceal.
@@ -259,6 +283,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
259
283
  it("publishes NOTHING when the sheet is capped and cannot fold", async () => {
260
284
  vi.useFakeTimers();
261
285
  freezeRaf();
286
+ stubPhoneViewport();
262
287
  stubClip();
263
288
  // Content that already overflows the cap: the natural height cannot
264
289
  // change, so the morph stages no sweep at all — the flow must park
@@ -293,6 +318,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
293
318
  // measurements) is unit-witnessed in useSizeMorph.test.ts.
294
319
  vi.useFakeTimers();
295
320
  freezeRaf();
321
+ stubPhoneViewport();
296
322
  stubDurations();
297
323
  const rig = await mountRig();
298
324
  rig.open.value = true;
@@ -317,6 +343,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
317
343
  it("ignores announcements while the surface is still unfolding", async () => {
318
344
  vi.useFakeTimers();
319
345
  freezeRaf();
346
+ stubPhoneViewport();
320
347
  stubDurations();
321
348
  const rig = await mountRig();
322
349
  rig.open.value = true;
@@ -335,6 +362,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
335
362
  it("drops the override when the modal unmounts mid-sweep", async () => {
336
363
  vi.useFakeTimers();
337
364
  freezeRaf();
365
+ stubPhoneViewport();
338
366
  stubDurations();
339
367
  const rig = await mountRig();
340
368
  rig.open.value = true;
@@ -423,9 +451,11 @@ describe("HkModal rides its chrome and gates the slide window", () => {
423
451
  // change).
424
452
  vi.useFakeTimers();
425
453
  freezeRaf();
454
+ stubPhoneViewport();
426
455
  stubDurations();
427
456
  stubClip();
428
457
  stubBox(500, Number.POSITIVE_INFINITY);
458
+ stubPhoneViewport();
429
459
  const roInstances: Array<{ callback: () => void }> = [];
430
460
  vi.stubGlobal("ResizeObserver", class {
431
461
  callback: () => void;
@@ -475,6 +505,7 @@ describe("HkModal rides its chrome and gates the slide window", () => {
475
505
  stubDurations();
476
506
  stubClip();
477
507
  stubBox(500, Number.POSITIVE_INFINITY);
508
+ stubPhoneViewport();
478
509
  const roInstances: Array<{ callback: () => void }> = [];
479
510
  vi.stubGlobal("ResizeObserver", class {
480
511
  callback: () => void;
@@ -520,6 +551,7 @@ describe("HkModal rides its chrome and gates the slide window", () => {
520
551
  // rides anything — it just transitions the frame's height inline.
521
552
  vi.useFakeTimers();
522
553
  freezeRaf();
554
+ stubPhoneViewport();
523
555
  stubClip();
524
556
  stubBox(500, Number.POSITIVE_INFINITY);
525
557
  const container = document.createElement("div");
@@ -339,7 +339,7 @@ export default defineComponent({
339
339
  // report: the window folded "together with the chrome"). The body
340
340
  // ride matters for the ≤767px sheet sweep, where the ride geometry
341
341
  // is disabled — so the two never legitimately overlap.
342
- if (bodyRef.value && rideStateClass() === null) out.push({ el: bodyRef.value });
342
+ if (bodyRef.value && !desktopRide()) out.push({ el: bodyRef.value });
343
343
  return out;
344
344
  },
345
345
  // Republish the morph's real sweep span and its landing on the body
@@ -393,18 +393,15 @@ export default defineComponent({
393
393
  { flush: "post" },
394
394
  );
395
395
 
396
- const rideStateClass = (): string | null => {
397
- switch (machine.phase.value) {
398
- case "openingFrom":
399
- case "closingTo":
400
- return "hk-ride--from";
401
- case "openingTo":
402
- case "closingFrom":
403
- return "hk-ride--to";
404
- default:
405
- return null;
406
- }
407
- };
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
+
408
405
  let previouslyFocused: HTMLElement | null = null;
409
406
  let unmounted = false;
410
407
 
@@ -655,7 +652,21 @@ export default defineComponent({
655
652
  // open branch's own start() covers them.
656
653
  void nextTick(() => {
657
654
  const p = machine.phase.value;
658
- if (p === "openingFrom" || p === "openingTo") morph.start();
655
+ 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();
659
670
  });
660
671
  } else if (to === "open") {
661
672
  surfTrack.cancel();
@@ -663,9 +674,50 @@ export default defineComponent({
663
674
  // from this box (content may have changed while the surface opened).
664
675
  void nextTick(() => ride.refresh());
665
676
  onAfterEnter();
666
- // Idempotent re-arm for the fast paths (see openingFrom); the
667
- // morph is usually armed already by the enter-edge tick.
668
- morph.start();
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
+ }
669
721
  // Flush the growth deferred through the unfold. One tick later
670
722
  // the transition classes are off the frame, so the base height
671
723
  // transition animates the pin change instead of the enter's
@@ -1132,7 +1184,7 @@ export default defineComponent({
1132
1184
  />
1133
1185
  <div
1134
1186
  ref={contentRef}
1135
- class={["hk-modal-content", "hk-ride", rideStateClass(), props.contentClass, ...machine.classesFor("hk-modal-content")]}
1187
+ class={["hk-modal-content", props.contentClass, ...machine.classesFor("hk-modal-content")]}
1136
1188
  data-hk-ride-ready={ride.hasCache() ? "" : undefined}
1137
1189
  role="dialog"
1138
1190
  aria-modal="true"
@@ -1201,7 +1253,7 @@ export default defineComponent({
1201
1253
  )}
1202
1254
  </>
1203
1255
  ) : null,
1204
- <div ref={bodyRef} class="hk-modal-body hk-ride-content">
1256
+ <div ref={bodyRef} class="hk-modal-body">
1205
1257
  <div
1206
1258
  ref={scrollContainerRef}
1207
1259
  class="hk-modal-body-scroll hk-scroll-pin-host"
@@ -862,6 +862,35 @@ describe("HkRangeRibbon reset", () => {
862
862
  expect(second.defaultPrevented).toBe(true);
863
863
  });
864
864
 
865
+ it("treats a full-domain window as nothing to reset (raw-records rest state)", async () => {
866
+ // A minimal-style host rests at the controlled FULL selection [0, 1]:
867
+ // the grips paint, but there is no narrower selection to come back to.
868
+ // Escape there must reach the host (the dialog closes), not "reset" the
869
+ // bar it already shows — before 2026-11-07 every press swallowed the key.
870
+ const host = mountHost([0, 1]);
871
+ const strip = host.root.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
872
+ const event = keydown(strip, "Escape");
873
+ await nextTick();
874
+ expect(host.windowChange, "full selection: nothing resets").not.toHaveBeenCalled();
875
+ expect(event.defaultPrevented, "a host dialog must still see Escape").toBe(false);
876
+
877
+ // The reset BUTTON agrees with Escape: nothing to reset at [0, 1].
878
+ expect(host.root.querySelector(".hk-range-ribbon-reset")).toBeNull();
879
+ // The grips stay (they are the selection itself, not a reset affordance).
880
+ expect(host.root.querySelector(".hk-range-ribbon-handle")).not.toBeNull();
881
+
882
+ // A real sub-domain still resets — and the chip is back for it.
883
+ await host.setWindow([0.3, 0.7]);
884
+ expect(host.root.querySelector(".hk-range-ribbon-reset")).not.toBeNull();
885
+ const second = keydown(host.root.querySelector<HTMLElement>(".hk-range-ribbon-strip")!, "Escape");
886
+ await nextTick();
887
+ expect(host.windowChange).toHaveBeenLastCalledWith(null);
888
+ expect(second.defaultPrevented).toBe(true);
889
+ // Back at the full selection the chip is gone again.
890
+ await host.setWindow([0, 1]);
891
+ expect(host.root.querySelector(".hk-range-ribbon-reset")).toBeNull();
892
+ });
893
+
865
894
  it("cancels an in-flight brush with Escape and consumes the key", async () => {
866
895
  const host = mountHost(null);
867
896
  const { strip, x } = stubStrip(host.root);
@@ -1496,10 +1525,13 @@ describe("HkRangeRibbon stylesheet contract", () => {
1496
1525
  });
1497
1526
 
1498
1527
  // Wave (2026-11-06 raw-records five): MINIMAL MODE - the bare dual-handle
1499
- // bar. Contract: data-minimal on the root, strip present + windowed at
1500
- // the full domain, ZERO lane chrome (no lanes block, no lane rows, no
1501
- // overview minimap, no gutter probe), and the standard mode keeps its
1502
- // lane chrome + alignment probe.
1528
+ // bar. Contract: data-minimal on the root, strip present, ZERO lane chrome
1529
+ // (no lanes block, no lane rows, no overview minimap, no gutter probe),
1530
+ // and the standard mode keeps its lane chrome + alignment probe. The
1531
+ // uncontrolled default window is NULL here — the grips need a controlled
1532
+ // non-null window (the chest raw-records adapter feeds [0, 1] for its
1533
+ // "default = everything selected" reading); the aria readings below come
1534
+ // from the strip's null-window fallbacks, not from a real window.
1503
1535
  it("minimal mode renders the bare dual-handle bar (no lane chrome)", () => {
1504
1536
  const root = mountRibbon({ minimal: true });
1505
1537
  expect(root.getAttribute("data-minimal")).toBe("true");
@@ -1507,8 +1539,9 @@ describe("HkRangeRibbon stylesheet contract", () => {
1507
1539
  expect(stripEl).not.toBeNull();
1508
1540
  // The strip carries the slider a11y (role/tabindex/label/valuenow).
1509
1541
  expect(stripEl.getAttribute("aria-label")).toBe("Observation range");
1510
- // Uncontrolled default window = the FULL domain: valuenow sits at 0
1511
- // (domain start) and the valuetext reads the whole range.
1542
+ // No window ⇒ the strip's aria falls back to the full range ("0" /
1543
+ // "0% – 100%"): a truthful reading for an unfiltered bar, even though
1544
+ // no grips paint without a controlled window.
1512
1545
  expect(stripEl.getAttribute("aria-valuenow")).toBe("0");
1513
1546
  expect(stripEl.getAttribute("aria-valuetext")).toContain("0%");
1514
1547
  // The lanes BLOCK is skipped entirely (no lane rows at all).
@@ -1522,7 +1555,6 @@ describe("HkRangeRibbon stylesheet contract", () => {
1522
1555
  // static scss assertion at the end of this file — happy-dom cannot
1523
1556
  // resolve the nested attribute selector.)
1524
1557
  expect(root.querySelector(".hk-range-ribbon-overview-segment")).toBeNull();
1525
- // Uncontrolled default window = the FULL domain (edge to edge).
1526
1558
  // Standard consumers keep the lane chrome + the alignment probe.
1527
1559
  const std = mountRibbon({});
1528
1560
  expect(std.querySelector(".hk-range-ribbon-lane")).not.toBeNull();
@@ -264,9 +264,12 @@ export default defineComponent({
264
264
  * range bar. No lane rows, no overview minimap, no label gutter; the
265
265
  * strip spans the full width and its track uses muted-based tokens
266
266
  * (the surface-based track read whitish inside dark phone modals).
267
- * The uncontrolled default window is already the FULL domain, so a
268
- * minimal bar starts pinned edge to edge. `segments` / `selectedId`
269
- * are IGNORED in this mode (no segment DOM to select from). */
267
+ * The grips paint only for a NON-null window, so an uncontrolled
268
+ * minimal bar starts as a bare track; a host that wants the "default =
269
+ * everything selected" reading feeds the controlled window `[0, 1]`
270
+ * (the chest raw-records adapter does exactly that).
271
+ * `segments` / `selectedId` are IGNORED in this mode (no segment DOM to
272
+ * select from). */
270
273
  minimal: { type: Boolean, default: false },
271
274
  },
272
275
  emits: {
@@ -350,9 +353,21 @@ export default defineComponent({
350
353
  return true;
351
354
  }
352
355
 
353
- /** Returns true when a window actually existed (and was cleared), so the
354
- * caller can leave Escape alone when the host needs it. */
356
+ /** A window that covers the whole domain IS the unfiltered state — there
357
+ * is nothing to reset back to (2026-11-07, the raw-records consumer: a
358
+ * minimal bar paints its grips for a non-null window, so its rest state
359
+ * is the FULL selection `[0, 1]`; without this check Escape "reset" it
360
+ * every press, swallowed the key, and the hosting dialog could never
361
+ * close while the bar held focus). */
362
+ function isFullDomain(win: WindowRange | null): boolean {
363
+ return win !== null && win[0] <= WINDOW_EPSILON && win[1] >= 1 - WINDOW_EPSILON;
364
+ }
365
+
366
+ /** Returns true when a window actually existed AND was narrower than the
367
+ * full domain (and was cleared), so the caller can leave Escape alone
368
+ * when the host needs it — a full-domain window has nothing to reset. */
355
369
  function resetWindow(): boolean {
370
+ if (isFullDomain(activeWindow.value)) return false;
356
371
  if (!commitIfChanged(null)) return false;
357
372
  // A reset that interrupts a drag (Escape mid-brush) must drop the
358
373
  // gesture too, or the next pointermove re-brushes from the stale
@@ -818,7 +833,10 @@ export default defineComponent({
818
833
  focus; this sheet only pins the seat (position) and a
819
834
  surface backing via its override vars. The glyph slots
820
835
  as a lucide component (the house-normal integration:
821
- every in-tree HkIconButton slots its glyph). */}
836
+ every in-tree HkIconButton slots its glyph). A
837
+ full-domain window has nothing to reset, so the chip
838
+ only renders for a REAL sub-domain selection. */}
839
+ {win && !isFullDomain(win) && (
822
840
  <HIconButton
823
841
  class="hk-range-ribbon-reset"
824
842
  size={24}
@@ -829,6 +847,7 @@ export default defineComponent({
829
847
  >
830
848
  {{ icon: () => <RotateCcw aria-hidden="true" /> }}
831
849
  </HIconButton>
850
+ )}
832
851
  </>
833
852
  )}
834
853
  </div>