diff --git a/docs/superpowers/specs/2026-06-03-localization-design.md b/docs/superpowers/specs/2026-06-03-localization-design.md new file mode 100644 index 0000000..4125290 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-localization-design.md @@ -0,0 +1,114 @@ +# 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.