---
phase: 34-i18n-foundation
plan: 04
type: execute
wave: 2
depends_on: [01, 03]
files_modified:
- src/client/routes/settings.tsx
- src/client/routes/__root.tsx
- src/client/lib/i18n.ts
autonomous: true
requirements: [D-09, D-10, D-11, D-12]
must_haves:
truths:
- "Language picker appears in settings page with English and Deutsch options"
- "Language picker uses the pill-toggle pattern matching weight unit and currency pickers"
- "Selecting a language persists via updateSetting('language', value)"
- "Selecting a language calls i18n.changeLanguage(value) to update the UI immediately"
- "Language picker is placed above weight unit in settings page"
- "Browser auto-detection works on first visit (navigator.language)"
- "Unknown browser locales fall back to English"
artifacts:
- path: "src/client/routes/settings.tsx"
provides: "Language picker UI"
contains: "language"
- path: "src/client/routes/__root.tsx"
provides: "i18n language sync with settings"
contains: "changeLanguage"
key_links:
- from: "src/client/routes/settings.tsx"
to: "src/client/hooks/useLanguage.ts"
via: "useLanguage() import"
pattern: "useLanguage"
- from: "src/client/routes/__root.tsx"
to: "src/client/lib/i18n.ts"
via: "i18n.changeLanguage"
pattern: "changeLanguage"
---
Add language picker to settings page and wire language changes to i18n instance.
Purpose: User controls — users can see their current language, change it, and the UI updates immediately.
Output: Language picker in settings, i18n sync on language change, browser auto-detection on first visit.
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/34-i18n-foundation/34-CONTEXT.md
@.planning/phases/34-i18n-foundation/34-RESEARCH.md
Current settings.tsx pill-toggle pattern (weight unit):
```tsx
Weight Unit
Choose the unit used to display weights across the app
{UNITS.map((u) => (
))}
```
useLanguage hook (from Plan 03):
```typescript
export const VALID_LANGUAGES = ["en", "de"] as const;
export type Language = (typeof VALID_LANGUAGES)[number];
export function useLanguage(): Language { ... }
```
i18n instance:
```typescript
import i18n from "../lib/i18n";
i18n.changeLanguage("de"); // switches language
```
Task 1: Add language picker to settings pagesrc/client/routes/settings.tsxsrc/client/routes/settings.tsx, src/client/hooks/useLanguage.ts, src/client/locales/en/settings.json
- Settings page imports useLanguage from hooks/useLanguage
- Settings page imports i18n from lib/i18n
- Language picker section appears ABOVE the weight unit section (first preference in the list)
- Language picker uses the same pill-toggle pattern as weight unit and currency
- Options: "English" (value: "en") and "Deutsch" (value: "de")
- Clicking an option calls updateSetting.mutate({ key: "language", value }) AND i18n.changeLanguage(value)
- Active language is highlighted with the same styling pattern
- Label and description use t() keys from settings namespace
Update `src/client/routes/settings.tsx`:
1. Add imports:
```typescript
import i18n from "../lib/i18n";
import { useLanguage } from "../hooks/useLanguage";
```
2. In the `SettingsPage` component, add after `const updateSetting = useUpdateSetting();`:
```typescript
const language = useLanguage();
```
3. Add a `LANGUAGES` constant at the top of the file (near UNITS and CURRENCIES):
```typescript
const LANGUAGES = [
{ value: "en", label: "English" },
{ value: "de", label: "Deutsch" },
];
```
4. Add the language picker section BEFORE the weight unit section (first item in the settings card). It uses the same pill-toggle pattern:
```tsx
{t("language.title")}
{t("language.description")}
{LANGUAGES.map((lang) => (
))}
```
5. Add these keys to `src/client/locales/en/settings.json` if not already present:
```json
{
"language": {
"title": "Language",
"description": "Change the display language of the app"
}
}
```
**NOTE:** Language labels ("English", "Deutsch") are intentionally NOT translated — they should always appear in their native language so users can identify their language even when the UI is in another language.
- settings.tsx imports useLanguage and i18n
- settings.tsx has LANGUAGES constant with "en"/"English" and "de"/"Deutsch"
- Language picker section appears before weight unit section
- onClick handler calls both updateSetting.mutate and i18n.changeLanguage
- Language labels use native names ("English", "Deutsch"), not translated
- Pill-toggle styling matches weight unit and currency pickers
- settings.json has language.title and language.description keys
- `bun run build` succeeds
cd /home/jlmak/Projects/jlmak/GearBox && grep -c "useLanguage\|changeLanguage\|LANGUAGES" src/client/routes/settings.tsxLanguage picker added to settings matching existing preference UI patternTask 2: Sync i18n language with settings on app loadsrc/client/routes/__root.tsxsrc/client/routes/__root.tsx, src/client/hooks/useLanguage.ts, src/client/lib/i18n.ts
- RootLayout component syncs i18n language when useLanguage() value changes
- On first load, if user has a saved language preference, i18n switches to it
- If no saved preference, i18n uses the browser-detected language (already configured in i18n.ts detection)
- useEffect watches language value and calls i18n.changeLanguage when it changes
- This handles the case where a user has "de" saved in settings but i18n initially detected "en" from browser
Update `src/client/routes/__root.tsx`:
1. Add imports:
```typescript
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLanguage } from "../hooks/useLanguage";
```
Note: `useState` is already imported. Check if `useEffect` is already imported — if not, add it.
2. In the `RootLayout` function, add after existing hooks:
```typescript
const language = useLanguage();
const { i18n } = useTranslation();
useEffect(() => {
if (language && i18n.language !== language) {
i18n.changeLanguage(language);
}
}, [language, i18n]);
```
This syncs the i18n instance with the persisted language setting. On first load:
- i18next's LanguageDetector picks browser locale or localStorage cache
- useSetting("language") resolves from the DB
- If they differ, useEffect syncs i18n to the DB value (DB is source of truth)
On subsequent language changes via settings:
- updateSetting immediately calls i18n.changeLanguage (in settings.tsx)
- useLanguage() updates via React Query invalidation
- useEffect acts as a safety net if the values drift
- __root.tsx imports useLanguage from hooks/useLanguage
- __root.tsx imports useTranslation from react-i18next
- RootLayout has useEffect that calls i18n.changeLanguage(language)
- useEffect depends on [language, i18n]
- `bun run build` succeeds
cd /home/jlmak/Projects/jlmak/GearBox && grep -c "useLanguage\|changeLanguage\|useTranslation" src/client/routes/__root.tsxLanguage syncs between settings DB and i18n instance on load and change
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| settings API→i18n | Language value from DB flows into i18n.changeLanguage — validated |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-34-05 | Tampering | settings.tsx language picker | accept | Language values limited to LANGUAGES constant array ("en", "de"). Even if tampered, worst case is fallback to "en". |
- `bun run build` succeeds
- Language picker visible in settings page
- Clicking a language option changes the UI language
- Language preference persists across page reloads
- Language picker in settings with English and Deutsch options
- Pill-toggle pattern matches weight unit and currency pickers
- Language change persists and syncs with i18n
- Browser auto-detection works for first visit