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