@celestia-island/hikari 0.55.276 → 0.55.277

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.276",
3
+ "version": "0.55.277",
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",
@@ -201,7 +201,9 @@ describe("HkButton icon-only square contract", () => {
201
201
  // over the square width. Textual assertions because happy-dom does not
202
202
  // lay out.
203
203
  describe("HkButton icon-only SCSS contract", () => {
204
- const scss = readFileSync(join(here, "HkButton.scss"), "utf-8");
204
+ // autocrlf checkouts write CRLF to disk; normalize so the LF-joined
205
+ // selector anchor below matches what git stores regardless of platform.
206
+ const scss = readFileSync(join(here, "HkButton.scss"), "utf-8").replace(/\r\n/g, "\n");
205
207
 
206
208
  it("self-declares the collapsed square geometry", () => {
207
209
  const rule = scss.match(/\.hk-btn-icon-only\s*{[^}]*}/)?.[0] ?? "";
@@ -310,7 +312,8 @@ describe("HkButton label prop + responsive collapse contract", () => {
310
312
  // useBreakpoint px scale (sm 640 / md 768 / lg 1024 / xl 1280), and must
311
313
  // land on the exact icon-only square width of its size.
312
314
  describe("HkButton label-collapse SCSS contract", () => {
313
- const scss = readFileSync(join(here, "HkButton.scss"), "utf-8");
315
+ // Normalized to LF (autocrlf checkouts) — see the icon-only contract above.
316
+ const scss = readFileSync(join(here, "HkButton.scss"), "utf-8").replace(/\r\n/g, "\n");
314
317
 
315
318
  const breakpoints: Array<[string, string]> = [
316
319
  ["sm", "639.98px"],
@@ -13,7 +13,9 @@ import { fileURLToPath } from "node:url";
13
13
  * the normalization fixed.
14
14
  */
15
15
  const here = dirname(fileURLToPath(import.meta.url));
16
- const scss = readFileSync(join(here, "HkJsonTree.scss"), "utf-8");
16
+ // autocrlf checkouts write CRLF to disk; normalize so the LF-joined
17
+ // selector anchor below matches what git stores regardless of platform.
18
+ const scss = readFileSync(join(here, "HkJsonTree.scss"), "utf-8").replace(/\r\n/g, "\n");
17
19
 
18
20
  describe("HkJsonTree spacing contract", () => {
19
21
  it("keeps the toggle exactly one spacing unit from the row text", () => {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Source contract for the DESKTOP modal open/close choreography (user
3
+ * direction 2026-11-04): the window grows by scaling the content itself
4
+ * about the frame's center — width pinned at 100% (scaleX 1), height
5
+ * scaling 90% → 100% — replacing the 2026-09-14 clip unveil (and the
6
+ * #508/#512/#524 lineage before it). The old "content rides rigidly,
7
+ * never squashed" invariant was DELIBERATELY dropped: the rigid ride is
8
+ * exactly what made the unveil read as dead weight on ordinary modals.
9
+ *
10
+ * Pinned on the stylesheet SOURCE (computed animation geometry is not
11
+ * observable in happy-dom), mirroring the sheetmorph / tokenHygiene
12
+ * guard pattern.
13
+ */
14
+ import { describe, expect, it } from "vitest";
15
+ import { readFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const src = readFileSync(join(here, "HkModal.scss"), "utf-8");
21
+
22
+ /** The desktop choreography block — the mobile sheet block (≤767px)
23
+ * carries its own enter/leave classes and must not leak in. */
24
+ const desktop = src.slice(
25
+ src.indexOf("@media (min-width: 768px)"),
26
+ src.indexOf("@media (max-width: 767px)"),
27
+ );
28
+
29
+ /** The mobile sheet block — sliced the same way, for the parity guard. */
30
+ const mobile = src.slice(src.indexOf("@media (max-width: 767px)"));
31
+
32
+ /** Body of one rule (selector possibly grouped / multi-line). Line
33
+ * comments are stripped so prose in the sheet cannot satisfy or break
34
+ * a structural assertion. */
35
+ function bodyOf(selector: string, scope: string = desktop): string {
36
+ const escaped = selector.replace(/\./g, "\\.");
37
+ const match = scope.match(new RegExp(`${escaped}[^{]*\\{([^}]*)\\}`));
38
+ expect(match, `rule ${selector} must exist in scope`).toBeTruthy();
39
+ return match![1]
40
+ .replace(/\/\/[^\n]*/g, "")
41
+ .replace(/\s+/g, " ")
42
+ .trim();
43
+ }
44
+
45
+ const ENTER_LEAVE = [
46
+ ".hk-modal-content-enter-active",
47
+ ".hk-modal-content-leave-active",
48
+ ".hk-modal-content-enter-from",
49
+ ".hk-modal-content-enter-to",
50
+ ".hk-modal-content-leave-from",
51
+ ".hk-modal-content-leave-to",
52
+ ] as const;
53
+
54
+ /** The centering translate: every transform state keeps it as the
55
+ * leading term, so the frame never re-seats mid-flight (dragged windows
56
+ * keep their shift through the animation). */
57
+ const CENTERING =
58
+ /translate\(\s*calc\(-50% \+ var\(--hk-modal-shift-x, 0px\)\),\s*calc\(-50% \+ var\(--hk-modal-shift-y, 0px\)\)\s*\)/;
59
+
60
+ describe("HkModal desktop content-scale open/close contract (user direction 2026-11-04)", () => {
61
+ it("scales the content from 90% height about the center on enter", () => {
62
+ const from = bodyOf(".hk-modal-content-enter-from");
63
+ expect(from).toMatch(CENTERING);
64
+ expect(from).toMatch(/scale\(1, 0\.9\)/);
65
+ // scaleX stays 1 — the width is pinned at 100% for the whole ride.
66
+ expect(from).not.toMatch(/scale\(0\.9\)/);
67
+ expect(from).toMatch(/opacity:\s*0/);
68
+ });
69
+
70
+ it("collapses back to 90% height on leave (the reveal's true reverse)", () => {
71
+ const to = bodyOf(".hk-modal-content-leave-to");
72
+ expect(to).toMatch(CENTERING);
73
+ expect(to).toMatch(/scale\(1, 0\.9\)/);
74
+ expect(to).toMatch(/opacity:\s*0/);
75
+ const from = bodyOf(".hk-modal-content-leave-from");
76
+ expect(from).toMatch(CENTERING);
77
+ expect(from).toMatch(/opacity:\s*1/);
78
+ });
79
+
80
+ it("rests at the identity transform (no scale, no offset) at both ends", () => {
81
+ const enterTo = bodyOf(".hk-modal-content-enter-to");
82
+ expect(enterTo).toMatch(CENTERING);
83
+ // The identity state carries NO trailing transform term — the
84
+ // scale-to-1 end of the interpolation is a bare translate, so the
85
+ // transform lists on both ends share one shape and interpolate
86
+ // continuously (the clip-era "pin the identity clip" workaround is
87
+ // structurally unnecessary now).
88
+ expect(enterTo).toMatch(new RegExp(`transform:\\s*${CENTERING.source};`));
89
+ expect(enterTo).not.toMatch(/scale\(|translate[XY]\(/);
90
+ });
91
+
92
+ it("anchors the scale at the frame's center for the whole ride", () => {
93
+ for (const active of [
94
+ ".hk-modal-content-enter-active",
95
+ ".hk-modal-content-leave-active",
96
+ ]) {
97
+ expect(bodyOf(active)).toMatch(/transform-origin:\s*50% 50%/);
98
+ }
99
+ });
100
+
101
+ it("retires the clip unveil: no clip-path and no Y drift in any open/close rule", () => {
102
+ // History: the 2026-09-14 unveil rode translateY(5%) + inset() clip
103
+ // (with the #508 identity-clip fix). The scale grammar replaces the
104
+ // whole mechanism — any reappearance is a regression to the retired
105
+ // choreography.
106
+ for (const rule of ENTER_LEAVE) {
107
+ const body = bodyOf(rule);
108
+ expect(body, `${rule} must not clip`).not.toMatch(/clip-path/);
109
+ expect(body, `${rule} must not drift`).not.toMatch(/translateY\(/);
110
+ }
111
+ });
112
+
113
+ it("keeps the ride animated on the timing tokens (never a snap)", () => {
114
+ // The retired close-collapse contract pinned "transitions clip-path
115
+ // on leave (the collapse animates, not snaps)"; its replacement must
116
+ // not be weaker on that axis. Deleting the transition blocks (or
117
+ // hardcoding their durations away from the family tokens) must turn
118
+ // this red — computed animation is unobservable in happy-dom, so
119
+ // this source pin is the only CI guard for "it animates at all".
120
+ const enter = bodyOf(".hk-modal-content-enter-active");
121
+ expect(enter).toMatch(
122
+ /opacity var\(--hk-modal-duration, 0\.42s\) var\(--hk-modal-ease-out, cubic-bezier\(0\.33, 1, 0\.68, 1\)\)/,
123
+ );
124
+ expect(enter).toMatch(
125
+ /transform var\(--hk-modal-duration, 0\.42s\) var\(--hk-modal-ease-out, cubic-bezier\(0\.33, 1, 0\.68, 1\)\)/,
126
+ );
127
+ expect(enter).not.toMatch(/clip-path/);
128
+
129
+ const leave = bodyOf(".hk-modal-content-leave-active");
130
+ expect(leave).toMatch(
131
+ /opacity var\(--hk-modal-duration, 0\.32s\) var\(--hk-modal-ease-in, cubic-bezier\(0\.45, 0, 0\.55, 1\)\)/,
132
+ );
133
+ expect(leave).toMatch(
134
+ /transform var\(--hk-modal-duration, 0\.32s\) var\(--hk-modal-ease-in, cubic-bezier\(0\.45, 0, 0\.55, 1\)\)/,
135
+ );
136
+ expect(leave).not.toMatch(/clip-path/);
137
+
138
+ for (const active of [
139
+ ".hk-modal-content-enter-active",
140
+ ".hk-modal-content-leave-active",
141
+ ]) {
142
+ expect(bodyOf(active)).toMatch(/will-change: transform, opacity/);
143
+ }
144
+ });
145
+
146
+ it("keeps the mobile sheet's translateY choreography untouched", () => {
147
+ const from = bodyOf(".hk-modal-content-enter-from", mobile);
148
+ expect(from).toMatch(/translateY\(100%\)/);
149
+ });
150
+ });
@@ -97,56 +97,46 @@
97
97
  // ------
98
98
  // Content enter/leave transitions (desktop ≥768px)
99
99
  //
100
- // Height-style unveil WITHOUT layout: the visible box starts at 90% of
101
- // its height and grows to 100% while the frame settles from a +5% offset
102
- // back to rest (user spec 2026-09-14). Numbers line up so the character
103
- // of the old height-grow reveal is preserved exactly:
104
- // t=0 box [center−0.45h, center+0.45h] shifted +0.05h, bottom 10%
105
- // clipped → visible [center−0.40h, center+0.40h] (the old 80%
106
- // centered start);
107
- // t=1 clip 0, offset 0 → visible [center−0.50h, center+0.50h];
108
- // the bottom edge is pinned at its final line the whole time and the
109
- // top edge sweeps up 0.1h — content rides rigidly (never squashed).
110
- // Implemented with transform (translate) + clip-path (inset) + opacity:
111
- // all compositor/paint-level, zero layout per frame — the previous
112
- // height-keyword reveal re-laid-out the entire modal subtree every frame
113
- // and re-filtered the frame's own backdrop blur per frame (visible frame
114
- // drops on content-heavy modals, chest history topic detail). Scoped to
115
- // ≥768px; ≤767px keeps the sheet's translateY choreography below.
100
+ // Content-scale reveal (user direction 2026-11-04): the frame grows by
101
+ // SCALING the content itself about the frame's center — the width stays
102
+ // pinned at 100% the whole time while the height scales from 90% up to
103
+ // 100%. No clip-path and no Y offset ride the open/close any more: the
104
+ // visible box IS the frame at every t, so nothing is ever sliced off
105
+ // (the 2026-09-14 clip unveil — and the #508/#512/#524 lineage before
106
+ // it — is superseded; the "content rides rigidly" invariant was the
107
+ // very thing that made the old reveal read as dead weight on ordinary
108
+ // modals). Implemented with transform (translate + uniform-axis scale)
109
+ // + opacity: all compositor-level, zero layout per frame, and the
110
+ // centering translate stays the leading term of EVERY transform state
111
+ // so the frame never re-seats mid-flight (dragged windows keep their
112
+ // shift). Scoped to ≥768px; ≤767px keeps the sheet's translateY
113
+ // choreography below.
116
114
  // ------
117
115
 
118
116
  @media (min-width: 768px) {
119
117
  .hk-modal-content-enter-active {
118
+ // The scale anchors at the frame's center: the window expands from
119
+ // the middle outward on the Y axis only (X is pinned by scaleX 1).
120
+ transform-origin: 50% 50%;
120
121
  transition:
121
122
  opacity var(--hk-modal-duration, 0.42s)
122
123
  var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
123
124
  transform var(--hk-modal-duration, 0.42s)
124
- var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
125
- clip-path var(--hk-modal-duration, 0.42s)
126
125
  var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
127
- will-change: transform, opacity, clip-path;
126
+ will-change: transform, opacity;
128
127
  }
129
128
 
130
- // Close: the unveil's TRUE reverse — the visible box collapses back
131
- // toward the center (top edge sweeps down, bottom edge sweeps up,
132
- // content rides rigidly — never squashed) while the frame fades
133
- // (user report 2026-09-15: the fade+drift close read as the box
134
- // shrinking straight down instead of folding back into its center).
135
- // The earlier re-clip attempt (#508) broke because leave-from had no
136
- // clip at all: interpolating `none → inset()` is DISCRETE, so the
137
- // bottom band (the footer) vanished at the first frame. Pinning the
138
- // identity clip in leave-from — byte-identical to enter-to — makes
139
- // the same-shape insets interpolate continuously, which structurally
140
- // rules that jump out while restoring the symmetric collapse.
129
+ // Close: the reveal's TRUE reverse — the frame scales back down to
130
+ // 90% height about its center (both vertical edges ease toward the
131
+ // center line) while fading out.
141
132
  .hk-modal-content-leave-active {
133
+ transform-origin: 50% 50%;
142
134
  transition:
143
135
  opacity var(--hk-modal-duration, 0.32s)
144
136
  var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
145
137
  transform var(--hk-modal-duration, 0.32s)
146
- var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
147
- clip-path var(--hk-modal-duration, 0.32s)
148
138
  var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
149
- will-change: transform, opacity, clip-path;
139
+ will-change: transform, opacity;
150
140
  }
151
141
 
152
142
  .hk-modal-content-enter-from {
@@ -155,9 +145,7 @@
155
145
  calc(-50% + var(--hk-modal-shift-x, 0px)),
156
146
  calc(-50% + var(--hk-modal-shift-y, 0px))
157
147
  )
158
- translateY(5%);
159
- clip-path: inset(0 0 10% 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
160
- var(--hk-modal-radius, var(--radius-lg, 12px)));
148
+ scale(1, 0.9);
161
149
  }
162
150
 
163
151
  .hk-modal-content-enter-to {
@@ -166,8 +154,6 @@
166
154
  calc(-50% + var(--hk-modal-shift-x, 0px)),
167
155
  calc(-50% + var(--hk-modal-shift-y, 0px))
168
156
  );
169
- clip-path: inset(0 0 0 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
170
- var(--hk-modal-radius, var(--radius-lg, 12px)));
171
157
  }
172
158
 
173
159
  .hk-modal-content-leave-from {
@@ -176,24 +162,20 @@
176
162
  calc(-50% + var(--hk-modal-shift-x, 0px)),
177
163
  calc(-50% + var(--hk-modal-shift-y, 0px))
178
164
  );
179
- // The pinned identity clip: identical to enter-to so the leave's
180
- // clip interpolates (never jumps) — see the close note above.
181
- clip-path: inset(0 0 0 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
182
- var(--hk-modal-radius, var(--radius-lg, 12px)));
183
165
  }
184
166
 
185
167
  .hk-modal-content-leave-to {
186
168
  opacity: 0;
187
- // Mirror of enter-from: shifted +5% with the bottom 10% clipped, the
188
- // visible box is 90% and centered — both edges ease toward the
189
- // center line, exactly reversing the unveil.
169
+ // Mirror of enter-from. No identity pin is needed any more: CSS
170
+ // transform-list interpolation pads the shorter list with identity
171
+ // scale(1, 1), so translate()+scale(1, 0.9) ↔ bare translate()
172
+ // interpolates continuously (unlike `none → inset()` clips, which
173
+ // are DISCRETE — the #508 lesson does not apply to transforms).
190
174
  transform: translate(
191
175
  calc(-50% + var(--hk-modal-shift-x, 0px)),
192
176
  calc(-50% + var(--hk-modal-shift-y, 0px))
193
177
  )
194
- translateY(5%);
195
- clip-path: inset(0 0 10% 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
196
- var(--hk-modal-radius, var(--radius-lg, 12px)));
178
+ scale(1, 0.9);
197
179
  }
198
180
  }
199
181
 
@@ -588,7 +588,7 @@ export default defineComponent({
588
588
  // Flush the growth deferred through the unfold. One tick later
589
589
  // the transition classes are off the frame, so the base height
590
590
  // transition animates the pin change instead of the enter's
591
- // transform/clip-only transition list swallowing it as a snap.
591
+ // opacity/transform-only transition list swallowing it as a snap.
592
592
  void nextTick(() => {
593
593
  if (machine.phase.value === "open") morph.remeasure();
594
594
  });
@@ -260,7 +260,9 @@ describe("settings dialog SCSS host contract (source-level)", () => {
260
260
  // depends on.
261
261
  // vitest serves modules through its own scheme, so import.meta.url is
262
262
  // not a file URL here; the suite's cwd is packages/vue.
263
- const scss = readFileSync("src/components/HkSettingsDialog.scss", "utf8");
263
+ // autocrlf checkouts write CRLF to disk; normalize so the LF-joined
264
+ // anchors below match what git stores regardless of platform.
265
+ const scss = readFileSync("src/components/HkSettingsDialog.scss", "utf8").replace(/\r\n/g, "\n");
264
266
 
265
267
  it("self-check: the extractor sees a rule that exists", () => {
266
268
  // The shell rules are BEM-nested in the source (&__rail-item inside
@@ -508,7 +510,8 @@ describe("phone shell (rail → top tab strip, 2026-10-28 user direction)", () =
508
510
  // assertions read INSIDE the &[data-phone] block — several of these
509
511
  // declarations also exist elsewhere in the sheet, and a bare
510
512
  // toContain would keep passing after the phone rule dies (R2).
511
- const scss = readFileSync("src/components/HkSettingsDialog.scss", "utf8");
513
+ // Normalized to LF (autocrlf checkouts) — see the host contract above.
514
+ const scss = readFileSync("src/components/HkSettingsDialog.scss", "utf8").replace(/\r\n/g, "\n");
512
515
  const phoneBlock = scss.slice(
513
516
  scss.indexOf("&[data-phone] {"),
514
517
  scss.indexOf("Dialog host"),
@@ -137,14 +137,24 @@ describe("static sweep — literal var() presentation attributes must ride an in
137
137
  void violations;
138
138
  });
139
139
 
140
- it("every painted component file is clean", async () => {
141
- const files = (await readdir(dir)).filter((f) => /^Hk.+\.tsx$/.test(f) && !f.includes(".test."));
142
- const offenders: string[] = [];
143
- for (const f of files) {
144
- offenders.push(...(await violations(f)));
145
- }
146
- expect(offenders).toEqual([]);
147
- });
140
+ // The sweep reads ~200 painter files before it can judge anything; on a
141
+ // starved runner (the CI worker contention that masked hikari#1058) those
142
+ // sequential reads once bled past the default 5s test timeout, which
143
+ // reads as a flake and proves nothing. The gate is about *what* the
144
+ // files contain, never about how fast they read. (Same reasoning as
145
+ // theme/wallpaperSsr.test.ts's IMPORT_TIMEOUT_MS.)
146
+ const SWEEP_TIMEOUT_MS = 30_000;
147
+
148
+ it(
149
+ "every painted component file is clean",
150
+ async () => {
151
+ const files = (await readdir(dir)).filter((f) => /^Hk.+\.tsx$/.test(f) && !f.includes(".test."));
152
+ // Parallel reads: one I/O round trip instead of ~200 awaited ones.
153
+ const offenders = (await Promise.all(files.map((f) => violations(f)))).flat();
154
+ expect(offenders).toEqual([]);
155
+ },
156
+ SWEEP_TIMEOUT_MS,
157
+ );
148
158
  });
149
159
 
150
160
  describe("index export surface smoke", () => {
@@ -32,7 +32,10 @@ import { describe, expect, it } from "vitest";
32
32
  */
33
33
 
34
34
  const here = dirname(fileURLToPath(import.meta.url));
35
- const read = (rel: string): string => readFileSync(join(here, rel), "utf-8");
35
+ // autocrlf checkouts write CRLF to disk; normalize so the LF-joined
36
+ // selector anchor below matches what git stores regardless of platform.
37
+ const read = (rel: string): string =>
38
+ readFileSync(join(here, rel), "utf-8").replace(/\r\n/g, "\n");
36
39
 
37
40
  const sheet = read("./HkWallpaperBackdrop.scss");
38
41
  const tsx = read("./HkWallpaperBackdrop.tsx");
@@ -0,0 +1,41 @@
1
+ import { useSSRContext } from "vue";
2
+
3
+ /**
4
+ * Real-file stand-in for @vitejs/plugin-vue-jsx's SSR virtual module.
5
+ *
6
+ * When the plugin SSR-transforms a component that registers JSX components,
7
+ * it appends `import { ssrRegisterHelper } from "/__vue-jsx-ssr-register-helper"`
8
+ * and serves that id as a VIRTUAL module. Vitest fetches the virtual module
9
+ * itself, but the module runner derives the module's `import.meta.url` from
10
+ * the id alone — a rootless `file:///__vue-jsx-ssr-register-helper` — and
11
+ * vitest's evaluator builds the module context with a plain Node
12
+ * `createRequire(url)`. Windows rejects a file URL without a drive letter
13
+ * (ERR_INVALID_ARG_VALUE), so every `@vitest-environment node` suite that
14
+ * imports a .tsx component died before running a single test (hikari#1058).
15
+ *
16
+ * The alias in vite.config.ts points that id HERE, so the runner sees a real
17
+ * file path and `createRequire` gets a normal, absolute file URL. The body
18
+ * mirrors the plugin's virtual module — it wraps `setup` so a server render
19
+ * records the component's module into `ssrContext.modules` — keep the two
20
+ * in sync when upgrading the plugin.
21
+ */
22
+
23
+ /** A component options object the plugin's SSR transform can rewire. */
24
+ interface SsrRegisterableComponent {
25
+ setup?: (props: unknown, ctx: unknown) => unknown;
26
+ }
27
+
28
+ export function ssrRegisterHelper(
29
+ comp: SsrRegisterableComponent,
30
+ filename: string,
31
+ ): void {
32
+ const setup = comp.setup;
33
+ comp.setup = (props, ctx) => {
34
+ const ssrContext = useSSRContext();
35
+ if (ssrContext) {
36
+ const modules = ssrContext.modules ?? (ssrContext.modules = new Set());
37
+ modules.add(filename);
38
+ }
39
+ if (setup) return setup(props, ctx);
40
+ };
41
+ }
@@ -1,79 +0,0 @@
1
- /**
2
- * Source contract for the DESKTOP modal close choreography (user report
3
- * 2026-09-15): the unveil expands the visible box outward from the
4
- * center (bottom-10% clip + +5% offset at t=0, full box at t=1), but the
5
- * close had degenerated into a fade + downward drift — the box read as
6
- * shrinking straight down instead of folding back into its center.
7
- *
8
- * The close now runs the unveil's TRUE reverse, which is only safe
9
- * because the leave pins an identity clip: interpolating
10
- * `none → inset()` is DISCRETE (the #508 regression — the footer band
11
- * vanished at the first frame), while identical `inset → inset` shape
12
- * functions interpolate continuously.
13
- *
14
- * Pinned on the stylesheet SOURCE (computed animation geometry is not
15
- * observable in happy-dom), mirroring the themeToggleChrome /
16
- * tokenHygiene guard pattern.
17
- */
18
- import { describe, expect, it } from "vitest";
19
- import { readFileSync } from "node:fs";
20
- import { dirname, join } from "node:path";
21
- import { fileURLToPath } from "node:url";
22
-
23
- const here = dirname(fileURLToPath(import.meta.url));
24
- const src = readFileSync(join(here, "HkModal.scss"), "utf-8");
25
-
26
- /** The desktop choreography block — the mobile sheet block (≤767px)
27
- * carries its own enter/leave classes and must not leak in. */
28
- const desktop = src.slice(
29
- src.indexOf("@media (min-width: 768px)"),
30
- src.indexOf("@media (max-width: 767px)"),
31
- );
32
-
33
- /** Body of one flat rule (no nested braces in this block). */
34
- function bodyOf(selector: string): string {
35
- const escaped = selector.replace(/\./g, "\\.");
36
- const match = desktop.match(new RegExp(`${escaped}\\s*{([^}]*)}`));
37
- expect(match, `rule ${selector} must exist in the desktop block`).toBeTruthy();
38
- return match![1].replace(/\s+/g, " ").trim();
39
- }
40
-
41
- function clipOf(selector: string): string {
42
- const body = bodyOf(selector);
43
- const match = body.match(/clip-path:\s*([^;]+);?/);
44
- expect(match, `${selector} must declare a clip-path`).toBeTruthy();
45
- return match![1].replace(/\s+/g, " ").trim();
46
- }
47
-
48
- describe("HkModal desktop close-collapse contract (user report 2026-09-15)", () => {
49
- it("pins the identity clip in leave-from, identical to enter-to (no discrete clip jump)", () => {
50
- // The #508 regression guard: leave-from WITHOUT a clip makes
51
- // none → inset() interpolate discretely and the footer band vanish
52
- // at the first frame of the close.
53
- expect(clipOf(".hk-modal-content-leave-from")).toBe(
54
- clipOf(".hk-modal-content-enter-to"),
55
- );
56
- });
57
-
58
- it("mirrors the unveil geometry: leave-to clips like enter-from", () => {
59
- // Bottom-10% clip + same rounding on both ends of the mirror — the
60
- // visible box collapses symmetrically back to 90% centered.
61
- expect(clipOf(".hk-modal-content-leave-to")).toBe(
62
- clipOf(".hk-modal-content-enter-from"),
63
- );
64
- });
65
-
66
- it("transitions clip-path on leave (the collapse animates, not snaps)", () => {
67
- const body = bodyOf(".hk-modal-content-leave-active");
68
- expect(body).toMatch(/clip-path\s+var\(--hk-modal-duration/);
69
- expect(body).toMatch(/will-change:\s*[^;]*clip-path/);
70
- });
71
-
72
- it("keeps the close a rigid ride: no scaleY squash anywhere in the desktop block", () => {
73
- // History: #512 collapsed via scaleY (content squashed), #524
74
- // reverted to a bare drift. The clip choreography moves whole
75
- // pixels of the visible window — the frame itself never distorts.
76
- expect(desktop).not.toContain("scaleY");
77
- expect(desktop).not.toContain("transform-origin");
78
- });
79
- });