Files
ClaudeDo/docs/superpowers/specs/2026-06-03-localization-design.md
mika kuns baeea9c2a7 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>
2026-06-03 11:19:03 +02:00

7.7 KiB

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:

{
  "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.