I built the localization checker before the localization. It still missed three defects.
Parlotype's UI was English-only, with the copy baked into markup and C#: 211 literal attributes across 26 .axaml files, roughly 200 string literals across 48 view models, plus the tray menu, the dialogs and the toasts. I wanted Russian and Spanish, and I wanted the ninth language to cost one translation file and nothing else. Most of that work is mechanical, which makes it a good fit for an agent. I ran it as a directed session with Claude Code: I made the architectural calls and reviewed everything, the agent did the extraction, the translations, the tests and most of the implementation. The result is on master. 389 keys in three languages, 122 files changed, +11,467 / -618. I built the guardrails before the bulk work: a parity script, an xUnit mirror of it, two Claude Code hooks and a skill file. Three user-visible defects shipped past all of them anyway. Each one was a different category of blind spot, and none was a translation error. That is what this post is about. Some context on me: twenty years across C, C++, Java and Scala, about two years into .NET. This is the second postmortem from Parlotype, a local voice-to-text desktop app I build in the open with .NET 10 and Avalonia 12. Guardrails before the work they guard The obvious order is: extract the strings, then write something that checks them. I inverted it. The checker, the test, the hooks and the skill all landed in phase 2, before a single one of the ~450 keys moved. The reason is specific to agent work. An agent doing bulk mechanical extraction across 26 markup files will drift: miss an attribute, invent a key naming scheme halfway through, translate one locale and forget the other. A human reviewer catches that at review time, which is the expensive moment. A checker catches it in the loop, which is free. What went in first: scripts/check-localization.ps1. Key parity across locales, placeholder parity ({0} counts must match, or string.Format throws in front of a user), every {loc:Tr} key in markup resolves, and a scan for hardcoded literals still in .axaml. LocalizationParityTests. The same rules as xUnit facts, so dotnet test and the release gate enforce them too. A hook only covers sessions that go through Claude. A PostToolUse and a Stop hook (.claude/settings.json) running the same script. The first so the agent fixes a break while the context is still in hand, the second so a session cannot end with a locale stale. .claude/skills/localization/SKILL.md. The rules of the road: key naming, composite formats, what stays English. The baseline file is the interesting piece. scripts/localization-baseline.json records how many hardcoded literals each .axaml still has. Counts may shrink, never grow. During extraction that turns "the check is red" into a useful signal instead of a wall. The hook distinguishes the two cases explicitly: # Extraction progress (baseline stale, nothing broken) -> tell the agent to re-baseline. # Actual regression (new literal, missing key, placeholder mismatch) -> exit 2, block. Exit code 2 is what makes a Claude Code hook blocking. The guard also exits 0 on any internal failure of its own, so a bug in the tripwire can never wedge a session. I verified every check by breaking it on purpose before trusting it: deleting a key from one locale, adding a literal to markup, mismatching a placeholder. That habit is also what exposed the first blind spot. The Avalonia part: making the switch live Two constraints in this codebase pointed in opposite directions. x:CompileBindings="True" is mandatory and {ReflectionBinding} is banned. But live language switching wants a binding to some localizer lookup, which is exactly the indexer-or-method shape that ban targets. I went in expecting to write a narrow, documented exemption. Avalonia 12 made the exemption unnecessary. CompiledBinding.Create takes an expression and a source object, so if the expression is a plain property access on a known type, it compiles: public sealed class TrExtension(string key) { public string Key { get; set; } = key; public object ProvideValue(IServiceProvider serviceProvider) => CompiledBinding.Create( s => s.Value, Localizer.Instance.Entry(Key)); } Localizer.Entry(key) returns one cached LocalizedString per key, an ObservableObject whose Value re-reads the ResourceManager at the current culture. That per-key object exists precisely so the binding expression can be s => s.Value instead of an indexer. Switching languages invalidates each entry, so a key used by twenty controls costs one object and one notification rather than a broadcast. The markup side is unremarkable: Two decisions around it that I would make the same way again. CurrentUICulture moves; CurrentCulture does not. The interface language is not a claim about where the user lives. Someone running an English Windows in a Russian interface still wants their own date and number formats. Strings.cs is generated by scripts/gen-strings.ps1, with the output checked in. Keys containing {0} also get a typed Format_(...) helper, so wrong arity is a compile error rather than a FormatException in front of a user. One .NET-specific trap here: the header opts the file out of the implicit nullable context, which turns object? in a generated signature into a CS8669 error under warnings-as-errors. The generator emits #nullable enable to compensate. Where Core had to change Parlotype.Core has zero dependencies and no resources. That is a deliberate architectural rule. But Core builds sentences: hotkey-conflict messages ("Win+L is reserved: Lock workstation"), cloud-provider errors, the record-button hint. Giving Core resources would invert the dependency direction. So the reason travels as data and the words get chosen in Desktop: // Core: identities, plus the payload the sentence needs. public enum ReservedShortcut { LockWorkstation, FileExplorer, RunDialog, /* ...17 */ } public enum HotkeyConflictReason { None, InvalidCombination, AlreadyBound, Reserved, /* ... */ } public readonly record struct HotkeyConflict( HotkeyConflictSeverity Severity, string? Description, // invariant English — the log line writes this HotkeyConflictReason Reason, ReservedShortcut? Reserved, ActivationMode? ConflictingMode); The English string stays in Core as the invariant form, because the hotkey log lines write it and a log that changes language with the UI is a log you cannot grep. Same split as HotkeyGesture.DisplayString. That pattern has one nasty property: resx parity cannot police it. Add a member to ReservedShortcut without its resx key and the key is missing from every language at once, so the locales still agree with each other and every parity check passes. The guardrail for that has to be a different shape. The tests loop the enum and assert the Russian rendering actually contains Cyrillic: foreach (var shortcut in Enum.GetValues()) AssertReadsAsRussian(HotkeyText.Reserved(shortcut), $"ReservedShortcut.{shortcut}"); I verified it by blanking one Russian value: it fails naming the member. That is the check I would have skipped without having already been burned once in the same session. Three defects that passed every check 1. The scanner was blind twice, and reported zero both times The hardcoded-literal scan matched attributes: Text="...", Content="...", Header="...". It reported zero. Two things were wrong with that zero. First, \b(Content) does not match inside OnContent or SizeToContent, because the preceding character is a word character and there is no boundary. Twelve ToggleSwitch literals sat there invisible. Every such attribute has to be listed on its own. Second, and worse, the scanner only looked at attributes. It never looked at element content: Two paragraphs of perfectly untranslated English Two paragraphs survived the entire extraction while the check reported a clean sweep. No test caught them. The layout-review harness renders all 19 settings pages in each language into reports/localized-layout//, and I read the images. That is the third distinct thing rendered screenshots have found in this project that no test would have. A third scanner bug in the same family: XML numeric entities like ✕ contain the letter x, so a glyph counted as translatable copy. 2. "Switching is live" was false on six surfaces The ADR says switching is live. That held for anything bound through {loc:Tr}. It failed for anything a view model composes in C#, because those values recompute correctly on the next read and nothing tells a bound view to re-read them. An external code review found six: the Language page's picker headers and special rows (never localized at all), the shared relationship view model's tooltip and summary, the engine cards, the runtime cards, the transcribe widget's status text and cloud badge, and the hotkey list's rows, presets and warnings. The fix pattern per surface: sections override the existing SettingsSectionViewModelBase.OnCultureChanged() hook; the two view models that are not sections subscribe to Localizer.CultureChanged directly. One of those needed more than a re-raise. TranscribeViewModel.StatusText is a stored string set from about ten different call sites, several of them long-lived errors ("Cloud API key rejected — check Settings"). A naive "recompute from the current recording state" would have silently replaced a real error with "Ready". It now remembers what it is currently saying: private enum StatusKind { Ready, Recording, CloudKeyRejected, RuntimeUnavailable, /* ... */ } private void SetStatus(StatusKind kind, object? param = null) { _statusKind = kind; _statusParam = param; StatusText = ComputeStatusText(kind, param); } The testing lesson generalizes past localization: a read-it-afterwards assertion proves nothing here. A property that recomputes correctly passes that test whether or not the fix exists, because the bug is entirely about whether anything told a bound view to re-read. Every regression test for these six asserts on PropertyChanged notifications or drives the real bound collection. 3. The tooltip that was documented as fine The record button's tooltip ("Hold Right Ctrl to talk · Esc to cancel") stayed English. HotkeyHint.Describe in Core built the whole sentence, and the earlier sweep of Core-built sentences had missed it because it is neither a conflict nor an error message. It had even been written down in the architecture notes as "still English on purpose." A user report with a screenshot is what closed it. The fix was the same shape as the others (SelectPrimary returns the binding as data, Desktop words it), plus one extra: the tooltip is pushed into the view model rather than bound, so HotkeyCoordinator needed its own CultureChanged subscription. What I would keep, and what I would change Keep: guardrails first, and verify each one by breaking it. The parity script, the placeholder check and the hooks paid for themselves inside the same session. Every check I injected a failure into either caught it or got fixed. The ones I did not test are exactly the ones that reported false green. Keep: render the UI and look at it. Three defects in this project have now been found by reading rendered screenshots and zero by reading test output. For a localization change specifically, where the failure mode is correct-looking text in the wrong language, a green suite is close to meaningless on its own. Change: treat "the checker is green" as a hypothesis about the checker. Both scanner blind spots produced a confident "0 literals." The correct reaction to a clean sweep on the first run is suspicion, not a commit. Change: hand the agent an acceptance criterion. "Extract the strings" produces a green parity check. "The window must be fully Russian, verified by looking at it" is what would have caught the element-content paragraphs on the first pass instead of the fifth. An honest note on the division of labor. The agent wrote the plan, the ADR, the generator, the checker, the hooks, the skill, the extraction, the translations and the tests, and, once each defect was identified, the fixes and their regression tests, each verified to fail against a targeted revert. What it did not do is notice that the app was wrong. Every one of the three defects above entered through a human looking at something: a settings page rendered and read as an image, a code review asking whether the switch was actually live, a user's screenshot of a tooltip. The mechanical throughput is genuinely high. Noticing that the result is wrong is still mine. What's next Phase 6 polish is still open: a pseudo-locale (qps-ploc) to catch stragglers and truncation without a translator, ICU-localized speech-language names, and an add-a-language recipe. The interesting one is ICU. Localized language names change what gets substituted into about six format strings, and one Russian slot then needs the accusative case rather than the nominative, which is a change to the format string rather than to the translation. Try it Everything here is in the repo: the markup extension, the generator, the parity script and test, the hooks, and ADR-064 with its three amendments (the Core split, the six stale surfaces, the tooltip). Repo: github.com/mdemin729/parlotype If you have shipped live language switching in Avalonia or WPF, I would like to know how you handled the C#-composed copy: a base-class hook like mine, a messenger, or something better. The {loc:Tr} half is easy. That half is where the bugs were.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to