# 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
