Skip to content

App internationalization: seven languages, 573 keys, gated - #95

Merged
jedijashwa merged 13 commits into
mainfrom
app-i18n
Aug 20, 2026
Merged

App internationalization: seven languages, 573 keys, gated#95
jedijashwa merged 13 commits into
mainfrom
app-i18n

Conversation

@jedijashwa

@jedijashwa jedijashwa commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Spec: docs/specs/app-i18n.md. The app joins the site in seven languages: Simplified Chinese, Japanese, French, German, Dutch, Spanish, Brazilian Portuguese — 603 localizable keys, translated and gate-verified, plus an in-app Language setting (Settings → General: System + the eight languages, each naming itself, applied via AppleLanguages in the app domain — the same mechanism as the system per-app override).

Architecture

  • loc/<locale>.lproj/Localizable.strings, hand-authored, deliberately OUTSIDE Sources/ (SwiftPM would bury them in the resource bundle where Bundle.main can't see them). make-app.sh assembles them into the app and the Quick Look appex and declares CFBundleLocalizations; ~10 lines of build change, no Xcode anywhere.
  • Rendered-page strings ride the payload: app.js UI copy (composer, thread chrome, headings, verdicts, tooltips — 87 keys) moved behind pmString/pmFormat, composed fragments rebuilt as whole {placeholder} templates with explicit singular/plural variants, resolved Swift-side via Bundle.main and stamped on every non-preview page.
  • scripts/check-strings.py gates make test: inventories SwiftUI literals (including .alert/.confirmationDialog/.navigationTitle titles), String(localized:) sites, and the page-strings table; diffs all seven .strings files; validates format specifiers. A missing translation now fails the build instead of silently rendering English.

Hard-won correctness details

  • Int interpolations look up %lld keys at runtime, not %@ — verified by a compiled test after a live Japanese launch showed count strings stubbornly English while string-keyed frames translated. The checker infers %lld for count-typed expressions and the seven files were migrated mechanically, translations keeping their positions.
  • Dates and numbers: timeline dates already flowed through locale-aware dateStyle formatters; the blame gutter's relative labels now format through RelativeDateTimeFormatter (identical bucketing, native units and plural rules); word counts via .formatted(); every English frame around them (reviewer tooltips, verdict lines, "N of M", "N reviews · N comments") became localized keys — with per-language template shapes (ja wraps relative times in 「(…)」 because "今に承認しました" is broken Japanese).
  • Fragment concatenations that would have rendered half-translated were folded into whole keys; the ternary Show/Hide menu items, sidebar section titles, picker labels, cockpit chips, and the shortcut-refusal frame all became localizable.
  • Translation: seven opus agents (one per locale) against the extracted inventory, using Apple's own per-language menu vocabulary (Ablage / Fichier / ファイル / 访达 for Finder) — one agent verified Apple's real pt-BR menus against Apple's docs and corrected the brief — plus delta agents as source repairs moved keys. Deliberate convention carried from the site corpus: landmark UI names some locales keep English (Open Files, Locations) stay English in prose so instructions match the screen.
  • Generator: --lang now pairs -AppleLanguages with -AppleLocale (language alone leaves US date formats), and registers the dist bundle with Launch Services per run — unregistered bundles silently route open -a folder delivery to Finder, which cost one scene its demo Location.

Verified

  • make test: 617 tests + strings OK: 603 keys; locales checked: de, es, fr, ja, nl, pt-BR, zh-Hans.
  • Live per-locale launches via the screenshot generator: Japanese PR cockpit (開く status chip, GitHub で表示, レビューを完了 · 3, native dates, verdict lines), German folder browsing (dialogs, width pass — German fits everywhere it appears), Chinese dark-mode margin notes.

Japanese PR cockpit:

PullMark in Japanese: PR overview with localized chrome

German folder browsing:

PullMark in German: location tree and localized chrome

Chinese, dark mode, margin notes:

PullMark in Chinese dark mode with margin notes

Standing tax

Every release that adds user-facing strings owes seven translations before make test goes green — the gate makes forgetting impossible, and the opus delta-translation pattern makes paying it minutes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6

jedijashwa and others added 13 commits August 20, 2026 18:49
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
app.js UI copy (74 keys: composer, thread chrome, headings, tooltips,
the attachment placeholder, link-status pill) moves behind pmString/
pmFormat, with composed fragments rebuilt as {placeholder} templates
and singular/plural variants as separate keys. PageStrings resolves
the table via Bundle.main and page(payload:) stamps it on every
non-preview page. make-app.sh assembles loc/*.lproj into the app and
QL appex with CFBundleLocalizations declared.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
KeyboardShortcuts display strings (titles, categories, scope notes,
spoken/reserved names — 86), the lastError/lastNotice channel (44,
interpolations become format keys, plural composites split into
explicit singular/plural), NSAlert/panel/menu/export sinks (26), the
review-thread location labels, and the .help() concatenation traps
(rejoined literals localize via LocalizedStringKey; label+hint
composites wrap the label).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
Inventories SwiftUI literal keys, String(localized:) sites, and the
PageStrings table (507 keys; interpolations rendered as format keys);
verifies PageStrings covers every app.js pmString/pmFormat key; diffs
each locale's Localizable.strings for missing/orphan keys and
mismatched format specifiers. --write-inventory emits
loc/_inventory.json for the translation step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
The wrap pass had left 14 String(localized:)+"tail" concatenations
rendering half-translated, six Text literal concatenations selecting
the verbatim overload, and several unlocalizable surfaces the
translation agents flagged: sidebar section titles, the ternary
Show/Hide menu items, appearance/width picker labels, and the
shortcut-refusal frame — all folded or wrapped. check-strings' Swift
literal scanning is now interpolation-aware (nested quotes inside
\(...) truncated keys and hid the real runtime keys like
"Commit to %@" from the inventory), and one plural-inside-
interpolation site split into explicit forms. Inventory: 527 keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
Opus fan-out per locale against the extracted inventory, plus the
delta pass after the source repairs. Apple's own menu vocabulary per
language (Ablage / Fichier / ファイル / 访达…), GitHub-domain terms
carried from the site corpus, format specifiers verified by the gate.
UI names the app still renders in English stay English inside
translated prose so instructions match the screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
Int interpolations render as %lld in the runtime lookup key, not %@ —
every count key was silently falling back to English (caught live: a
Japanese launch translated string-interpolated frames but left '3
Markdown files changed' in English). The checker now infers %lld for
count-typed expressions, the seven .strings migrated mechanically
(translations keep their positions), and bare-number badge keys are
excluded as non-language. Also: reviewer/verdict frames and timeline
verbs localized (RelativeDateTimeFormatter output slots into per-
language templates), blame relative labels format through the system
formatter (same buckets, native units/plurals), the file-position and
review-control counters split singular/plural, conversation counts
and thread-badge tooltips become whole {n} templates, and the
screenshot generator passes -AppleLocale with -AppleLanguages so
captures carry native date/number formats. 551 keys, gate green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
Live per-locale captures caught the stragglers: the review-decision
and checks chips, the files-changed summary (split singular/plural),
Resolve/Unresolve/Resolved in the rendered page, and the APIError
frame + demo-mode message that surface in alerts. The checker now
extracts .alert/.confirmationDialog/.navigationTitle titles too
("Something went wrong" had never entered the inventory). Generator
delivers the demo Location twice — the document open can race the
launch and get dropped, which sent one scene into the demo-network
alert instead of the folder tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
The alert-title batch and cockpit stragglers translated; six more
misclassified Int keys (summary line, words·min pill, PR overview
help, APIError status) migrated to %lld across all seven files after
an agent compiled a verification confirming the runtime lookup shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
Folder delivery via open -a silently routes to Finder when the bundle
is unregistered — and the standing cleanup rule unregisters dist after
every trial, which is why the demo Location vanished from scenes after
the dark-mode wave. The generator now registers at start and
unregisters on exit, keeping the no-residue rule intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
PRStatus.label was a bare computed String (the chip stayed English in
every locale) and Link titles never entered the extraction — both
caught by Josh on the Japanese screenshot. Link joins the checker's
call list; six new keys authored across the seven files (GitHub's own
localized state vocabulary). 579 keys, gate green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
Settings → General gains a Language picker (System + the eight
languages, each naming itself) that writes AppleLanguages into the
app's own defaults domain — the same mechanism as the system's
per-app Language & Region override — with a takes-effect-on-reopen
note and a settings deep-link anchor. A systematic sweep of the two
string classes the gate cannot see (bare literals in computed display
vars, ternary titles) caught and localized the connection-status
label, the checks detail line, the commit sheet's git-status words,
the credential-source names, and six ternary sites. 603 keys, seven
locales, gate green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
The takes-effect note now appears only when the selection differs from
the language the process launched with, alongside a Relaunch Now
button (detached-shell quit-and-reopen, verified live). 604 keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcXC4FCipx7zTVNRVbTjq6
@jedijashwa
jedijashwa merged commit 8dda164 into main Aug 20, 2026
@jedijashwa
jedijashwa deleted the app-i18n branch August 20, 2026 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant