@celestia-island/hikari 0.55.302 → 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.302",
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",
@@ -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
  });
@@ -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 WHOLE transform: no
61
- // enter/leave state carries a transform any more (the open/close scales
62
- // the content region instead — see the choreography block below), so
63
- // nothing can re-seat the frame mid-flight and dragged windows keep
64
- // their shift.
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.
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,69 +99,96 @@
99
99
  // ------
100
100
  // Content enter/leave transitions (desktop ≥768px)
101
101
  //
102
- // Default window open/close (user direction 2026-11-05, superseding the
103
- // 2026-11-04 whole-frame reveal): what scales is the CONTENT REGION
104
- // only. The frame, the title bar and the footer action row are chrome —
105
- // they materialize at their final size on one opacity fade, because
106
- // scaling them squashed title/button text and the corner radii while
107
- // the eye was still reading them. `.hk-modal-body` is the content
108
- // region and the ONLY scaled surface: it settles from 90% to 100% of
109
- // its own box about its center, with ONE factor on BOTH axes
110
- // (`scale(0.9)` on both axes — the 2026-11-04 single-axis
111
- // `scale(1, 0.9)` grammar is retired because X stays uniform, so text,
112
- // radii and shadows inside the body are never stretched along one
113
- // axis). Header and footer are SIBLINGS of the body, never
114
- // descendants, so no transform they inherit can reach them.
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.
115
+ //
116
+ // The content region keeps a ride of its own: `.hk-modal-body` settles
117
+ // `scale(1.1, 1) → scale(1, 1)` about its center. The horizontal factor
118
+ // deliberately OVERSHOOTS past 1 (user direction 2026-11-06: at the
119
+ // open/close extremes the content is WIDER than the window — the frame's
120
+ // `overflow: hidden` clips the overhang at the window edges, reading as
121
+ // a press-in from both sides). The vertical factor stays exactly 1: the
122
+ // body's Y dimension already rides the frame's `scaleY`, so its own 1
123
+ // makes the compound 0.9 → 1 — the same vertical ride as before, never
124
+ // the doubled 0.81 a second Y factor would produce.
115
125
  //
116
126
  // Hosts retune the ride through the `--hk-modal-*` tokens (duration /
117
127
  // easing) or the body padding vars — NOT by setting `transform` on
118
- // `.hk-modal-body`: these state rules match at (0,2,0), so a host's
128
+ // `.hk-modal-body` or the frame: the state rules pair matched function
129
+ // lists (`translate+scaleY` on the frame, two-arg `scale` on the body)
130
+ // so both ends of every interpolation ride the same shape, and a host's
119
131
  // single-class transform would be overridden mid-ride and again under
120
132
  // reduced motion.
121
133
  //
122
- // No clip-path and no Y offset ride the open/close either: the visible
123
- // box IS the frame at every t, so nothing is ever sliced off (the
124
- // 2026-09-14 clip unveil — and the #508/#512/#524 lineage before it —
125
- // stays retired). All compositor-level: zero layout per frame. The
126
- // centering translate lives on the base .hk-modal-content rule, so the
127
- // frame never re-seats mid-flight and dragged windows keep their shift
128
- // through the whole ride. Scoped to ≥768px; ≤767px keeps the sheet's
129
- // translateY choreography below.
134
+ // No clip-path and no Y offset ride the open/close: besides the deliberate
135
+ // X overshoot above, the visible box IS the frame at every t, so nothing
136
+ // is ever sliced off (the 2026-09-14 clip unveil — and the #508/#512/#524
137
+ // lineage before it — stays retired). All compositor-level: zero layout
138
+ // per frame. The centering translate lives on the base .hk-modal-content
139
+ // rule, so the frame never re-seats mid-flight. Scoped to ≥768px; ≤767px
140
+ // keeps the sheet's translateY choreography below.
130
141
  // ------
131
142
 
132
143
  @media (min-width: 768px) {
133
- // Chrome: fade only. No transform state touches the frame here — the
134
- // frame's transform is the static centering translate from its base
135
- // rule, and that is the whole list.
144
+ // Chrome: opacity fade + the frame's height ride, on one shared token
145
+ // pair so the window's growth lands exactly with its fade.
136
146
  .hk-modal-content-enter-active {
137
147
  transition:
138
148
  opacity var(--hk-modal-duration, 0.42s)
149
+ var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
150
+ transform var(--hk-modal-duration, 0.42s)
139
151
  var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
140
- will-change: opacity;
152
+ will-change: opacity, transform;
141
153
  }
142
154
 
143
- // Close: the reveal's TRUE reverse — same chrome fade, while the
144
- // content settles back toward 90% and the window dissolves.
155
+ // Close: the reveal's TRUE reverse — same chrome fade, while the window
156
+ // settles back toward 90% height and the content widens back out.
145
157
  .hk-modal-content-leave-active {
146
158
  transition:
147
159
  opacity var(--hk-modal-duration, 0.32s)
160
+ var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
161
+ transform var(--hk-modal-duration, 0.32s)
148
162
  var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
149
- will-change: opacity;
163
+ will-change: opacity, transform;
150
164
  }
151
165
 
166
+ // The translate prefix is byte-identical to the base rule; only the
167
+ // appended scaleY changes. scaleY(1) at the settled end — never `none`
168
+ // — keeps both ends of the interpolation on the same function shape.
152
169
  .hk-modal-content-enter-from,
153
170
  .hk-modal-content-leave-to {
154
171
  opacity: 0;
172
+ transform: translate(
173
+ calc(-50% + var(--hk-modal-shift-x, 0px)),
174
+ calc(-50% + var(--hk-modal-shift-y, 0px))
175
+ )
176
+ scaleY(0.9);
155
177
  }
156
178
 
157
179
  .hk-modal-content-enter-to,
158
180
  .hk-modal-content-leave-from {
159
181
  opacity: 1;
182
+ transform: translate(
183
+ calc(-50% + var(--hk-modal-shift-x, 0px)),
184
+ calc(-50% + var(--hk-modal-shift-y, 0px))
185
+ )
186
+ scaleY(1);
160
187
  }
161
188
 
162
- // Content region: the one scaled surface. The timing lives on the
163
- // per-state transition so the enter and the leave can differ (0.42s
164
- // open / 0.32s close) exactly like the chrome fade does.
189
+ // Content region: the one surface with its own transform. The timing
190
+ // lives on the per-state transition so the enter and the leave can
191
+ // differ (0.42s open / 0.32s close) exactly like the chrome fade does.
165
192
  .hk-modal-content-enter-active .hk-modal-body {
166
193
  transform-origin: 50% 50%;
167
194
  transition:
@@ -178,17 +205,16 @@
178
205
  will-change: transform;
179
206
  }
180
207
 
208
+ // Both ends two-arg `scale`: X overshoots 1.1 → 1, Y stays 1 (the body's
209
+ // vertical ride comes from the frame's scaleY, not from itself).
181
210
  .hk-modal-content-enter-from .hk-modal-body,
182
211
  .hk-modal-content-leave-to .hk-modal-body {
183
- transform: scale(0.9);
212
+ transform: scale(1.1, 1);
184
213
  }
185
214
 
186
- // The resting ends are explicit scale(1) — never `transform: none` —
187
- // so both ends of the interpolation carry the same function shape and
188
- // the ride is a plain two-point scale.
189
215
  .hk-modal-content-enter-to .hk-modal-body,
190
216
  .hk-modal-content-leave-from .hk-modal-body {
191
- transform: scale(1);
217
+ transform: scale(1, 1);
192
218
  }
193
219
  }
194
220
 
@@ -667,6 +693,17 @@
667
693
  .hk-modal-content {
668
694
  transition: none;
669
695
  }
696
+ // The frame's height ride flattens with it: the ride states fall back to
697
+ // the bare centering translate (same specificity as the desktop state
698
+ // rules above, later in the sheet, so the flattened state wins by source
699
+ // order — one painted frame at opacity 0 hides the flip).
700
+ .hk-modal-content-enter-from,
701
+ .hk-modal-content-leave-to {
702
+ transform: translate(
703
+ calc(-50% + var(--hk-modal-shift-x, 0px)),
704
+ calc(-50% + var(--hk-modal-shift-y, 0px))
705
+ );
706
+ }
670
707
  // The content-scale reveal flattens with it. Both selectors sit at the
671
708
  // same specificity as the desktop pairs above and this block comes
672
709
  // later in the sheet, so the flattened state wins by source order.