# 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 1–4), 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 10–13), installer parity + picker + config write-through (Tasks 14–15), 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.
