@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
|
@@ -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
|
|
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
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
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
|
|
package/src/utils/format.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/utils/format.ts
CHANGED
|
@@ -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 (
|
|
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). */
|