@celestia-island/hikari 0.52.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.52.1",
3
+ "version": "0.53.0",
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",
@@ -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";
@@ -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
+ });
@@ -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(undefined, {
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. */