docs: localization (i18n) design spec
Live-switching, JSON locale files, shared ClaudeDo.Localization project, English-only at launch with data-driven extensibility, installer parity. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
114
docs/superpowers/specs/2026-06-03-localization-design.md
Normal file
114
docs/superpowers/specs/2026-06-03-localization-design.md
Normal file
@@ -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<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.
|
||||
Reference in New Issue
Block a user