# 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`** 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.