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

  1. Add or update stable keys in ClawdHome/Stable.xcstrings.
  2. Use semantic keys (for example user.add.title), not sentence-as-key.
  3. Keep placeholders consistent across languages (for example %@, %d, \(name)).
  4. 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-Hans and en
  • 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_picker with English value "Views Detail Config Source Picker"). Historical offenders are listed in scripts/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.json under warnings.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.

ControlMax English charsExamples (good / bad)
Segmented Picker / Tab option12Existing / New / Built-in UIConfiguration Source Existing
Picker label / form field label14Source / Account / Model ProviderConfiguration Source Picker
Button title14Save & Apply / ReloadSave And Apply Configuration
Table header / column name16Last Used / StatusLast Used Timestamp
Sentence-style copy (hint, error, tip)no hard capBe 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: Pool not From The Pool.
  • Don't translate the key path. The key views.detail.template_picker is structure; the user-facing English is just Account.
  • 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.

KeyBad (auto-fill English)Good
views.detail.config_source_pickerViews Detail Config Source PickerSource
views.detail.config_source_existingViews Detail Config Source ExistingExisting
views.detail.template_pickerViews Detail Template PickerAccount
views.detail.template_picker_placeholderViews Detail Template Picker PlaceholderChoose…
wizard.model_config.existing_channelWizard Model Config Existing ChannelChannel

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

  1. Add the language localization block for each key in ClawdHome/Stable.xcstrings.
  2. Keep key names unchanged.
  3. 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.