Files
ClaudeDo/docs/superpowers/plans/2026-06-03-localization.md
mika kuns 8dc8b8ba8e docs: localization implementation plan
Phased TDD plan: shared ClaudeDo.Localization lib, Avalonia + WPF markup
extensions, settings/installer pickers, parallel string-extraction batches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:32:06 +02:00

1482 lines
53 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<string,string>`).
- `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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ClaudeDo.Localization.Tests" />
</ItemGroup>
</Project>
```
- [ ] **Step 2: Create the test csproj + register both in the solution**
`tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" />
</ItemGroup>
</Project>
```
In `ClaudeDo.slnx`, add under `/src/`:
```xml
<Project Path="src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" />
```
and under `/tests/`:
```xml
<Project Path="tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj" />
```
- [ ] **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<string, string> strings)
{
Code = code;
Name = name;
Strings = strings;
}
public string Code { get; }
public string Name { get; }
public IReadOnlyDictionary<string, string> 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<string, string>(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<string, string> 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<string, LocaleFile> _byCode;
private LocaleStore(Dictionary<string, LocaleFile> byCode) => _byCode = byCode;
public IReadOnlyList<LocaleFile> 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<string, LocaleFile>(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<LanguageOption> 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<LanguageOption> 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<string> 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
<Project>
<ItemGroup>
<Content Include="$(MSBuildThisFileDirectory)locales\*.json"
Link="locales\%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
```
In `src/ClaudeDo.Localization/ClaudeDo.Localization.csproj`, add inside `<Project>`:
```xml
<Import Project="Locales.targets" />
```
In `src/ClaudeDo.App/ClaudeDo.App.csproj`, add a project reference and import (paths relative to App):
```xml
<ItemGroup>
<ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />
</ItemGroup>
<Import Project="..\ClaudeDo.Localization\Locales.targets" />
```
- [ ] **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 `<ItemGroup>`:
```xml
<ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />
```
- [ ] **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<AppSettings>(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<ILocalizer>(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<string>? _persist;
public GeneralSettingsTabViewModel() { }
public GeneralSettingsTabViewModel(ILocalizer localizer, Action<string> persist)
{
_localizer = localizer;
_persist = persist;
Languages = localizer.AvailableLanguages;
_selectedLanguage = Languages.FirstOrDefault(l => l.Code == localizer.CurrentCode);
}
public IReadOnlyList<LanguageOption> Languages { get; } = Array.Empty<LanguageOption>();
[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 `<Window>`:
```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
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.language}"/>
<ComboBox ItemsSource="{Binding General.Languages}"
SelectedItem="{Binding General.SelectedLanguage, Mode=TwoWay}"
HorizontalAlignment="Left" Width="220">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
```
- [ ] **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 <key>}` (XAML) or `localizer[...]` / `localizer.Get(...)` (VMs); (2) adds the corresponding keys under its assigned section in a **separate partial file** `locales/_parts/<section>.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
<TextBlock Text="Default instructions"/>
<Button Content="Save"/>
<TextBox PlaceholderText="Add a task…"/>
```
XAML, after (add `xmlns:loc="using:ClaudeDo.Ui.Localization"` to the root element once per file):
```xml
<TextBlock Text="{loc:Tr settings.general.defaultInstructions}"/>
<Button Content="{loc:Tr settings.save}"/>
<TextBox PlaceholderText="{loc:Tr tasks.addPlaceholder}"/>
```
Inline `ComboBoxItem` text (e.g. weekday names) becomes an `ItemsSource` of localized strings OR each item's content uses `{loc:Tr ...}`; prefer binding the item content:
```xml
<ComboBoxItem Content="{loc:Tr settings.general.weekday.sunday}"/>
```
ViewModel strings, before:
```csharp
HeaderTitle = "Tasks";
StatusPill = overdue ? "OVERDUE" : "ON TRACK";
```
ViewModel strings, after (inject `ILocalizer _loc` via constructor; for live updates re-raise on `LanguageChanged` — see per-property note in spec):
```csharp
HeaderTitle = _loc["tasks.headerTitle"];
StatusPill = overdue ? _loc["tasks.overdue"] : _loc["tasks.onTrack"];
```
For VM properties that must update live, subscribe in the ctor:
```csharp
_loc.LanguageChanged += (_, _) => RefreshLocalizedText();
```
where `RefreshLocalizedText()` re-assigns the localized `[ObservableProperty]` values.
**Rules for every subagent:**
- Only translate **user-facing** strings. Leave log messages, format/CSS-like tokens, `x:Name`, style classes, binding paths, and `CommandParameter` values (e.g. `"System"`, `"Planning"`, `"Agent"`) unchanged.
- Keep underlying bound values intact for enum-ish combos (translate only display text).
- Key naming: `<section>.<screenOrModal>.<thing>` in camelCase leaf (e.g. `tasks.addPlaceholder`, `modals.merge.confirm`).
- Do **not** edit `en.json` directly — write `locales/_parts/<section>.en.json` as a nested JSON object rooted at your section name.
- Build your project after edits: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` must succeed (missing keys won't break build — they fall back to the key string).
- Report: the list of keys added and any strings you intentionally left untranslated.
### Task 10: Extraction batch A — Islands views + VMs
**Section:** `tasks`, `lists`, `details`, `agent`, `notes`, `session`
**Files (Views/Islands + matching VMs):**
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` + `ViewModels/Islands/TasksIslandViewModel.cs`
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml` + `ViewModels/Islands/ListsIslandViewModel.cs`
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` + `ViewModels/Islands/DetailsIslandViewModel.cs`
- `src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml`
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- `src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml` + `ViewModels/.../NotesEditorViewModel.cs`
- `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml`
- [ ] Apply the extraction pattern to each file above.
- [ ] Write `locales/_parts/tasks.en.json` etc. (one nested object per section this batch owns).
- [ ] Build succeeds; report keys added.
### Task 11: Extraction batch B — Settings + small modals
**Section:** `settings`, `modals.about`, `modals.workerConnection`, `modals.listSettings`
**Files:**
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` (remaining hardcoded strings — language field already done) + the four Settings tab VMs in `ViewModels/Modals/Settings/`
- `src/ClaudeDo.Ui/Views/Modals/AboutModal.axaml`
- `src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModal.axaml`
- `src/ClaudeDo.Ui/Views/Modals/ListSettingsModal.axaml` + its VM
- [ ] Apply the extraction pattern. Weekday `ComboBoxItem`s → `settings.general.weekday.*`.
- [ ] Write `locales/_parts/settings.en.json` and `locales/_parts/modals.en.json` (this batch's modal keys only; see merge note — coordinate the `modals` section split with Task 12 by using distinct sub-objects).
- [ ] Build succeeds; report keys added.
### Task 12: Extraction batch C — Worktree / merge / diff / planning modals
**Section:** `modals.merge`, `modals.diff`, `modals.worktree`, `modals.worktreesOverview`, `modals.repoImport`, `modals.unfinishedPlanning`, `modals.weeklyReport`
**Files:**
- `src/ClaudeDo.Ui/Views/Modals/MergeModal.axaml` + VM
- `src/ClaudeDo.Ui/Views/Modals/DiffModal.axaml`
- `src/ClaudeDo.Ui/Views/Modals/WorktreeModal.axaml` + VM
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModal.axaml` + VM
- `src/ClaudeDo.Ui/Views/Modals/RepoImportModal.axaml` + VM
- `src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModal.axaml`
- `src/ClaudeDo.Ui/Views/Modals/WeeklyReportModalView.axaml` + VM (UI labels only — the German report **body** stays as-is)
- [ ] Apply the extraction pattern.
- [ ] Write `locales/_parts/modals.merge.en.json`-style partials (one file per top-level `modals.<x>` sub-object to avoid clashing with Task 11). Use file naming `modals_<sub>.en.json`.
- [ ] Build succeeds; report keys added.
### Task 13: Extraction batch D — Planning + Controls
**Section:** `planning`, `controls`
**Files:**
- `src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml` + VM
- `src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml`
- `src/ClaudeDo.Ui/Views/Controls/ThemedDatePicker.axaml` (only if it has user-facing text)
- `src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml` (only if it has user-facing text)
- Any menu/header strings in `MainWindow`/`IslandsShellView` (search for remaining literals)
- [ ] Apply the extraction pattern.
- [ ] Write `locales/_parts/planning.en.json`, `locales/_parts/controls.en.json`.
- [ ] Build succeeds; report keys added.
> **After dispatch:** the orchestrator reviews each subagent's report and diff (per dispatching-parallel-agents), then proceeds to Task 16 to merge partials.
---
## Phase 3 — Installer localization (sequential, after Phase 1)
### Task 14: Installer config + WPF localization primitives
**Files:**
- Modify: `src/ClaudeDo.Installer/Core/ConfigModels.cs` (add `Language` to `InstallerAppSettings`)
- Modify: `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` (reference Localization project + import `Locales.targets`)
- Create: `src/ClaudeDo.Installer/Localization/LocalizedString.cs` (WPF INPC holder)
- Create: `src/ClaudeDo.Installer/Localization/TrExtension.cs` (WPF markup extension)
- [ ] **Step 1: Add `Language` to `InstallerAppSettings`**
In `src/ClaudeDo.Installer/Core/ConfigModels.cs`, in `InstallerAppSettings` (after `SignalRUrl`, line 79):
```csharp
public string Language { get; set; } = "";
```
- [ ] **Step 2: Reference the Localization project + copy locales**
In `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`, add to the project-references `<ItemGroup>`:
```xml
<ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />
```
and add:
```xml
<Import Project="..\ClaudeDo.Localization\Locales.targets" />
```
(With `IncludeAllContentForSelfExtract=true` the `locales/` content extracts beside the single-file installer at runtime.)
- [ ] **Step 3: Implement the WPF `LocalizedString`**
`src/ClaudeDo.Installer/Localization/LocalizedString.cs`:
```csharp
using System.ComponentModel;
using ClaudeDo.Localization;
namespace ClaudeDo.Installer.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;
}
```
- [ ] **Step 4: Implement the WPF `TrExtension`**
`src/ClaudeDo.Installer/Localization/TrExtension.cs`:
```csharp
using System;
using System.Windows.Data;
using System.Windows.Markup;
using ClaudeDo.Localization;
namespace ClaudeDo.Installer.Localization;
public sealed class TrExtension : MarkupExtension
{
public TrExtension() { }
public TrExtension(string key) => Key = key;
public string Key { get; set; } = "";
public static ILocalizer? Localizer { get; set; }
public override object ProvideValue(IServiceProvider serviceProvider)
{
var loc = Localizer ?? throw new InvalidOperationException("TrExtension.Localizer not initialized");
var binding = new Binding(nameof(LocalizedString.Value))
{
Source = new LocalizedString(loc, Key),
Mode = BindingMode.OneWay
};
return binding.ProvideValue(serviceProvider);
}
}
```
- [ ] **Step 5: Build the installer**
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj -c Debug`
Expected: Build succeeded.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Installer/Core/ConfigModels.cs src/ClaudeDo.Installer/ClaudeDo.Installer.csproj src/ClaudeDo.Installer/Localization
git commit -m "feat(i18n): add WPF localization primitives and Language config to installer"
```
---
### Task 15: Installer bootstrap + language picker + write config
**Files:**
- Modify: installer entry/bootstrap (read `App.xaml.cs` / `WizardWindow` startup to find where the wizard is constructed; init `Localizer` there)
- Modify: `src/ClaudeDo.Installer/Views/WizardWindow.xaml` (add a language dropdown in the top bar)
- Modify: the wizard view-model that owns navigation (`WizardViewModel`) — expose `Languages` + `SelectedLanguage`, call `Localizer.SetLanguage`, and persist into the `InstallerAppSettings` it writes
- Modify: wherever the installer writes `ui.config.json` (search for `InstallerAppSettings ... .Save()`)
- [ ] **Step 1: Initialize the installer `Localizer` at startup**
Read `src/ClaudeDo.Installer/App.xaml.cs` to find startup. Before the wizard window is shown, add:
```csharp
using System.Globalization;
using System.IO;
using ClaudeDo.Localization;
using ClaudeDo.Installer.Localization;
// ...
var localesDir = Path.Combine(AppContext.BaseDirectory, "locales");
var store = LocaleStore.Load(localesDir);
var existing = InstallerAppSettings.Load(); // upgrade: reuse prior language
var initial = !string.IsNullOrWhiteSpace(existing.Language)
? existing.Language
: CultureResolver.Resolve(CultureInfo.CurrentUICulture.Name,
store.Available.Select(l => l.Code).ToArray(), "en");
var localizer = new Localizer(store, initial);
TrExtension.Localizer = localizer;
// pass `localizer` into the WizardViewModel
```
- [ ] **Step 2: Expose language selection on `WizardViewModel`**
Add to the wizard VM (mirror Task 9 pattern with `CommunityToolkit.Mvvm`):
```csharp
public IReadOnlyList<LanguageOption> Languages { get; }
[ObservableProperty] private LanguageOption? _selectedLanguage;
partial void OnSelectedLanguageChanged(LanguageOption? value)
{
if (value is null) return;
_localizer.SetLanguage(value.Value.Code);
}
```
Initialize `Languages = _localizer.AvailableLanguages;` and `_selectedLanguage` to the current code in the ctor.
- [ ] **Step 3: Add the dropdown to `WizardWindow.xaml`**
Add the namespace to the root `<Window>`:
```xml
xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"
```
Add a ComboBox to the step-indicator bar (Grid.Row 0), right-aligned:
```xml
<ComboBox ItemsSource="{Binding Languages}"
SelectedItem="{Binding SelectedLanguage, Mode=TwoWay}"
Width="160" HorizontalAlignment="Right" Margin="0,0,4,0"
DisplayMemberPath="Name"/>
```
(Place it appropriately within the existing Row 0 `Border`; you may need to wrap the existing `ItemsControl` and the ComboBox in a `DockPanel`.)
- [ ] **Step 4: Persist `Language` when writing `ui.config.json`**
At the point where the installer constructs/saves `InstallerAppSettings` (search the codebase), set:
```csharp
appSettings.Language = localizer.CurrentCode;
```
before `appSettings.Save();`.
- [ ] **Step 5: Build the installer**
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj -c Debug`
Expected: Build succeeded.
- [ ] **Step 6: Extract installer UI strings**
Apply the extraction pattern (Phase 2) to all 10 installer XAML/page files + VM-driven headings (`Heading`, `Subheading`, `NextButtonText`). Section: `installer.*` (e.g. `installer.welcome.heading`, `installer.nav.back`, `installer.nav.next`). VM headings go through `_localizer[...]` with a `LanguageChanged` refresh. Add keys to `locales/_parts/installer.en.json`.
- [ ] **Step 7: Build + commit**
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj -c Debug`
```bash
git add src/ClaudeDo.Installer
git commit -m "feat(i18n): localize installer with language picker and config write-through"
```
---
## Phase 4 — Merge, verify, finalize
### Task 16: Merge locale partials into `en.json` + coverage passes
**Files:**
- Modify: `src/ClaudeDo.Localization/locales/en.json`
- Delete: `src/ClaudeDo.Localization/locales/_parts/*` (after merge)
- [ ] **Step 1: Merge all `_parts/*.en.json` into `en.json`**
Combine every partial's nested object under its top-level section into one well-formed `en.json` (preserve the `metadata` block). Ensure no duplicate keys across sections.
- [ ] **Step 2: Run the full coverage test**
Run: `dotnet test tests/ClaudeDo.Localization.Tests`
Expected: PASS — with only `en.json` present, coverage trivially holds; this confirms the merged file parses and flattens cleanly.
- [ ] **Step 3: Remove the `_parts` folder**
```bash
rm -r src/ClaudeDo.Localization/locales/_parts
```
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Localization/locales
git commit -m "feat(i18n): consolidate extracted strings into en.json"
```
---
### Task 17: End-to-end verification (manual UI pass)
- [ ] **Step 1: Create a throwaway `de.json` for testing instant switching**
Copy `en.json``src/ClaudeDo.Localization/locales/de.json`, set `metadata.code`=`de`, `metadata.name`=`Deutsch`, and translate a handful of visible strings (e.g. `settings.save`=`Speichern`, `tasks.addPlaceholder`=`Aufgabe hinzufügen…`).
- [ ] **Step 2: Run the coverage test (should still pass — de has same keys)**
Run: `dotnet test tests/ClaudeDo.Localization.Tests`
Expected: PASS (de.json copied from en.json → identical key set).
- [ ] **Step 3: Build + launch the app**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Launch, open Settings → General, switch language to Deutsch. Confirm visible labels update **instantly** (no restart), and that reopening the app keeps Deutsch (persisted to `ui.config.json`).
- [ ] **Step 4: Build + launch the installer**
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj -c Debug`
Launch, switch the language dropdown, confirm the wizard updates instantly and that completing it writes `Language` into `ui.config.json`.
- [ ] **Step 5: Remove the throwaway `de.json`**
```bash
rm src/ClaudeDo.Localization/locales/de.json
```
(English-only ships; `de.json` was a test fixture.)
- [ ] **Step 6: Run the entire test suite**
Run: `dotnet test tests/ClaudeDo.Localization.Tests && dotnet test tests/ClaudeDo.Ui.Tests && dotnet test tests/ClaudeDo.Installer.Tests`
Expected: all PASS.
- [ ] **Step 7: Final commit**
```bash
git add -A
git commit -m "test(i18n): verify instant switching and persistence end-to-end"
```
---
## Self-Review Notes (addressed)
- **Spec coverage:** shared library (Tasks 14), JSON format/flatten (Task 1), loose-file discovery (Task 2), Localizer + fallback + live switch (Tasks 3, 6), `ui.config.json` storage (Task 7), startup/OS-culture (Tasks 4, 8), settings dropdown (Task 9), Avalonia extraction (Tasks 1013), installer parity + picker + config write-through (Tasks 1415), key-coverage test (Task 5), round-trip test (Task 9), build wiring (Tasks 5, 14). VM live-switch handled via `LanguageChanged` subscription (Phase 2 pattern). Out-of-scope items (report body, pluralization) excluded.
- **Type consistency:** `Localizer(LocaleStore, string code, string fallbackCode="en")`, `ILocalizer` indexer + `Get` + `SetLanguage` + `LanguageChanged` + `AvailableLanguages` + `CurrentCode`, `LanguageOption(Code,Name)`, `TrExtension.Localizer` static, `LocalizedString(ILocalizer,string)` with `Value` — used consistently across tasks.
- **Open verification point flagged in-task:** Task 9 Step 5 requires reading `SettingsModalViewModel` to confirm how `General` is instantiated before threading dependencies; Task 15 requires reading `App.xaml.cs` / `WizardViewModel` for the installer bootstrap point. These are reads, not placeholders — the wiring code is specified.