@celestia-island/hikari 0.55.301 → 0.55.303

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.301",
3
+ "version": "0.55.303",
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",
@@ -58,6 +58,7 @@
58
58
  "lint": "vue-tsc --noEmit",
59
59
  "test": "vitest run",
60
60
  "test:watch": "vitest",
61
+ "gen:boot-landing-css": "node scripts/gen-boot-landing-css.mjs",
61
62
  "prepublishOnly": "vue-tsc --noEmit"
62
63
  },
63
64
  "peerDependencies": {
@@ -105,29 +105,13 @@
105
105
  }
106
106
 
107
107
  /* ── Close button ──────────────────────────────── */
108
-
109
- .hk-alert-close {
110
- display: flex;
111
- align-items: center;
112
- justify-content: center;
108
+ /* The control is the STANDARD icon button (2026-11-06 family sweep): box,
109
+ glyph size, ink, hover, press and focus all come from HkIconButton. This
110
+ sheet keeps only a redundant flex-shrink nudge — the same value the
111
+ standard layer already declares. The key stays at (0,2,0) as defensive
112
+ specificity: if this seat ever has to override a component declaration,
113
+ it already out-ranks `.hk-icon-button`'s own (0,1,0) rules regardless of
114
+ bundle order. */
115
+ .hk-alert .hk-alert-close {
113
116
  flex-shrink: 0;
114
- width: 22px;
115
- height: 22px;
116
- border: 0;
117
- background: transparent;
118
- border-radius: var(--radius-sm, 4px);
119
- color: var(--hi-color-muted, #6c6c6c);
120
- cursor: pointer;
121
- padding: 0;
122
- transition: background var(--duration-fast, 0.15s) ease, color var(--duration-fast, 0.15s) ease;
123
-
124
- &:hover {
125
- background: var(--hi-color-surface, #ffffff);
126
- color: var(--hi-color-text-primary, #1e1e1e);
127
- }
128
-
129
- &:focus-visible {
130
- outline: 2px solid var(--hi-color-primary, #7aa2f7);
131
- outline-offset: 2px;
132
- }
133
117
  }
@@ -3,6 +3,8 @@ import { useI18n } from "../i18n/context";
3
3
  import { hkPadStyle, type HkPad } from "../runtime/containerPad";
4
4
  import { AlertTriangle, CheckCircle, Info, X } from "lucide-vue-next";
5
5
 
6
+ import HIconButton from "./HkIconButton";
7
+
6
8
  import "./HkAlert.scss";
7
9
 
8
10
  export type AlertVariant = "error" | "warning" | "info" | "success";
@@ -73,14 +75,18 @@ export default defineComponent({
73
75
  </div>
74
76
 
75
77
  {props.closable && (
76
- <button
78
+ /* The close affordance is the STANDARD icon button (2026-11-06
79
+ family sweep): HkIconButton owns box, glyph size, hover, press
80
+ and focus; the sheet keeps only the seat. */
81
+ <HIconButton
77
82
  class="hk-alert-close"
78
- type="button"
83
+ size={24}
84
+ variant="ghost"
79
85
  aria-label={t("hikari::alert.close", "Close")}
80
86
  onClick={onClose}
81
87
  >
82
- <X size={14} />
83
- </button>
88
+ {{ icon: () => <X aria-hidden="true" /> }}
89
+ </HIconButton>
84
90
  )}
85
91
  </div>
86
92
  );
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
- import { createApp, createCommentVNode, defineComponent, h } from "vue";
2
+ import { createApp, createCommentVNode, defineComponent, h, ref } from "vue";
3
3
 
4
4
  import { HkCardList, HkListRow } from "./HkCardList";
5
5
 
@@ -79,6 +79,33 @@ describe("HkCardList", () => {
79
79
  expect(c.querySelector(".probe-empty")).toBeTruthy();
80
80
  });
81
81
 
82
+ it("falls back to the empty slot when the default slot is a fragment of dead v-if comments (JSX <>{cond && …}</> idiom)", () => {
83
+ // The compiled shape of a consumer's JSX-conditional default slot is
84
+ // a Fragment carrying the dead comments — the shared guard must
85
+ // recurse into it or an empty list renders an empty bordered card
86
+ // (R2 S2-1 integration pin: this case is what makes the recursion
87
+ // load-bearing at the HkCardList seam, not just in the unit table).
88
+ const show = ref(false);
89
+ const c = mount(
90
+ h(HkCardList, null, {
91
+ default: () => (<>{show.value && <div class="row" />}</>) as never,
92
+ empty: () => h("p", { class: "probe-empty" }, "empty"),
93
+ }),
94
+ );
95
+ expect(c.querySelector(".hk-card-list")).toBeNull();
96
+ expect(c.querySelector(".probe-empty")).toBeTruthy();
97
+ // The live side: real rows keep the list and hide the empty slot.
98
+ show.value = true;
99
+ const live = mount(
100
+ h(HkCardList, null, {
101
+ default: () => (<>{show.value && <div class="row" />}</>) as never,
102
+ empty: () => h("p", { class: "probe-empty" }, "empty"),
103
+ }),
104
+ );
105
+ expect(live.querySelector(".hk-card-list")).toBeTruthy();
106
+ expect(live.querySelector(".probe-empty")).toBeNull();
107
+ });
108
+
82
109
  it("keeps the list when rows exist even with the empty slot present", () => {
83
110
  const c = mount(
84
111
  h(HkCardList, null, {
@@ -1,4 +1,4 @@
1
- import { Comment, computed, defineComponent, h, onBeforeUnmount, onMounted, ref, type Component, type PropType, type VNode } from "vue";
1
+ import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref, type Component, type PropType } from "vue";
2
2
  import { MoreHorizontal } from "lucide-vue-next";
3
3
 
4
4
  import HkCard from "./HkCard";
@@ -6,24 +6,23 @@ import { HkIconChip } from "./HkIconChip";
6
6
  import HkMenu from "./HkMenu";
7
7
  import type { HkMenuItem } from "./HkMenu";
8
8
  import HkTooltip from "./HkTooltip";
9
+ import { hasRenderedContent } from "../runtime/hasRenderedContent";
9
10
  import { useMediaQuery } from "../runtime/useMediaQuery";
10
11
  import type { StatTone } from "./HkStatCard";
11
12
  import "./HkCardList.scss";
12
13
 
13
14
  /**
14
- * Whether a default slot actually rendered any row. Testing
15
- * `slots.default` for truthiness is not enough: the canonical template
16
- * idiom (`<HkListRow v-for>` plus `<template #empty>`) always provides a
17
- * default slot whose v-for renders zero vnodes, and a `v-if` row renders
18
- * a comment placeholder — both would otherwise show an empty bordered
19
- * card where the empty state belongs.
15
+ * Whether a default slot actually rendered any row — the SHARED slot
16
+ * guard (runtime/hasRenderedContent, recursed into fragments so the
17
+ * JSX `<>{cond && …}</>` shape reads as empty too; HkModelCard uses the
18
+ * same one). Testing `slots.default` for truthiness is not enough: the
19
+ * canonical template idiom (`<HkListRow v-for>` plus
20
+ * `<template #empty>`) always provides a default slot whose v-for
21
+ * renders zero vnodes, and a `v-if` row renders a comment placeholder
22
+ * — both would otherwise show an empty bordered card where the empty
23
+ * state belongs.
20
24
  */
21
- function renderedRows(nodes: unknown): boolean {
22
- if (nodes == null || nodes === false || nodes === "") return false;
23
- if (Array.isArray(nodes)) return nodes.some((n) => renderedRows(n));
24
- if (typeof nodes === "object") return (nodes as VNode).type !== Comment;
25
- return true; // non-empty text content
26
- }
25
+ const renderedRows = hasRenderedContent;
27
26
 
28
27
  /**
29
28
  * HkCardList — the standard card list: an unpadded HkCard whose body is
@@ -2,13 +2,20 @@
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-05 (supersedes the 2026-11-04 whole-frame
6
- * reveal): the CONTENT REGION is the ONLY scaled surface. The frame, the
7
- * title bar, the sub-header and the footer action row are chrome — they
8
- * materialize at their final size on one opacity fade, while
9
- * `.hk-modal-body` settles from 90% to 100% of its own box about its
10
- * center with ONE factor on BOTH axes (transfer-style `scale(0.9)`). The
11
- * retired 2026-11-04 grammar scaled the whole frame `scale(1, 0.9)`.
5
+ * User direction 2026-11-06 (supersedes the 2026-11-05 content-only ride):
6
+ * the window's own HEIGHT rides the open/close — the frame settles
7
+ * `translate(…) scaleY(0.9) → translate(…) scaleY(1)` about its center, so
8
+ * the box grows symmetrically and anything centered in it (the content
9
+ * region above all) stays horizontally AND vertically centered at every t;
10
+ * the translate prefix is byte-identical in every state so the ride can
11
+ * never re-seat the frame. `.hk-modal-body` keeps a ride of its own:
12
+ * `scale(1.1, 1) → scale(1, 1)` — the horizontal factor deliberately
13
+ * OVERSHOOTS (user direction 2026-11-06: at the extremes the content is
14
+ * WIDER than the window, clipped by the frame's overflow), while its
15
+ * vertical factor stays 1 because the body's Y already rides the frame's
16
+ * scaleY (own 0.9 would compound to 0.81). The retired grammars: the
17
+ * 2026-11-04 whole-frame `scale(1, 0.9)` and the 2026-11-05 body-only
18
+ * uniform `scale(0.9)`.
12
19
  *
13
20
  * Why compile: a source-text scanner can be dodged by a later duplicate
14
21
  * rule, a block comment, SCSS interpolation, `@extend` or an `@use`d
@@ -291,6 +298,7 @@ function winner(
291
298
  const DESKTOP = ["(min-width: 768px)"];
292
299
  const MOBILE = ["(max-width: 767px)"];
293
300
  const DESKTOP_REDUCED = ["(min-width: 768px)", "(prefers-reduced-motion: reduce)"];
301
+ const MOBILE_REDUCED = ["(max-width: 767px)", "(prefers-reduced-motion: reduce)"];
294
302
 
295
303
  /** A frame carrying one ride state (plus the shared -active pair). */
296
304
  function frameWith(...stateClasses: readonly string[]): MockElement {
@@ -317,6 +325,28 @@ const child = (parent: MockElement, cls: string): MockElement => el([cls], paren
317
325
  const CENTERING =
318
326
  "translate(calc(-50% + var(--hk-modal-shift-x, 0px)), calc(-50% + var(--hk-modal-shift-y, 0px)))";
319
327
 
328
+ /** Frame ride states: the centering translate is a PERMANENT prefix and
329
+ * only the appended scaleY changes (2026-11-06: the window's height rides
330
+ * the open/close about its center). */
331
+ const FRAME_FROM = `${CENTERING} scaleY(0.9)`;
332
+ const FRAME_TO = `${CENTERING} scaleY(1)`;
333
+
334
+ /** Exact compiled transition values (winner()-normalized) — pinned whole,
335
+ * not by prefix: an embedded fourth time value (delay) or a smuggled extra
336
+ * segment must fail, because the surface machine reads duration only and a
337
+ * delay would desync the fade from the height ride.
338
+ * R2 finding 2026-11-06 (S1): prefix `toContain` let both survive. */
339
+ const ENTER_TRANSITION =
340
+ "opacity var(--hk-modal-duration, 0.42s) var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)), " +
341
+ "transform var(--hk-modal-duration, 0.42s) var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1))";
342
+ const LEAVE_TRANSITION =
343
+ "opacity var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)), " +
344
+ "transform var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1))";
345
+ const BODY_ENTER_TRANSITION =
346
+ "transform var(--hk-modal-duration, 0.42s) var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1))";
347
+ const BODY_LEAVE_TRANSITION =
348
+ "transform var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1))";
349
+
320
350
  describe("HkModal desktop open/close contract (compiled cascade, user direction 2026-11-05)", () => {
321
351
  it("compiles into a stylesheet with the ride rules present (known-positive)", () => {
322
352
  // Guard against a vacuous suite: the compiled sheet must really contain
@@ -327,19 +357,36 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
327
357
  expect(selectors.some((s) => s.includes("hk-modal-content-enter-from"))).toBe(true);
328
358
  });
329
359
 
330
- it("keeps the frame's whole transform at the centering translate in every ride state", () => {
360
+ it("rides the frame height as scaleY appended to the byte-identical centering translate", () => {
361
+ // 2026-11-06: the window's own height moves with the open/close. The
362
+ // translate prefix must be byte-identical in EVERY state (rest too) so
363
+ // the ride can never re-seat the frame or drop a dragged window's
364
+ // shift; only the appended scaleY flips 0.9 → 1.
365
+ expect(winner(frameWith(...RIDE[0].classes), "transform", DESKTOP), RIDE[0].name).toBe(FRAME_FROM);
366
+ expect(winner(frameWith(...RIDE[1].classes), "transform", DESKTOP), RIDE[1].name).toBe(FRAME_TO);
367
+ expect(winner(frameWith(...RIDE[2].classes), "transform", DESKTOP), RIDE[2].name).toBe(FRAME_TO);
368
+ expect(winner(frameWith(...RIDE[3].classes), "transform", DESKTOP), RIDE[3].name).toBe(FRAME_FROM);
369
+ // …and the same with the opt-in draggable flag on, where an
370
+ // attribute-qualified frame rule could otherwise slip past the mocks
371
+ // (R2 finding 2026-11-06 (S2): the body had this loop, the frame
372
+ // didn't — the repo's own [data-draggable] block is the natural next
373
+ // mutation site).
331
374
  for (const state of RIDE) {
332
- const frame = frameWith(...state.classes);
333
- expect(winner(frame, "transform", DESKTOP), state.name).toBe(CENTERING);
375
+ const expected = state.name === "enter-from" || state.name === "leave-to" ? FRAME_FROM : FRAME_TO;
376
+ expect(
377
+ winner(draggableFrameWith(...state.classes), "transform", DESKTOP),
378
+ `draggable frame ${state.name}`,
379
+ ).toBe(expected);
334
380
  }
335
- // …and at rest, too.
381
+ // …and at rest, too: the settled frame carries the translate only.
336
382
  expect(winner(frameWith(), "transform", DESKTOP)).toBe(CENTERING);
383
+ expect(winner(draggableFrameWith(), "transform", DESKTOP)).toBe(CENTERING);
337
384
  });
338
385
 
339
- it("never lets a transform property creep onto the frame", () => {
340
- // A frame-level `scale:` / `zoom:` / `translate:` (or an origin) would
341
- // scale the chrome with it — the retired reveal expressed with the
342
- // longhand properties instead of `transform`.
386
+ it("never lets a transform longhand creep onto the frame", () => {
387
+ // A frame-level `scale:` / `zoom:` / `translate:` / `rotate:` longhand
388
+ // (or an origin override) would ride the chrome on its own grammar
389
+ // instead of the contracted translate+scaleY list.
343
390
  for (const state of RIDE) {
344
391
  const frame = frameWith(...state.classes);
345
392
  for (const prop of ["scale", "zoom", "translate", "rotate", "transform-origin"]) {
@@ -353,25 +400,47 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
353
400
  }
354
401
  });
355
402
 
356
- it("fades the frame without animating any transform", () => {
403
+ it("fades the frame and rides its height on one shared token pair per direction", () => {
404
+ // Pinned WHOLE (winner() normalizes whitespace), not by prefix: a
405
+ // fourth time value (transition-delay) or an extra smuggled segment
406
+ // must fail — the surface machine measures duration only, so a delay
407
+ // would start the scaleY ride late and the measured deadline would cut
408
+ // it short (R2 finding 2026-11-06 (S1): `toContain` prefixes let both
409
+ // survive).
357
410
  const enter = winner(
358
411
  frameWith("hk-modal-content-enter-from", "hk-modal-content-enter-active"),
359
412
  "transition",
360
413
  DESKTOP,
361
414
  );
362
- expect(enter).toContain("opacity var(--hk-modal-duration, 0.42s)");
363
- expect(enter).not.toContain("transform");
415
+ expect(enter).toBe(ENTER_TRANSITION);
364
416
 
365
417
  const leave = winner(
366
418
  frameWith("hk-modal-content-leave-to", "hk-modal-content-leave-active"),
367
419
  "transition",
368
420
  DESKTOP,
369
421
  );
370
- expect(leave).toContain("opacity var(--hk-modal-duration, 0.32s)");
371
- expect(leave).not.toContain("transform");
422
+ expect(leave).toBe(LEAVE_TRANSITION);
423
+
424
+ const enterWill = winner(
425
+ frameWith("hk-modal-content-enter-from", "hk-modal-content-enter-active"),
426
+ "will-change",
427
+ DESKTOP,
428
+ );
429
+ expect(enterWill).toBe("opacity, transform");
430
+ // The close direction promotes the same pair of layers.
431
+ const leaveWill = winner(
432
+ frameWith("hk-modal-content-leave-to", "hk-modal-content-leave-active"),
433
+ "will-change",
434
+ DESKTOP,
435
+ );
436
+ expect(leaveWill).toBe("opacity, transform");
372
437
  });
373
438
 
374
- it("scales ONLY the content region, from 90% on both axes to 100%", () => {
439
+ it("rides the content region X 1.1→1 with Y pinned at 1, about its center", () => {
440
+ // 2026-11-06: the horizontal factor OVERSHOOTS (wider-than-window at
441
+ // the extremes; the frame's overflow clips the overhang) and the
442
+ // vertical factor stays 1 — the body's Y already rides the frame's
443
+ // scaleY, so its own 1 keeps the compound at exactly 0.9→1.
375
444
  const expect_ = (state: string, value: string) => {
376
445
  const frame = frameWith(...RIDE.find((r) => r.name === state)!.classes);
377
446
  const body = child(frame, "hk-modal-body");
@@ -384,16 +453,17 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
384
453
  const [x = "50%", y = "50%"] = origin;
385
454
  expect([x, y], state).toEqual(["50%", "50%"]);
386
455
  };
387
- expect_("enter-from", "scale(0.9)");
388
- expect_("leave-to", "scale(0.9)");
389
- expect_("enter-to", "scale(1)");
390
- expect_("leave-from", "scale(1)");
456
+ expect_("enter-from", "scale(1.1, 1)");
457
+ expect_("leave-to", "scale(1.1, 1)");
458
+ expect_("enter-to", "scale(1, 1)");
459
+ expect_("leave-from", "scale(1, 1)");
391
460
  // …and the same with the opt-in draggable flag on, where an
392
461
  // attribute-qualified rule could otherwise slip past the mocks.
393
462
  for (const state of RIDE) {
394
463
  const frame = draggableFrameWith(...state.classes);
395
464
  const body = child(frame, "hk-modal-body");
396
- const expected = state.name === "enter-from" || state.name === "leave-to" ? "scale(0.9)" : "scale(1)";
465
+ const expected =
466
+ state.name === "enter-from" || state.name === "leave-to" ? "scale(1.1, 1)" : "scale(1, 1)";
397
467
  expect(winner(body, "transform", DESKTOP), `draggable ${state.name}`).toBe(expected);
398
468
  }
399
469
  // At rest the body carries no transform at all.
@@ -405,12 +475,9 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
405
475
  const frame = frameWith(...RIDE.find((r) => r.name === state)!.classes);
406
476
  return winner(child(frame, "hk-modal-body"), prop, DESKTOP);
407
477
  };
408
- expect(body("enter-from", "transition")).toContain(
409
- "transform var(--hk-modal-duration, 0.42s) var(--hk-modal-ease-out",
410
- );
411
- expect(body("leave-to", "transition")).toContain(
412
- "transform var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in",
413
- );
478
+ // Whole-value pins, same rationale as the frame transitions above.
479
+ expect(body("enter-from", "transition")).toBe(BODY_ENTER_TRANSITION);
480
+ expect(body("leave-to", "transition")).toBe(BODY_LEAVE_TRANSITION);
414
481
  expect(body("enter-from", "will-change")).toBe("transform");
415
482
  });
416
483
 
@@ -448,16 +515,41 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
448
515
  expect(winner(body, "transition", DESKTOP_REDUCED)).toBe("none");
449
516
  expect(winner(body, "will-change", DESKTOP_REDUCED)).toBe("auto");
450
517
  expect(winner(frame, "transition", DESKTOP_REDUCED)).toBe("none");
518
+ // The frame's height ride flattens too: the ride states fall back to
519
+ // the bare centering translate (no appended scaleY) — same specificity
520
+ // as the desktop state rules, later in the sheet, so the flatten wins
521
+ // by source order. Both ride directions pin (R2 finding 2026-11-06
522
+ // (S3): the body flatten used to pin enter-from only).
523
+ expect(winner(frame, "transform", DESKTOP_REDUCED)).toBe(CENTERING);
524
+ const leaveEnd = frameWith("hk-modal-content-leave-to", "hk-modal-content-leave-active");
525
+ expect(winner(leaveEnd, "transform", DESKTOP_REDUCED)).toBe(CENTERING);
526
+ const leaveBody = child(leaveEnd, "hk-modal-body");
527
+ expect(winner(leaveBody, "transform", DESKTOP_REDUCED)).toBe("none");
528
+ // Phones flatten too: the media-independent reduce block also wins over
529
+ // the ≤767px sheet's translateY(100%) — a reduced-motion phone falls
530
+ // back to the bare translate (one opacity-0 frame hides the flip)
531
+ // instead of a full-height slide flash.
532
+ expect(winner(frame, "transform", MOBILE_REDUCED)).toBe(CENTERING);
533
+ expect(winner(leaveEnd, "transform", MOBILE_REDUCED)).toBe(CENTERING);
451
534
  });
452
535
 
453
536
  it("keeps the mobile sheet's translateY choreography untouched", () => {
454
537
  const frame = frameWith("hk-modal-content-enter-from", "hk-modal-content-enter-active");
455
538
  expect(winner(frame, "transform", MOBILE)).toBe("translateY(100%)");
539
+ // The close direction rides the same downward slide (R2 finding
540
+ // 2026-11-06 (S2): leave-to wasn't pinned — splitting the shared rule
541
+ // and flipping it to translateY(-100%) used to stay green).
542
+ const leaveEnd = frameWith("hk-modal-content-leave-to", "hk-modal-content-leave-active");
543
+ expect(winner(leaveEnd, "transform", MOBILE)).toBe("translateY(100%)");
544
+ // Settled states: no ride transform at either end of the sheet ride.
545
+ expect(winner(frameWith("hk-modal-content-enter-to", "hk-modal-content-enter-active"), "transform", MOBILE)).toBe("none");
546
+ expect(winner(frameWith("hk-modal-content-leave-from", "hk-modal-content-leave-active"), "transform", MOBILE)).toBe("none");
456
547
  const resting = frameWith();
457
548
  expect(winner(resting, "transform", MOBILE)).toBe("none");
458
549
  // No content-region scale in the sheet layout.
459
550
  const body = child(frame, "hk-modal-body");
460
551
  expect(winner(body, "transform", MOBILE)).toBeUndefined();
552
+ expect(winner(child(leaveEnd, "hk-modal-body"), "transform", MOBILE)).toBeUndefined();
461
553
  });
462
554
 
463
555
  it("never lets ANY rule put a transform on a chrome surface", () => {
@@ -480,7 +572,7 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
480
572
 
481
573
  it("lets the ride states declare only the animation properties", () => {
482
574
  // A layout declaration smuggled beside the scale (height/max-width/
483
- // padding) breaks "uniform on both axes" without touching any scale
575
+ // padding) breaks the geometry contract without touching any scale
484
576
  // token, and a `transition-delay` desyncs the surface machine's
485
577
  // measured deadline (it reads duration only). Both must be impossible:
486
578
  // the ride rules may carry nothing but the animation properties.
@@ -491,22 +583,30 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
491
583
  "will-change",
492
584
  "opacity",
493
585
  ]);
494
- const CHROME_ALLOWED = new Set(["opacity", "transition", "will-change"]);
586
+ // Chrome ride states now also carry `transform` (the 2026-11-06 frame
587
+ // height ride) — but nothing else beyond opacity/transition/will-change.
588
+ const CHROME_ALLOWED = new Set(["opacity", "transition", "will-change", "transform"]);
589
+ // The contracted transform values a ride rule may carry. Frame: the
590
+ // three translate+scaleY states (reduced-motion flatten included);
591
+ // body: the two two-arg scales.
592
+ const LEGAL_FRAME = new Set([CENTERING, FRAME_FROM, FRAME_TO]);
593
+ const LEGAL_BODY = new Set(["scale(1.1, 1)", "scale(1, 1)"]);
495
594
  const offenders: string[] = [];
496
595
  // Media-independent on purpose: a ride selector declared at top level
497
596
  // (no media query) applies at every width, so it must obey the same
498
- // allowlist as the desktop block.
597
+ // allowlist as the desktop block. And UNANCHORED on purpose (R2 finding
598
+ // 2026-11-06 (S2): the previous scan matched `^selector$`, so a
599
+ // `[data-draggable] .hk-modal-content-enter-from`-style duplicate —
600
+ // anything the cascade mock cannot match, like `:not()`/type-qualified/
601
+ // pseudo-element rules — sailed past it): ANY rule whose selector
602
+ // carries a ride class must obey the allowlist, wherever it sits.
499
603
  for (const rule of RULES) {
500
604
  // The ≤767px sheet has its own choreography (translateY + height), and
501
605
  // the mobile assertions below cover it; this allowlist is the desktop
502
606
  // contract.
503
607
  if (rule.media.some((m) => m.includes("max-width"))) continue;
504
- const isBodyRide =
505
- /-enter-(from|to|active)\s+\.hk-modal-body$|-leave-(from|to|active)\s+\.hk-modal-body$/.test(
506
- rule.selector,
507
- );
508
- const isChromeRide = /^\.hk-modal-content-(enter|leave)-(from|to|active)$/.test(rule.selector);
509
- if (!isBodyRide && !isChromeRide) continue;
608
+ if (!/hk-modal-content-(enter|leave)-(from|to|active)/.test(rule.selector)) continue;
609
+ const isBodyRide = rule.selector.trim().endsWith(".hk-modal-body");
510
610
  const allowed = isBodyRide ? MATRIX_ALLOWED : CHROME_ALLOWED;
511
611
  for (const prop of rule.declarations.keys()) {
512
612
  if (!allowed.has(prop) && !prop.startsWith("--")) {
@@ -516,20 +616,47 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
516
616
  offenders.push(`${rule.selector} { ${prop} (transition longhand) }`);
517
617
  }
518
618
  }
619
+ // …and any transform such a rule carries must be one of the
620
+ // contracted ride values, verbatim (normalized whitespace only).
621
+ const raw = rule.declarations.get("transform");
622
+ if (raw !== undefined) {
623
+ const value = raw.replace(/\s+/g, " ").trim();
624
+ const legal = isBodyRide ? LEGAL_BODY : LEGAL_FRAME;
625
+ if (!legal.has(value)) {
626
+ offenders.push(`${rule.selector} { transform: ${value} (not a contracted ride value) }`);
627
+ }
628
+ }
519
629
  }
520
630
  expect(offenders, "ride rules must carry only animation properties").toEqual([]);
521
631
  });
522
632
 
523
- it("keeps the scale on both axes (no per-axis / longhand grammar)", () => {
633
+ it("keeps the contracted per-axis grammar (no alternate syntax escapes)", () => {
634
+ // 2026-11-06 grammar: the frame rides `translate(…) scaleY(s)`; the
635
+ // body rides two-arg `scale(x, 1)`. Any other expression of the same
636
+ // idea — single-arg scale on the frame list, per-axis longhand
637
+ // functions, scale3d, a computed matrix, or the independent transform
638
+ // properties — would break the contracted interpolation shape and must
639
+ // be impossible.
640
+ const BODY_ESCAPES = /scale[XYZ]\(|scale3d\(|matrix[23]d?\(/;
641
+ const FRAME_ESCAPES = /scale\([^)]*,|scaleX\(|scale3d\(|matrix[23]d?\(|\brotate\(/;
524
642
  for (const state of RIDE) {
525
643
  const frame = frameWith(...state.classes);
526
644
  const body = child(frame, "hk-modal-body");
527
- const transform = winner(body, "transform", DESKTOP) ?? "";
528
- expect(transform, state.name).not.toMatch(/scale\([^)]*,/); // scale(x, y)
529
- expect(transform, state.name).not.toMatch(/scale[XYZ]\(/);
530
- expect(winner(body, "scale", DESKTOP), state.name).toBeUndefined();
531
- expect(winner(body, "translate", DESKTOP), state.name).toBeUndefined();
532
- expect(winner(body, "zoom", DESKTOP), state.name).toBeUndefined();
645
+ const bodyTransform = winner(body, "transform", DESKTOP) ?? "";
646
+ expect(bodyTransform, `body ${state.name}`).toMatch(/^scale\(([\d.]+), 1\)$/);
647
+ expect(bodyTransform, `body ${state.name}`).not.toMatch(BODY_ESCAPES);
648
+ expect(winner(body, "scale", DESKTOP), `body ${state.name}`).toBeUndefined();
649
+ expect(winner(body, "translate", DESKTOP), `body ${state.name}`).toBeUndefined();
650
+ expect(winner(body, "zoom", DESKTOP), `body ${state.name}`).toBeUndefined();
651
+
652
+ const frameTransform = winner(frame, "transform", DESKTOP) ?? "";
653
+ const centeringRe = CENTERING.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
654
+ expect(frameTransform, `frame ${state.name}`).toMatch(
655
+ new RegExp(`^${centeringRe} scaleY\\((0\\.9|1)\\)$`),
656
+ );
657
+ expect(frameTransform, `frame ${state.name}`).not.toMatch(FRAME_ESCAPES);
658
+ expect(winner(frame, "scale", DESKTOP), `frame ${state.name}`).toBeUndefined();
659
+ expect(winner(frame, "zoom", DESKTOP), `frame ${state.name}`).toBeUndefined();
533
660
  }
534
661
  });
535
662
  });