diff --git a/.planning/phases/34-i18n-foundation/34-RESEARCH.md b/.planning/phases/34-i18n-foundation/34-RESEARCH.md new file mode 100644 index 0000000..7b7fafb --- /dev/null +++ b/.planning/phases/34-i18n-foundation/34-RESEARCH.md @@ -0,0 +1,281 @@ +# 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 diff --git a/.planning/phases/34-i18n-foundation/34-VALIDATION.md b/.planning/phases/34-i18n-foundation/34-VALIDATION.md new file mode 100644 index 0000000..4f1eb19 --- /dev/null +++ b/.planning/phases/34-i18n-foundation/34-VALIDATION.md @@ -0,0 +1,79 @@ +--- +phase: 34 +slug: i18n-foundation +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-04-13 +--- + +# Phase 34 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | Bun test runner + Playwright | +| **Config file** | `bunfig.toml` / `playwright.config.ts` | +| **Quick run command** | `bun test` | +| **Full suite command** | `bun test && bun run test:e2e` | +| **Estimated runtime** | ~15 seconds (unit) + ~60 seconds (e2e) | + +--- + +## Sampling Rate + +- **After every task commit:** Run `bun test` +- **After every plan wave:** Run `bun test && bun run build` +- **Before `/gsd-verify-work`:** Full suite must be green +- **Max feedback latency:** 15 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| 34-01-01 | 01 | 1 | D-05 | — | N/A | unit | `bun test tests/i18n/init.test.ts` | ❌ W0 | ⬜ pending | +| 34-01-02 | 01 | 1 | D-06 | — | N/A | unit | `bun test tests/i18n/locales.test.ts` | ❌ W0 | ⬜ pending | +| 34-02-01 | 02 | 1 | D-01 | — | N/A | unit | `bun test` | ✅ | ⬜ pending | +| 34-03-01 | 03 | 2 | D-04 | — | N/A | unit | `bun test tests/i18n/formatters.test.ts` | ❌ W0 | ⬜ pending | +| 34-04-01 | 04 | 2 | D-09, D-10, D-11 | — | N/A | unit | `bun test tests/i18n/language-hook.test.ts` | ❌ W0 | ⬜ pending | +| 34-05-01 | 05 | 3 | D-13, D-14 | — | N/A | manual | Visual check: German UI text | N/A | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/i18n/init.test.ts` — i18n initialization loads both locales +- [ ] `tests/i18n/locales.test.ts` — all en keys have corresponding de keys +- [ ] `tests/i18n/formatters.test.ts` — locale-aware formatting produces correct output +- [ ] `tests/i18n/language-hook.test.ts` — language hook returns correct value from settings + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| German UI text renders correctly | D-13 | Visual quality of AI-generated translations | Switch to German in settings, navigate all pages, verify text is natural German | +| Language picker pill-toggle UX | D-11 | Visual layout consistency with weight/currency toggles | Open settings, verify language picker matches existing toggle patterns | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 15s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending