{"slug": "i-built-the-localization-checker-before-the-localization-it-still-missed-three", "title": "I built the localization checker before the localization. It still missed three defects.", "summary": "A developer building Parlotype, an open-source local voice-to-text desktop app in .NET 10 and Avalonia 12, used Claude Code to extract roughly 450 hardcoded strings into 389 localization keys across three languages, changing 122 files. The developer built parity scripts, xUnit tests, and Claude Code hooks before the extraction began, yet three user-visible defects still shipped, each representing a distinct category of blind spot rather than a translation error. The work is documented as a postmortem on the project's GitHub repository.", "body_md": "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.\n\nMost 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.\n\nI 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.\n\nSome context on me: twenty years across C, C++, Java and Scala, about two years into .NET. This is the second postmortem from [Parlotype](https://github.com/mdemin729/parlotype), a local voice-to-text desktop app I build in the open with .NET 10 and Avalonia 12.\n\nThe 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.\n\nThe 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.\n\nWhat went in first:\n\n`{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`.` dotnet test` and the release gate enforce them too. A hook only covers sessions that go through Claude.`PostToolUse` and a `Stop` hook\nThe baseline file is the interesting piece. [scripts/localization-baseline.json](https://github.com/mdemin729/parlotype/blob/v0.5.0/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:\n\n``` php\n# Extraction progress (baseline stale, nothing broken) -> tell the agent to re-baseline.\n# Actual regression (new literal, missing key, placeholder mismatch) -> exit 2, block.\n```\n\nExit 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.\n\nI 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.\n\nTwo 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.\n\nAvalonia 12 made the exemption unnecessary. `CompiledBinding.Create<TIn, TOut>` takes an expression and a source object, so if the expression is a *plain property access on a known type*, it compiles:\n\n```\npublic sealed class TrExtension(string key)\n{\n    public string Key { get; set; } = key;\n\n    public object ProvideValue(IServiceProvider serviceProvider) =>\n        CompiledBinding.Create<LocalizedString, string>(\n            s => s.Value,\n            Localizer.Instance.Entry(Key));\n}\n```\n\n`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.\n\nThe markup side is unremarkable:\n\n```\n<TextBlock Text=\"{loc:Tr Settings_Theme_Title}\" />\n```\n\nTwo decisions around it that I would make the same way again.\n\n**`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.\n\n**`Strings.cs` is generated** by [scripts/gen-strings.ps1](https://github.com/mdemin729/parlotype/blob/v0.5.0/scripts/gen-strings.ps1), with the output checked in. Keys containing `{0}` also get a typed `Format_<key>(...)` helper, so wrong arity is a compile error rather than a `FormatException` in front of a user. One .NET-specific trap here: the `<auto-generated>` 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.\n\n`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.\n\nGiving Core resources would invert the dependency direction. So the reason travels as data and the words get chosen in Desktop:\n\n```\n// Core: identities, plus the payload the sentence needs.\npublic enum ReservedShortcut { LockWorkstation, FileExplorer, RunDialog, /* ...17 */ }\npublic enum HotkeyConflictReason { None, InvalidCombination, AlreadyBound, Reserved, /* ... */ }\n\npublic readonly record struct HotkeyConflict(\n    HotkeyConflictSeverity Severity,\n    string? Description,               // invariant English — the log line writes this\n    HotkeyConflictReason Reason,\n    ReservedShortcut? Reserved,\n    ActivationMode? ConflictingMode);\n```\n\nThe 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`.\n\nThat 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:\n\n``` js\nforeach (var shortcut in Enum.GetValues<ReservedShortcut>())\n    AssertReadsAsRussian(HotkeyText.Reserved(shortcut), $\"ReservedShortcut.{shortcut}\");\n```\n\nI 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.\n\nThe hardcoded-literal scan matched attributes: `Text=\"...\"`, `Content=\"...\"`, `Header=\"...\"`. It reported zero. Two things were wrong with that zero.\n\nFirst, `\\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.\n\nSecond, and worse, the scanner only looked at *attributes*. It never looked at element content:\n\n```\n<TextBlock>Two paragraphs of perfectly untranslated English</TextBlock>\n```\n\nTwo 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/<culture>/`, and I read the images. That is the third distinct thing rendered screenshots have found in this project that no test would have.\n\nA third scanner bug in the same family: XML numeric entities like `✕` contain the letter `x`, so a glyph counted as translatable copy.\n\nThe 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.\n\nAn 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.\n\nThe fix pattern per surface: sections override the existing `SettingsSectionViewModelBase.OnCultureChanged()` hook; the two view models that are not sections subscribe to `Localizer.CultureChanged` directly.\n\nOne 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:\n\n```\nprivate enum StatusKind { Ready, Recording, CloudKeyRejected, RuntimeUnavailable, /* ... */ }\n\nprivate void SetStatus(StatusKind kind, object? param = null)\n{\n    _statusKind = kind;\n    _statusParam = param;\n    StatusText = ComputeStatusText(kind, param);\n}\n```\n\nThe 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.\n\nThe 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.\"\n\nA 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.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\nAn 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.\n\nPhase 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.\n\nEverything 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).\n\n**Repo:** [github.com/mdemin729/parlotype](https://github.com/mdemin729/parlotype)\n\nIf 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.", "url": "https://wpnews.pro/news/i-built-the-localization-checker-before-the-localization-it-still-missed-three", "canonical_source": "https://dev.to/mdemin729/i-built-the-localization-checker-before-the-localization-it-still-missed-three-defects-4l36", "published_at": "2026-09-11 21:28:47+00:00", "updated_at": "2026-09-11 21:51:00.384244+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools", "ai-products"], "entities": ["Parlotype", "Claude Code", "Avalonia", ".NET", "GitHub", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/i-built-the-localization-checker-before-the-localization-it-still-missed-three", "markdown": "https://wpnews.pro/news/i-built-the-localization-checker-before-the-localization-it-still-missed-three.md", "text": "https://wpnews.pro/news/i-built-the-localization-checker-before-the-localization-it-still-missed-three.txt", "jsonld": "https://wpnews.pro/news/i-built-the-localization-checker-before-the-localization-it-still-missed-three.jsonld"}}