Files
GearBox/.planning/phases/34-i18n-foundation/34-RESEARCH.md

11 KiB

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 reusei18next 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 loadingi18next-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)

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:

// 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:

// 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:

// 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:

{
  "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:

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 testsuseLanguage() hook returns correct language from settings
  3. Unit testsformatPrice() with locale produces correct output for en and de
  4. Unit testsformatWeight() 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