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

53 KiB
Raw Blame History

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 LocaleFiles, 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:

<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:

<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/:

    <Project Path="src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" />

and under /tests/:

    <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:

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:

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:

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
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:

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:

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
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:

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:

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:

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
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:

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:

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
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):

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

<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>:

  <Import Project="Locales.targets" />

In src/ClaudeDo.App/ClaudeDo.App.csproj, add a project reference and import (paths relative to App):

  <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:

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
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>:

    <ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />
  • Step 2: Write the failing test

tests/ClaudeDo.Ui.Tests/LocalizedStringTests.cs:

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:

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:

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
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:

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):

    public string Language { get; set; } = "";

And add a Save() method after Load():

    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
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:

using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using System.Globalization;
using System.IO;

In BuildServices(), after sc.AddSingleton(settings); (line 73), add:

        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
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 news 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:

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:

using ClaudeDo.Localization;

Add fields/members and a constructor (keep the existing [ObservableProperty] fields):

    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:

        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>:

        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):

              <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
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:

<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):

<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:

<ComboBoxItem Content="{loc:Tr settings.general.weekday.sunday}"/>

ViewModel strings, before:

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):

HeaderTitle = _loc["tasks.headerTitle"];
StatusPill = overdue ? _loc["tasks.overdue"] : _loc["tasks.onTrack"];

For VM properties that must update live, subscribe in the ctor:

_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 ComboBoxItems → 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):

    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>:

    <ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />

and add:

  <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:

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:

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
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:

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):

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>:

        xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"

Add a ComboBox to the step-indicator bar (Grid.Row 0), right-aligned:

        <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:

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

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
rm -r src/ClaudeDo.Localization/locales/_parts
  • Step 4: Commit
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.jsonsrc/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
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
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.