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>
1482 lines
53 KiB
Markdown
1482 lines
53 KiB
Markdown
# Localization (i18n) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add data-driven, instant-switching UI localization to ClaudeDo (Avalonia app + WPF installer), shipping English only but extensible by dropping a JSON file into a `locales/` folder.
|
||
|
||
**Architecture:** A new `ClaudeDo.Localization` net8.0 library owns locale-file discovery (`LocaleStore`), the active-language lookup singleton (`Localizer` with fallback chain + change notification), and OS-culture resolution. Each UI framework adds a thin `{loc:Tr key}` markup extension that binds to a per-string `LocalizedString` object which refreshes when the language changes. Language preference lives in `ui.config.json`, read at startup and written from the Settings modal / installer.
|
||
|
||
**Tech Stack:** .NET 8, Avalonia 12, WPF (installer), CommunityToolkit.Mvvm, System.Text.Json, xUnit.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-06-03-localization-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**New project `src/ClaudeDo.Localization/`:**
|
||
- `ClaudeDo.Localization.csproj` — net8.0 library, no deps beyond BCL.
|
||
- `LocaleFile.cs` — immutable parsed locale: `Code`, `Name`, `Strings` (flat `IReadOnlyDictionary<string,string>`).
|
||
- `LocaleJson.cs` — parses nested JSON into `LocaleFile` (flattens to dot-paths).
|
||
- `LocaleStore.cs` — scans a folder for `*.json`, builds `LocaleFile`s, exposes `Available` list + `TryGet(code)`.
|
||
- `ILocalizer.cs` / `Localizer.cs` — active-language singleton: indexer, `Get(key,args)`, `SetLanguage(code)`, `AvailableLanguages`, `CurrentCode`, change event.
|
||
- `CultureResolver.cs` — maps an OS culture name to an available locale code (English fallback).
|
||
|
||
**Locale data:**
|
||
- `src/ClaudeDo.Localization/locales/en.json` — the single source of truth (English).
|
||
- Copied to App + Installer output via a shared MSBuild include.
|
||
|
||
**Avalonia side (`src/ClaudeDo.Ui/`):**
|
||
- `Localization/TrExtension.cs` — `{loc:Tr key}` markup extension.
|
||
- `Localization/LocalizedString.cs` — per-binding INPC holder.
|
||
- `AppSettings.cs` — add `Language` + `Save()`.
|
||
- `ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs` — add `Languages`, `SelectedLanguage`.
|
||
- `Views/Modals/SettingsModalView.axaml` — language dropdown.
|
||
|
||
**App side (`src/ClaudeDo.App/Program.cs`):** init `LocaleStore` + `Localizer`, register in DI, set language from config.
|
||
|
||
**Installer side (`src/ClaudeDo.Installer/`):**
|
||
- `Localization/TrExtension.cs` + `Localization/LocalizedString.cs` (WPF variants).
|
||
- `Core/ConfigModels.cs` — add `Language` to `InstallerAppSettings`.
|
||
- `App.xaml.cs` / wizard bootstrap — init `Localizer`.
|
||
- Wizard: language dropdown + write `Language` to config.
|
||
|
||
**Tests:**
|
||
- New `tests/ClaudeDo.Localization.Tests/` — store/parse/lookup/fallback/culture/key-coverage.
|
||
- `tests/ClaudeDo.Ui.Tests/` — settings round-trip.
|
||
|
||
---
|
||
|
||
## Phase 1 — Foundation (sequential)
|
||
|
||
### Task 1: Create `ClaudeDo.Localization` project + parse/flatten locale JSON
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Localization/ClaudeDo.Localization.csproj`
|
||
- Create: `src/ClaudeDo.Localization/LocaleFile.cs`
|
||
- Create: `src/ClaudeDo.Localization/LocaleJson.cs`
|
||
- Create: `tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj`
|
||
- Create: `tests/ClaudeDo.Localization.Tests/LocaleJsonTests.cs`
|
||
- Modify: `ClaudeDo.slnx` (register both projects)
|
||
|
||
- [ ] **Step 1: Create the library csproj**
|
||
|
||
`src/ClaudeDo.Localization/ClaudeDo.Localization.csproj`:
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk">
|
||
<PropertyGroup>
|
||
<TargetFramework>net8.0</TargetFramework>
|
||
<ImplicitUsings>enable</ImplicitUsings>
|
||
<Nullable>enable</Nullable>
|
||
</PropertyGroup>
|
||
<ItemGroup>
|
||
<InternalsVisibleTo Include="ClaudeDo.Localization.Tests" />
|
||
</ItemGroup>
|
||
</Project>
|
||
```
|
||
|
||
- [ ] **Step 2: Create the test csproj + register both in the solution**
|
||
|
||
`tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj`:
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk">
|
||
<PropertyGroup>
|
||
<TargetFramework>net8.0</TargetFramework>
|
||
<ImplicitUsings>enable</ImplicitUsings>
|
||
<Nullable>enable</Nullable>
|
||
<IsPackable>false</IsPackable>
|
||
<IsTestProject>true</IsTestProject>
|
||
</PropertyGroup>
|
||
<ItemGroup>
|
||
<Using Include="Xunit" />
|
||
</ItemGroup>
|
||
<ItemGroup>
|
||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||
<PackageReference Include="xunit" Version="2.9.3" />
|
||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||
</ItemGroup>
|
||
<ItemGroup>
|
||
<ProjectReference Include="../../src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" />
|
||
</ItemGroup>
|
||
</Project>
|
||
```
|
||
|
||
In `ClaudeDo.slnx`, add under `/src/`:
|
||
```xml
|
||
<Project Path="src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" />
|
||
```
|
||
and under `/tests/`:
|
||
```xml
|
||
<Project Path="tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj" />
|
||
```
|
||
|
||
- [ ] **Step 3: Write the failing test for nested-JSON flattening**
|
||
|
||
`tests/ClaudeDo.Localization.Tests/LocaleJsonTests.cs`:
|
||
```csharp
|
||
using ClaudeDo.Localization;
|
||
|
||
namespace ClaudeDo.Localization.Tests;
|
||
|
||
public class LocaleJsonTests
|
||
{
|
||
private const string Sample = """
|
||
{
|
||
"metadata": { "code": "en", "name": "English" },
|
||
"settings": { "save": "Save", "general": { "model": "Model" } },
|
||
"tasks": { "addPlaceholder": "Add a task" }
|
||
}
|
||
""";
|
||
|
||
[Fact]
|
||
public void Parse_reads_metadata()
|
||
{
|
||
var f = LocaleJson.Parse(Sample);
|
||
Assert.Equal("en", f.Code);
|
||
Assert.Equal("English", f.Name);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_flattens_nested_keys_to_dot_paths()
|
||
{
|
||
var f = LocaleJson.Parse(Sample);
|
||
Assert.Equal("Save", f.Strings["settings.save"]);
|
||
Assert.Equal("Model", f.Strings["settings.general.model"]);
|
||
Assert.Equal("Add a task", f.Strings["tasks.addPlaceholder"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_excludes_metadata_from_strings()
|
||
{
|
||
var f = LocaleJson.Parse(Sample);
|
||
Assert.False(f.Strings.ContainsKey("metadata.code"));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test, verify it fails**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests`
|
||
Expected: FAIL — `LocaleJson` / `LocaleFile` do not exist (compile error).
|
||
|
||
- [ ] **Step 5: Implement `LocaleFile` and `LocaleJson`**
|
||
|
||
`src/ClaudeDo.Localization/LocaleFile.cs`:
|
||
```csharp
|
||
namespace ClaudeDo.Localization;
|
||
|
||
public sealed class LocaleFile
|
||
{
|
||
public LocaleFile(string code, string name, IReadOnlyDictionary<string, string> strings)
|
||
{
|
||
Code = code;
|
||
Name = name;
|
||
Strings = strings;
|
||
}
|
||
|
||
public string Code { get; }
|
||
public string Name { get; }
|
||
public IReadOnlyDictionary<string, string> Strings { get; }
|
||
}
|
||
```
|
||
|
||
`src/ClaudeDo.Localization/LocaleJson.cs`:
|
||
```csharp
|
||
using System.Text.Json;
|
||
|
||
namespace ClaudeDo.Localization;
|
||
|
||
public static class LocaleJson
|
||
{
|
||
public static LocaleFile Parse(string json)
|
||
{
|
||
using var doc = JsonDocument.Parse(json);
|
||
var root = doc.RootElement;
|
||
|
||
var code = "";
|
||
var name = "";
|
||
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
|
||
{
|
||
if (meta.TryGetProperty("code", out var c)) code = c.GetString() ?? "";
|
||
if (meta.TryGetProperty("name", out var n)) name = n.GetString() ?? "";
|
||
}
|
||
|
||
var strings = new Dictionary<string, string>(StringComparer.Ordinal);
|
||
foreach (var prop in root.EnumerateObject())
|
||
{
|
||
if (prop.NameEquals("metadata")) continue;
|
||
Flatten(prop.Name, prop.Value, strings);
|
||
}
|
||
|
||
return new LocaleFile(code, name, strings);
|
||
}
|
||
|
||
private static void Flatten(string prefix, JsonElement el, IDictionary<string, string> into)
|
||
{
|
||
switch (el.ValueKind)
|
||
{
|
||
case JsonValueKind.Object:
|
||
foreach (var p in el.EnumerateObject())
|
||
Flatten($"{prefix}.{p.Name}", p.Value, into);
|
||
break;
|
||
case JsonValueKind.String:
|
||
into[prefix] = el.GetString() ?? "";
|
||
break;
|
||
default:
|
||
into[prefix] = el.ToString();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run the test, verify it passes**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests`
|
||
Expected: PASS (3 tests).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Localization tests/ClaudeDo.Localization.Tests ClaudeDo.slnx
|
||
git commit -m "feat(i18n): add ClaudeDo.Localization project with nested-JSON locale parser"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `LocaleStore` — discover locale files in a folder
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Localization/LocaleStore.cs`
|
||
- Test: `tests/ClaudeDo.Localization.Tests/LocaleStoreTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`tests/ClaudeDo.Localization.Tests/LocaleStoreTests.cs`:
|
||
```csharp
|
||
using ClaudeDo.Localization;
|
||
|
||
namespace ClaudeDo.Localization.Tests;
|
||
|
||
public class LocaleStoreTests
|
||
{
|
||
private static string WriteTempLocales(params (string file, string json)[] files)
|
||
{
|
||
var dir = Path.Combine(Path.GetTempPath(), "loc_" + Guid.NewGuid().ToString("N"));
|
||
Directory.CreateDirectory(dir);
|
||
foreach (var (file, json) in files)
|
||
File.WriteAllText(Path.Combine(dir, file), json);
|
||
return dir;
|
||
}
|
||
|
||
[Fact]
|
||
public void Load_discovers_all_json_files()
|
||
{
|
||
var dir = WriteTempLocales(
|
||
("en.json", """{ "metadata": { "code": "en", "name": "English" }, "a": { "b": "x" } }"""),
|
||
("de.json", """{ "metadata": { "code": "de", "name": "Deutsch" }, "a": { "b": "y" } }"""));
|
||
|
||
var store = LocaleStore.Load(dir);
|
||
|
||
Assert.Equal(2, store.Available.Count);
|
||
Assert.True(store.TryGet("en", out var en));
|
||
Assert.Equal("x", en!.Strings["a.b"]);
|
||
Assert.True(store.TryGet("de", out var de));
|
||
Assert.Equal("Deutsch", de!.Name);
|
||
}
|
||
|
||
[Fact]
|
||
public void Load_returns_empty_when_folder_missing()
|
||
{
|
||
var store = LocaleStore.Load(Path.Combine(Path.GetTempPath(), "does_not_exist_" + Guid.NewGuid()));
|
||
Assert.Empty(store.Available);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test, verify it fails**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter LocaleStoreTests`
|
||
Expected: FAIL — `LocaleStore` does not exist.
|
||
|
||
- [ ] **Step 3: Implement `LocaleStore`**
|
||
|
||
`src/ClaudeDo.Localization/LocaleStore.cs`:
|
||
```csharp
|
||
namespace ClaudeDo.Localization;
|
||
|
||
public sealed class LocaleStore
|
||
{
|
||
private readonly Dictionary<string, LocaleFile> _byCode;
|
||
|
||
private LocaleStore(Dictionary<string, LocaleFile> byCode) => _byCode = byCode;
|
||
|
||
public IReadOnlyList<LocaleFile> Available => _byCode.Values.ToList();
|
||
|
||
public bool TryGet(string code, out LocaleFile? file) => _byCode.TryGetValue(code, out file);
|
||
|
||
public static LocaleStore Load(string folder)
|
||
{
|
||
var byCode = new Dictionary<string, LocaleFile>(StringComparer.OrdinalIgnoreCase);
|
||
if (Directory.Exists(folder))
|
||
{
|
||
foreach (var path in Directory.EnumerateFiles(folder, "*.json"))
|
||
{
|
||
try
|
||
{
|
||
var file = LocaleJson.Parse(File.ReadAllText(path));
|
||
if (!string.IsNullOrWhiteSpace(file.Code))
|
||
byCode[file.Code] = file;
|
||
}
|
||
catch { /* skip malformed locale files */ }
|
||
}
|
||
}
|
||
return new LocaleStore(byCode);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test, verify it passes**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter LocaleStoreTests`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Localization/LocaleStore.cs tests/ClaudeDo.Localization.Tests/LocaleStoreTests.cs
|
||
git commit -m "feat(i18n): add LocaleStore folder discovery"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `ILocalizer` / `Localizer` — active language, fallback, change event
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Localization/ILocalizer.cs`
|
||
- Create: `src/ClaudeDo.Localization/Localizer.cs`
|
||
- Test: `tests/ClaudeDo.Localization.Tests/LocalizerTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`tests/ClaudeDo.Localization.Tests/LocalizerTests.cs`:
|
||
```csharp
|
||
using ClaudeDo.Localization;
|
||
|
||
namespace ClaudeDo.Localization.Tests;
|
||
|
||
public class LocalizerTests
|
||
{
|
||
private static LocaleStore TwoLangStore()
|
||
{
|
||
var dir = Path.Combine(Path.GetTempPath(), "loc_" + Guid.NewGuid().ToString("N"));
|
||
Directory.CreateDirectory(dir);
|
||
File.WriteAllText(Path.Combine(dir, "en.json"),
|
||
"""{ "metadata": { "code": "en", "name": "English" }, "settings": { "save": "Save" }, "msg": { "count": "{0} items" } }""");
|
||
File.WriteAllText(Path.Combine(dir, "de.json"),
|
||
"""{ "metadata": { "code": "de", "name": "Deutsch" }, "settings": { "save": "Speichern" } }""");
|
||
return LocaleStore.Load(dir);
|
||
}
|
||
|
||
[Fact]
|
||
public void Indexer_returns_active_language_value()
|
||
{
|
||
var loc = new Localizer(TwoLangStore(), "de", fallbackCode: "en");
|
||
Assert.Equal("Speichern", loc["settings.save"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Indexer_falls_back_to_english_then_key()
|
||
{
|
||
var loc = new Localizer(TwoLangStore(), "de", fallbackCode: "en");
|
||
Assert.Equal("{0} items", loc["msg.count"]); // missing in de -> english
|
||
Assert.Equal("nope.missing", loc["nope.missing"]); // missing everywhere -> key itself
|
||
}
|
||
|
||
[Fact]
|
||
public void Get_formats_arguments()
|
||
{
|
||
var loc = new Localizer(TwoLangStore(), "en", fallbackCode: "en");
|
||
Assert.Equal("3 items", loc.Get("msg.count", 3));
|
||
}
|
||
|
||
[Fact]
|
||
public void SetLanguage_raises_change_and_switches_values()
|
||
{
|
||
var loc = new Localizer(TwoLangStore(), "en", fallbackCode: "en");
|
||
var raised = 0;
|
||
loc.LanguageChanged += (_, _) => raised++;
|
||
loc.SetLanguage("de");
|
||
Assert.Equal("de", loc.CurrentCode);
|
||
Assert.Equal("Speichern", loc["settings.save"]);
|
||
Assert.Equal(1, raised);
|
||
}
|
||
|
||
[Fact]
|
||
public void AvailableLanguages_lists_code_and_name()
|
||
{
|
||
var loc = new Localizer(TwoLangStore(), "en", fallbackCode: "en");
|
||
Assert.Contains(loc.AvailableLanguages, l => l.Code == "de" && l.Name == "Deutsch");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test, verify it fails**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter LocalizerTests`
|
||
Expected: FAIL — `Localizer` / `ILocalizer` do not exist.
|
||
|
||
- [ ] **Step 3: Implement `ILocalizer` and `Localizer`**
|
||
|
||
`src/ClaudeDo.Localization/ILocalizer.cs`:
|
||
```csharp
|
||
namespace ClaudeDo.Localization;
|
||
|
||
public readonly record struct LanguageOption(string Code, string Name);
|
||
|
||
public interface ILocalizer
|
||
{
|
||
string this[string key] { get; }
|
||
string Get(string key, params object[] args);
|
||
string CurrentCode { get; }
|
||
IReadOnlyList<LanguageOption> AvailableLanguages { get; }
|
||
void SetLanguage(string code);
|
||
event EventHandler? LanguageChanged;
|
||
}
|
||
```
|
||
|
||
`src/ClaudeDo.Localization/Localizer.cs`:
|
||
```csharp
|
||
namespace ClaudeDo.Localization;
|
||
|
||
public sealed class Localizer : ILocalizer
|
||
{
|
||
private readonly LocaleStore _store;
|
||
private readonly string _fallbackCode;
|
||
private LocaleFile? _active;
|
||
private LocaleFile? _fallback;
|
||
|
||
public Localizer(LocaleStore store, string code, string fallbackCode = "en")
|
||
{
|
||
_store = store;
|
||
_fallbackCode = fallbackCode;
|
||
_store.TryGet(fallbackCode, out _fallback);
|
||
SetLanguage(code);
|
||
}
|
||
|
||
public string CurrentCode { get; private set; } = "";
|
||
|
||
public IReadOnlyList<LanguageOption> AvailableLanguages =>
|
||
_store.Available.Select(f => new LanguageOption(f.Code, f.Name)).ToList();
|
||
|
||
public event EventHandler? LanguageChanged;
|
||
|
||
public string this[string key]
|
||
{
|
||
get
|
||
{
|
||
if (_active is not null && _active.Strings.TryGetValue(key, out var v)) return v;
|
||
if (_fallback is not null && _fallback.Strings.TryGetValue(key, out var fv)) return fv;
|
||
return key;
|
||
}
|
||
}
|
||
|
||
public string Get(string key, params object[] args)
|
||
{
|
||
var fmt = this[key];
|
||
return args.Length == 0 ? fmt : string.Format(fmt, args);
|
||
}
|
||
|
||
public void SetLanguage(string code)
|
||
{
|
||
if (_store.TryGet(code, out var f) && f is not null)
|
||
{
|
||
_active = f;
|
||
CurrentCode = f.Code;
|
||
}
|
||
else
|
||
{
|
||
_active = _fallback;
|
||
CurrentCode = _fallback?.Code ?? code;
|
||
}
|
||
LanguageChanged?.Invoke(this, EventArgs.Empty);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test, verify it passes**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter LocalizerTests`
|
||
Expected: PASS (5 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Localization/ILocalizer.cs src/ClaudeDo.Localization/Localizer.cs tests/ClaudeDo.Localization.Tests/LocalizerTests.cs
|
||
git commit -m "feat(i18n): add Localizer with fallback chain and change event"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `CultureResolver` — OS culture → available locale code
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Localization/CultureResolver.cs`
|
||
- Test: `tests/ClaudeDo.Localization.Tests/CultureResolverTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`tests/ClaudeDo.Localization.Tests/CultureResolverTests.cs`:
|
||
```csharp
|
||
using ClaudeDo.Localization;
|
||
|
||
namespace ClaudeDo.Localization.Tests;
|
||
|
||
public class CultureResolverTests
|
||
{
|
||
private static readonly string[] Codes = { "en", "de" };
|
||
|
||
[Theory]
|
||
[InlineData("de-DE", "de")]
|
||
[InlineData("de", "de")]
|
||
[InlineData("en-US", "en")]
|
||
[InlineData("fr-FR", "en")] // unsupported -> fallback
|
||
[InlineData("", "en")]
|
||
public void Resolve_maps_culture_to_available_code(string culture, string expected)
|
||
{
|
||
Assert.Equal(expected, CultureResolver.Resolve(culture, Codes, fallback: "en"));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test, verify it fails**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter CultureResolverTests`
|
||
Expected: FAIL — `CultureResolver` does not exist.
|
||
|
||
- [ ] **Step 3: Implement `CultureResolver`**
|
||
|
||
`src/ClaudeDo.Localization/CultureResolver.cs`:
|
||
```csharp
|
||
namespace ClaudeDo.Localization;
|
||
|
||
public static class CultureResolver
|
||
{
|
||
public static string Resolve(string cultureName, IReadOnlyCollection<string> available, string fallback = "en")
|
||
{
|
||
if (string.IsNullOrWhiteSpace(cultureName)) return fallback;
|
||
|
||
// exact match (e.g. "de")
|
||
var exact = available.FirstOrDefault(c => string.Equals(c, cultureName, StringComparison.OrdinalIgnoreCase));
|
||
if (exact is not null) return exact;
|
||
|
||
// primary subtag (e.g. "de-DE" -> "de")
|
||
var primary = cultureName.Split('-')[0];
|
||
var byPrimary = available.FirstOrDefault(c => string.Equals(c, primary, StringComparison.OrdinalIgnoreCase));
|
||
return byPrimary ?? fallback;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test, verify it passes**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter CultureResolverTests`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Localization/CultureResolver.cs tests/ClaudeDo.Localization.Tests/CultureResolverTests.cs
|
||
git commit -m "feat(i18n): add CultureResolver for OS-culture mapping"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Seed `en.json` + key-coverage test + output-copy wiring
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Localization/locales/en.json`
|
||
- Create: `src/ClaudeDo.Localization/Locales.targets` (shared copy include)
|
||
- Modify: `src/ClaudeDo.Localization/ClaudeDo.Localization.csproj` (import targets, copy locales)
|
||
- Modify: `src/ClaudeDo.App/ClaudeDo.App.csproj` (import targets)
|
||
- Test: `tests/ClaudeDo.Localization.Tests/EnJsonCoverageTests.cs`
|
||
|
||
- [ ] **Step 1: Create the seed `en.json`**
|
||
|
||
`src/ClaudeDo.Localization/locales/en.json` (starter — extraction tasks append to it):
|
||
```json
|
||
{
|
||
"metadata": { "code": "en", "name": "English" },
|
||
"settings": {
|
||
"title": "SETTINGS",
|
||
"save": "Save",
|
||
"cancel": "Cancel",
|
||
"language": "Language",
|
||
"tabGeneral": "General",
|
||
"tabWorktrees": "Worktrees",
|
||
"tabFiles": "Files",
|
||
"tabPrime": "Prime Claude"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create the shared copy targets**
|
||
|
||
`src/ClaudeDo.Localization/Locales.targets`:
|
||
```xml
|
||
<Project>
|
||
<ItemGroup>
|
||
<Content Include="$(MSBuildThisFileDirectory)locales\*.json"
|
||
Link="locales\%(Filename)%(Extension)"
|
||
CopyToOutputDirectory="PreserveNewest" />
|
||
</ItemGroup>
|
||
</Project>
|
||
```
|
||
|
||
In `src/ClaudeDo.Localization/ClaudeDo.Localization.csproj`, add inside `<Project>`:
|
||
```xml
|
||
<Import Project="Locales.targets" />
|
||
```
|
||
|
||
In `src/ClaudeDo.App/ClaudeDo.App.csproj`, add a project reference and import (paths relative to App):
|
||
```xml
|
||
<ItemGroup>
|
||
<ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />
|
||
</ItemGroup>
|
||
<Import Project="..\ClaudeDo.Localization\Locales.targets" />
|
||
```
|
||
|
||
- [ ] **Step 3: Write the failing key-coverage test**
|
||
|
||
This test loads every shipped locale file and asserts each has the same key set as `en.json`. It locates the source `locales/` folder by walking up from the test assembly.
|
||
|
||
`tests/ClaudeDo.Localization.Tests/EnJsonCoverageTests.cs`:
|
||
```csharp
|
||
using ClaudeDo.Localization;
|
||
|
||
namespace ClaudeDo.Localization.Tests;
|
||
|
||
public class EnJsonCoverageTests
|
||
{
|
||
private static string LocalesDir()
|
||
{
|
||
var dir = AppContext.BaseDirectory;
|
||
while (dir is not null)
|
||
{
|
||
var candidate = Path.Combine(dir, "src", "ClaudeDo.Localization", "locales");
|
||
if (Directory.Exists(candidate)) return candidate;
|
||
dir = Path.GetDirectoryName(dir);
|
||
}
|
||
throw new DirectoryNotFoundException("Could not locate src/ClaudeDo.Localization/locales");
|
||
}
|
||
|
||
[Fact]
|
||
public void Every_locale_has_same_keys_as_english()
|
||
{
|
||
var store = LocaleStore.Load(LocalesDir());
|
||
Assert.True(store.TryGet("en", out var en));
|
||
var enKeys = en!.Strings.Keys.ToHashSet();
|
||
|
||
foreach (var locale in store.Available)
|
||
{
|
||
var keys = locale.Strings.Keys.ToHashSet();
|
||
var missing = enKeys.Except(keys).OrderBy(k => k).ToList();
|
||
var extra = keys.Except(enKeys).OrderBy(k => k).ToList();
|
||
Assert.True(missing.Count == 0,
|
||
$"Locale '{locale.Code}' missing keys: {string.Join(", ", missing)}");
|
||
Assert.True(extra.Count == 0,
|
||
$"Locale '{locale.Code}' has extra keys not in en.json: {string.Join(", ", extra)}");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test, verify it passes (only en.json present)**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests --filter EnJsonCoverageTests`
|
||
Expected: PASS (only `en.json` exists, so it trivially matches itself).
|
||
|
||
- [ ] **Step 5: Verify locales copy to App output**
|
||
|
||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||
Then confirm the file exists:
|
||
Run: `ls src/ClaudeDo.App/bin/Release/net8.0/locales/en.json`
|
||
Expected: path listed (file copied).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Localization/locales src/ClaudeDo.Localization/Locales.targets src/ClaudeDo.Localization/ClaudeDo.Localization.csproj src/ClaudeDo.App/ClaudeDo.App.csproj tests/ClaudeDo.Localization.Tests/EnJsonCoverageTests.cs
|
||
git commit -m "feat(i18n): seed en.json and wire locale copy to app output"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Avalonia `{loc:Tr}` markup extension + `LocalizedString`
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Ui/Localization/LocalizedString.cs`
|
||
- Create: `src/ClaudeDo.Ui/Localization/TrExtension.cs`
|
||
- Modify: `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` (reference Localization project)
|
||
- Test: `tests/ClaudeDo.Ui.Tests/LocalizedStringTests.cs`
|
||
|
||
> **Why a per-string holder instead of an indexer binding:** binding directly to `Localizer["a.b.c"]` requires the Avalonia path parser to accept dotted keys inside `[...]`. Wrapping each key in a tiny `LocalizedString` (a one-property INPC object) sidesteps path parsing entirely and gives clean live updates.
|
||
|
||
- [ ] **Step 1: Add the project reference**
|
||
|
||
In `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`, add to the first `<ItemGroup>`:
|
||
```xml
|
||
<ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
`tests/ClaudeDo.Ui.Tests/LocalizedStringTests.cs`:
|
||
```csharp
|
||
using ClaudeDo.Localization;
|
||
using ClaudeDo.Ui.Localization;
|
||
|
||
namespace ClaudeDo.Ui.Tests;
|
||
|
||
public class LocalizedStringTests
|
||
{
|
||
private static Localizer Make()
|
||
{
|
||
var dir = Path.Combine(Path.GetTempPath(), "loc_" + Guid.NewGuid().ToString("N"));
|
||
Directory.CreateDirectory(dir);
|
||
File.WriteAllText(Path.Combine(dir, "en.json"),
|
||
"""{ "metadata": { "code": "en", "name": "English" }, "settings": { "save": "Save" } }""");
|
||
File.WriteAllText(Path.Combine(dir, "de.json"),
|
||
"""{ "metadata": { "code": "de", "name": "Deutsch" }, "settings": { "save": "Speichern" } }""");
|
||
return new Localizer(LocaleStore.Load(dir), "en");
|
||
}
|
||
|
||
[Fact]
|
||
public void Value_tracks_language_change()
|
||
{
|
||
var loc = Make();
|
||
var ls = new LocalizedString(loc, "settings.save");
|
||
Assert.Equal("Save", ls.Value);
|
||
|
||
string? changed = null;
|
||
ls.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(LocalizedString.Value)) changed = ls.Value; };
|
||
|
||
loc.SetLanguage("de");
|
||
Assert.Equal("Speichern", ls.Value);
|
||
Assert.Equal("Speichern", changed);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run the test, verify it fails**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter LocalizedStringTests`
|
||
Expected: FAIL — `LocalizedString` does not exist.
|
||
|
||
- [ ] **Step 4: Implement `LocalizedString` and `TrExtension`**
|
||
|
||
`src/ClaudeDo.Ui/Localization/LocalizedString.cs`:
|
||
```csharp
|
||
using System.ComponentModel;
|
||
using ClaudeDo.Localization;
|
||
|
||
namespace ClaudeDo.Ui.Localization;
|
||
|
||
public sealed class LocalizedString : INotifyPropertyChanged
|
||
{
|
||
private readonly ILocalizer _localizer;
|
||
private readonly string _key;
|
||
|
||
public LocalizedString(ILocalizer localizer, string key)
|
||
{
|
||
_localizer = localizer;
|
||
_key = key;
|
||
_localizer.LanguageChanged += (_, _) =>
|
||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
|
||
}
|
||
|
||
public string Value => _localizer[_key];
|
||
|
||
public event PropertyChangedEventHandler? PropertyChanged;
|
||
}
|
||
```
|
||
|
||
`src/ClaudeDo.Ui/Localization/TrExtension.cs`:
|
||
```csharp
|
||
using Avalonia.Data;
|
||
using Avalonia.Markup.Xaml;
|
||
using ClaudeDo.Localization;
|
||
|
||
namespace ClaudeDo.Ui.Localization;
|
||
|
||
public sealed class TrExtension : MarkupExtension
|
||
{
|
||
public TrExtension() { }
|
||
public TrExtension(string key) => Key = key;
|
||
|
||
public string Key { get; set; } = "";
|
||
|
||
// Set once at startup (App) so XAML-created extensions can resolve the singleton.
|
||
public static ILocalizer? Localizer { get; set; }
|
||
|
||
public override object ProvideValue(IServiceProvider serviceProvider)
|
||
{
|
||
var loc = Localizer ?? throw new InvalidOperationException("TrExtension.Localizer not initialized");
|
||
return new Binding(nameof(LocalizedString.Value))
|
||
{
|
||
Source = new LocalizedString(loc, Key),
|
||
Mode = BindingMode.OneWay
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the test, verify it passes**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter LocalizedStringTests`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/Localization src/ClaudeDo.Ui/ClaudeDo.Ui.csproj tests/ClaudeDo.Ui.Tests/LocalizedStringTests.cs
|
||
git commit -m "feat(i18n): add Avalonia loc:Tr markup extension and LocalizedString"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: `AppSettings.Language` + `Save()`
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Ui/AppSettings.cs`
|
||
- Test: `tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs`:
|
||
```csharp
|
||
using System.Text.Json;
|
||
using ClaudeDo.Ui;
|
||
|
||
namespace ClaudeDo.Ui.Tests;
|
||
|
||
public class AppSettingsTests
|
||
{
|
||
[Fact]
|
||
public void Language_defaults_to_empty()
|
||
{
|
||
Assert.Equal("", new AppSettings().Language);
|
||
}
|
||
|
||
[Fact]
|
||
public void Language_round_trips_through_json()
|
||
{
|
||
var json = JsonSerializer.Serialize(new AppSettings { Language = "de" });
|
||
var back = JsonSerializer.Deserialize<AppSettings>(json,
|
||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||
Assert.Equal("de", back.Language);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test, verify it fails**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter AppSettingsTests`
|
||
Expected: FAIL — `Language` property does not exist.
|
||
|
||
- [ ] **Step 3: Add `Language` and `Save()` to `AppSettings`**
|
||
|
||
In `src/ClaudeDo.Ui/AppSettings.cs`, add the property after `SignalRUrl` (line 9):
|
||
```csharp
|
||
public string Language { get; set; } = "";
|
||
```
|
||
And add a `Save()` method after `Load()`:
|
||
```csharp
|
||
public void Save()
|
||
{
|
||
var dir = Path.GetDirectoryName(ConfigPath);
|
||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
|
||
File.WriteAllText(ConfigPath, json);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test, verify it passes**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter AppSettingsTests`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/AppSettings.cs tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs
|
||
git commit -m "feat(i18n): add Language preference and Save() to AppSettings"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: App startup wiring — build `Localizer`, register in DI, set `TrExtension.Localizer`
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.App/Program.cs`
|
||
|
||
- [ ] **Step 1: Add a locales-path + Localizer factory in `BuildServices()`**
|
||
|
||
In `src/ClaudeDo.App/Program.cs`, add these usings at the top:
|
||
```csharp
|
||
using ClaudeDo.Localization;
|
||
using ClaudeDo.Ui.Localization;
|
||
using System.Globalization;
|
||
using System.IO;
|
||
```
|
||
|
||
In `BuildServices()`, after `sc.AddSingleton(settings);` (line 73), add:
|
||
```csharp
|
||
var localesDir = Path.Combine(AppContext.BaseDirectory, "locales");
|
||
var localeStore = LocaleStore.Load(localesDir);
|
||
var initialLang = !string.IsNullOrWhiteSpace(settings.Language)
|
||
? settings.Language
|
||
: CultureResolver.Resolve(
|
||
CultureInfo.CurrentUICulture.Name,
|
||
localeStore.Available.Select(l => l.Code).ToArray(),
|
||
fallback: "en");
|
||
var localizer = new Localizer(localeStore, initialLang);
|
||
TrExtension.Localizer = localizer;
|
||
sc.AddSingleton<ILocalizer>(localizer);
|
||
```
|
||
|
||
- [ ] **Step 2: Build and verify the app compiles**
|
||
|
||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||
Expected: Build succeeded.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.App/Program.cs
|
||
git commit -m "feat(i18n): initialize Localizer at app startup from config/OS culture"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Settings General tab — language dropdown + persistence
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs`
|
||
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
|
||
- Test: `tests/ClaudeDo.Ui.Tests/LanguageSettingTests.cs`
|
||
|
||
> **Note:** `GeneralSettingsTabViewModel` is currently parameterless. Add an optional `ILocalizer` + `AppSettings` via constructor; the parent `SettingsModalViewModel` constructs it. Check how `SettingsModalViewModel` instantiates `General` and thread the dependencies through (it is resolved transiently from DI per `Program.cs:106`). If `SettingsModalViewModel` `new`s up the tab VMs directly, inject `ILocalizer` and `AppSettings` into `SettingsModalViewModel` and pass them in.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`tests/ClaudeDo.Ui.Tests/LanguageSettingTests.cs`:
|
||
```csharp
|
||
using ClaudeDo.Localization;
|
||
using ClaudeDo.Ui;
|
||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||
|
||
namespace ClaudeDo.Ui.Tests;
|
||
|
||
public class LanguageSettingTests
|
||
{
|
||
private static Localizer Make()
|
||
{
|
||
var dir = Path.Combine(Path.GetTempPath(), "loc_" + Guid.NewGuid().ToString("N"));
|
||
Directory.CreateDirectory(dir);
|
||
File.WriteAllText(Path.Combine(dir, "en.json"),
|
||
"""{ "metadata": { "code": "en", "name": "English" }, "settings": { "save": "Save" } }""");
|
||
File.WriteAllText(Path.Combine(dir, "de.json"),
|
||
"""{ "metadata": { "code": "de", "name": "Deutsch" }, "settings": { "save": "Speichern" } }""");
|
||
return new Localizer(LocaleStore.Load(dir), "en");
|
||
}
|
||
|
||
[Fact]
|
||
public void Selecting_language_switches_localizer_and_persists()
|
||
{
|
||
var loc = Make();
|
||
var settings = new AppSettings();
|
||
var saved = false;
|
||
var vm = new GeneralSettingsTabViewModel(loc, code => { settings.Language = code; saved = true; });
|
||
|
||
var de = vm.Languages.First(l => l.Code == "de");
|
||
vm.SelectedLanguage = de;
|
||
|
||
Assert.Equal("de", loc.CurrentCode);
|
||
Assert.True(saved);
|
||
Assert.Equal("de", settings.Language);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test, verify it fails**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter LanguageSettingTests`
|
||
Expected: FAIL — constructor signature / `Languages` / `SelectedLanguage` do not exist.
|
||
|
||
- [ ] **Step 3: Add language members to `GeneralSettingsTabViewModel`**
|
||
|
||
In `src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs`, add usings:
|
||
```csharp
|
||
using ClaudeDo.Localization;
|
||
```
|
||
Add fields/members and a constructor (keep the existing `[ObservableProperty]` fields):
|
||
```csharp
|
||
private readonly ILocalizer? _localizer;
|
||
private readonly Action<string>? _persist;
|
||
|
||
public GeneralSettingsTabViewModel() { }
|
||
|
||
public GeneralSettingsTabViewModel(ILocalizer localizer, Action<string> persist)
|
||
{
|
||
_localizer = localizer;
|
||
_persist = persist;
|
||
Languages = localizer.AvailableLanguages;
|
||
_selectedLanguage = Languages.FirstOrDefault(l => l.Code == localizer.CurrentCode);
|
||
}
|
||
|
||
public IReadOnlyList<LanguageOption> Languages { get; } = Array.Empty<LanguageOption>();
|
||
|
||
[ObservableProperty] private LanguageOption? _selectedLanguage;
|
||
|
||
partial void OnSelectedLanguageChanged(LanguageOption? value)
|
||
{
|
||
if (value is null || _localizer is null) return;
|
||
_localizer.SetLanguage(value.Value.Code);
|
||
_persist?.Invoke(value.Value.Code);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test, verify it passes**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter LanguageSettingTests`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Wire dependencies through `SettingsModalViewModel`**
|
||
|
||
Open `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`. Inject `ILocalizer` and `AppSettings` into its constructor (DI provides both — `ILocalizer` from Task 8, `AppSettings` is already a singleton per `Program.cs:73`). Where it sets `General = new GeneralSettingsTabViewModel(...)`, pass:
|
||
```csharp
|
||
General = new GeneralSettingsTabViewModel(localizer, code =>
|
||
{
|
||
appSettings.Language = code;
|
||
appSettings.Save();
|
||
});
|
||
```
|
||
(Confirm the exact field name and instantiation point by reading the file first.)
|
||
|
||
- [ ] **Step 6: Add the language dropdown to the General tab**
|
||
|
||
In `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`, add the `loc` namespace to the root `<Window>`:
|
||
```xml
|
||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||
```
|
||
Then add a language field as the first child of the General tab's `StackPanel` (before "Default instructions", after line 47):
|
||
```xml
|
||
<StackPanel Spacing="4">
|
||
<TextBlock Classes="field-label" Text="{loc:Tr settings.language}"/>
|
||
<ComboBox ItemsSource="{Binding General.Languages}"
|
||
SelectedItem="{Binding General.SelectedLanguage, Mode=TwoWay}"
|
||
HorizontalAlignment="Left" Width="220">
|
||
<ComboBox.ItemTemplate>
|
||
<DataTemplate>
|
||
<TextBlock Text="{Binding Name}"/>
|
||
</DataTemplate>
|
||
</ComboBox.ItemTemplate>
|
||
</ComboBox>
|
||
</StackPanel>
|
||
```
|
||
|
||
- [ ] **Step 7: Build and run the app; verify language dropdown appears**
|
||
|
||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||
Expected: Build succeeded. Launch the app, open Settings → General; the Language dropdown shows "English". (Instant-switch is verified end-to-end in Task 11 with a temporary `de.json`.)
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml tests/ClaudeDo.Ui.Tests/LanguageSettingTests.cs
|
||
git commit -m "feat(i18n): add language dropdown to settings and persist selection"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2 — Avalonia string extraction (parallel subagents)
|
||
|
||
> **Dispatch model:** Use superpowers:dispatching-parallel-agents. Each batch below owns a **disjoint set of view/VM files** and a **disjoint top-level `en.json` section**, so the work is conflict-free. Each subagent: (1) replaces hardcoded user-facing strings in its files with `{loc:Tr <key>}` (XAML) or `localizer[...]` / `localizer.Get(...)` (VMs); (2) adds the corresponding keys under its assigned section in a **separate partial file** `locales/_parts/<section>.en.json`; (3) reports the keys it added. The orchestrator merges all partials into `en.json` (Task 16) so no two agents edit `en.json` concurrently.
|
||
|
||
**Extraction pattern (applies to every batch):**
|
||
|
||
XAML, before:
|
||
```xml
|
||
<TextBlock Text="Default instructions"/>
|
||
<Button Content="Save"/>
|
||
<TextBox PlaceholderText="Add a task…"/>
|
||
```
|
||
XAML, after (add `xmlns:loc="using:ClaudeDo.Ui.Localization"` to the root element once per file):
|
||
```xml
|
||
<TextBlock Text="{loc:Tr settings.general.defaultInstructions}"/>
|
||
<Button Content="{loc:Tr settings.save}"/>
|
||
<TextBox PlaceholderText="{loc:Tr tasks.addPlaceholder}"/>
|
||
```
|
||
|
||
Inline `ComboBoxItem` text (e.g. weekday names) becomes an `ItemsSource` of localized strings OR each item's content uses `{loc:Tr ...}`; prefer binding the item content:
|
||
```xml
|
||
<ComboBoxItem Content="{loc:Tr settings.general.weekday.sunday}"/>
|
||
```
|
||
|
||
ViewModel strings, before:
|
||
```csharp
|
||
HeaderTitle = "Tasks";
|
||
StatusPill = overdue ? "OVERDUE" : "ON TRACK";
|
||
```
|
||
ViewModel strings, after (inject `ILocalizer _loc` via constructor; for live updates re-raise on `LanguageChanged` — see per-property note in spec):
|
||
```csharp
|
||
HeaderTitle = _loc["tasks.headerTitle"];
|
||
StatusPill = overdue ? _loc["tasks.overdue"] : _loc["tasks.onTrack"];
|
||
```
|
||
For VM properties that must update live, subscribe in the ctor:
|
||
```csharp
|
||
_loc.LanguageChanged += (_, _) => RefreshLocalizedText();
|
||
```
|
||
where `RefreshLocalizedText()` re-assigns the localized `[ObservableProperty]` values.
|
||
|
||
**Rules for every subagent:**
|
||
- Only translate **user-facing** strings. Leave log messages, format/CSS-like tokens, `x:Name`, style classes, binding paths, and `CommandParameter` values (e.g. `"System"`, `"Planning"`, `"Agent"`) unchanged.
|
||
- Keep underlying bound values intact for enum-ish combos (translate only display text).
|
||
- Key naming: `<section>.<screenOrModal>.<thing>` in camelCase leaf (e.g. `tasks.addPlaceholder`, `modals.merge.confirm`).
|
||
- Do **not** edit `en.json` directly — write `locales/_parts/<section>.en.json` as a nested JSON object rooted at your section name.
|
||
- Build your project after edits: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` must succeed (missing keys won't break build — they fall back to the key string).
|
||
- Report: the list of keys added and any strings you intentionally left untranslated.
|
||
|
||
### Task 10: Extraction batch A — Islands views + VMs
|
||
|
||
**Section:** `tasks`, `lists`, `details`, `agent`, `notes`, `session`
|
||
**Files (Views/Islands + matching VMs):**
|
||
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` + `ViewModels/Islands/TasksIslandViewModel.cs`
|
||
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml` + `ViewModels/Islands/ListsIslandViewModel.cs`
|
||
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` + `ViewModels/Islands/DetailsIslandViewModel.cs`
|
||
- `src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml`
|
||
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||
- `src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml` + `ViewModels/.../NotesEditorViewModel.cs`
|
||
- `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml`
|
||
|
||
- [ ] Apply the extraction pattern to each file above.
|
||
- [ ] Write `locales/_parts/tasks.en.json` etc. (one nested object per section this batch owns).
|
||
- [ ] Build succeeds; report keys added.
|
||
|
||
### Task 11: Extraction batch B — Settings + small modals
|
||
|
||
**Section:** `settings`, `modals.about`, `modals.workerConnection`, `modals.listSettings`
|
||
**Files:**
|
||
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` (remaining hardcoded strings — language field already done) + the four Settings tab VMs in `ViewModels/Modals/Settings/`
|
||
- `src/ClaudeDo.Ui/Views/Modals/AboutModal.axaml`
|
||
- `src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModal.axaml`
|
||
- `src/ClaudeDo.Ui/Views/Modals/ListSettingsModal.axaml` + its VM
|
||
|
||
- [ ] Apply the extraction pattern. Weekday `ComboBoxItem`s → `settings.general.weekday.*`.
|
||
- [ ] Write `locales/_parts/settings.en.json` and `locales/_parts/modals.en.json` (this batch's modal keys only; see merge note — coordinate the `modals` section split with Task 12 by using distinct sub-objects).
|
||
- [ ] Build succeeds; report keys added.
|
||
|
||
### Task 12: Extraction batch C — Worktree / merge / diff / planning modals
|
||
|
||
**Section:** `modals.merge`, `modals.diff`, `modals.worktree`, `modals.worktreesOverview`, `modals.repoImport`, `modals.unfinishedPlanning`, `modals.weeklyReport`
|
||
**Files:**
|
||
- `src/ClaudeDo.Ui/Views/Modals/MergeModal.axaml` + VM
|
||
- `src/ClaudeDo.Ui/Views/Modals/DiffModal.axaml`
|
||
- `src/ClaudeDo.Ui/Views/Modals/WorktreeModal.axaml` + VM
|
||
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModal.axaml` + VM
|
||
- `src/ClaudeDo.Ui/Views/Modals/RepoImportModal.axaml` + VM
|
||
- `src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModal.axaml`
|
||
- `src/ClaudeDo.Ui/Views/Modals/WeeklyReportModalView.axaml` + VM (UI labels only — the German report **body** stays as-is)
|
||
|
||
- [ ] Apply the extraction pattern.
|
||
- [ ] Write `locales/_parts/modals.merge.en.json`-style partials (one file per top-level `modals.<x>` sub-object to avoid clashing with Task 11). Use file naming `modals_<sub>.en.json`.
|
||
- [ ] Build succeeds; report keys added.
|
||
|
||
### Task 13: Extraction batch D — Planning + Controls
|
||
|
||
**Section:** `planning`, `controls`
|
||
**Files:**
|
||
- `src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml` + VM
|
||
- `src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml`
|
||
- `src/ClaudeDo.Ui/Views/Controls/ThemedDatePicker.axaml` (only if it has user-facing text)
|
||
- `src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml` (only if it has user-facing text)
|
||
- Any menu/header strings in `MainWindow`/`IslandsShellView` (search for remaining literals)
|
||
|
||
- [ ] Apply the extraction pattern.
|
||
- [ ] Write `locales/_parts/planning.en.json`, `locales/_parts/controls.en.json`.
|
||
- [ ] Build succeeds; report keys added.
|
||
|
||
> **After dispatch:** the orchestrator reviews each subagent's report and diff (per dispatching-parallel-agents), then proceeds to Task 16 to merge partials.
|
||
|
||
---
|
||
|
||
## Phase 3 — Installer localization (sequential, after Phase 1)
|
||
|
||
### Task 14: Installer config + WPF localization primitives
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Installer/Core/ConfigModels.cs` (add `Language` to `InstallerAppSettings`)
|
||
- Modify: `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` (reference Localization project + import `Locales.targets`)
|
||
- Create: `src/ClaudeDo.Installer/Localization/LocalizedString.cs` (WPF INPC holder)
|
||
- Create: `src/ClaudeDo.Installer/Localization/TrExtension.cs` (WPF markup extension)
|
||
|
||
- [ ] **Step 1: Add `Language` to `InstallerAppSettings`**
|
||
|
||
In `src/ClaudeDo.Installer/Core/ConfigModels.cs`, in `InstallerAppSettings` (after `SignalRUrl`, line 79):
|
||
```csharp
|
||
public string Language { get; set; } = "";
|
||
```
|
||
|
||
- [ ] **Step 2: Reference the Localization project + copy locales**
|
||
|
||
In `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`, add to the project-references `<ItemGroup>`:
|
||
```xml
|
||
<ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />
|
||
```
|
||
and add:
|
||
```xml
|
||
<Import Project="..\ClaudeDo.Localization\Locales.targets" />
|
||
```
|
||
(With `IncludeAllContentForSelfExtract=true` the `locales/` content extracts beside the single-file installer at runtime.)
|
||
|
||
- [ ] **Step 3: Implement the WPF `LocalizedString`**
|
||
|
||
`src/ClaudeDo.Installer/Localization/LocalizedString.cs`:
|
||
```csharp
|
||
using System.ComponentModel;
|
||
using ClaudeDo.Localization;
|
||
|
||
namespace ClaudeDo.Installer.Localization;
|
||
|
||
public sealed class LocalizedString : INotifyPropertyChanged
|
||
{
|
||
private readonly ILocalizer _localizer;
|
||
private readonly string _key;
|
||
|
||
public LocalizedString(ILocalizer localizer, string key)
|
||
{
|
||
_localizer = localizer;
|
||
_key = key;
|
||
_localizer.LanguageChanged += (_, _) =>
|
||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
|
||
}
|
||
|
||
public string Value => _localizer[_key];
|
||
|
||
public event PropertyChangedEventHandler? PropertyChanged;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement the WPF `TrExtension`**
|
||
|
||
`src/ClaudeDo.Installer/Localization/TrExtension.cs`:
|
||
```csharp
|
||
using System;
|
||
using System.Windows.Data;
|
||
using System.Windows.Markup;
|
||
using ClaudeDo.Localization;
|
||
|
||
namespace ClaudeDo.Installer.Localization;
|
||
|
||
public sealed class TrExtension : MarkupExtension
|
||
{
|
||
public TrExtension() { }
|
||
public TrExtension(string key) => Key = key;
|
||
|
||
public string Key { get; set; } = "";
|
||
|
||
public static ILocalizer? Localizer { get; set; }
|
||
|
||
public override object ProvideValue(IServiceProvider serviceProvider)
|
||
{
|
||
var loc = Localizer ?? throw new InvalidOperationException("TrExtension.Localizer not initialized");
|
||
var binding = new Binding(nameof(LocalizedString.Value))
|
||
{
|
||
Source = new LocalizedString(loc, Key),
|
||
Mode = BindingMode.OneWay
|
||
};
|
||
return binding.ProvideValue(serviceProvider);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Build the installer**
|
||
|
||
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj -c Debug`
|
||
Expected: Build succeeded.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Installer/Core/ConfigModels.cs src/ClaudeDo.Installer/ClaudeDo.Installer.csproj src/ClaudeDo.Installer/Localization
|
||
git commit -m "feat(i18n): add WPF localization primitives and Language config to installer"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Installer bootstrap + language picker + write config
|
||
|
||
**Files:**
|
||
- Modify: installer entry/bootstrap (read `App.xaml.cs` / `WizardWindow` startup to find where the wizard is constructed; init `Localizer` there)
|
||
- Modify: `src/ClaudeDo.Installer/Views/WizardWindow.xaml` (add a language dropdown in the top bar)
|
||
- Modify: the wizard view-model that owns navigation (`WizardViewModel`) — expose `Languages` + `SelectedLanguage`, call `Localizer.SetLanguage`, and persist into the `InstallerAppSettings` it writes
|
||
- Modify: wherever the installer writes `ui.config.json` (search for `InstallerAppSettings ... .Save()`)
|
||
|
||
- [ ] **Step 1: Initialize the installer `Localizer` at startup**
|
||
|
||
Read `src/ClaudeDo.Installer/App.xaml.cs` to find startup. Before the wizard window is shown, add:
|
||
```csharp
|
||
using System.Globalization;
|
||
using System.IO;
|
||
using ClaudeDo.Localization;
|
||
using ClaudeDo.Installer.Localization;
|
||
// ...
|
||
var localesDir = Path.Combine(AppContext.BaseDirectory, "locales");
|
||
var store = LocaleStore.Load(localesDir);
|
||
var existing = InstallerAppSettings.Load(); // upgrade: reuse prior language
|
||
var initial = !string.IsNullOrWhiteSpace(existing.Language)
|
||
? existing.Language
|
||
: CultureResolver.Resolve(CultureInfo.CurrentUICulture.Name,
|
||
store.Available.Select(l => l.Code).ToArray(), "en");
|
||
var localizer = new Localizer(store, initial);
|
||
TrExtension.Localizer = localizer;
|
||
// pass `localizer` into the WizardViewModel
|
||
```
|
||
|
||
- [ ] **Step 2: Expose language selection on `WizardViewModel`**
|
||
|
||
Add to the wizard VM (mirror Task 9 pattern with `CommunityToolkit.Mvvm`):
|
||
```csharp
|
||
public IReadOnlyList<LanguageOption> Languages { get; }
|
||
[ObservableProperty] private LanguageOption? _selectedLanguage;
|
||
partial void OnSelectedLanguageChanged(LanguageOption? value)
|
||
{
|
||
if (value is null) return;
|
||
_localizer.SetLanguage(value.Value.Code);
|
||
}
|
||
```
|
||
Initialize `Languages = _localizer.AvailableLanguages;` and `_selectedLanguage` to the current code in the ctor.
|
||
|
||
- [ ] **Step 3: Add the dropdown to `WizardWindow.xaml`**
|
||
|
||
Add the namespace to the root `<Window>`:
|
||
```xml
|
||
xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"
|
||
```
|
||
Add a ComboBox to the step-indicator bar (Grid.Row 0), right-aligned:
|
||
```xml
|
||
<ComboBox ItemsSource="{Binding Languages}"
|
||
SelectedItem="{Binding SelectedLanguage, Mode=TwoWay}"
|
||
Width="160" HorizontalAlignment="Right" Margin="0,0,4,0"
|
||
DisplayMemberPath="Name"/>
|
||
```
|
||
(Place it appropriately within the existing Row 0 `Border`; you may need to wrap the existing `ItemsControl` and the ComboBox in a `DockPanel`.)
|
||
|
||
- [ ] **Step 4: Persist `Language` when writing `ui.config.json`**
|
||
|
||
At the point where the installer constructs/saves `InstallerAppSettings` (search the codebase), set:
|
||
```csharp
|
||
appSettings.Language = localizer.CurrentCode;
|
||
```
|
||
before `appSettings.Save();`.
|
||
|
||
- [ ] **Step 5: Build the installer**
|
||
|
||
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj -c Debug`
|
||
Expected: Build succeeded.
|
||
|
||
- [ ] **Step 6: Extract installer UI strings**
|
||
|
||
Apply the extraction pattern (Phase 2) to all 10 installer XAML/page files + VM-driven headings (`Heading`, `Subheading`, `NextButtonText`). Section: `installer.*` (e.g. `installer.welcome.heading`, `installer.nav.back`, `installer.nav.next`). VM headings go through `_localizer[...]` with a `LanguageChanged` refresh. Add keys to `locales/_parts/installer.en.json`.
|
||
|
||
- [ ] **Step 7: Build + commit**
|
||
|
||
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj -c Debug`
|
||
```bash
|
||
git add src/ClaudeDo.Installer
|
||
git commit -m "feat(i18n): localize installer with language picker and config write-through"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4 — Merge, verify, finalize
|
||
|
||
### Task 16: Merge locale partials into `en.json` + coverage passes
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Localization/locales/en.json`
|
||
- Delete: `src/ClaudeDo.Localization/locales/_parts/*` (after merge)
|
||
|
||
- [ ] **Step 1: Merge all `_parts/*.en.json` into `en.json`**
|
||
|
||
Combine every partial's nested object under its top-level section into one well-formed `en.json` (preserve the `metadata` block). Ensure no duplicate keys across sections.
|
||
|
||
- [ ] **Step 2: Run the full coverage test**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests`
|
||
Expected: PASS — with only `en.json` present, coverage trivially holds; this confirms the merged file parses and flattens cleanly.
|
||
|
||
- [ ] **Step 3: Remove the `_parts` folder**
|
||
|
||
```bash
|
||
rm -r src/ClaudeDo.Localization/locales/_parts
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Localization/locales
|
||
git commit -m "feat(i18n): consolidate extracted strings into en.json"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: End-to-end verification (manual UI pass)
|
||
|
||
- [ ] **Step 1: Create a throwaway `de.json` for testing instant switching**
|
||
|
||
Copy `en.json` → `src/ClaudeDo.Localization/locales/de.json`, set `metadata.code`=`de`, `metadata.name`=`Deutsch`, and translate a handful of visible strings (e.g. `settings.save`=`Speichern`, `tasks.addPlaceholder`=`Aufgabe hinzufügen…`).
|
||
|
||
- [ ] **Step 2: Run the coverage test (should still pass — de has same keys)**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests`
|
||
Expected: PASS (de.json copied from en.json → identical key set).
|
||
|
||
- [ ] **Step 3: Build + launch the app**
|
||
|
||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||
Launch, open Settings → General, switch language to Deutsch. Confirm visible labels update **instantly** (no restart), and that reopening the app keeps Deutsch (persisted to `ui.config.json`).
|
||
|
||
- [ ] **Step 4: Build + launch the installer**
|
||
|
||
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj -c Debug`
|
||
Launch, switch the language dropdown, confirm the wizard updates instantly and that completing it writes `Language` into `ui.config.json`.
|
||
|
||
- [ ] **Step 5: Remove the throwaway `de.json`**
|
||
|
||
```bash
|
||
rm src/ClaudeDo.Localization/locales/de.json
|
||
```
|
||
(English-only ships; `de.json` was a test fixture.)
|
||
|
||
- [ ] **Step 6: Run the entire test suite**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests && dotnet test tests/ClaudeDo.Ui.Tests && dotnet test tests/ClaudeDo.Installer.Tests`
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 7: Final commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "test(i18n): verify instant switching and persistence end-to-end"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Notes (addressed)
|
||
|
||
- **Spec coverage:** shared library (Tasks 1–4), JSON format/flatten (Task 1), loose-file discovery (Task 2), Localizer + fallback + live switch (Tasks 3, 6), `ui.config.json` storage (Task 7), startup/OS-culture (Tasks 4, 8), settings dropdown (Task 9), Avalonia extraction (Tasks 10–13), installer parity + picker + config write-through (Tasks 14–15), key-coverage test (Task 5), round-trip test (Task 9), build wiring (Tasks 5, 14). VM live-switch handled via `LanguageChanged` subscription (Phase 2 pattern). Out-of-scope items (report body, pluralization) excluded.
|
||
- **Type consistency:** `Localizer(LocaleStore, string code, string fallbackCode="en")`, `ILocalizer` indexer + `Get` + `SetLanguage` + `LanguageChanged` + `AvailableLanguages` + `CurrentCode`, `LanguageOption(Code,Name)`, `TrExtension.Localizer` static, `LocalizedString(ILocalizer,string)` with `Value` — used consistently across tasks.
|
||
- **Open verification point flagged in-task:** Task 9 Step 5 requires reading `SettingsModalViewModel` to confirm how `General` is instantiated before threading dependencies; Task 15 requires reading `App.xaml.cs` / `WizardViewModel` for the installer bootstrap point. These are reads, not placeholders — the wiring code is specified.
|