@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 +2 -1
- package/src/components/HkAlert.scss +8 -24
- package/src/components/HkAlert.tsx +10 -4
- package/src/components/HkCardList.test.tsx +28 -1
- package/src/components/HkCardList.tsx +12 -13
- package/src/components/HkModal.open-close-scale.test.ts +175 -48
- package/src/components/HkModal.scss +79 -42
- package/src/components/HkModelCard.tsx +1 -18
- package/src/components/HkPersistentToast.scss +5 -18
- package/src/components/HkPersistentToast.tsx +7 -4
- package/src/components/HkToast.scss +11 -30
- package/src/components/HkToast.tsx +16 -10
- package/src/components/closeFamily.contract.test.tsx +120 -0
- package/src/plugins/boot-landing.scss +53 -0
- package/src/plugins/boot-recovery.d.mts +26 -10
- package/src/plugins/boot-recovery.mjs +204 -83
- package/src/plugins/boot-recovery.test.ts +141 -42
- package/src/plugins/bootLandingCss.d.mts +10 -0
- package/src/plugins/bootLandingCss.mjs +14 -0
- package/src/plugins/bootLandingCss.test.ts +126 -0
- package/src/plugins/index.ts +1 -0
- package/src/runtime/hasRenderedContent.test.ts +58 -0
- package/src/runtime/hasRenderedContent.ts +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celestia-island/hikari",
|
|
3
|
-
"version": "0.55.
|
|
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
|
-
.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
+
size={24}
|
|
84
|
+
variant="ghost"
|
|
79
85
|
aria-label={t("hikari::alert.close", "Close")}
|
|
80
86
|
onClick={onClose}
|
|
81
87
|
>
|
|
82
|
-
<X
|
|
83
|
-
</
|
|
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 {
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
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-
|
|
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
|
});
|