diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx
index 7c0a2d7c51..c923c418bb 100644
--- a/packages/dashboard/app/App.tsx
+++ b/packages/dashboard/app/App.tsx
@@ -49,6 +49,8 @@ import { useProjects } from "./hooks/useProjects";
import { useAgents } from "./hooks/useAgents";
import { useNodes } from "./hooks/useNodes";
import { useCurrentProject } from "./hooks/useCurrentProject";
+import { I18nextProvider } from "react-i18next";
+import i18n from "./i18n";
import { ToastProvider, useToast } from "./hooks/useToast";
import { ConfirmDialogProvider } from "./hooks/useConfirm";
import { useTheme } from "./hooks/useTheme";
@@ -2043,16 +2045,18 @@ function AppInner() {
export function App() {
return (
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/packages/dashboard/app/i18n/index.ts b/packages/dashboard/app/i18n/index.ts
new file mode 100644
index 0000000000..ef1d498f92
--- /dev/null
+++ b/packages/dashboard/app/i18n/index.ts
@@ -0,0 +1,55 @@
+import { baseInitOptions, DASHBOARD_NAMESPACES, DEFAULT_NAMESPACE } from "@fusion/i18n/config";
+import i18next from "i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import resourcesToBackend from "i18next-resources-to-backend";
+import { initReactI18next } from "react-i18next";
+
+/**
+ * Browser i18next instance for the dashboard.
+ *
+ * Catalogs are loaded lazily per locale: only the active locale's namespaces
+ * are fetched on first paint; switching language fetches the new locale's
+ * chunk on demand. The dynamic import is **app-relative** over the generated
+ * `app/locales/` tree (synced from @fusion/i18n by scripts/sync-locales.mjs)
+ * so Vite statically analyses it and emits one chunk per locale/namespace.
+ */
+
+/** localStorage key for the persisted language. Uses the neighbor-consistent
+ * `kb-dashboard-*` prefix (see useTheme.ts) — not changed to `fn-` here; that
+ * belongs to the brand-rename track. */
+export const LANGUAGE_STORAGE_KEY = "kb-dashboard-language";
+
+i18next
+ .use(LanguageDetector)
+ .use(
+ resourcesToBackend(
+ (language: string, namespace: string) =>
+ import(`../locales/${language}/${namespace}.json`),
+ ),
+ )
+ .use(initReactI18next);
+
+export const i18nReady = i18next.init({
+ ...baseInitOptions(),
+ ns: [...DASHBOARD_NAMESPACES],
+ defaultNS: DEFAULT_NAMESPACE,
+ detection: {
+ order: ["localStorage", "navigator", "htmlTag"],
+ lookupLocalStorage: LANGUAGE_STORAGE_KEY,
+ caches: ["localStorage"],
+ },
+ react: {
+ // First paint is gated on `i18nReady` in main.tsx, so Suspense is not
+ // needed to avoid raw-key flashes and would otherwise require a boundary
+ // around every translated subtree.
+ useSuspense: false,
+ },
+});
+
+i18next.on("languageChanged", (language) => {
+ if (typeof document !== "undefined") {
+ document.documentElement.lang = language;
+ }
+});
+
+export default i18next;
diff --git a/packages/dashboard/app/main.tsx b/packages/dashboard/app/main.tsx
index 4af2daf17e..c40723db0a 100644
--- a/packages/dashboard/app/main.tsx
+++ b/packages/dashboard/app/main.tsx
@@ -8,6 +8,7 @@ import { installVersionCheck } from "./versionCheck";
import { installSwUpdate } from "./swUpdate";
import { bootstrapShellHostContext } from "./shell-host";
import { registerBundledPluginViews } from "./plugins/registerBundledPluginViews";
+import { i18nReady } from "./i18n";
import "./styles.css";
// Install the bearer-token fetch wrapper before React mounts so every API
@@ -19,14 +20,20 @@ installVersionCheck();
bootstrapShellHostContext();
registerBundledPluginViews();
-createRoot(document.getElementById("root")!).render(
-
-
-
-
-
-
- ,
-);
+// Gate first paint on the active locale's catalogs so the UI never flashes raw
+// translation keys. The catalog is a small local chunk, so this is a brief
+// wait; `.finally` ensures we still render if i18n init fails (strings then
+// fall back to keys/en rather than blocking the app).
+void i18nReady.finally(() => {
+ createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+ ,
+ );
-installSwUpdate();
+ installSwUpdate();
+});
diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json
index 7ff2cd7be2..65169a169e 100644
--- a/packages/dashboard/package.json
+++ b/packages/dashboard/package.json
@@ -52,8 +52,12 @@
"README.md"
],
"scripts": {
+ "gen:locales": "node scripts/sync-locales.mjs",
+ "verify:locale-chunks": "node scripts/assert-locale-chunks.mjs",
+ "prebuild": "node scripts/sync-locales.mjs",
"build": "vite build && tsc",
"build:client": "vite build",
+ "predev:serve": "node scripts/sync-locales.mjs",
"dev": "pnpm build && pnpm typecheck && pnpm dev:serve",
"dev:serve": "vite dev",
"pretest": "node ../../scripts/ensure-test-artifacts.mjs",
diff --git a/packages/dashboard/scripts/assert-locale-chunks.mjs b/packages/dashboard/scripts/assert-locale-chunks.mjs
new file mode 100644
index 0000000000..37cf38c71f
--- /dev/null
+++ b/packages/dashboard/scripts/assert-locale-chunks.mjs
@@ -0,0 +1,50 @@
+// KTD3a regression guard: after a client build, assert that each locale's
+// catalogs are emitted as their own async chunks and are NOT folded into the
+// main entry chunk. If the app-relative dynamic import ever stops being
+// statically analysable, Vite silently inlines every catalog into the main
+// bundle with only a build warning — this check turns that into a hard failure.
+import { existsSync, readFileSync, readdirSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const assetsDir = join(here, "..", "dist", "client", "assets");
+const namespaces = ["common", "app", "errors"];
+
+if (!existsSync(assetsDir)) {
+ console.error(`assert-locale-chunks: ${assetsDir} not found — run the client build first.`);
+ process.exit(1);
+}
+
+const files = readdirSync(assetsDir);
+const errors = [];
+
+// One chunk per dashboard namespace per locale (5 locales) → at least 5 each.
+for (const ns of namespaces) {
+ const matches = files.filter((f) => new RegExp(`^${ns}-[^/]+\\.js$`).test(f));
+ if (matches.length < 5) {
+ errors.push(
+ `expected >=5 split chunks for namespace "${ns}" (one per locale), found ${matches.length}`,
+ );
+ }
+}
+
+// The main entry chunk must not carry catalog payloads — a translated marker
+// string from a non-en catalog appearing in index-*.js means splitting broke.
+const indexFile = files.find((f) => /^index-[^/]+\.js$/.test(f));
+if (indexFile) {
+ const body = readFileSync(join(assetsDir, indexFile), "utf8");
+ // i18next-resources-to-backend chunks are referenced by dynamic import, not
+ // inlined; a literal catalog object in index would show the column labels.
+ if (/"in-review":"In Review"/.test(body) && /"archived":"Archived"/.test(body)) {
+ errors.push(`catalog content found inlined in ${indexFile} — locale chunks were not split`);
+ }
+}
+
+if (errors.length) {
+ console.error("assert-locale-chunks FAILED:");
+ for (const e of errors) console.error(` - ${e}`);
+ process.exit(1);
+}
+
+console.log("assert-locale-chunks: per-locale catalog chunks emitted correctly.");
diff --git a/packages/dashboard/scripts/sync-locales.mjs b/packages/dashboard/scripts/sync-locales.mjs
new file mode 100644
index 0000000000..e442f47141
--- /dev/null
+++ b/packages/dashboard/scripts/sync-locales.mjs
@@ -0,0 +1,30 @@
+// Copies the authored @fusion/i18n catalogs into the dashboard tree so Vite can
+// code-split them per locale via a plainly app-relative dynamic import. The
+// generated app/locales/ dir is gitignored — @fusion/i18n/locales is the
+// source-of-truth. Only the dashboard namespaces are copied (the terminal-only
+// `cli` namespace is skipped). Runs as a predev/prebuild step.
+import { cpSync, mkdirSync, readdirSync, rmSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const dashboardRoot = join(here, "..");
+const srcLocales = join(dashboardRoot, "..", "i18n", "locales");
+const destLocales = join(dashboardRoot, "app", "locales");
+
+// Keep in sync with DASHBOARD_NAMESPACES in @fusion/i18n config.ts.
+const DASHBOARD_NAMESPACES = ["common", "app", "errors"];
+
+const locales = readdirSync(srcLocales, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => d.name);
+
+rmSync(destLocales, { recursive: true, force: true });
+for (const lng of locales) {
+ mkdirSync(join(destLocales, lng), { recursive: true });
+ for (const ns of DASHBOARD_NAMESPACES) {
+ cpSync(join(srcLocales, lng, `${ns}.json`), join(destLocales, lng, `${ns}.json`));
+ }
+}
+
+console.log(`Synced ${locales.length} locale(s) into app/locales: ${locales.join(", ")}`);
diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts
index b7e9adc6cc..f973f74394 100644
--- a/packages/dashboard/vite.config.ts
+++ b/packages/dashboard/vite.config.ts
@@ -168,6 +168,15 @@ export default defineConfig({
return "vendor-codemirror";
}
+ if (
+ id.includes("/node_modules/i18next/") ||
+ id.includes("/node_modules/react-i18next/") ||
+ id.includes("/node_modules/i18next-browser-languagedetector/") ||
+ id.includes("/node_modules/i18next-resources-to-backend/")
+ ) {
+ return "vendor-i18n";
+ }
+
return undefined;
},
},