@celestia-island/hikari 0.52.0 → 0.53.0
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 +1 -1
- package/src/components/HkDrawer.scss +25 -1
- package/src/components/HkModal.scss +35 -1
- package/src/components/HkModal.sheetmorph.test.ts +127 -0
- package/src/components/HkPopover.scss +9 -0
- package/src/components/HkSelect.scss +10 -2
- package/src/composables/useSizeMorph.test.ts +174 -0
- package/src/composables/useSizeMorph.ts +133 -9
- package/src/i18n/context.ts +7 -0
- package/src/index.ts +3 -0
- package/src/utils/format.test.ts +45 -1
- package/src/utils/format.ts +39 -4
package/package.json
CHANGED
|
@@ -22,9 +22,19 @@
|
|
|
22
22
|
display: flex;
|
|
23
23
|
flex-direction: column;
|
|
24
24
|
background: var(--hk-drawer-bg, var(--hi-color-surface, rgba(240,244,248,0.95)));
|
|
25
|
-
|
|
25
|
+
// Token-gated (was a hard blur(12px)): the drawer resizes through
|
|
26
|
+
// animated width/height, and a backdrop-filter on a resizing fixed
|
|
27
|
+
// layer re-rasters the blurred region every frame — on phones that
|
|
28
|
+
// lag shows as patchy flicker along the moving edge. The mobile
|
|
29
|
+
// guard below turns it off ≤767px; hosts restore a finish per
|
|
30
|
+
// surface with --hk-drawer-blur / --hk-drawer-blur-mobile.
|
|
31
|
+
backdrop-filter: var(--hk-drawer-blur, blur(12px));
|
|
26
32
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.16);
|
|
27
33
|
outline: none;
|
|
34
|
+
// Scope layout/style invalidation to the drawer subtree — the page
|
|
35
|
+
// behind a phone drawer is usually streaming, and containment keeps
|
|
36
|
+
// those invalidations from cascading into each other.
|
|
37
|
+
contain: layout style;
|
|
28
38
|
transition:
|
|
29
39
|
width var(--duration-normal, 0.3s) ease,
|
|
30
40
|
height var(--duration-normal, 0.3s) ease;
|
|
@@ -178,6 +188,20 @@
|
|
|
178
188
|
// family's 44px touch lift. Same geometry, same tokens, one family.
|
|
179
189
|
// ------
|
|
180
190
|
@media (max-width: 767px) {
|
|
191
|
+
// Phones: docked drawers never backdrop-filter — the panel resizes
|
|
192
|
+
// through animated width/height, and a backdrop-filter on a resizing
|
|
193
|
+
// fixed layer re-rasters the blurred region every frame; on a phone
|
|
194
|
+
// GPU that lag shows as patchy flicker along the moving edge (same
|
|
195
|
+
// family guard as the modal/select sheets, 2026-09-15 chest report).
|
|
196
|
+
// The scrim's dim alone reads correctly there.
|
|
197
|
+
.hk-drawer-overlay {
|
|
198
|
+
backdrop-filter: var(--hk-drawer-overlay-blur-mobile, none);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.hk-drawer-panel {
|
|
202
|
+
backdrop-filter: var(--hk-drawer-blur-mobile, none);
|
|
203
|
+
}
|
|
204
|
+
|
|
181
205
|
.hk-drawer-bottom {
|
|
182
206
|
bottom: var(--hk-sheet-bottom-gap, 0px);
|
|
183
207
|
border-radius: var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
@@ -68,9 +68,14 @@
|
|
|
68
68
|
// (capped) height and pins it as px on every content change — the
|
|
69
69
|
// transition below animates old→new px. Both pinned values are
|
|
70
70
|
// explicit px, so this needs no interpolate-size keyword support.
|
|
71
|
+
// clip-path rides the same token pair for the mobile sheet's reveal
|
|
72
|
+
// morph (--hk-sheet-morph: clip below): the pin lands instantly and
|
|
73
|
+
// the top edge sweeps up paint-only — no per-frame layout, no
|
|
74
|
+
// per-frame backdrop re-raster over the resizing fixed layer.
|
|
71
75
|
transition:
|
|
72
76
|
height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
|
|
73
|
-
max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1))
|
|
77
|
+
max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
|
|
78
|
+
clip-path var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
|
|
74
79
|
background: var(--hk-modal-bg, var(--hi-color-surface, rgba(240,244,248,0.95)));
|
|
75
80
|
backdrop-filter: var(--hk-modal-blur, none);
|
|
76
81
|
border: 1px solid var(--hk-modal-border-color, var(--hi-color-border, rgba(100,100,100,0.08)));
|
|
@@ -402,6 +407,19 @@
|
|
|
402
407
|
// - rounded top corners only, full width
|
|
403
408
|
// ------
|
|
404
409
|
@media (max-width: 767px) {
|
|
410
|
+
// Docked sheets never re-filter their backdrop per frame: the sheet
|
|
411
|
+
// is a fixed, full-width layer whose geometry changes with every
|
|
412
|
+
// content morph, and a backdrop-filter on a resizing layer re-rasters
|
|
413
|
+
// the blurred region each frame — on a phone GPU that lag shows as
|
|
414
|
+
// patchy flicker along the moving edge (2026-09-15 chest report:
|
|
415
|
+
// model dialog etc. while content streamed). The scrim already dims
|
|
416
|
+
// the page, so an opaque-ish sheet reads identically. Hosts restore
|
|
417
|
+
// the finish per surface with --hk-modal-blur-mobile /
|
|
418
|
+
// --hk-modal-bg-mobile.
|
|
419
|
+
.hk-modal-overlay {
|
|
420
|
+
backdrop-filter: var(--hk-modal-overlay-blur-mobile, none);
|
|
421
|
+
}
|
|
422
|
+
|
|
405
423
|
.hk-modal-content {
|
|
406
424
|
// !important beats the inline maxWidth (width prop); base width: 100%
|
|
407
425
|
// already applies at every viewport, so only the cap is re-asserted.
|
|
@@ -419,6 +437,22 @@
|
|
|
419
437
|
left: 0;
|
|
420
438
|
right: 0;
|
|
421
439
|
transform: none;
|
|
440
|
+
// Content morphs reveal by clip instead of animating height (see
|
|
441
|
+
// useSizeMorph): the pin lands instantly and the top edge sweeps up
|
|
442
|
+
// as a paint-only clip with the box's own corner radii — the same
|
|
443
|
+
// "content rides rigidly" grammar as the desktop unveil, and the
|
|
444
|
+
// CSS flag keeps the media query the single owner of the behavior.
|
|
445
|
+
--hk-sheet-morph: clip;
|
|
446
|
+
// Scope layout/style invalidation to the sheet subtree: the page
|
|
447
|
+
// behind a phone sheet is usually streaming (chat updates), and
|
|
448
|
+
// containment keeps those invalidations — and the sheet's own —
|
|
449
|
+
// from cascading into each other.
|
|
450
|
+
contain: layout style;
|
|
451
|
+
backdrop-filter: var(--hk-modal-blur-mobile, none);
|
|
452
|
+
background: var(
|
|
453
|
+
--hk-modal-bg-mobile,
|
|
454
|
+
var(--hk-modal-bg, var(--hi-color-surface, rgba(240, 244, 248, 0.95)))
|
|
455
|
+
);
|
|
422
456
|
border-radius: var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
423
457
|
var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
424
458
|
0 0;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the sheet family's morph-performance grammar
|
|
3
|
+
* (2026-09-15 chest mobile report: bottom sheets — model dialog, select
|
|
4
|
+
* sheets, drawers — flickered in patches while their height changed;
|
|
5
|
+
* a phone's raster of a backdrop-filtered fixed layer lags the layer's
|
|
6
|
+
* animated geometry, and an animated `height` relaid out the sheet
|
|
7
|
+
* subtree every frame on the same main thread that was streaming the
|
|
8
|
+
* page behind).
|
|
9
|
+
*
|
|
10
|
+
* The family answer, pinned here so a refactor cannot silently regress:
|
|
11
|
+
* - growth morphs REVEAL through clip-path (--hk-sheet-morph: clip on
|
|
12
|
+
* the docking surfaces, useSizeMorph does the pin-instantly +
|
|
13
|
+
* sweep-up dance) — paint/compositor-level, zero per-frame layout;
|
|
14
|
+
* the frame stylesheet carries the clip-path transition on the SAME
|
|
15
|
+
* duration/ease tokens as the height transition, so the stretch look
|
|
16
|
+
* and its reduced-motion / data-css-animations governance are intact
|
|
17
|
+
* - docked phone surfaces NEVER backdrop-filter (mobile-guard tokens
|
|
18
|
+
* defaulting to none: --hk-modal-blur-mobile, --hk-modal-overlay-
|
|
19
|
+
* blur-mobile, --hk-drawer-blur-mobile, --hk-drawer-overlay-blur-
|
|
20
|
+
* mobile, --hk-popover-blur-mobile) — the scrim's dim reads alone
|
|
21
|
+
* - docking surfaces carry `contain: layout style` so the streaming
|
|
22
|
+
* page behind and the sheet subtree stop invalidating into each
|
|
23
|
+
* other
|
|
24
|
+
*
|
|
25
|
+
* The JS half of the contract (pin instantly, sweep from the old edge,
|
|
26
|
+
* clear on transitionend / new dance / stop) lives in
|
|
27
|
+
* composables/useSizeMorph.test.ts.
|
|
28
|
+
*/
|
|
29
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
30
|
+
import { readFileSync } from "node:fs";
|
|
31
|
+
import { dirname, join } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
|
|
34
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const read = (name: string): string => readFileSync(join(here, name), "utf-8");
|
|
36
|
+
const modal = read("HkModal.scss");
|
|
37
|
+
const select = read("HkSelect.scss");
|
|
38
|
+
const drawer = read("HkDrawer.scss");
|
|
39
|
+
const popover = read("HkPopover.scss");
|
|
40
|
+
|
|
41
|
+
describe("sheet family morph-performance contract", () => {
|
|
42
|
+
let mobileModal = "";
|
|
43
|
+
beforeAll(() => {
|
|
44
|
+
mobileModal = modal.slice(modal.indexOf("@media (max-width: 767px)"));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Declaration assertions are LINE-ANCHORED (property → value →
|
|
48
|
+
// semicolon, own line): a plain toContain() on the token happily
|
|
49
|
+
// matches the explanatory COMMENT beside the rule — R2 mutation N2
|
|
50
|
+
// proved the select-sheet flag could be deleted with the suite still
|
|
51
|
+
// green because its comment mentions the same token.
|
|
52
|
+
|
|
53
|
+
it("flags the docked modal sheet for clip-mode growth morphs", () => {
|
|
54
|
+
expect(mobileModal).toMatch(/^[ \t]*--hk-sheet-morph:[ \t]*clip;$/m);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("carries a clip-path transition on the morph timing tokens", () => {
|
|
58
|
+
// Same duration/ease pair as the height morph — the reveal must keep
|
|
59
|
+
// the stretch look, not invent new timing.
|
|
60
|
+
const frame = modal.match(/\.hk-modal-content\s*{[^}]*}/)?.[0] ?? "";
|
|
61
|
+
expect(frame).toMatch(
|
|
62
|
+
/^[ \t]*clip-path var\(--duration-fast, 0\.15s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\);$/m,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("never backdrop-filters the docked modal sheet or its scrim", () => {
|
|
67
|
+
expect(mobileModal).toMatch(
|
|
68
|
+
/^[ \t]*backdrop-filter:[ \t]*var\(--hk-modal-blur-mobile, none\);$/m,
|
|
69
|
+
);
|
|
70
|
+
expect(mobileModal).toMatch(
|
|
71
|
+
/^[ \t]*backdrop-filter:[ \t]*var\(--hk-modal-overlay-blur-mobile, none\);$/m,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("scopes the docked modal sheet with layout containment", () => {
|
|
76
|
+
expect(mobileModal).toMatch(/^[ \t]*contain:[ \t]*layout style;$/m);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("applies the same grammar to the select sheet panel", () => {
|
|
80
|
+
const panel = select.match(/\.hk-select-sheet-panel\s*{[\s\S]*?^}/m)?.[0] ?? "";
|
|
81
|
+
expect(panel).toMatch(/^[ \t]*--hk-sheet-morph:[ \t]*clip;$/m);
|
|
82
|
+
expect(panel).toMatch(/^[ \t]*contain:[ \t]*layout style;$/m);
|
|
83
|
+
expect(panel).toMatch(
|
|
84
|
+
/^[ \t]*clip-path var\(--duration-fast, 0\.15s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\);$/m,
|
|
85
|
+
);
|
|
86
|
+
// Opaque already — the panel must not regress to a translucent
|
|
87
|
+
// finish that re-composites against the streaming page.
|
|
88
|
+
expect(panel).toMatch(/^[ \t]*background:[ \t]*rgb\(var\(--color-surface\)\);$/m);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("token-gates the drawer panel blur and guards it on phones", () => {
|
|
92
|
+
// Desktop keeps its finish through the token; the hard-coded blur
|
|
93
|
+
// must not return (it escaped the mobile guard entirely).
|
|
94
|
+
const panel = drawer.match(/\.hk-drawer-panel\s*{[^}]*}/)?.[0] ?? "";
|
|
95
|
+
expect(panel).toMatch(
|
|
96
|
+
/^[ \t]*backdrop-filter:[ \t]*var\(--hk-drawer-blur, blur\(12px\)\);$/m,
|
|
97
|
+
);
|
|
98
|
+
expect(panel).toMatch(/^[ \t]*contain:[ \t]*layout style;$/m);
|
|
99
|
+
|
|
100
|
+
const mobile = drawer.slice(drawer.indexOf("@media (max-width: 767px)"));
|
|
101
|
+
expect(mobile).toMatch(
|
|
102
|
+
/^[ \t]*backdrop-filter:[ \t]*var\(--hk-drawer-blur-mobile, none\);$/m,
|
|
103
|
+
);
|
|
104
|
+
expect(mobile).toMatch(
|
|
105
|
+
/^[ \t]*backdrop-filter:[ \t]*var\(--hk-drawer-overlay-blur-mobile, none\);$/m,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("keeps the drawer's mobile rules in ONE media block", () => {
|
|
110
|
+
// HkDrawer.sheetfamily.test.ts slices the source from the FIRST
|
|
111
|
+
// max-width: 767px block and regex-matches rules after it — where
|
|
112
|
+
// that first block sits decides which rules its slice can even see
|
|
113
|
+
// (a guard block placed before the .hk-drawer-bottom base rule once
|
|
114
|
+
// redirected the family match to the base rule and reddened the
|
|
115
|
+
// contract). One block, one concern: the mobile guards live inside
|
|
116
|
+
// the family block, and no second block may wander in.
|
|
117
|
+
expect(drawer.match(/@media \(max-width: 767px\)/g)).toHaveLength(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("guards the popover sheet variant the same way", () => {
|
|
121
|
+
const sheet = popover.match(/\.hk-popover-panel\.hk-is-sheet\s*{[\s\S]*?^}/m)?.[0] ?? "";
|
|
122
|
+
expect(sheet).toMatch(
|
|
123
|
+
/^[ \t]*backdrop-filter:[ \t]*var\(--hk-popover-blur-mobile, none\);$/m,
|
|
124
|
+
);
|
|
125
|
+
expect(sheet).toMatch(/^[ \t]*contain:[ \t]*layout style;$/m);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -106,6 +106,15 @@
|
|
|
106
106
|
* rule's max-content width would over-constrain it (left+right+width
|
|
107
107
|
* drops `right` in LTR) and dock it at content width instead. */
|
|
108
108
|
width: auto;
|
|
109
|
+
/* Docked popover sheets never backdrop-filter: the sheet is a fixed
|
|
110
|
+
* full-width layer over a usually-streaming page, and a
|
|
111
|
+
* backdrop-filter re-rasters the blurred region on every repaint
|
|
112
|
+
* behind it — on phones that lag shows as patchy flicker (same
|
|
113
|
+
* family guard as the modal/select sheets, 2026-09-15). Hosts
|
|
114
|
+
* restore a finish with --hk-popover-blur-mobile. Containment
|
|
115
|
+
* scopes the sheet's layout/style invalidations away from the page. */
|
|
116
|
+
backdrop-filter: var(--hk-popover-blur-mobile, none);
|
|
117
|
+
contain: layout style;
|
|
109
118
|
/* The base .hk-popover-panel max-width clamp (calc(100vw - 16px), the
|
|
110
119
|
* anchored panel's ratchet guard) must NOT leak into the sheet: with
|
|
111
120
|
* the inline left:0 + right:0 docking the clamp over-constrains the
|
|
@@ -304,11 +304,19 @@
|
|
|
304
304
|
* cannot fire a CSS transition, so HkSelectPanel's useSizeMorph pins
|
|
305
305
|
* the measured natural height as px (see useSizeMorph) and the
|
|
306
306
|
* transition below animates old→new px; `interpolate-size: allow-keywords`
|
|
307
|
-
* stays harmless for the enter/leave slide.
|
|
307
|
+
* stays harmless for the enter/leave slide. Growth reveals through
|
|
308
|
+
* clip-path instead (--hk-sheet-morph: clip, see useSizeMorph): the
|
|
309
|
+
* pin lands instantly and the top edge sweeps up paint-only — the
|
|
310
|
+
* sheet is a fixed full-width layer, and an animated height relaid
|
|
311
|
+
* out + repainted it every frame (mobile patchy-flicker source,
|
|
312
|
+
* 2026-09-15). Same duration/ease tokens, same stretch look. */
|
|
308
313
|
interpolate-size: allow-keywords;
|
|
314
|
+
--hk-sheet-morph: clip;
|
|
315
|
+
contain: layout style;
|
|
309
316
|
transition:
|
|
310
317
|
height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
|
|
311
|
-
max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1))
|
|
318
|
+
max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
|
|
319
|
+
clip-path var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
|
|
312
320
|
max-height: min(
|
|
313
321
|
72vh,
|
|
314
322
|
calc(
|
|
@@ -253,3 +253,177 @@ describe("useSizeMorph", () => {
|
|
|
253
253
|
expect(h.frame.style.height).toBe("");
|
|
254
254
|
});
|
|
255
255
|
});
|
|
256
|
+
|
|
257
|
+
// ── Clip reveal mode (--hk-sheet-morph: clip, mobile sheets) ─────────
|
|
258
|
+
// Growth morphs reveal through paint-only clip-path instead of an
|
|
259
|
+
// animated height: the pin lands instantly and the top edge sweeps up,
|
|
260
|
+
// keeping the stretch look without per-frame layout on the fixed,
|
|
261
|
+
// backdrop-carrying sheet layer (2026-09-15 mobile flicker report).
|
|
262
|
+
|
|
263
|
+
/** happy-dom has no TransitionEvent constructor on some builds — the
|
|
264
|
+
* generic Event plus an assigned propertyName reads the same to the
|
|
265
|
+
* composable's listener. */
|
|
266
|
+
function fireTransitionEnd(el: HTMLElement, propertyName: string): void {
|
|
267
|
+
// happy-dom's TransitionEvent (when present) ignores the init dict's
|
|
268
|
+
// propertyName — always build the generic event and assign the field.
|
|
269
|
+
const ev = new Event("transitionend");
|
|
270
|
+
Object.defineProperty(ev, "propertyName", { value: propertyName });
|
|
271
|
+
el.dispatchEvent(ev);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
describe("useSizeMorph clip reveal", () => {
|
|
275
|
+
it("reveals growth through clip-path with an instant pin", async () => {
|
|
276
|
+
const h = mountHarness(300);
|
|
277
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
278
|
+
h.start();
|
|
279
|
+
expect(h.frame.style.height).toBe("300px");
|
|
280
|
+
|
|
281
|
+
h.setNatural(360);
|
|
282
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
283
|
+
await settle();
|
|
284
|
+
// The pin landed at the new height with no height animation staged.
|
|
285
|
+
expect(h.frame.style.height).toBe("360px");
|
|
286
|
+
// The sweep runs: end-state clip + layer promotion in flight.
|
|
287
|
+
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
288
|
+
expect(h.frame.style.willChange).toBe("clip-path");
|
|
289
|
+
expect(h.frame.style.transition).toBe("");
|
|
290
|
+
|
|
291
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
292
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
293
|
+
expect(h.frame.style.willChange).toBe("");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("starts the sweep from the old visual edge (delta inset)", () => {
|
|
297
|
+
const h = mountHarness(300);
|
|
298
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
299
|
+
h.start();
|
|
300
|
+
|
|
301
|
+
// The dance overwrites the inline clip synchronously (start → flush
|
|
302
|
+
// → end), so the START state is only observable at the forced-layout
|
|
303
|
+
// flush: wrap the frame's offsetHeight getter to record the clip
|
|
304
|
+
// each flush reads.
|
|
305
|
+
const reads: string[] = [];
|
|
306
|
+
const desc = Object.getOwnPropertyDescriptor(h.frame, "offsetHeight")!;
|
|
307
|
+
Object.defineProperty(h.frame, "offsetHeight", {
|
|
308
|
+
configurable: true,
|
|
309
|
+
get: () => {
|
|
310
|
+
reads.push(h.frame.style.clipPath);
|
|
311
|
+
return (desc.get as () => number)();
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
h.setNatural(420);
|
|
316
|
+
h.remeasure();
|
|
317
|
+
// Flush sequence: the released measure (clip ""), then the staged
|
|
318
|
+
// start state — the reveal hides exactly the 120px the sheet grew
|
|
319
|
+
// (420 − 300), putting the visible top edge back at the old line.
|
|
320
|
+
expect(reads).toEqual([
|
|
321
|
+
"",
|
|
322
|
+
"inset(120px 0 0 0 round 0px 0px 0px 0px)",
|
|
323
|
+
]);
|
|
324
|
+
expect(h.frame.style.height).toBe("420px");
|
|
325
|
+
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("keeps the height morph for shrink and sub-threshold growth", async () => {
|
|
329
|
+
const h = mountHarness(400);
|
|
330
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
331
|
+
h.start();
|
|
332
|
+
|
|
333
|
+
// Shrink: no clip state, the pin flips under the height transition.
|
|
334
|
+
h.setNatural(320);
|
|
335
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
336
|
+
await settle();
|
|
337
|
+
expect(h.frame.style.height).toBe("320px");
|
|
338
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
339
|
+
expect(h.frame.style.willChange).toBe("");
|
|
340
|
+
|
|
341
|
+
// Sub-threshold growth (2px < REVEAL_MIN_PX): snaps, no reveal.
|
|
342
|
+
h.setNatural(322);
|
|
343
|
+
h.remeasure();
|
|
344
|
+
expect(h.frame.style.height).toBe("322px");
|
|
345
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("never clips without the mode flag (desktop height morph intact)", async () => {
|
|
349
|
+
const h = mountHarness(300);
|
|
350
|
+
h.start();
|
|
351
|
+
|
|
352
|
+
h.setNatural(400);
|
|
353
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
354
|
+
await settle();
|
|
355
|
+
expect(h.frame.style.height).toBe("400px");
|
|
356
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
357
|
+
expect(h.frame.style.willChange).toBe("");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("clears an in-flight reveal when a new dance starts", async () => {
|
|
361
|
+
const h = mountHarness(300);
|
|
362
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
363
|
+
h.start();
|
|
364
|
+
|
|
365
|
+
h.setNatural(380);
|
|
366
|
+
h.remeasure();
|
|
367
|
+
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
368
|
+
|
|
369
|
+
// A second growth lands before transitionend fired: the stale clip
|
|
370
|
+
// must come off inside the new dance, then the new reveal stages.
|
|
371
|
+
h.setNatural(450);
|
|
372
|
+
h.remeasure();
|
|
373
|
+
expect(h.frame.style.height).toBe("450px");
|
|
374
|
+
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
375
|
+
expect(h.frame.style.willChange).toBe("clip-path");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("clears an interrupted reveal inside the next dance (no transitionend)", () => {
|
|
379
|
+
const h = mountHarness(300);
|
|
380
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
381
|
+
h.start();
|
|
382
|
+
|
|
383
|
+
h.setNatural(380);
|
|
384
|
+
h.remeasure();
|
|
385
|
+
expect(h.frame.style.clipPath).not.toBe("");
|
|
386
|
+
|
|
387
|
+
// A SHRINK lands before the reveal's transitionend fired: unlike a
|
|
388
|
+
// follow-up growth (which restages its own clip), the height-morph
|
|
389
|
+
// branch writes no clip at all — the dance-start teardown is the
|
|
390
|
+
// only thing that returns the frame to CSS ownership (R1 mutation
|
|
391
|
+
// M1 evidence: without it the stale inset(0px) + will-change ride
|
|
392
|
+
// the shrink and linger at rest).
|
|
393
|
+
h.setNatural(310);
|
|
394
|
+
h.remeasure();
|
|
395
|
+
expect(h.frame.style.height).toBe("310px");
|
|
396
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
397
|
+
expect(h.frame.style.willChange).toBe("");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("releases the clip state on stop so the leave animation owns the frame", async () => {
|
|
401
|
+
const h = mountHarness(300);
|
|
402
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
403
|
+
h.start();
|
|
404
|
+
|
|
405
|
+
h.setNatural(380);
|
|
406
|
+
h.remeasure();
|
|
407
|
+
expect(h.frame.style.clipPath).not.toBe("");
|
|
408
|
+
|
|
409
|
+
h.stop();
|
|
410
|
+
expect(h.frame.style.height).toBe("");
|
|
411
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
412
|
+
expect(h.frame.style.willChange).toBe("");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("ignores transitionend events for other properties", async () => {
|
|
416
|
+
const h = mountHarness(300);
|
|
417
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
418
|
+
h.start();
|
|
419
|
+
|
|
420
|
+
h.setNatural(360);
|
|
421
|
+
h.remeasure();
|
|
422
|
+
fireTransitionEnd(h.frame, "height");
|
|
423
|
+
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
424
|
+
fireTransitionEnd(h.frame, "opacity");
|
|
425
|
+
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
426
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
427
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
428
|
+
});
|
|
429
|
+
});
|
|
@@ -8,6 +8,11 @@ import { onBeforeUnmount, type Ref } from "vue";
|
|
|
8
8
|
const CHROME_ALLOWANCE_FLOOR = 96;
|
|
9
9
|
const CHROME_ALLOWANCE_SLACK = 32;
|
|
10
10
|
|
|
11
|
+
/** Growth below this (px) snaps instead of revealing — under half a
|
|
12
|
+
* text row the sweep is imperceptible and not worth promoting a
|
|
13
|
+
* layer for. */
|
|
14
|
+
const REVEAL_MIN_PX = 3;
|
|
15
|
+
|
|
11
16
|
export interface SizeMorph {
|
|
12
17
|
/** Arm the morph: observe the content and pin the frame's natural
|
|
13
18
|
* height on every change. Call once the surface finished its open
|
|
@@ -48,6 +53,17 @@ export interface SizeMorph {
|
|
|
48
53
|
* pin chases the target with the CSS transition — bounded, one-shot
|
|
49
54
|
* choreography, not an infinite per-frame animation.
|
|
50
55
|
*
|
|
56
|
+
* Growth morphs on clip-mode surfaces (the mobile sheet docking, flagged
|
|
57
|
+
* `--hk-sheet-morph: clip` in CSS) reveal instead of animating height:
|
|
58
|
+
* the new pin lands instantly and the box's top edge sweeps up through
|
|
59
|
+
* `clip-path: inset()` — same duration/ease tokens as the height
|
|
60
|
+
* transition, same "content rides rigidly" grammar as the modal unveil,
|
|
61
|
+
* but paint/compositor-level: no per-frame layout and no per-frame
|
|
62
|
+
* backdrop-filter re-raster over the resizing fixed layer (the mobile
|
|
63
|
+
* patchy-flicker source, 2026-09-15 chest report). Shrinks and every
|
|
64
|
+
* height-mode surface keep the height transition — the rare direction
|
|
65
|
+
* is not worth the flex-compression look-ahead trade.
|
|
66
|
+
*
|
|
51
67
|
* Reduced motion / the global animation switch stay honored: the frame's
|
|
52
68
|
* transition-duration collapses to one frame under
|
|
53
69
|
* `html[data-css-animations="0"]`, so the pin updates snap.
|
|
@@ -68,10 +84,70 @@ export function useSizeMorph(
|
|
|
68
84
|
* bodies that overflow at rest, plus subpixel slack. See the guard in
|
|
69
85
|
* remeasure(). */
|
|
70
86
|
let chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
|
|
87
|
+
/** In-flight clip reveal (clip-mode growth morph): the frame whose
|
|
88
|
+
* inline clip-path/will-change must come off again once the sweep
|
|
89
|
+
* lands, plus the listener that does it. */
|
|
90
|
+
let revealEl: HTMLElement | null = null;
|
|
91
|
+
let revealEnd: ((ev: Event) => void) | null = null;
|
|
92
|
+
|
|
93
|
+
/** Tear down an in-flight clip reveal: drop the listener and return
|
|
94
|
+
* the inline clip/will-change to CSS ownership. Safe to call when no
|
|
95
|
+
* reveal is running (every dance start, stop, and unmount). */
|
|
96
|
+
function stopReveal(): void {
|
|
97
|
+
if (revealEl && revealEnd) {
|
|
98
|
+
revealEl.removeEventListener("transitionend", revealEnd);
|
|
99
|
+
}
|
|
100
|
+
if (revealEl) {
|
|
101
|
+
revealEl.style.clipPath = "";
|
|
102
|
+
revealEl.style.willChange = "";
|
|
103
|
+
}
|
|
104
|
+
revealEl = null;
|
|
105
|
+
revealEnd = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Clip-mode opt-in, owned by CSS: the modal's mobile media block
|
|
109
|
+
* sets `--hk-sheet-morph: clip` inside its ≤767px query, and the
|
|
110
|
+
* select sheet sets it on its class rule directly (that class only
|
|
111
|
+
* renders in JS-gated sheet mode, so the breakpoint still owns the
|
|
112
|
+
* behavior); a host can override per surface either way (an inline
|
|
113
|
+
* custom property wins over the stylesheet's). */
|
|
114
|
+
function clipMode(f: HTMLElement): boolean {
|
|
115
|
+
const inline = f.style.getPropertyValue("--hk-sheet-morph").trim();
|
|
116
|
+
if (inline) return inline === "clip";
|
|
117
|
+
try {
|
|
118
|
+
return getComputedStyle(f).getPropertyValue("--hk-sheet-morph").trim() === "clip";
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The frame's corner radii for the reveal's `round` clause — the
|
|
125
|
+
* moving clip edge keeps the sheet's own rounded corners instead of
|
|
126
|
+
* shaving them straight for the sweep's duration. First token only
|
|
127
|
+
* (horizontal radius); non-px values degrade to square. */
|
|
128
|
+
function cornerRadii(f: HTMLElement): string {
|
|
129
|
+
let cs: CSSStyleDeclaration | null = null;
|
|
130
|
+
try {
|
|
131
|
+
cs = getComputedStyle(f);
|
|
132
|
+
} catch {
|
|
133
|
+
cs = null;
|
|
134
|
+
}
|
|
135
|
+
const first = (v: string | undefined): string => {
|
|
136
|
+
const token = (v ?? "").trim().split(/\s+/)[0] ?? "";
|
|
137
|
+
return token.endsWith("px") ? token : "0px";
|
|
138
|
+
};
|
|
139
|
+
return [
|
|
140
|
+
first(cs?.borderTopLeftRadius),
|
|
141
|
+
first(cs?.borderTopRightRadius),
|
|
142
|
+
first(cs?.borderBottomRightRadius),
|
|
143
|
+
first(cs?.borderBottomLeftRadius),
|
|
144
|
+
].join(" ");
|
|
145
|
+
}
|
|
71
146
|
|
|
72
147
|
function release(): void {
|
|
73
148
|
const f = frame.value;
|
|
74
149
|
if (f) f.style.height = "";
|
|
150
|
+
stopReveal();
|
|
75
151
|
pinned = 0;
|
|
76
152
|
}
|
|
77
153
|
|
|
@@ -98,15 +174,23 @@ export function useSizeMorph(
|
|
|
98
174
|
if (!f || !c) return;
|
|
99
175
|
// 1. Disable transitions, release the pin and measure the frame's
|
|
100
176
|
// natural (CSS-capped) height in one layout flush.
|
|
101
|
-
// 2.
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
177
|
+
// 2. Clip-mode growth: pin the NEW height outright and stage the
|
|
178
|
+
// clip start (still transition-disabled), so the reveal that
|
|
179
|
+
// follows sweeps a fully-laid-out box — layout happens once,
|
|
180
|
+
// here, never per frame.
|
|
181
|
+
// Otherwise re-establish the OLD pin (still transition-disabled)
|
|
182
|
+
// and flush it, so the style history is exactly "old height" when
|
|
183
|
+
// the live CSS transition returns.
|
|
184
|
+
// 3. Flip to the new state under the live transition — a real
|
|
185
|
+
// computed-value change, so the CSS transition animates.
|
|
106
186
|
// No paint happens between the steps: they run in one task and the
|
|
107
187
|
// layout flushes are invisible to the screen.
|
|
108
188
|
const inlineTransition = f.style.transition;
|
|
109
189
|
f.style.transition = "none";
|
|
190
|
+
// A second content change mid-reveal restarts from the new delta
|
|
191
|
+
// (the settle debounce already collapses bursts; this makes it a
|
|
192
|
+
// hard guarantee that no stale clip survives into the new dance).
|
|
193
|
+
stopReveal();
|
|
110
194
|
f.style.height = "";
|
|
111
195
|
const natural = f.offsetHeight;
|
|
112
196
|
if (natural <= 0) {
|
|
@@ -130,12 +214,51 @@ export function useSizeMorph(
|
|
|
130
214
|
f.style.transition = inlineTransition;
|
|
131
215
|
return;
|
|
132
216
|
}
|
|
133
|
-
|
|
134
|
-
|
|
217
|
+
const next = Math.round(natural);
|
|
218
|
+
const growth = next - pinned;
|
|
219
|
+
// Clip reveal (see the composable doc): the pin lands instantly and
|
|
220
|
+
// the top edge sweeps up through paint-only clip-path, with the
|
|
221
|
+
// box's own corner radii riding the moving edge. The frame's
|
|
222
|
+
// stylesheet owns the clip-path transition (duration/ease tokens
|
|
223
|
+
// shared with the height transition), so reduced-motion and the
|
|
224
|
+
// global animation switch collapse it exactly like the height morph
|
|
225
|
+
// they already govern. Everything else — shrink, first pin,
|
|
226
|
+
// sub-threshold growth, height-mode surfaces — keeps the height
|
|
227
|
+
// morph below (desktop stays exactly as it was).
|
|
228
|
+
const reveal = pinned > 0 && growth >= REVEAL_MIN_PX && clipMode(f);
|
|
229
|
+
let radii = "";
|
|
230
|
+
if (reveal) {
|
|
231
|
+
radii = cornerRadii(f);
|
|
232
|
+
f.style.height = `${next}px`;
|
|
233
|
+
f.style.clipPath = `inset(${growth}px 0 0 0 round ${radii})`;
|
|
234
|
+
} else if (pinned > 0) {
|
|
235
|
+
f.style.height = `${pinned}px`;
|
|
236
|
+
}
|
|
237
|
+
// Flush the staged state (new pin + clip start, or the old pin)
|
|
238
|
+
// before re-enabling the transition, so the sweep starts from the
|
|
239
|
+
// old visual edge / the height transition starts from the old pin.
|
|
135
240
|
void f.offsetHeight;
|
|
136
241
|
f.style.transition = inlineTransition;
|
|
137
|
-
|
|
138
|
-
|
|
242
|
+
if (reveal) {
|
|
243
|
+
const onEnd = (ev: Event): void => {
|
|
244
|
+
// transitionend bubbles: a descendant animating its own
|
|
245
|
+
// clip-path must not end the frame's reveal early.
|
|
246
|
+
if (
|
|
247
|
+
ev.target === f &&
|
|
248
|
+
(ev as TransitionEvent).propertyName === "clip-path"
|
|
249
|
+
) {
|
|
250
|
+
stopReveal();
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
f.addEventListener("transitionend", onEnd);
|
|
254
|
+
revealEl = f;
|
|
255
|
+
revealEnd = onEnd;
|
|
256
|
+
f.style.willChange = "clip-path";
|
|
257
|
+
f.style.clipPath = `inset(0px 0 0 0 round ${radii})`;
|
|
258
|
+
} else {
|
|
259
|
+
f.style.height = `${next}px`;
|
|
260
|
+
}
|
|
261
|
+
pinned = next;
|
|
139
262
|
// Self-heal the allowance on every VALIDATED pin: chrome that grew
|
|
140
263
|
// after calibration (an async footer, a header slot mounting
|
|
141
264
|
// mid-open) updates the baseline instead of tripping the guard on
|
|
@@ -204,6 +327,7 @@ export function useSizeMorph(
|
|
|
204
327
|
ro?.disconnect();
|
|
205
328
|
if (settleTimer) clearTimeout(settleTimer);
|
|
206
329
|
if (raf) cancelAnimationFrame(raf);
|
|
330
|
+
stopReveal();
|
|
207
331
|
});
|
|
208
332
|
|
|
209
333
|
return { start, stop, remeasure };
|
package/src/i18n/context.ts
CHANGED
|
@@ -54,6 +54,13 @@ localeCache.set("en", enFallback);
|
|
|
54
54
|
// every language without knowing which locale is currently active.
|
|
55
55
|
let mergedMessages: Record<string, Messages> = {};
|
|
56
56
|
|
|
57
|
+
/** The app-selected hikari locale ("en" until setLocale runs). Date and
|
|
58
|
+
* time formatters consume this so display follows the user's chosen
|
|
59
|
+
* language instead of the browser's. */
|
|
60
|
+
export function activeLocale(): string {
|
|
61
|
+
return state.locale;
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
export async function setLocale(locale: string): Promise<void> {
|
|
58
65
|
if (!localeCache.has(locale)) {
|
|
59
66
|
localeCache.set(locale, buildLocaleMessages(locale));
|
package/src/index.ts
CHANGED
|
@@ -268,6 +268,7 @@ export {
|
|
|
268
268
|
export {
|
|
269
269
|
useI18n,
|
|
270
270
|
setLocale,
|
|
271
|
+
activeLocale,
|
|
271
272
|
mergeMessages,
|
|
272
273
|
} from "./i18n/context";
|
|
273
274
|
|
|
@@ -455,6 +456,8 @@ export {
|
|
|
455
456
|
formatPriceUsd,
|
|
456
457
|
formatRelativeTime,
|
|
457
458
|
formatDateTime,
|
|
459
|
+
formatDate,
|
|
460
|
+
formatTime,
|
|
458
461
|
formatMs,
|
|
459
462
|
type RelativeTimeT,
|
|
460
463
|
} from "./utils/format";
|
package/src/utils/format.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
|
-
import { formatRelativeTime, type RelativeTimeT } from "./format";
|
|
3
|
+
import { formatDate, formatDateTime, formatRelativeTime, formatTime, type RelativeTimeT } from "./format";
|
|
4
4
|
|
|
5
5
|
const MIN = 60_000;
|
|
6
6
|
const HOUR = 3_600_000;
|
|
@@ -114,3 +114,47 @@ describe("formatRelativeTime", () => {
|
|
|
114
114
|
expect(calls).toHaveLength(0);
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
|
+
|
|
118
|
+
// ── Locale awareness (2026-09-15) ────────────────────────────────────
|
|
119
|
+
// Every locale-sensitive formatter must follow the app-selected hikari
|
|
120
|
+
// locale (setLocale), not the browser default: a zh-Hans app on an en
|
|
121
|
+
// browser used to render dates as "9/13/2026" next to zh words.
|
|
122
|
+
|
|
123
|
+
describe("locale-aware date formatting", () => {
|
|
124
|
+
it("formatDate composes the locale's full day label", async () => {
|
|
125
|
+
const { setLocale } = await import("../i18n/context");
|
|
126
|
+
const d = new Date(2026, 8, 13);
|
|
127
|
+
await setLocale("zh-Hans");
|
|
128
|
+
expect(formatDate(d, { month: "short", day: "numeric" })).toBe("9月13日");
|
|
129
|
+
await setLocale("en");
|
|
130
|
+
expect(formatDate(d, { month: "short", day: "numeric" })).toBe("Sep 13");
|
|
131
|
+
// Whole-date default: no opts = the locale's own date format.
|
|
132
|
+
expect(formatDate(d)).toContain("2026");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("formatTime follows the locale's clock convention", async () => {
|
|
136
|
+
const { setLocale } = await import("../i18n/context");
|
|
137
|
+
const d = new Date(2026, 8, 13, 15, 24);
|
|
138
|
+
await setLocale("en");
|
|
139
|
+
expect(formatTime(d)).toMatch(/3:24/);
|
|
140
|
+
await setLocale("zh-Hans");
|
|
141
|
+
expect(formatTime(d)).toMatch(/15:24/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("formatDateTime renders through the app locale", async () => {
|
|
145
|
+
const { setLocale } = await import("../i18n/context");
|
|
146
|
+
await setLocale("ja");
|
|
147
|
+
const out = formatDateTime(new Date(2026, 8, 13, 15, 24));
|
|
148
|
+
expect(out).toMatch(/2026/);
|
|
149
|
+
// ja month rendering carries the 月 particle from the ja locale data.
|
|
150
|
+
expect(out).toMatch(/9月13日|9\/13/);
|
|
151
|
+
await setLocale("en");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("formatDate/formatTime return empty for missing or invalid input", () => {
|
|
155
|
+
expect(formatDate("")).toBe("");
|
|
156
|
+
expect(formatDate("nope")).toBe("");
|
|
157
|
+
expect(formatTime(0)).toBe("");
|
|
158
|
+
expect(formatTime("junk")).toBe("");
|
|
159
|
+
});
|
|
160
|
+
});
|
package/src/utils/format.ts
CHANGED
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
* Consolidates the hand-rolled copies previously scattered across
|
|
6
6
|
* arona (formatDate/formatUptime/formatNumber) and shittim-chest
|
|
7
7
|
* (formatTokenCount/formatMediaTime).
|
|
8
|
-
|
|
8
|
+
*
|
|
9
|
+
* Every locale-sensitive formatter resolves its locale through the
|
|
10
|
+
* hikari i18n state (activeLocale()), NOT the browser default: the app
|
|
11
|
+
* selects its language explicitly (setLocale on switch), and dates that
|
|
12
|
+
* follow the browser while every word around them follows the app read
|
|
13
|
+
* as mixed-language output (2026-09-15 user report: "9月 13" headings
|
|
14
|
+
* under a zh-Hans app on an en browser). */
|
|
15
|
+
import { activeLocale } from "../i18n/context";
|
|
9
16
|
|
|
10
17
|
/** "1234" -> "1.2k", "2500000" -> "2.5M". */
|
|
11
18
|
export function formatTokenCount(n: number): string {
|
|
@@ -75,14 +82,15 @@ export function formatRelativeTime(
|
|
|
75
82
|
t?.("common.time.weeksAgo", "{n} w ago", { n: weeks }) ?? `${weeks}w ago`
|
|
76
83
|
);
|
|
77
84
|
}
|
|
78
|
-
return d.toLocaleDateString();
|
|
85
|
+
return d.toLocaleDateString(activeLocale());
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
// Media timestamps ("m:ss") already live on the media-player kit — one
|
|
82
89
|
// definition, re-exported so `../utils/format` is the single import site.
|
|
83
90
|
export { formatMediaTime } from "../components/HkMediaControlBar";
|
|
84
91
|
|
|
85
|
-
/** Absolute timestamp formatting with a shared locale-aware renderer.
|
|
92
|
+
/** Absolute timestamp formatting with a shared locale-aware renderer.
|
|
93
|
+
* Follows the app-selected hikari locale (see activeLocale). */
|
|
86
94
|
export function formatDateTime(
|
|
87
95
|
input: string | number | Date,
|
|
88
96
|
opts?: { dateStyle?: "short" | "medium" | "long"; timeStyle?: "short" | "medium" },
|
|
@@ -90,12 +98,39 @@ export function formatDateTime(
|
|
|
90
98
|
if (!input) return "";
|
|
91
99
|
const d = input instanceof Date ? input : new Date(input);
|
|
92
100
|
if (isNaN(d.getTime())) return "";
|
|
93
|
-
return d.toLocaleString(
|
|
101
|
+
return d.toLocaleString(activeLocale(), {
|
|
94
102
|
dateStyle: opts?.dateStyle ?? "medium",
|
|
95
103
|
timeStyle: opts?.timeStyle ?? "short",
|
|
96
104
|
});
|
|
97
105
|
}
|
|
98
106
|
|
|
107
|
+
/** Date-only rendering (no time), following the app-selected hikari
|
|
108
|
+
* locale. `opts` is the raw Intl.DateTimeFormatOptions passthrough —
|
|
109
|
+
* omit it for the locale's whole-date default, or scope to a slice
|
|
110
|
+
* (e.g. { month: "short", day: "numeric" } for day-group headings). */
|
|
111
|
+
export function formatDate(
|
|
112
|
+
input: string | number | Date,
|
|
113
|
+
opts?: Intl.DateTimeFormatOptions,
|
|
114
|
+
): string {
|
|
115
|
+
if (!input) return "";
|
|
116
|
+
const d = input instanceof Date ? input : new Date(input);
|
|
117
|
+
if (isNaN(d.getTime())) return "";
|
|
118
|
+
return d.toLocaleDateString(activeLocale(), opts);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Time-only rendering (no date), following the app-selected hikari
|
|
122
|
+
* locale. `opts` is the raw Intl.DateTimeFormatOptions passthrough —
|
|
123
|
+
* omit it for the locale's default (en "3:24 PM", zh "15:24"). */
|
|
124
|
+
export function formatTime(
|
|
125
|
+
input: string | number | Date,
|
|
126
|
+
opts?: Intl.DateTimeFormatOptions,
|
|
127
|
+
): string {
|
|
128
|
+
if (!input) return "";
|
|
129
|
+
const d = input instanceof Date ? input : new Date(input);
|
|
130
|
+
if (isNaN(d.getTime())) return "";
|
|
131
|
+
return d.toLocaleTimeString(activeLocale(), opts);
|
|
132
|
+
}
|
|
133
|
+
|
|
99
134
|
/** Milliseconds for latency/duration displays: sub-second keeps one
|
|
100
135
|
* decimal, >=1s switches to whole seconds (and beyond to minutes).
|
|
101
136
|
* Thousands-grouped for the rare large value. */
|