Internationalization (i18n)
May 8, 2026 · View on GitHub
This project uses a single String Catalog file: ClawdHome/Stable.xcstrings.
All UI/prompt copy should use stable keys via L10n.k(...) / L10n.f(...).
Supported Languages
zh-Hans(Simplified Chinese)en(English)
Workflow
- Add or update stable keys in
ClawdHome/Stable.xcstrings. - Use semantic keys (for example
user.add.title), not sentence-as-key. - Keep placeholders consistent across languages (for example
%@,%d,\(name)). - In Swift, always reference copy by key, for example:
L10n.k("user.add.title", fallback: "Add macOS User")
L10n.f("user.remove.confirm", fallback: "Delete %@?", username)
Quality Checks
Run:
make i18n-check
Current checks include:
- direct hard-coded CJK UI literals in Swift
- missing translations in
Stable.xcstrings - placeholder consistency between
zh-Hansanden - keys used in code but missing in
Stable.xcstrings - English value equal to the auto-titlecased key — Xcode's untranslated default
(e.g. key
views.detail.config_source_pickerwith English value"Views Detail Config Source Picker"). Historical offenders are listed inscripts/i18n_placeholder_en_allowlist.json; new offenders fail CI. - Stale allowlist entries — once a placeholder English value has been replaced with a real translation, the key must be removed from the allowlist.
- Length disparity warning (does not block CI): English visual width >
Chinese × 2 for label-style strings. CJK char counts as 2 visual units, ASCII
char as 1. Only fires for entries that look like UI labels (≤ 30 visual
units, single-line, no sentence punctuation), so flowing body copy is
exempt. Full list lives in
build/i18n-feedback/latest.jsonunderwarnings.length_disparity.
UI Translation Style Rules
The default Xcode-fill ("Views Detail Config Source Picker") is the source of broken English layouts: long key-paths get rendered as paragraph-length labels, segmented pickers with such labels squeeze the label into vertical text, and forms get pushed off-screen.
Length budgets
These are budgets for English (the more demanding language for our UI widths). Chinese is roughly half the character count for the same screen footprint (1 CJK glyph ≈ 2 ASCII chars in display width — this is also how the CI's length-disparity check measures things).
CI also emits a length disparity warning when an English label's visual width exceeds the Chinese version by more than ×2 (label-style strings only — ≤ 30 visual units, single-line, no sentence punctuation). The warning doesn't block CI but is a strong hint that the English will overflow a UI sized for Chinese; shorten the English or restructure the control.
| Control | Max English chars | Examples (good / bad) |
|---|---|---|
| Segmented Picker / Tab option | 12 | Existing / New / Built-in UI ❌ Configuration Source Existing |
| Picker label / form field label | 14 | Source / Account / Model Provider ❌ Configuration Source Picker |
| Button title | 14 | Save & Apply / Reload ❌ Save And Apply Configuration |
| Table header / column name | 16 | Last Used / Status ❌ Last Used Timestamp |
| Sentence-style copy (hint, error, tip) | no hard cap | Be concise. Sentence-style copy lives in body areas, not segments/labels. |
Translation style
- Prefer single noun or verb over phrases.
Source>Config Source.Save>Save Now. - Drop articles and prepositions in labels:
PoolnotFrom The Pool. - Don't translate the key path. The key
views.detail.template_pickeris structure; the user-facing English is justAccount. - Keep punctuation parity with the Chinese (e.g.
请选择…→Choose…, both ellipses).
Segmented Picker pattern (≥ 4 options)
Segmented Picker with ≥ 4 options does not have room for the built-in label —
the label collapses to vertical text. Use this pattern instead:
// ❌ Bad — label "Model Provider" gets squeezed to vertical text
Picker(L10n.k("views.user_detail_view.provider", fallback: "模型提供商"),
selection: $selectedProvider) {
ForEach(DirectProviderChoice.allCases) { p in Text(p.title).tag(p) }
}
.pickerStyle(.segmented)
// ✅ Good — explicit label above, picker labels hidden
Text(L10n.k("views.user_detail_view.provider", fallback: "模型提供商"))
.font(.subheadline).foregroundStyle(.secondary)
Picker(selection: $selectedProvider) {
ForEach(DirectProviderChoice.allCases) { p in Text(p.title).tag(p) }
} label: { EmptyView() }
.pickerStyle(.segmented)
.labelsHidden()
Anti-patterns we have actually shipped
These are all real cases caught in Stable.xcstrings. Avoid by always writing a
real English value at the same time you add the Chinese.
| Key | Bad (auto-fill English) | Good |
|---|---|---|
views.detail.config_source_picker | Views Detail Config Source Picker | Source |
views.detail.config_source_existing | Views Detail Config Source Existing | Existing |
views.detail.template_picker | Views Detail Template Picker | Account |
views.detail.template_picker_placeholder | Views Detail Template Picker Placeholder | Choose… |
wizard.model_config.existing_channel | Wizard Model Config Existing Channel | Channel |
Allowlist for historical debt
scripts/i18n_placeholder_en_allowlist.json tracks the keys that still have
auto-titlecased English. CI permits these so we don't have to fix all 200+ at
once, but two rules apply:
- Do not add new entries. New keys must ship with a real English value.
- Remove an entry the moment you fix its translation. CI flags stale allowlist entries (key has been translated but is still listed).
When working on a feature near a screen with auto-titlecased strings, fixing a handful as you pass through is a low-cost win.
How To Contribute a New Language
- Add the language localization block for each key in
ClawdHome/Stable.xcstrings. - Keep key names unchanged.
- Keep placeholders exactly as-is.
Runtime Language Switch
Users can switch language in:
Settings -> Language -> Follow System / English / Simplified Chinese
The selected language applies immediately to all app windows.