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" },