@celestia-island/hikari 0.54.1 → 0.54.2

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.54.1",
3
+ "version": "0.54.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -81,7 +81,7 @@ function formatModified(value: string | number | undefined): string {
81
81
  if (value === undefined) return "";
82
82
  if (typeof value === "number") {
83
83
  const date = new Date(value);
84
- return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
84
+ return Number.isNaN(date.getTime()) ? String(value) : formatDateTime(date);
85
85
  }
86
86
  return value;
87
87
  }
@@ -0,0 +1,79 @@
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
+ });
@@ -123,18 +123,26 @@
123
123
  will-change: transform, opacity, clip-path;
124
124
  }
125
125
 
126
- // Close: the whole frame fades and drifts down as ONE piece — the
127
- // unveil's clip does NOT re-run on close. Re-clipping first made the
128
- // bottom band (usually the footer) vanish at the very first frame of
129
- // the close while the rest was still on screen, which read as broken
130
- // (user report 2026-09-14).
126
+ // Close: the unveil's TRUE reverse — the visible box collapses back
127
+ // toward the center (top edge sweeps down, bottom edge sweeps up,
128
+ // content rides rigidly — never squashed) while the frame fades
129
+ // (user report 2026-09-15: the fade+drift close read as the box
130
+ // shrinking straight down instead of folding back into its center).
131
+ // The earlier re-clip attempt (#508) broke because leave-from had no
132
+ // clip at all: interpolating `none → inset()` is DISCRETE, so the
133
+ // bottom band (the footer) vanished at the first frame. Pinning the
134
+ // identity clip in leave-from — byte-identical to enter-to — makes
135
+ // the same-shape insets interpolate continuously, which structurally
136
+ // rules that jump out while restoring the symmetric collapse.
131
137
  .hk-modal-content-leave-active {
132
138
  transition:
133
139
  opacity var(--hk-modal-duration, 0.32s)
134
140
  var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
135
141
  transform var(--hk-modal-duration, 0.32s)
142
+ var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
143
+ clip-path var(--hk-modal-duration, 0.32s)
136
144
  var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
137
- will-change: transform, opacity;
145
+ will-change: transform, opacity, clip-path;
138
146
  }
139
147
 
140
148
  .hk-modal-content-enter-from {
@@ -154,11 +162,20 @@
154
162
  .hk-modal-content-leave-from {
155
163
  opacity: 1;
156
164
  transform: translate(-50%, -50%);
165
+ // The pinned identity clip: identical to enter-to so the leave's
166
+ // clip interpolates (never jumps) — see the close note above.
167
+ clip-path: inset(0 0 0 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
168
+ var(--hk-modal-radius, var(--radius-lg, 12px)));
157
169
  }
158
170
 
159
171
  .hk-modal-content-leave-to {
160
172
  opacity: 0;
173
+ // Mirror of enter-from: shifted +5% with the bottom 10% clipped, the
174
+ // visible box is 90% and centered — both edges ease toward the
175
+ // center line, exactly reversing the unveil.
161
176
  transform: translate(-50%, -50%) translateY(5%);
177
+ clip-path: inset(0 0 10% 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
178
+ var(--hk-modal-radius, var(--radius-lg, 12px)));
162
179
  }
163
180
  }
164
181
 
@@ -55,12 +55,16 @@ describe("formatRelativeTime", () => {
55
55
  expect(formatRelativeTime(ago(29 * DAY))).toBe("4w ago");
56
56
  });
57
57
 
58
- it("falls to the absolute-date path at >= 30d", () => {
58
+ it("falls to the absolute-date path at >= 30d, in the app locale", async () => {
59
59
  const d = ago(30 * DAY);
60
- // Do not pin a locale-specific rendering; assert the value comes
61
- // from the same Date's toLocaleDateString().
62
- expect(formatRelativeTime(d)).toBe(d.toLocaleDateString());
63
- expect(formatRelativeTime(d).length).toBeGreaterThan(0);
60
+ // The absolute tier must follow the app-selected hikari locale (the
61
+ // R2 sweep found the old assertion compared the BROWSER default,
62
+ // which proves nothing about locale-following).
63
+ const { setLocale } = await import("../i18n/context");
64
+ await setLocale("zh-Hans");
65
+ expect(formatRelativeTime(d)).toBe(d.toLocaleDateString("zh-Hans"));
66
+ await setLocale("en");
67
+ expect(formatRelativeTime(d)).toBe(d.toLocaleDateString("en"));
64
68
  });
65
69
 
66
70
  it("clamps future timestamps into the justNow tier", () => {
@@ -107,10 +111,11 @@ describe("formatRelativeTime", () => {
107
111
  });
108
112
  });
109
113
 
110
- it("keeps absolute dates away from the translator", () => {
114
+ it("keeps absolute dates away from the translator", async () => {
111
115
  const { t, calls } = makeCaptureT();
112
116
  const d = ago(45 * DAY);
113
- expect(formatRelativeTime(d, t)).toBe(d.toLocaleDateString());
117
+ const { activeLocale } = await import("../i18n/context");
118
+ expect(formatRelativeTime(d, t)).toBe(d.toLocaleDateString(activeLocale()));
114
119
  expect(calls).toHaveLength(0);
115
120
  });
116
121
  });
@@ -56,7 +56,8 @@ export type RelativeTimeT = (
56
56
  * Pass an optional translator for localized variants; defaults to
57
57
  * compact English text. Tiers: <1min justNow, <60min minutes,
58
58
  * <24h hours, <7d days, <30d weeks, otherwise an absolute
59
- * locale-rendered date (browser locale, like formatDateTime).
59
+ * locale-rendered date (the app-selected hikari locale, like
60
+ * formatDateTime).
60
61
  * The translator owns {n} interpolation — hikari's own useI18n().t
61
62
  * does NOT interpolate named params, so wrap it (the canonical key
62
63
  * set lives in the per-locale i18n time bundles). */