diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
index 4f358cc634..b2459cd926 100644
--- a/frontend/.storybook/preview.tsx
+++ b/frontend/.storybook/preview.tsx
@@ -1,3 +1,4 @@
+///
// Storybook compiles .storybook/* with the classic JSX runtime, so the JSX in
// the decorators below transpiles to React.createElement and needs React in
// scope. (The app + story files use the automatic runtime via the portal vite
@@ -22,28 +23,38 @@ import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import { parse as parseToml } from "smol-toml";
-// Load the real English copy so stories render human text, not raw keys. Bundled
-// synchronously via ?raw so it's present on the very first render (no async flash).
-import enTranslationToml from "@public/locales/en-US/translation.toml?raw";
+import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
import "@mantine/core/styles.css";
import "@core/tokens/tokens.css";
import "@core/theme/index.css";
import "@core/tokens/base.css";
-// Storybook-only: init react-i18next with the real English resources parsed from
-// the app's TOML, so t(key) renders the shipped copy (e.g. "No sources connected
-// yet") rather than the raw key. Falls back to an empty bundle if parsing ever
-// fails, so a malformed TOML can't take the whole Storybook down.
-function parseEnTranslation(): Record {
+// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
+// glob, so the toolbar language switcher can flip between all languages with no
+// async fetch (Storybook has no backend to serve /locales/). t(key) then renders
+// the shipped copy (e.g. "No sources connected yet") rather than the raw key.
+const localeModules = import.meta.glob(
+ "../editor/public/locales/*/translation.toml",
+ { query: "?raw", import: "default", eager: true },
+);
+
+// Parse each locale into an i18next resources map. A malformed TOML degrades to
+// an empty bundle for that one locale (its keys fall back to en-US) rather than
+// taking the whole Storybook down.
+const resources: Record }> = {};
+for (const [path, raw] of Object.entries(localeModules)) {
+ const lng = path.match(/\/locales\/([^/]+)\/translation\.toml$/)?.[1];
+ if (!lng) continue;
+ let translation: Record;
try {
- return parseToml(enTranslationToml) as Record;
+ translation = parseToml(raw) as Record;
} catch {
- return {};
+ translation = {};
}
+ resources[lng] = { translation };
}
-const enTranslationResources = parseEnTranslation();
if (!i18next.isInitialized) {
// initImmediate: false → initialise synchronously from the inline resources
// (there's no async backend here), so i18next is ready before the first story
@@ -51,24 +62,25 @@ if (!i18next.isInitialized) {
void i18next.use(initReactI18next).init({
lng: "en-US",
fallbackLng: "en-US",
- supportedLngs: ["en-US"],
- resources: { "en-US": { translation: enTranslationResources } },
+ supportedLngs: Object.keys(resources),
+ resources,
interpolation: { escapeValue: false },
react: { useSuspense: false },
initImmediate: false,
});
} else {
// Something initialised i18next first (e.g. the app's async TOML backend):
- // inject the shipped English copy under the resolved language so t() renders
- // real copy, not raw keys.
- const lng = i18next.resolvedLanguage || i18next.language || "en-US";
- i18next.addResourceBundle(
- lng,
- "translation",
- enTranslationResources,
- true,
- true,
- );
+ // inject every shipped locale's copy so t() renders real copy, not raw keys,
+ // and the toolbar switcher can still change to any of them.
+ for (const [lng, bundle] of Object.entries(resources)) {
+ i18next.addResourceBundle(
+ lng,
+ "translation",
+ bundle.translation,
+ true,
+ true,
+ );
+ }
}
// Start MSW once. Storybook runs in a browser so this uses the service worker.
@@ -156,6 +168,19 @@ function SchemeSetup({ scheme }: { scheme: "light" | "dark" }) {
return null;
}
+/** Switches i18next to the toolbar locale and keeps document dir/lang in sync. */
+const withLocale: Decorator = (Story, context) => {
+ const locale = (context.globals.locale as string) ?? "en-US";
+ useEffect(() => {
+ void i18next.changeLanguage(locale);
+ document.documentElement.dir = rtlLanguages.includes(locale)
+ ? "rtl"
+ : "ltr";
+ document.documentElement.lang = locale;
+ }, [locale]);
+ return ;
+};
+
const withProviders: Decorator = (Story, context) => {
const tier = (context.globals.tier as Tier) ?? "pro";
const linkState =
@@ -243,8 +268,22 @@ const preview: Preview = {
dynamicTitle: true,
},
},
+ locale: {
+ name: "Locale",
+ description: "Active language — drives useTranslation() in all stories",
+ defaultValue: "en-US",
+ toolbar: {
+ icon: "globe",
+ items: Object.entries(supportedLanguages).map(([value, title]) => ({
+ value,
+ title: `${value} - ${title}`,
+ })),
+ dynamicTitle: true,
+ },
+ },
},
decorators: [
+ withLocale,
withProviders,
withThemeByDataAttribute({
themes: { light: "light", dark: "dark" },