diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx
index 5ecb66aadf..3c6a3ac2c4 100644
--- a/packages/dashboard/app/components/SettingsModal.tsx
+++ b/packages/dashboard/app/components/SettingsModal.tsx
@@ -19,6 +19,7 @@ import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse,
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import type { ToastType } from "../hooks/useToast";
+import { useTranslation } from "react-i18next";
import { ThemeSelector } from "./ThemeSelector";
import { LanguageSelector } from "./LanguageSelector";
import { useSessionBannersHidden, setSessionBannersHidden } from "../hooks/useSessionBannerPref";
@@ -431,6 +432,7 @@ export function SettingsModal({
onReopenOnboarding,
onOpenApprovals,
}: SettingsModalProps) {
+ const { t } = useTranslation("app");
const { confirm } = useConfirm();
const worktrunkInstall = useWorktrunkInstallStatus(projectId);
const worktrunkInstallVerified = worktrunkInstall.status === "installed";
@@ -3693,7 +3695,7 @@ export function SettingsModal({
return (
<>
{renderScopeBanner()}
-
Appearance
+ {t("settings.appearance.title", "Appearance")}
({ lng: "en" }));
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ i18n: { resolvedLanguage: state.lng, language: state.lng },
+ t: (k: string, d?: string) => d ?? k,
+ }),
+}));
+
+const { useLocaleFormat } = await import("../format");
+
+describe("useLocaleFormat", () => {
+ it("formats numbers with the active locale's separators", () => {
+ state.lng = "en";
+ const en = renderHook(() => useLocaleFormat()).result.current.formatNumber(1234567);
+ expect(en).toContain(",");
+
+ state.lng = "fr";
+ const fr = renderHook(() => useLocaleFormat()).result.current.formatNumber(1234567);
+ expect(fr).not.toContain(",");
+ expect(fr).not.toBe(en);
+ });
+
+ it("formats dates per active locale", () => {
+ const date = new Date(Date.UTC(2026, 0, 15));
+ state.lng = "en";
+ const en = renderHook(() => useLocaleFormat()).result.current.formatDate(date, {
+ month: "long",
+ timeZone: "UTC",
+ });
+ state.lng = "fr";
+ const fr = renderHook(() => useLocaleFormat()).result.current.formatDate(date, {
+ month: "long",
+ timeZone: "UTC",
+ });
+ expect(en).toMatch(/January/);
+ expect(fr).toMatch(/janvier/);
+ });
+
+ it("exposes the resolved locale", () => {
+ state.lng = "zh-CN";
+ expect(renderHook(() => useLocaleFormat()).result.current.locale).toBe("zh-CN");
+ });
+});
diff --git a/packages/dashboard/app/i18n/__tests__/labels.test.ts b/packages/dashboard/app/i18n/__tests__/labels.test.ts
new file mode 100644
index 0000000000..04c9837a07
--- /dev/null
+++ b/packages/dashboard/app/i18n/__tests__/labels.test.ts
@@ -0,0 +1,27 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+const state = vi.hoisted(() => ({ map: {} as Record }));
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, def?: string) => state.map[key] ?? def ?? key,
+ }),
+}));
+
+const { useColumnLabel } = await import("../labels");
+
+describe("useColumnLabel", () => {
+ it("falls back to the English COLUMN_LABELS when no translation exists", () => {
+ state.map = {};
+ const label = renderHook(() => useColumnLabel()).result.current;
+ expect(label("done")).toBe("Done");
+ expect(label("in-progress")).toBe("In Progress");
+ });
+
+ it("uses the translated value when present", () => {
+ state.map = { "columns.done": "完成", "columns.in-progress": "进行中" };
+ const label = renderHook(() => useColumnLabel()).result.current;
+ expect(label("done")).toBe("完成");
+ expect(label("in-progress")).toBe("进行中");
+ });
+});
diff --git a/packages/dashboard/app/i18n/format.ts b/packages/dashboard/app/i18n/format.ts
new file mode 100644
index 0000000000..75231cb755
--- /dev/null
+++ b/packages/dashboard/app/i18n/format.ts
@@ -0,0 +1,25 @@
+import { useTranslation } from "react-i18next";
+
+/**
+ * Locale-aware date/number formatting bound to the active i18n locale.
+ *
+ * Replaces the ~45 `toLocale*(undefined, …)` call sites that previously used
+ * the implicit browser locale. Routing them through this hook threads the
+ * user's chosen language into all date/number formatting (R8).
+ */
+export function useLocaleFormat() {
+ const { i18n } = useTranslation();
+ const locale = i18n.resolvedLanguage || i18n.language || "en";
+
+ return {
+ locale,
+ formatDate: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) =>
+ new Date(value).toLocaleDateString(locale, options),
+ formatTime: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) =>
+ new Date(value).toLocaleTimeString(locale, options),
+ formatDateTime: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) =>
+ new Date(value).toLocaleString(locale, options),
+ formatNumber: (value: number, options?: Intl.NumberFormatOptions) =>
+ value.toLocaleString(locale, options),
+ };
+}
diff --git a/packages/dashboard/app/i18n/labels.ts b/packages/dashboard/app/i18n/labels.ts
new file mode 100644
index 0000000000..6f9a8981dc
--- /dev/null
+++ b/packages/dashboard/app/i18n/labels.ts
@@ -0,0 +1,13 @@
+import { COLUMN_LABELS, type Column } from "@fusion/core";
+import { useTranslation } from "react-i18next";
+
+/**
+ * Returns a translator for board column labels, backed by the `common:columns.*`
+ * keys with the English `COLUMN_LABELS` as the fallback. This is the migration
+ * pattern for the centralized core label constants: import the hook, call it,
+ * and replace `COLUMN_LABELS[col]` with `columnLabel(col)`.
+ */
+export function useColumnLabel(): (column: Column) => string {
+ const { t } = useTranslation("common");
+ return (column: Column) => t(`columns.${column}`, COLUMN_LABELS[column]);
+}