# Phase 34: i18n Foundation - Research **Researched:** 2026-04-13 **Status:** Complete ## Library Evaluation ### react-i18next vs Lingui | Criterion | react-i18next | Lingui | |-----------|--------------|--------| | React 19 support | Yes (v15+) | Yes (v5+) | | Hook-based API | `useTranslation()` | `useLingui()` | | Lazy loading | Built-in (`react-i18next/icu`) backend, dynamic imports | Catalog-based lazy loading via `@lingui/loader` | | TypeScript support | Strong with `i18next` typed resources | Strong with compiled catalogs | | Bundle size | ~10kb (i18next core + react-i18next) | ~5kb (runtime only) | | Vite plugin | `i18next-resources-for-ts` or manual | `@lingui/vite-plugin` | | Extraction tooling | `i18next-parser` (CLI) | `@lingui/cli extract` (built-in) | | JSON file format | Native JSON key-value | PO files or JSON catalogs | | Bun/Hono server-side | `i18next` works standalone (no React dependency) | `@lingui/core` works standalone | | Community/ecosystem | Larger ecosystem, more plugins | Growing, more opinionated | | Namespace support | Built-in first-class | Via message IDs with prefixes | | Interpolation | `{{name}}` syntax | `{name}` syntax with ICU | **Recommendation: react-i18next** Reasons: 1. **Namespace support is first-class** — CONTEXT.md decision D-08 requires namespaces by feature area. react-i18next has this built-in; Lingui requires manual ID prefixing. 2. **JSON translation files** — Decision D-06 specifies JSON files in `src/client/locales/`. react-i18next uses plain JSON natively. Lingui prefers PO files or its own catalog format. 3. **Server-side reuse** — `i18next` core (no React) can be used directly in Hono routes and MCP tool descriptions. Same translation files, same API. 4. **Larger ecosystem** — More documentation, Stack Overflow answers, and community plugins for future needs (Crowdin/Lokalise integration mentioned in D-06). 5. **Lazy loading** — `i18next-http-backend` or dynamic imports work cleanly with Vite code splitting. ### Required Packages ``` i18next # Core translation engine react-i18next # React bindings (useTranslation hook) i18next-browser-languagedetector # Auto-detect browser locale (D-10) ``` No additional Vite plugins needed — JSON imports work natively. ## Architecture Design ### Client-Side Setup ``` src/client/ ├── locales/ │ ├── en/ │ │ ├── common.json # Shared: buttons, labels, navigation │ │ ├── collection.json # Collection page strings │ │ ├── threads.json # Research threads strings │ │ ├── setups.json # Setups strings │ │ ├── onboarding.json # Onboarding flow strings │ │ └── settings.json # Settings page strings │ └── de/ │ ├── common.json │ ├── collection.json │ ├── threads.json │ ├── setups.json │ ├── onboarding.json │ └── settings.json ├── lib/ │ └── i18n.ts # i18next initialization ``` **Namespace strategy (D-08):** - `common` — buttons ("Save", "Cancel", "Delete"), nav items, shared labels, error messages, empty states - `collection` — collection page, item forms, item cards - `threads` — thread list, thread detail, candidate forms - `setups` — setup list, setup detail, impact preview - `onboarding` — welcome, hobby picker, item browser, review, done screens - `settings` — settings page labels, API keys section, import/export ### i18n Initialization (`src/client/lib/i18n.ts`) ```typescript import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; // Eager-load both locales (small app, 2 languages) import enCommon from "../locales/en/common.json"; import enCollection from "../locales/en/collection.json"; import enThreads from "../locales/en/threads.json"; import enSetups from "../locales/en/setups.json"; import enOnboarding from "../locales/en/onboarding.json"; import enSettings from "../locales/en/settings.json"; import deCommon from "../locales/de/common.json"; import deCollection from "../locales/de/collection.json"; import deThreads from "../locales/de/threads.json"; import deSetups from "../locales/de/setups.json"; import deOnboarding from "../locales/de/onboarding.json"; import deSettings from "../locales/de/settings.json"; i18n .use(LanguageDetector) .use(initReactI18next) .init({ resources: { en: { common: enCommon, collection: enCollection, threads: enThreads, setups: enSetups, onboarding: enOnboarding, settings: enSettings, }, de: { common: deCommon, collection: deCollection, threads: deThreads, setups: deSetups, onboarding: deOnboarding, settings: deSettings, }, }, fallbackLng: "en", defaultNS: "common", interpolation: { escapeValue: false, // React handles XSS }, detection: { order: ["localStorage", "navigator"], lookupLocalStorage: "gearbox-language", caches: ["localStorage"], }, }); export default i18n; ``` **Note on lazy loading:** With only 2 languages and ~200 strings, eager loading all namespaces is simpler and avoids loading spinners. Total JSON payload is <20KB gzipped. Lazy loading can be added later when more languages are added. ### Integration with `useFormatters()` Decision D-04 specifies i18n integrates with the existing formatters hook. The formatters currently use manual string concatenation. With i18n, number and date formatting should use `Intl.NumberFormat` and `Intl.DateTimeFormat` for locale-aware output. **Approach:** Extend `useFormatters()` to accept locale from i18n context and pass it to `formatWeight()` and `formatPrice()`. The format functions gain a `locale` parameter: ```typescript // formatPrice now uses Intl.NumberFormat for locale-aware number display export function formatPrice(cents: number | null, currency: Currency, locale: string): string { if (cents == null) return "--"; return new Intl.NumberFormat(locale, { style: "currency", currency, minimumFractionDigits: currency === "JPY" ? 0 : 2, }).format(cents / 100); } ``` This replaces the manual symbol lookup with `Intl.NumberFormat` which handles symbol placement, decimal separators, and grouping per locale (e.g., German: `1.234,56 €` vs English: `$1,234.56`). ### Language Setting Storage Following the `useWeightUnit()` and `useCurrency()` pattern: ```typescript // src/client/hooks/useLanguage.ts export function useLanguage(): string { const { data } = useSetting("language"); return data && VALID_LANGUAGES.includes(data) ? data : "en"; } ``` **Key difference from weight/currency:** Language changes need to call `i18n.changeLanguage()` in addition to persisting via `useSetting()`. A `useEffect` in the root layout (or the `useLanguage` hook) syncs the i18n instance when the setting changes. ### Server-Side Translation MCP tool descriptions and API error messages need server-side translation. Since Hono runs on Bun (not browser), use `i18next` core directly: ```typescript // src/server/lib/i18n.ts import i18next from "i18next"; import en from "../../client/locales/en/common.json"; import de from "../../client/locales/de/common.json"; const serverI18n = i18next.createInstance(); serverI18n.init({ lng: "en", // Default server language resources: { en: { common: en }, de: { common: de } }, defaultNS: "common", }); export function t(key: string, lng?: string): string { return serverI18n.t(key, { lng }); } ``` **MCP tool descriptions:** These are registered once at server start and consumed by AI clients. They should remain in English — AI models work best with English tool descriptions. Server-side i18n applies to API error messages returned to the browser, not MCP tool descriptions. ### String Key Convention **Nested keys with dot notation:** ```json { "nav": { "collection": "Collection", "setups": "Setups", "discover": "Discover", "settings": "Settings" }, "actions": { "save": "Save", "cancel": "Cancel", "delete": "Delete", "edit": "Edit", "create": "Create" }, "empty": { "noItems": "No items yet", "noThreads": "No research threads yet" } } ``` Access pattern: `t("nav.collection")`, `t("actions.save")`. ### Language Picker UX Reuse the pill-toggle pattern from weight unit and currency in settings: ```tsx const LANGUAGES = [ { value: "en", label: "English" }, { value: "de", label: "Deutsch" }, ]; ``` Place in the settings page above weight unit, since language is the most fundamental preference. ### String Extraction Strategy Given ~200 strings across ~50 components, extraction should be done systematically by feature area matching the namespace structure: 1. **Common** — TopNav, BottomTabBar, FabMenu, ConfirmDialog, AuthPromptModal, empty states 2. **Collection** — CollectionView, ItemCard, ItemForm, CategoryPicker, CategoryHeader, WeightSummaryCard 3. **Threads** — ThreadCard, ThreadTabs, CandidateCard, CandidateForm, ComparisonTable, CreateThreadModal 4. **Setups** — SetupsView, SetupCard, SetupImpactSelector, ShareModal 5. **Onboarding** — OnboardingWelcome, OnboardingHobbyPicker, OnboardingItemBrowser, OnboardingReview, OnboardingDone 6. **Settings** — SettingsPage (weight, currency, language, API keys, import/export) ## Validation Architecture ### Test Strategy 1. **Unit tests** — i18n initialization loads both locales without error 2. **Unit tests** — `useLanguage()` hook returns correct language from settings 3. **Unit tests** — `formatPrice()` with locale produces correct output for en and de 4. **Unit tests** — `formatWeight()` with locale produces correct output for en and de 5. **Integration test** — Language change via settings API persists and takes effect 6. **E2E test** — Switch language in settings, verify UI text changes to German ### Completeness Checks - Every `en/*.json` key has a corresponding `de/*.json` key (no missing translations) - No hardcoded English strings remain in components that have been extracted - `i18n.ts` registers all namespaces for both languages - Language picker appears in settings and persists selection ## Risk Assessment | Risk | Impact | Mitigation | |------|--------|------------| | React 19 compatibility with react-i18next | Medium | react-i18next v15+ supports React 19. Pin compatible version. | | Bundle size increase | Low | i18next + react-i18next is ~10KB gzipped. JSON files add <10KB per language. | | String extraction misses some strings | Low | Incremental approach — extract by namespace area, verify each area. | | German translation quality | Low | AI-generated is acceptable per D-14. User corrects organically. | | Formatter locale breaking existing tests | Medium | Update test helpers to pass locale. Existing tests keep "en" default. | ## Dependencies - **Phase 33 (Currency System):** Language detection should follow the same browser auto-detection pattern. The `useCurrency` hook pattern is the model for `useLanguage`. Phase 33 may add market auto-detection; language auto-detection is independent but similar. - **No schema changes needed:** Language preference stored in existing `settings` table via `useSetting("language")`. ## RESEARCH COMPLETE