# Localization (i18n) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add data-driven, instant-switching UI localization to ClaudeDo (Avalonia app + WPF installer), shipping English only but extensible by dropping a JSON file into a `locales/` folder. **Architecture:** A new `ClaudeDo.Localization` net8.0 library owns locale-file discovery (`LocaleStore`), the active-language lookup singleton (`Localizer` with fallback chain + change notification), and OS-culture resolution. Each UI framework adds a thin `{loc:Tr key}` markup extension that binds to a per-string `LocalizedString` object which refreshes when the language changes. Language preference lives in `ui.config.json`, read at startup and written from the Settings modal / installer. **Tech Stack:** .NET 8, Avalonia 12, WPF (installer), CommunityToolkit.Mvvm, System.Text.Json, xUnit. **Spec:** `docs/superpowers/specs/2026-06-03-localization-design.md` --- ## File Structure **New project `src/ClaudeDo.Localization/`:** - `ClaudeDo.Localization.csproj` — net8.0 library, no deps beyond BCL. - `LocaleFile.cs` — immutable parsed locale: `Code`, `Name`, `Strings` (flat `IReadOnlyDictionary`). - `LocaleJson.cs` — parses nested JSON into `LocaleFile` (flattens to dot-paths). - `LocaleStore.cs` — scans a folder for `*.json`, builds `LocaleFile`s, exposes `Available` list + `TryGet(code)`. - `ILocalizer.cs` / `Localizer.cs` — active-language singleton: indexer, `Get(key,args)`, `SetLanguage(code)`, `AvailableLanguages`, `CurrentCode`, change event. - `CultureResolver.cs` — maps an OS culture name to an available locale code (English fallback). **Locale data:** - `src/ClaudeDo.Localization/locales/en.json` — the single source of truth (English). - Copied to App + Installer output via a shared MSBuild include. **Avalonia side (`src/ClaudeDo.Ui/`):** - `Localization/TrExtension.cs` — `{loc:Tr key}` markup extension. - `Localization/LocalizedString.cs` — per-binding INPC holder. - `AppSettings.cs` — add `Language` + `Save()`. - `ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs` — add `Languages`, `SelectedLanguage`. - `Views/Modals/SettingsModalView.axaml` — language dropdown. **App side (`src/ClaudeDo.App/Program.cs`):** init `LocaleStore` + `Localizer`, register in DI, set language from config. **Installer side (`src/ClaudeDo.Installer/`):** - `Localization/TrExtension.cs` + `Localization/LocalizedString.cs` (WPF variants). - `Core/ConfigModels.cs` — add `Language` to `InstallerAppSettings`. - `App.xaml.cs` / wizard bootstrap — init `Localizer`. - Wizard: language dropdown + write `Language` to config. **Tests:** - New `tests/ClaudeDo.Localization.Tests/` — store/parse/lookup/fallback/culture/key-coverage. - `tests/ClaudeDo.Ui.Tests/` — settings round-trip. --- ## Phase 1 — Foundation (sequential) ### Task 1: Create `ClaudeDo.Localization` project + parse/flatten locale JSON **Files:** - Create: `src/ClaudeDo.Localization/ClaudeDo.Localization.csproj` - Create: `src/ClaudeDo.Localization/LocaleFile.cs` - Create: `src/ClaudeDo.Localization/LocaleJson.cs` - Create: `tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj` - Create: `tests/ClaudeDo.Localization.Tests/LocaleJsonTests.cs` - Modify: `ClaudeDo.slnx` (register both projects) - [ ] **Step 1: Create the library csproj** `src/ClaudeDo.Localization/ClaudeDo.Localization.csproj`: ```xml net8.0 enable enable ``` - [ ] **Step 2: Create the test csproj + register both in the solution** `tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj`: ```xml net8.0 enable enable false true ``` In `ClaudeDo.slnx`, add under `/src/`: ```xml ``` and under `/tests/`: ```xml ``` - [ ] **Step 3: Write the failing test for nested-JSON flattening** `tests/ClaudeDo.Localization.Tests/LocaleJsonTests.cs`: ```csharp using ClaudeDo.Localization; namespace ClaudeDo.Localization.Tests; public class LocaleJsonTests { private const string Sample = """ { "metadata": { "code": "en", "name": "English" }, "settings": { "save": "Save", "general": { "model": "Model" } }, "tasks": { "addPlaceholder": "Add a task" } } """; [Fact] public void Parse_reads_metadata() { var f = LocaleJson.Parse(Sample); Assert.Equal("en", f.Code); Assert.Equal("English", f.Name); } [Fact] public void Parse_flattens_nested_keys_to_dot_paths() { var f = LocaleJson.Parse(Sample); Assert.Equal("Save", f.Strings["settings.save"]); Assert.Equal("Model", f.Strings["settings.general.model"]); Assert.Equal("Add a task", f.Strings["tasks.addPlaceholder"]); } [Fact] public void Parse_excludes_metadata_from_strings() { var f = LocaleJson.Parse(Sample); Assert.False(f.Strings.ContainsKey("metadata.code")); } } ``` - [ ] **Step 4: Run the test, verify it fails** Run: `dotnet test tests/ClaudeDo.Localization.Tests` Expected: FAIL — `LocaleJson` / `LocaleFile` do not exist (compile error). - [ ] **Step 5: Implement `LocaleFile` and `LocaleJson`** `src/ClaudeDo.Localization/LocaleFile.cs`: ```csharp namespace ClaudeDo.Localization; public sealed class LocaleFile { public LocaleFile(string code, string name, IReadOnlyDictionary strings) { Code = code; Name = name; Strings = strings; } public string Code { get; } public string Name { get; } public IReadOnlyDictionary Strings { get; } } ``` `src/ClaudeDo.Localization/LocaleJson.cs`: ```csharp using System.Text.Json; namespace ClaudeDo.Localization; public static class LocaleJson { public static LocaleFile Parse(string json) { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; var code = ""; var name = ""; if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object) { if (meta.TryGetProperty("code", out var c)) code = c.GetString() ?? ""; if (meta.TryGetProperty("name", out var n)) name = n.GetString() ?? ""; } var strings = new Dictionary(StringComparer.Ordinal); foreach (var prop in root.EnumerateObject()) { if (prop.NameEquals("metadata")) continue; Flatten(prop.Name, prop.Value, strings); } return new LocaleFile(code, name, strings); } private static void Flatten(string prefix, JsonElement el, IDictionary into) { switch (el.ValueKind) { case JsonValueKind.Object: foreach (var p in el.EnumerateObject()) Flatten($"{prefix}.{p.Name}", p.Value, into); break; case JsonValueKind.String: into[prefix] = el.GetString() ?? ""; break; default: into[prefix] = el.ToString(); break; } } } ``` - [ ] **Step 6: Run the test, verify it passes** Run: `dotnet test tests/ClaudeDo.Localization.Tests` Expected: PASS (3 tests). - [ ] **Step 7: Commit** ```bash git add src/ClaudeDo.Localization tests/ClaudeDo.Localization.Tests ClaudeDo.slnx git commit -m "feat(i18n): add ClaudeDo.Localization project with nested-JSON locale parser" ``` --- ### Task 2: `LocaleStore` — discover locale files in a folder **Files:** - Create: `src/ClaudeDo.Localization/LocaleStore.cs` - Test: `tests/ClaudeDo.Localization.Tests/LocaleStoreTests.cs` - [ ] **Step 1: Write the failing test** `tests/ClaudeDo.Localization.Tests/LocaleStoreTests.cs`: ```csharp using ClaudeDo.Localization; namespace ClaudeDo.Localization.Tests; public class LocaleStoreTests { private static string WriteTempLocales(params (string file, string json)[] files) { var dir = Path.Combine(Path.GetTempPath(), "loc_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); foreach (var (file, json) in files) File.WriteAllText(Path.Combine(dir, file), json); return dir; } [Fact] public void Load_discovers_all_json_files() { var dir = WriteTempLocales( ("en.json", """{ "metadata": { "code": "en", "name": "English" }, "a": { "b": "x" } }"""), ("de.json", """{ "metadata": { "code": "de", "name": "Deutsch" }, "a": { "b": "y" } }""")); var store = LocaleStore.Load(dir); Assert.Equal(2, store.Available.Count); Assert.True(store.TryGet("en", out var en)); Assert.Equal("x", en!.Strings["a.b"]); Assert.True(store.TryGet("de", out var de)); Assert.Equal("Deutsch", de!.Name); } [Fact] public void Load_returns_empty_when_folder_missing() { var store = LocaleStore.Load(Path.Combine(Path.GetTempPath(), "does_not_exist_" + Guid.NewGuid())); Assert.Empty(store.Available); } } ``` - [ ] **Step 2: Run the test, verify it fails** Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter LocaleStoreTests` Expected: FAIL — `LocaleStore` does not exist. - [ ] **Step 3: Implement `LocaleStore`** `src/ClaudeDo.Localization/LocaleStore.cs`: ```csharp namespace ClaudeDo.Localization; public sealed class LocaleStore { private readonly Dictionary _byCode; private LocaleStore(Dictionary byCode) => _byCode = byCode; public IReadOnlyList Available => _byCode.Values.ToList(); public bool TryGet(string code, out LocaleFile? file) => _byCode.TryGetValue(code, out file); public static LocaleStore Load(string folder) { var byCode = new Dictionary(StringComparer.OrdinalIgnoreCase); if (Directory.Exists(folder)) { foreach (var path in Directory.EnumerateFiles(folder, "*.json")) { try { var file = LocaleJson.Parse(File.ReadAllText(path)); if (!string.IsNullOrWhiteSpace(file.Code)) byCode[file.Code] = file; } catch { /* skip malformed locale files */ } } } return new LocaleStore(byCode); } } ``` - [ ] **Step 4: Run the test, verify it passes** Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter LocaleStoreTests` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Localization/LocaleStore.cs tests/ClaudeDo.Localization.Tests/LocaleStoreTests.cs git commit -m "feat(i18n): add LocaleStore folder discovery" ``` --- ### Task 3: `ILocalizer` / `Localizer` — active language, fallback, change event **Files:** - Create: `src/ClaudeDo.Localization/ILocalizer.cs` - Create: `src/ClaudeDo.Localization/Localizer.cs` - Test: `tests/ClaudeDo.Localization.Tests/LocalizerTests.cs` - [ ] **Step 1: Write the failing test** `tests/ClaudeDo.Localization.Tests/LocalizerTests.cs`: ```csharp using ClaudeDo.Localization; namespace ClaudeDo.Localization.Tests; public class LocalizerTests { private static LocaleStore TwoLangStore() { var dir = Path.Combine(Path.GetTempPath(), "loc_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); File.WriteAllText(Path.Combine(dir, "en.json"), """{ "metadata": { "code": "en", "name": "English" }, "settings": { "save": "Save" }, "msg": { "count": "{0} items" } }"""); File.WriteAllText(Path.Combine(dir, "de.json"), """{ "metadata": { "code": "de", "name": "Deutsch" }, "settings": { "save": "Speichern" } }"""); return LocaleStore.Load(dir); } [Fact] public void Indexer_returns_active_language_value() { var loc = new Localizer(TwoLangStore(), "de", fallbackCode: "en"); Assert.Equal("Speichern", loc["settings.save"]); } [Fact] public void Indexer_falls_back_to_english_then_key() { var loc = new Localizer(TwoLangStore(), "de", fallbackCode: "en"); Assert.Equal("{0} items", loc["msg.count"]); // missing in de -> english Assert.Equal("nope.missing", loc["nope.missing"]); // missing everywhere -> key itself } [Fact] public void Get_formats_arguments() { var loc = new Localizer(TwoLangStore(), "en", fallbackCode: "en"); Assert.Equal("3 items", loc.Get("msg.count", 3)); } [Fact] public void SetLanguage_raises_change_and_switches_values() { var loc = new Localizer(TwoLangStore(), "en", fallbackCode: "en"); var raised = 0; loc.LanguageChanged += (_, _) => raised++; loc.SetLanguage("de"); Assert.Equal("de", loc.CurrentCode); Assert.Equal("Speichern", loc["settings.save"]); Assert.Equal(1, raised); } [Fact] public void AvailableLanguages_lists_code_and_name() { var loc = new Localizer(TwoLangStore(), "en", fallbackCode: "en"); Assert.Contains(loc.AvailableLanguages, l => l.Code == "de" && l.Name == "Deutsch"); } } ``` - [ ] **Step 2: Run the test, verify it fails** Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter LocalizerTests` Expected: FAIL — `Localizer` / `ILocalizer` do not exist. - [ ] **Step 3: Implement `ILocalizer` and `Localizer`** `src/ClaudeDo.Localization/ILocalizer.cs`: ```csharp namespace ClaudeDo.Localization; public readonly record struct LanguageOption(string Code, string Name); public interface ILocalizer { string this[string key] { get; } string Get(string key, params object[] args); string CurrentCode { get; } IReadOnlyList AvailableLanguages { get; } void SetLanguage(string code); event EventHandler? LanguageChanged; } ``` `src/ClaudeDo.Localization/Localizer.cs`: ```csharp namespace ClaudeDo.Localization; public sealed class Localizer : ILocalizer { private readonly LocaleStore _store; private readonly string _fallbackCode; private LocaleFile? _active; private LocaleFile? _fallback; public Localizer(LocaleStore store, string code, string fallbackCode = "en") { _store = store; _fallbackCode = fallbackCode; _store.TryGet(fallbackCode, out _fallback); SetLanguage(code); } public string CurrentCode { get; private set; } = ""; public IReadOnlyList AvailableLanguages => _store.Available.Select(f => new LanguageOption(f.Code, f.Name)).ToList(); public event EventHandler? LanguageChanged; public string this[string key] { get { if (_active is not null && _active.Strings.TryGetValue(key, out var v)) return v; if (_fallback is not null && _fallback.Strings.TryGetValue(key, out var fv)) return fv; return key; } } public string Get(string key, params object[] args) { var fmt = this[key]; return args.Length == 0 ? fmt : string.Format(fmt, args); } public void SetLanguage(string code) { if (_store.TryGet(code, out var f) && f is not null) { _active = f; CurrentCode = f.Code; } else { _active = _fallback; CurrentCode = _fallback?.Code ?? code; } LanguageChanged?.Invoke(this, EventArgs.Empty); } } ``` - [ ] **Step 4: Run the test, verify it passes** Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter LocalizerTests` Expected: PASS (5 tests). - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Localization/ILocalizer.cs src/ClaudeDo.Localization/Localizer.cs tests/ClaudeDo.Localization.Tests/LocalizerTests.cs git commit -m "feat(i18n): add Localizer with fallback chain and change event" ``` --- ### Task 4: `CultureResolver` — OS culture → available locale code **Files:** - Create: `src/ClaudeDo.Localization/CultureResolver.cs` - Test: `tests/ClaudeDo.Localization.Tests/CultureResolverTests.cs` - [ ] **Step 1: Write the failing test** `tests/ClaudeDo.Localization.Tests/CultureResolverTests.cs`: ```csharp using ClaudeDo.Localization; namespace ClaudeDo.Localization.Tests; public class CultureResolverTests { private static readonly string[] Codes = { "en", "de" }; [Theory] [InlineData("de-DE", "de")] [InlineData("de", "de")] [InlineData("en-US", "en")] [InlineData("fr-FR", "en")] // unsupported -> fallback [InlineData("", "en")] public void Resolve_maps_culture_to_available_code(string culture, string expected) { Assert.Equal(expected, CultureResolver.Resolve(culture, Codes, fallback: "en")); } } ``` - [ ] **Step 2: Run the test, verify it fails** Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter CultureResolverTests` Expected: FAIL — `CultureResolver` does not exist. - [ ] **Step 3: Implement `CultureResolver`** `src/ClaudeDo.Localization/CultureResolver.cs`: ```csharp namespace ClaudeDo.Localization; public static class CultureResolver { public static string Resolve(string cultureName, IReadOnlyCollection available, string fallback = "en") { if (string.IsNullOrWhiteSpace(cultureName)) return fallback; // exact match (e.g. "de") var exact = available.FirstOrDefault(c => string.Equals(c, cultureName, StringComparison.OrdinalIgnoreCase)); if (exact is not null) return exact; // primary subtag (e.g. "de-DE" -> "de") var primary = cultureName.Split('-')[0]; var byPrimary = available.FirstOrDefault(c => string.Equals(c, primary, StringComparison.OrdinalIgnoreCase)); return byPrimary ?? fallback; } } ``` - [ ] **Step 4: Run the test, verify it passes** Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter CultureResolverTests` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Localization/CultureResolver.cs tests/ClaudeDo.Localization.Tests/CultureResolverTests.cs git commit -m "feat(i18n): add CultureResolver for OS-culture mapping" ``` --- ### Task 5: Seed `en.json` + key-coverage test + output-copy wiring **Files:** - Create: `src/ClaudeDo.Localization/locales/en.json` - Create: `src/ClaudeDo.Localization/Locales.targets` (shared copy include) - Modify: `src/ClaudeDo.Localization/ClaudeDo.Localization.csproj` (import targets, copy locales) - Modify: `src/ClaudeDo.App/ClaudeDo.App.csproj` (import targets) - Test: `tests/ClaudeDo.Localization.Tests/EnJsonCoverageTests.cs` - [ ] **Step 1: Create the seed `en.json`** `src/ClaudeDo.Localization/locales/en.json` (starter — extraction tasks append to it): ```json { "metadata": { "code": "en", "name": "English" }, "settings": { "title": "SETTINGS", "save": "Save", "cancel": "Cancel", "language": "Language", "tabGeneral": "General", "tabWorktrees": "Worktrees", "tabFiles": "Files", "tabPrime": "Prime Claude" } } ``` - [ ] **Step 2: Create the shared copy targets** `src/ClaudeDo.Localization/Locales.targets`: ```xml ``` In `src/ClaudeDo.Localization/ClaudeDo.Localization.csproj`, add inside ``: ```xml ``` In `src/ClaudeDo.App/ClaudeDo.App.csproj`, add a project reference and import (paths relative to App): ```xml ``` - [ ] **Step 3: Write the failing key-coverage test** This test loads every shipped locale file and asserts each has the same key set as `en.json`. It locates the source `locales/` folder by walking up from the test assembly. `tests/ClaudeDo.Localization.Tests/EnJsonCoverageTests.cs`: ```csharp using ClaudeDo.Localization; namespace ClaudeDo.Localization.Tests; public class EnJsonCoverageTests { private static string LocalesDir() { var dir = AppContext.BaseDirectory; while (dir is not null) { var candidate = Path.Combine(dir, "src", "ClaudeDo.Localization", "locales"); if (Directory.Exists(candidate)) return candidate; dir = Path.GetDirectoryName(dir); } throw new DirectoryNotFoundException("Could not locate src/ClaudeDo.Localization/locales"); } [Fact] public void Every_locale_has_same_keys_as_english() { var store = LocaleStore.Load(LocalesDir()); Assert.True(store.TryGet("en", out var en)); var enKeys = en!.Strings.Keys.ToHashSet(); foreach (var locale in store.Available) { var keys = locale.Strings.Keys.ToHashSet(); var missing = enKeys.Except(keys).OrderBy(k => k).ToList(); var extra = keys.Except(enKeys).OrderBy(k => k).ToList(); Assert.True(missing.Count == 0, $"Locale '{locale.Code}' missing keys: {string.Join(", ", missing)}"); Assert.True(extra.Count == 0, $"Locale '{locale.Code}' has extra keys not in en.json: {string.Join(", ", extra)}"); } } } ``` - [ ] **Step 4: Run the test, verify it passes (only en.json present)** Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter EnJsonCoverageTests` Expected: PASS (only `en.json` exists, so it trivially matches itself). - [ ] **Step 5: Verify locales copy to App output** Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` Then confirm the file exists: Run: `ls src/ClaudeDo.App/bin/Release/net8.0/locales/en.json` Expected: path listed (file copied). - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Localization/locales src/ClaudeDo.Localization/Locales.targets src/ClaudeDo.Localization/ClaudeDo.Localization.csproj src/ClaudeDo.App/ClaudeDo.App.csproj tests/ClaudeDo.Localization.Tests/EnJsonCoverageTests.cs git commit -m "feat(i18n): seed en.json and wire locale copy to app output" ``` --- ### Task 6: Avalonia `{loc:Tr}` markup extension + `LocalizedString` **Files:** - Create: `src/ClaudeDo.Ui/Localization/LocalizedString.cs` - Create: `src/ClaudeDo.Ui/Localization/TrExtension.cs` - Modify: `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` (reference Localization project) - Test: `tests/ClaudeDo.Ui.Tests/LocalizedStringTests.cs` > **Why a per-string holder instead of an indexer binding:** binding directly to `Localizer["a.b.c"]` requires the Avalonia path parser to accept dotted keys inside `[...]`. Wrapping each key in a tiny `LocalizedString` (a one-property INPC object) sidesteps path parsing entirely and gives clean live updates. - [ ] **Step 1: Add the project reference** In `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`, add to the first ``: ```xml ``` - [ ] **Step 2: Write the failing test** `tests/ClaudeDo.Ui.Tests/LocalizedStringTests.cs`: ```csharp using ClaudeDo.Localization; using ClaudeDo.Ui.Localization; namespace ClaudeDo.Ui.Tests; public class LocalizedStringTests { private static Localizer Make() { var dir = Path.Combine(Path.GetTempPath(), "loc_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); File.WriteAllText(Path.Combine(dir, "en.json"), """{ "metadata": { "code": "en", "name": "English" }, "settings": { "save": "Save" } }"""); File.WriteAllText(Path.Combine(dir, "de.json"), """{ "metadata": { "code": "de", "name": "Deutsch" }, "settings": { "save": "Speichern" } }"""); return new Localizer(LocaleStore.Load(dir), "en"); } [Fact] public void Value_tracks_language_change() { var loc = Make(); var ls = new LocalizedString(loc, "settings.save"); Assert.Equal("Save", ls.Value); string? changed = null; ls.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(LocalizedString.Value)) changed = ls.Value; }; loc.SetLanguage("de"); Assert.Equal("Speichern", ls.Value); Assert.Equal("Speichern", changed); } } ``` - [ ] **Step 3: Run the test, verify it fails** Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter LocalizedStringTests` Expected: FAIL — `LocalizedString` does not exist. - [ ] **Step 4: Implement `LocalizedString` and `TrExtension`** `src/ClaudeDo.Ui/Localization/LocalizedString.cs`: ```csharp using System.ComponentModel; using ClaudeDo.Localization; namespace ClaudeDo.Ui.Localization; public sealed class LocalizedString : INotifyPropertyChanged { private readonly ILocalizer _localizer; private readonly string _key; public LocalizedString(ILocalizer localizer, string key) { _localizer = localizer; _key = key; _localizer.LanguageChanged += (_, _) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); } public string Value => _localizer[_key]; public event PropertyChangedEventHandler? PropertyChanged; } ``` `src/ClaudeDo.Ui/Localization/TrExtension.cs`: ```csharp using Avalonia.Data; using Avalonia.Markup.Xaml; using ClaudeDo.Localization; namespace ClaudeDo.Ui.Localization; public sealed class TrExtension : MarkupExtension { public TrExtension() { } public TrExtension(string key) => Key = key; public string Key { get; set; } = ""; // Set once at startup (App) so XAML-created extensions can resolve the singleton. public static ILocalizer? Localizer { get; set; } public override object ProvideValue(IServiceProvider serviceProvider) { var loc = Localizer ?? throw new InvalidOperationException("TrExtension.Localizer not initialized"); return new Binding(nameof(LocalizedString.Value)) { Source = new LocalizedString(loc, Key), Mode = BindingMode.OneWay }; } } ``` - [ ] **Step 5: Run the test, verify it passes** Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter LocalizedStringTests` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Ui/Localization src/ClaudeDo.Ui/ClaudeDo.Ui.csproj tests/ClaudeDo.Ui.Tests/LocalizedStringTests.cs git commit -m "feat(i18n): add Avalonia loc:Tr markup extension and LocalizedString" ``` --- ### Task 7: `AppSettings.Language` + `Save()` **Files:** - Modify: `src/ClaudeDo.Ui/AppSettings.cs` - Test: `tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs` - [ ] **Step 1: Write the failing test** `tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs`: ```csharp using System.Text.Json; using ClaudeDo.Ui; namespace ClaudeDo.Ui.Tests; public class AppSettingsTests { [Fact] public void Language_defaults_to_empty() { Assert.Equal("", new AppSettings().Language); } [Fact] public void Language_round_trips_through_json() { var json = JsonSerializer.Serialize(new AppSettings { Language = "de" }); var back = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; Assert.Equal("de", back.Language); } } ``` - [ ] **Step 2: Run the test, verify it fails** Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter AppSettingsTests` Expected: FAIL — `Language` property does not exist. - [ ] **Step 3: Add `Language` and `Save()` to `AppSettings`** In `src/ClaudeDo.Ui/AppSettings.cs`, add the property after `SignalRUrl` (line 9): ```csharp public string Language { get; set; } = ""; ``` And add a `Save()` method after `Load()`: ```csharp public void Save() { var dir = Path.GetDirectoryName(ConfigPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(ConfigPath, json); } ``` - [ ] **Step 4: Run the test, verify it passes** Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter AppSettingsTests` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Ui/AppSettings.cs tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs git commit -m "feat(i18n): add Language preference and Save() to AppSettings" ``` --- ### Task 8: App startup wiring — build `Localizer`, register in DI, set `TrExtension.Localizer` **Files:** - Modify: `src/ClaudeDo.App/Program.cs` - [ ] **Step 1: Add a locales-path + Localizer factory in `BuildServices()`** In `src/ClaudeDo.App/Program.cs`, add these usings at the top: ```csharp using ClaudeDo.Localization; using ClaudeDo.Ui.Localization; using System.Globalization; using System.IO; ``` In `BuildServices()`, after `sc.AddSingleton(settings);` (line 73), add: ```csharp var localesDir = Path.Combine(AppContext.BaseDirectory, "locales"); var localeStore = LocaleStore.Load(localesDir); var initialLang = !string.IsNullOrWhiteSpace(settings.Language) ? settings.Language : CultureResolver.Resolve( CultureInfo.CurrentUICulture.Name, localeStore.Available.Select(l => l.Code).ToArray(), fallback: "en"); var localizer = new Localizer(localeStore, initialLang); TrExtension.Localizer = localizer; sc.AddSingleton(localizer); ``` - [ ] **Step 2: Build and verify the app compiles** Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` Expected: Build succeeded. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.App/Program.cs git commit -m "feat(i18n): initialize Localizer at app startup from config/OS culture" ``` --- ### Task 9: Settings General tab — language dropdown + persistence **Files:** - Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs` - Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` - Test: `tests/ClaudeDo.Ui.Tests/LanguageSettingTests.cs` > **Note:** `GeneralSettingsTabViewModel` is currently parameterless. Add an optional `ILocalizer` + `AppSettings` via constructor; the parent `SettingsModalViewModel` constructs it. Check how `SettingsModalViewModel` instantiates `General` and thread the dependencies through (it is resolved transiently from DI per `Program.cs:106`). If `SettingsModalViewModel` `new`s up the tab VMs directly, inject `ILocalizer` and `AppSettings` into `SettingsModalViewModel` and pass them in. - [ ] **Step 1: Write the failing test** `tests/ClaudeDo.Ui.Tests/LanguageSettingTests.cs`: ```csharp using ClaudeDo.Localization; using ClaudeDo.Ui; using ClaudeDo.Ui.ViewModels.Modals.Settings; namespace ClaudeDo.Ui.Tests; public class LanguageSettingTests { private static Localizer Make() { var dir = Path.Combine(Path.GetTempPath(), "loc_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); File.WriteAllText(Path.Combine(dir, "en.json"), """{ "metadata": { "code": "en", "name": "English" }, "settings": { "save": "Save" } }"""); File.WriteAllText(Path.Combine(dir, "de.json"), """{ "metadata": { "code": "de", "name": "Deutsch" }, "settings": { "save": "Speichern" } }"""); return new Localizer(LocaleStore.Load(dir), "en"); } [Fact] public void Selecting_language_switches_localizer_and_persists() { var loc = Make(); var settings = new AppSettings(); var saved = false; var vm = new GeneralSettingsTabViewModel(loc, code => { settings.Language = code; saved = true; }); var de = vm.Languages.First(l => l.Code == "de"); vm.SelectedLanguage = de; Assert.Equal("de", loc.CurrentCode); Assert.True(saved); Assert.Equal("de", settings.Language); } } ``` - [ ] **Step 2: Run the test, verify it fails** Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter LanguageSettingTests` Expected: FAIL — constructor signature / `Languages` / `SelectedLanguage` do not exist. - [ ] **Step 3: Add language members to `GeneralSettingsTabViewModel`** In `src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs`, add usings: ```csharp using ClaudeDo.Localization; ``` Add fields/members and a constructor (keep the existing `[ObservableProperty]` fields): ```csharp private readonly ILocalizer? _localizer; private readonly Action? _persist; public GeneralSettingsTabViewModel() { } public GeneralSettingsTabViewModel(ILocalizer localizer, Action persist) { _localizer = localizer; _persist = persist; Languages = localizer.AvailableLanguages; _selectedLanguage = Languages.FirstOrDefault(l => l.Code == localizer.CurrentCode); } public IReadOnlyList Languages { get; } = Array.Empty(); [ObservableProperty] private LanguageOption? _selectedLanguage; partial void OnSelectedLanguageChanged(LanguageOption? value) { if (value is null || _localizer is null) return; _localizer.SetLanguage(value.Value.Code); _persist?.Invoke(value.Value.Code); } ``` - [ ] **Step 4: Run the test, verify it passes** Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter LanguageSettingTests` Expected: PASS. - [ ] **Step 5: Wire dependencies through `SettingsModalViewModel`** Open `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`. Inject `ILocalizer` and `AppSettings` into its constructor (DI provides both — `ILocalizer` from Task 8, `AppSettings` is already a singleton per `Program.cs:73`). Where it sets `General = new GeneralSettingsTabViewModel(...)`, pass: ```csharp General = new GeneralSettingsTabViewModel(localizer, code => { appSettings.Language = code; appSettings.Save(); }); ``` (Confirm the exact field name and instantiation point by reading the file first.) - [ ] **Step 6: Add the language dropdown to the General tab** In `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`, add the `loc` namespace to the root ``: ```xml xmlns:loc="using:ClaudeDo.Ui.Localization" ``` Then add a language field as the first child of the General tab's `StackPanel` (before "Default instructions", after line 47): ```xml ``` - [ ] **Step 7: Build and run the app; verify language dropdown appears** Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` Expected: Build succeeded. Launch the app, open Settings → General; the Language dropdown shows "English". (Instant-switch is verified end-to-end in Task 11 with a temporary `de.json`.) - [ ] **Step 8: Commit** ```bash git add src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml tests/ClaudeDo.Ui.Tests/LanguageSettingTests.cs git commit -m "feat(i18n): add language dropdown to settings and persist selection" ``` --- ## Phase 2 — Avalonia string extraction (parallel subagents) > **Dispatch model:** Use superpowers:dispatching-parallel-agents. Each batch below owns a **disjoint set of view/VM files** and a **disjoint top-level `en.json` section**, so the work is conflict-free. Each subagent: (1) replaces hardcoded user-facing strings in its files with `{loc:Tr }` (XAML) or `localizer[...]` / `localizer.Get(...)` (VMs); (2) adds the corresponding keys under its assigned section in a **separate partial file** `locales/_parts/
.en.json`; (3) reports the keys it added. The orchestrator merges all partials into `en.json` (Task 16) so no two agents edit `en.json` concurrently. **Extraction pattern (applies to every batch):** XAML, before: ```xml