@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
|
@@ -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-
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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("
|
|
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
|
|
333
|
-
expect(
|
|
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
|
|
340
|
-
// A frame-level `scale:` / `zoom:` / `translate:`
|
|
341
|
-
//
|
|
342
|
-
//
|
|
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
|
|
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).
|
|
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).
|
|
371
|
-
|
|
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("
|
|
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(
|
|
388
|
-
expect_("leave-to", "scale(
|
|
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 =
|
|
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
|
-
|
|
409
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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
|
|
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
|
|
528
|
-
expect(
|
|
529
|
-
expect(
|
|
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
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
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-
|
|
103
|
-
// 2026-11-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
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
|
|
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
|
|
123
|
-
// box IS the frame at every t, so nothing
|
|
124
|
-
// 2026-09-14 clip unveil — and the #508/#512/#524
|
|
125
|
-
// stays retired). All compositor-level: zero layout
|
|
126
|
-
// centering translate lives on the base .hk-modal-content
|
|
127
|
-
// frame never re-seats mid-flight
|
|
128
|
-
//
|
|
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
|
|
134
|
-
//
|
|
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
|
-
//
|
|
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
|
|
163
|
-
// per-state transition so the enter and the leave can
|
|
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(
|
|
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.
|