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>
53 KiB
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(flatIReadOnlyDictionary<string,string>).LocaleJson.cs— parses nested JSON intoLocaleFile(flattens to dot-paths).LocaleStore.cs— scans a folder for*.json, buildsLocaleFiles, exposesAvailablelist +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— addLanguage+Save().ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs— addLanguages,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— addLanguagetoInstallerAppSettings.App.xaml.cs/ wizard bootstrap — initLocalizer.- Wizard: language dropdown + write
Languageto 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
LocaleFileandLocaleJson
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
ILocalizerandLocalizer
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 tinyLocalizedString(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
LocalizedStringandTrExtension
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
LanguageandSave()toAppSettings
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:
GeneralSettingsTabViewModelis currently parameterless. Add an optionalILocalizer+AppSettingsvia constructor; the parentSettingsModalViewModelconstructs it. Check howSettingsModalViewModelinstantiatesGeneraland thread the dependencies through (it is resolved transiently from DI perProgram.cs:106). IfSettingsModalViewModelnews up the tab VMs directly, injectILocalizerandAppSettingsintoSettingsModalViewModeland 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.jsonsection, so the work is conflict-free. Each subagent: (1) replaces hardcoded user-facing strings in its files with{loc:Tr <key>}(XAML) orlocalizer[...]/localizer.Get(...)(VMs); (2) adds the corresponding keys under its assigned section in a separate partial filelocales/_parts/<section>.en.json; (3) reports the keys it added. The orchestrator merges all partials intoen.json(Task 16) so no two agents editen.jsonconcurrently.
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, andCommandParametervalues (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.jsondirectly — writelocales/_parts/<section>.en.jsonas a nested JSON object rooted at your section name. - Build your project after edits:
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Releasemust 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.jsonetc. (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 inViewModels/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.jsonandlocales/_parts/modals.en.json(this batch's modal keys only; see merge note — coordinate themodalssection 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-levelmodals.<x>sub-object to avoid clashing with Task 11). Use file namingmodals_<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(addLanguagetoInstallerAppSettings) -
Modify:
src/ClaudeDo.Installer/ClaudeDo.Installer.csproj(reference Localization project + importLocales.targets) -
Create:
src/ClaudeDo.Installer/Localization/LocalizedString.cs(WPF INPC holder) -
Create:
src/ClaudeDo.Installer/Localization/TrExtension.cs(WPF markup extension) -
Step 1: Add
LanguagetoInstallerAppSettings
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/WizardWindowstartup to find where the wizard is constructed; initLocalizerthere) -
Modify:
src/ClaudeDo.Installer/Views/WizardWindow.xaml(add a language dropdown in the top bar) -
Modify: the wizard view-model that owns navigation (
WizardViewModel) — exposeLanguages+SelectedLanguage, callLocalizer.SetLanguage, and persist into theInstallerAppSettingsit writes -
Modify: wherever the installer writes
ui.config.json(search forInstallerAppSettings ... .Save()) -
Step 1: Initialize the installer
Localizerat 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
Languagewhen writingui.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.jsonintoen.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
_partsfolder
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.jsonfor 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
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 1–4), JSON format/flatten (Task 1), loose-file discovery (Task 2), Localizer + fallback + live switch (Tasks 3, 6),
ui.config.jsonstorage (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 viaLanguageChangedsubscription (Phase 2 pattern). Out-of-scope items (report body, pluralization) excluded. - Type consistency:
Localizer(LocaleStore, string code, string fallbackCode="en"),ILocalizerindexer +Get+SetLanguage+LanguageChanged+AvailableLanguages+CurrentCode,LanguageOption(Code,Name),TrExtension.Localizerstatic,LocalizedString(ILocalizer,string)withValue— used consistently across tasks. - Open verification point flagged in-task: Task 9 Step 5 requires reading
SettingsModalViewModelto confirm howGeneralis instantiated before threading dependencies; Task 15 requires readingApp.xaml.cs/WizardViewModelfor the installer bootstrap point. These are reads, not placeholders — the wiring code is specified.