# Localization (i18n) Support — Design

**Date:** 2026-06-03
**Status:** Approved (pending spec review)

## Goal

Add translation support to ClaudeDo. The user picks a language in the Settings modal and **all** UI text reflects it instantly (no restart). The WPF installer is localized the same way and gets its own language picker. Ship **English only** now, but the system is fully data-driven: adding a new language means dropping one JSON file into a folder — **no code changes, no rebuild**.

## Decisions (from brainstorming)

- **Languages:** English only at launch; extensible via translation files.
- **Switching:** Live / instant — all bound UI text updates the moment the language changes.
- **Storage:** Selected language stored in `~/.todo-app/ui.config.json` (the local UI config that also holds `DbPath`/`SignalRUrl`). Purely a UI concern — does **not** go through the worker/SignalR settings path.
- **Installer:** Defaults to existing config language (upgrade) → OS culture → English. Shows a language picker in the wizard, live-switches its own UI, and writes the chosen language into `ui.config.json` so the app launches matching the installer.
- **Locale files:** Loose `*.json` files in a `locales/` folder next to the running exe, scanned at startup to discover available languages.
- **Code sharing:** A shared `ClaudeDo.Localization` project holds the loading/lookup/language-list logic, referenced by `ClaudeDo.Ui`, `ClaudeDo.App`, and `ClaudeDo.Installer`. Each UI framework keeps its own thin markup-extension binding layer (Avalonia ≠ WPF).

## Architecture & Components

### New shared project: `ClaudeDo.Localization`

- **`LocaleStore`** — discovers and loads `*.json` files from the `locales/` folder next to the running exe. Parses each file's nested JSON, **flattens it into an internal `Dictionary<string,string>`** keyed by dot-path for O(1) lookup, and captures `metadata.code` / `metadata.name`. Exposes the list of available languages for the dropdowns.
- **`ILocalizer` / `Localizer`** — singleton holding the *active* language dictionary. Members:
  - indexer `this[string key]` → translated string (with fallback),
  - `string Get(string key, params object[] args)` → `string.Format` for parameterized strings,
  - `void SetLanguage(string code)` → swaps the active dictionary and raises `PropertyChanged` for the indexer so **all live bindings refresh** (this is what enables instant switching),
  - `AvailableLanguages` (list of `{ code, name }`), `CurrentCode`.
- **Fallback chain:** requested key in active language → same key in English → the key path string itself (a missing translation is visible, never a crash).
- **OS-culture resolution:** helper that maps the current OS UI culture to an available locale code, falling back to English.

### Per-framework binding layer (not shared)

- **Avalonia:** a `{loc:Tr Some.Key}` markup extension that binds to `Localizer[key]` (Source = the singleton `Localizer`, Path = `[key]`). Language change raises the indexer `PropertyChanged`, refreshing every binding.
- **WPF installer:** an equivalent markup extension doing the same against the installer's own `Localizer` instance.

Both consume the **same JSON files and the same `LocaleStore`/`Localizer` logic** from the shared project.

## Translation File Format

`locales/en.json` (and future `de.json`, `fr.json`, …) — nested, human-friendly hierarchy:

```json
{
  "metadata": { "code": "en", "name": "English" },
  "settings": {
    "save": "Save",
    "cancel": "Cancel",
    "general": { "model": "Model", "maxParallel": "Max parallel executions" }
  },
  "tasks": {
    "addPlaceholder": "Add a task…",
    "overdue": "OVERDUE"
  },
  "worktrees": { "autoCleanupDays": "{0} days" }
}
```

- `metadata.code` is the language id stored in `ui.config.json` and matched to OS culture; `metadata.name` is the dropdown label.
- **Lookup by dot-path key** (`"settings.general.model"`). On-disk file stays grouped/nested; the runtime flattens it for fast lookup. Authors edit a clean hierarchy.
- **Parameters:** `{0}`, `{1}` placeholders resolved via `Get(key, args)`.
- **Encoding:** UTF-8 — non-ASCII languages work out of the box.

## Data Flow & Wiring

### App config

- Add `Language` (string, e.g. `"en"`) to `AppSettings` (`ClaudeDo.Ui/AppSettings.cs`) and to the installer mirror `InstallerAppSettings` (`ClaudeDo.Installer/Core/ConfigModels.cs`).
- Add a `Save()` method to `AppSettings` (today the UI only reads it).

### App startup (`ClaudeDo.App/Program.cs`)

1. `AppSettings.Load()` reads `Language` (missing/empty → resolve from OS culture, else `"en"`).
2. `LocaleStore` scans `locales/` next to the exe; `Localizer` is registered as a singleton and set to the configured language.
3. UI renders; every `{loc:Tr ...}` binding pulls from the active dictionary.

### Changing language in Settings (General tab)

- New "Language" dropdown bound to `Localizer.AvailableLanguages`; selection bound to current code.
- On change → `Localizer.SetLanguage(code)` (instant UI refresh) **and** `AppSettings.Language = code; AppSettings.Save()`. Local UI state only — not routed through worker/SignalR.

### Installer (`ClaudeDo.Installer`)

- On launch: default language = existing `ui.config.json` `Language` if present (upgrade), else OS culture, else English.
- Wizard gets a language dropdown (same `LocaleStore`, installer's own markup extension) → live-switches the installer UI.
- When writing `ui.config.json`, persists the chosen `Language` so the app launches matching the installer.

### Build wiring

- `locales/*.json` copied to output (`CopyToOutputDirectory`) for both App and Installer.
- Installer packages the `locales/` folder so it lands beside the installed exe.

## String-Extraction Scope

Mechanical but large; done screen-by-screen so each commit is reviewable, building one `en.json` as the single source of truth.

- **22 Avalonia `.axaml` views** — replace inline `Text="..."`, `Content="..."`, `PlaceholderText="..."`, and inline `ComboBoxItem` text with `{loc:Tr key}`.
- **ViewModel strings** — user-facing literals built in C# (e.g. `HeaderTitle`, `StatusPill`, status text, parameterized messages) resolve via injected `ILocalizer` (`localizer.Get(...)`). Log messages and non-user-facing strings stay as-is. **Live-switch note:** a VM string resolved once will not refresh on language change. For VM-built user-facing text, either (a) prefer resolving in XAML via `{loc:Tr}` where possible, or (b) have the VM subscribe to the `Localizer` change event and re-raise `PropertyChanged` (or re-resolve) for its localized properties. Decide per-property during extraction.
- **10 WPF installer files** — same treatment with the installer's markup extension; VM-driven headings (`Heading`, `NextButtonText`, etc.) go through `ILocalizer`.
- **Enum-ish display values** (model names, permission modes, weekday names) — translate the *display* text while keeping the underlying value/binding intact.

## Testing

- `ClaudeDo.Localization` unit tests: load/flatten nested JSON, dot-path lookup, fallback chain (active→en→key), `{0}` formatting, OS-culture resolution.
- `LocaleStore` discovery test (folder scan → available languages).
- **Key-coverage test:** every locale file's flattened key set matches `en.json`; fails the build if `en.json` drifts from other locale files.
- Settings round-trip test: `SetLanguage` updates `Localizer` **and** persists to `ui.config.json`.
- Manual UI pass (user's visual review): confirm instant switching with a throwaway `de.json` stub during dev, then remove it.

## Out of Scope (YAGNI)

- Pluralization rules, RTL layout, per-string gender.
- Translating the German weekly-report **body** (generated content — stays as-is).
- Localizing log output and non-user-facing strings.
