Skip to content

Instantly share code, notes, and snippets.

@drewster99
Last active July 26, 2026 02:41
Show Gist options
  • Select an option

  • Save drewster99/ae6fbfb950c9c79e5c976893e14a7a97 to your computer and use it in GitHub Desktop.

Select an option

Save drewster99/ae6fbfb950c9c79e5c976893e14a7a97 to your computer and use it in GitHub Desktop.
Localization Super-Prompt v4 — iOS/macOS SwiftUI localization template

Localization Super-Prompt v4 — iOS/macOS (SwiftUI) — Implementation & Testing/Validation

Reusable, end-to-end prompt for localizing ANY SwiftUI app's user-facing strings into the App Store locales, correctly and verifiably.

This document is structured into TWO self-contained sections:

  • SECTION 1 — IMPLEMENTATION: How to actually localize an app (recon through translation, build, verify)
  • SECTION 2 — TESTING/VALIDATION: How to verify localization works at runtime (simulator testing, screenshots, validation)

Each section can power a separate reusable template task: "Localize App" and "Test App Localization".


SHARED PREAMBLE

App Details Placeholder Block

Replace these placeholders with your app's actual values:

Placeholder Description Example
<APP_NAME> Your app's display name FishIdentifierCam
<APP_PATH> Path to the built .app bundle /path/to/YourApp.app
<BUNDLE_ID> Your app's bundle identifier com.example.yourapp
<SCHEME_NAME> Your Xcode scheme name YourApp
<PROJECT_PATH> Path to your .xcodeproj or .xcworkspace /path/to/YourApp.xcodeproj
<INFOPLIST_PATH> Path to your Info.plist or InfoPlist.xcstrings YourApp/InfoPlist.xcstrings
<PACKAGE_PATH> Path to any SPM package with strings Packages/Onboarding

Objective

Make every user-facing string localizable, translate it into the project's App Store locales with natural/native quality, review it per-locale for context errors, and prove it builds and renders — never trust "it compiles" or a single build. Then exercise every screen in multiple locales and configurations, capture screenshots, analyze them visually, and produce a validation report.

Prerequisites

Before starting, ensure you have:

  • Xcode installed (latest stable or beta)
  • A Mac with iOS/macOS simulators available
  • An Xcode MCP Server that drives Xcode directly (e.g., drews-xcode-mcp from https://github.com/drewster99/drews-xcode-mcp) — capability-first: if this specific tool is not available, use any equivalent tool that achieves the same capability; if NO equivalent exists, for an IMPLEMENTATION step, stop and report the missing capability as a blocker via request_help; for a TESTING step, skip the step and document the skip in the report (consistent with anti-interruption rules)
  • LLM-based code generation CLI with sufficient usage quota (e.g., Claude Code, or equivalent) — capability-first fallback rule applies (see above)
  • Access to native speakers or LLM reviewers for each target locale (or plan to use LLM-as-judge with a different model than the translator)
  • Apple localization.md: Export the latest version via xcrun agent skills export /tmp/xcode-agent-skills (or to a temporary folder you have available for this task), then copy swiftui-specialist/references/localization.md from the export. The latest version is also included in the APPENDIX below, but you should refresh it at runtime if possible.

The App Store Localizations

Apple supports ~50 locales for App Store submissions. Your app should support the same set. Here is the canonical list:

Locale Language Script RTL Notes
ar Arabic Arabic Yes All 6 plural categories; Arabic-Indic digits
ca Catalan Latin No
cs Czech Latin No one/few/many/other plurals
da Danish Latin No
de German Latin No Longer text; ä, ö, ü, ß
el Greek Greek No
en English Latin No Base locale (en = default)
en-AU English (Australia) Latin No Falls back to en
en-CA English (Canada) Latin No Falls back to en
en-GB English (UK) Latin No Falls back to en
es Spanish Latin No
es-MX Spanish (Mexico) Latin No Regional variant
fi Finnish Latin No
fr French Latin No
fr-CA French (Canada) Latin No Regional variant
he Hebrew Hebrew Yes RTL
hi Hindi Devanagari No Tall script; custom fonts may lack glyphs
hr Croatian Latin No
hu Hungarian Latin No
id Indonesian Latin No other-only plurals
it Italian Latin No
ja Japanese CJK No CJK script; no spaces
ko Korean Hangul No
ms Malay Latin No
nl Dutch Latin No
no Norwegian Latin No
pl Polish Latin No one/few/many/other plurals
pt-BR Portuguese (Brazil) Latin No
pt-PT Portuguese (Portugal) Latin No
ro Romanian Latin No
ru Russian Cyrillic No one/few/many/other plurals
sk Slovak Latin No one/few/many/other plurals
sv Swedish Latin No
th Thai Thai No Tall script; stacked diacritics
tr Turkish Latin No one/other plurals; dotless-i
uk Ukrainian Cyrillic No one/few/many/other plurals
vi Vietnamese Latin No
zh-Hans Chinese (Simplified) Han No
zh-Hant Chinese (Traditional) Han No

Note on plurals: CLDR plural categories vary by language:

  • ar (Arabic): zero, one, two, few, many, other (all 6)
  • pl, ru, uk, cs, sk (Polish, Russian, Ukrainian, Czech, Slovak): one, few, many, other
  • tr (Turkish): one, other (nouns stay singular after numerals)
  • zh, ja, ko, th, id (CJK, Thai, Indonesian): other-only

Available Tools Inventory

simctl (command-line)

The most reliable tool for simulator control:

# Boot a simulator
xcrun simctl boot "iPhone 16"

# Install an app
xcrun simctl install booted <APP_PATH>

# Launch with locale arguments
xcrun simctl launch booted <BUNDLE_ID> -AppleLanguages "(de)" -AppleLocale "de_DE"

# Take a screenshot
xcrun simctl io booted screenshot /tmp/screen.png

# Erase simulator (clean state)
xcrun simctl erase "iPhone 16"

# Set appearance
xcrun simctl ui booted appearance dark

# Set content size
xcrun simctl ui booted content_size accessibility-extra-extra-extra-large

# Terminate app
xcrun simctl terminate booted <BUNDLE_ID>

Xcode MCP Server (capability-first)

Fallback rule: If the specific tool named below (e.g., drews-xcode-mcp) is not available, use any equivalent available tool that achieves the same capability. If NO equivalent exists:

  • For IMPLEMENTATION steps: Stop and report the missing capability as a blocker via request_help
  • For TESTING steps: Skip the step and document the skip in the report (consistent with anti-interruption rules)
Capability Example Tool Notes
Build project build_project (drews-xcode-mcp) Runs true Xcode build with string extraction
Set run destination set_run_destination Target specific simulator
Get build results get_build_results Find build output path
Get build errors get_build_errors Diagnose build failures
Take simulator screenshot take_simulator_screenshot Captures simulator screen
Take app screenshot take_app_screenshot Captures running app
Take window screenshot take_window_screenshot Captures specific window
Get directory listing get_directory_listing Browse project files
Version check version Get MCP server version

Key advantage: build_project runs a true Xcode build (with string extraction), unlike raw xcodebuild. Use it to build the app before installing via simctl.

Preferred workflow:

  1. Use set_run_destination to target the desired simulator (e.g., "iPhone 16")
  2. Use build_project to build the app
  3. Find the built .app in DerivedData (use get_build_results or check the build output path)
  4. Install with xcrun simctl install booted <path-to-app>
  5. Launch with xcrun simctl launch booted <BUNDLE_ID> <launch-args>

Xcode AppleScript (via osascript)

Xcode (com.apple.dt.Xcode) is scriptable. The Xcode Scheme Suite supports:

-- Build a scheme
tell application "Xcode" to build scheme "<SCHEME>" of workspace "<WORKSPACE>"

-- Run with command line arguments and environment variables
tell application "Xcode" to run scheme "<SCHEME>" of workspace "<WORKSPACE>" with command line arguments {"-AppleLanguages", "(ar)", "-AppleLocale", "ar_SA"}

-- Stop
tell application "Xcode" to stop scheme "<SCHEME>"

-- Test
tell application "Xcode" to test scheme "<SCHEME>"

Note: The simctl launch approach is generally more reliable and scriptable than AppleScript for launching with specific locale arguments. Prefer simctl for locale-specific launches. Use Xcode MCP build_project for building.

Screenshot Capture Methods

Multiple methods are available:

Method Command Notes
simctl io xcrun simctl io booted screenshot /tmp/screen.png Most reliable; captures whatever is on screen
Xcode MCP take_simulator_screenshot Captures simulator screen
Xcode MCP take_app_screenshot Captures running app specifically
Xcode MCP take_window_screenshot Captures a specific window

Screenshots are saved as PNG files. Attach them to task updates and the final result via attachment_paths.

Code-Based Navigation

The worker can navigate the app through several mechanisms:

Launch arguments for screen forcing

Discover what debug launch arguments the app supports by reading its source code. Common patterns:

  • --reset-onboarding / --onboarding-only — force onboarding flow
  • --debug-paywall — force paywall display
  • --show-test-image — bypass live camera with a test image

Accessibility identifiers and coordinate-based interaction

  • Tab bar: Tap coordinates to switch tabs.
  • simctl does not have a built-in tap command, but you can:
    • Use xcrun simctl to launch the app in a specific state
    • Write temporary XCUITest code to drive navigation (see below)
    • Use AppleScript to interact with the Simulator window (less reliable)

Writing temporary UI test code (XCUITest)

The worker can create a temporary UI test target or add tests to an existing one to navigate programmatically:

import XCTest

class LocalizationNavigationTests: XCTestCase {
    func testNavigateAllScreens() {
        let app = XCUIApplication()
        app.launchArguments = ["-AppleLanguages", "(de)", "-AppleLocale", "de_DE", "--show-test-image"]
        app.launch()

        // Navigate tabs
        let tabBar = app.tabBars.firstMatch
        tabBar.buttons.element(boundBy: 0).tap()  // First tab
        tabBar.buttons.element(boundBy: 1).tap()  // Second tab
        tabBar.buttons.element(boundBy: 2).tap()  // Third tab

        // Scroll to bottom
        app.swipeUp()

        // Take screenshot
        let screenshot = app.screenshot()
        let attachment = XCTAttachment(screenshot: screenshot)
        attachment.name = "Settings-scrolled-de"
        attachment.lifetime = .keepAlways
        add(attachment)
    }
}

Note: Writing and running XCUITests requires a test target in the Xcode project. If the project doesn't have one, the worker should prefer the simctl launch + simctl io screenshot approach, navigating between tabs by relaunching the app with different launch arguments or by using the Simulator UI. The worker should NOT modify the project's source files — any temporary test files should be created in a temporary folder (e.g., /tmp/) and cleaned up afterward.

Compiled Bundle Inspection

Canonical reference (used throughout this document):

# Compile the String Catalog standalone (no Xcode build needed)
xcrun xcstringstool compile --output-directory /tmp/xcstrings_out <PROJECT_PATH>/Localizable.xcstrings

# Inspect compiled strings for a specific locale
plutil -p /tmp/xcstrings_out/de.lproj/Localizable.strings | head -20
plutil -p /tmp/xcstrings_out/ar.lproj/Localizable.strings | head -20

# Find the compiled .app in DerivedData
APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData -name "<APP_NAME>.app" -type d 2>/dev/null | sort -r | head -1)
echo "App path: $APP_PATH"

# Inspect compiled .strings inside the built .app
plutil -p "$APP_PATH/de.lproj/Localizable.strings" | head -20
plutil -p "$APP_PATH/ar.lproj/Localizable.strings" | head -20

# Check InfoPlist strings (system permission dialogs)
plutil -p "$APP_PATH/ar.lproj/InfoPlist.strings"

# List all .lproj directories in the built app
ls -d "$APP_PATH"/*.lproj | sort

# Compile and inspect SPM package catalogs
xcrun xcstringstool compile --output-directory /tmp/package_out \
  <PROJECT_PATH>/<PACKAGE_PATH>/Resources/Localizable.xcstrings
plutil -p /tmp/package_out/de.lproj/Localizable.strings | head -20

SECTION 1 — IMPLEMENTATION

This section covers everything about actually LOCALIZING an app — from recon through translation, build, and verification. Use this section to power the "Localize App" template task.

Implementation-specific rules:

  • You MUST modify source files and String Catalogs — that is the purpose of this task
  • All work should be done in the project directory as specified by <PROJECT_PATH>
  • Temporary files should be cleaned up after use

Phase 0 — Recon (find ALL user-facing strings)

Sweep, don't assume. The misses are always in the same places:

  • SPM packages bundled in the app (paywall, onboarding, monetization, etc.) — their literals resolve against the app's main bundle, so they silently render English even when the app is localized.
  • App extensions: widget, share, action, AppIntents/Shortcuts, notification content.
  • Info.plist strings — permission usage descriptions (NSMicrophoneUsageDescription, etc.), CFBundleDisplayName, background-mode strings. These render in system alerts — the most visible strings in the app. They localize via a separate InfoPlist.xcstrings per target (including each extension), NOT Localizable.xcstrings.
  • Accessibility: Text in .accessibilityLabel / .accessibilityAction(named:).
  • Accessibility VALUES, not just labels — closures/computed properties returning String that feed accessibilityValue/status text render verbatim (spoken English). Fix pattern: return Text("…") so the interpolation compiles to LocalizedStringKey.
  • Config-object data passed into packages — display strings (feature bullets, titles) in a config struct handed to a package initializer are invisible to any Text()-oriented sweep and ship English in every locale. Grep for structs/arrays of display strings passed to package inits.
  • Homemade localization layers that don't work — if the codebase has its own LocalizedString-ish type, read its implementation AND confirm views call the localized accessor (one had a stub localizedValue returning English while all 17 views read .defaultValue anyway). Verify end-to-end on screen, not by reading the type.
  • Strings that double as logic keys — "Today"/"Yesterday"/"This Week" used as both display headers and comparison/sort keys. Localize at render only; keep English internally. (This bit twice.)
  • Loading/progress/status messages — rotating message arrays, model-enum states, export/import errors. Counter-rule: confirm an enum's .description is actually displayed before localizing it — skipping undisplayed ones avoids catalog pollution.
  • Error/alert messages, progress overlays, empty states.
  • Strings computed in models (e.g. "Year"/"Monthly"/"3 Days Free") not just in views.
  • Text as DATA — user-facing strings living in data, not code, invisible to every code-oriented sweep: bundled JSON/plist/CSV content, seed databases, sample/demo content, server-delivered strings. Push notifications: the payload should use title-loc-key/loc-args so the device localizes at display time. And the migration trap: display strings persisted in user data (UserDefaults, the database) stay English forever after you localize the app — persist stable identifiers, localize at render.
  • Exclusions: #Preview blocks don't ship. Filter non-translatables (pure punctuation/format-specifier/emoji/unit keys) before dispatching to translators, and record the skip list so the decision is auditable.
  • Text(verbatim:) bypasses localization entirely — it renders its String as-is. Find every one. Localize at the source of the value: if it comes from a (package) model property, route that property through String(localized:…, bundle: .module); then Text(verbatim:) shows the already-localized string and the call site needs no change.

Locate ALL existing String Catalogs and their locale set; that set is your target. Strings live in three distinct catalog types, each in its owning target (app, each extension, each package): Localizable.xcstrings (in-app strings), InfoPlist.xcstrings (system-surface strings), and AppShortcuts.xcstrings (App Shortcut trigger phrases). A pass that only touches Localizable.xcstrings is incomplete — enumerate every catalog in every target up front. Audit every target with a checklist — app, widget, share, action, intents: literal-grep + its own catalog — and never conclude "no strings" from a skim: a share extension declared string-free turned out to have 4 user-facing strings and no String Catalog at all. (A synchronized folder group auto-includes a dropped-in catalog.)

Comprehensive Screen Inventory Methodology (Recon → Testing Handoff)

During Phase 0 recon, build a screen inventory that will drive runtime testing. This inventory is a KEY HANDOFF ARTIFACT to the Testing task. Enumerate every screen, mode, and state from the app's source files:

  1. Read every view file and identify each distinct screen/view it renders.
  2. Identify all states for each screen (empty, loading, error, populated, edit mode, etc.).
  3. Map how to reach each state (launch arguments, navigation paths, preconditions).
  4. Identify SPM package screens (onboarding, paywall, monetization — these have their own Localizable.xcstrings and need separate runtime verification).
  5. Identify system surfaces — permission dialogs (from InfoPlist.xcstrings), app display name on the home screen, share extensions, widget.
  6. Record the source file each screen comes from, for traceability.
  7. Produce a numbered table of every screen/state with: screen ID, screen name, key elements, how to reach it, and whether it needs RTL / pseudolocalization / edge-case captures.

Example: When testing a sample app, the screen inventory started from 28 Swift files across the main target and 3 SPM packages, yielding 46 numbered screen/states including 5 onboarding screens, paywall, 3-tab navigation, 7 camera states, 6 photo list states, 7 fish-info states, taxonomy, 8 settings states, share sheet, overlays, error states, and system permission dialogs. Your app will have different screens — enumerate yours from source.

HANDOFF: Save the screen inventory as ScreenInventory.md or ScreenInventory.json in the project root. This file MUST be passed to the Testing task as input.


Phase 1 — Make strings localizable

  • Text("literal") is auto-localizable; for non-literal/model code use String(localized:).
  • Fix-pattern menu — pick by call-site shape:
    • static-only String parameter → change the type to LocalizedStringKey (the resulting build failure then proves every call site is a literal);
    • mixed static/dynamic parameter → String(localized:) at the static call sites only;
    • computed model property → String(localized:) inside the property;
    • display-plus-logic string → localize at render, keep English internally (Phase 0). Watch for interpolated literals (title: "\(x)") — the one shape that compiles fine but stays dynamic and never localizes.
  • Don't alter user-visible text while localizing. "Improving" ... to changes the key, breaks the lookup, and is scope creep. Localize what's there; propose copy edits separately.
  • Escaping: catalog keys must contain REAL newlines matching the runtime string — an extraction once produced a literal \n in the key and every lookup would have missed.
  • Add a Localizable.xcstrings per target/module; source language en.
  • SPM PACKAGE GOTCHA (the #1 silent bug): SwiftUI Text("x") / Button("x") resolve LocalizedStringKey against the consuming app's main bundle, not the package. In a package:
    1. add defaultLocalization: "en" to Package.swift;
    2. add Localizable.xcstrings under the target's Resources (.process);
    3. pass bundle: .module on every Text/Button, and use String(localized: "x", bundle: .module) in non-View code;
    4. convert Button("x") { … }Button(action: { … }, label: { Text("x", bundle: .module) }).
  • Never make a key out of pure format specifiers / punctuation ("%@, %@", "%@ - %@"). Xcode can't derive a symbol name from it → String Catalog BUILD FAILURE ("Unable to derive a symbol name from this key"). Use an explicit stable identifier key + defaultValue:: String(localized: "a11y.dialSelection", defaultValue: "\(a), \(b)", comment: …) — the symbol comes from the identifier, the format lives in the value. (These are two different initializers with two different rules: the defaultValue: overload's localized: key is a StaticString — no interpolation in the key itself — while plain String(localized: "\(n) Months") takes a String.LocalizationValue, where interpolation is the mechanism and becomes the format specifier. Don't "fix" one to match the other.)
  • Counts need plural variations, not "%lld Months". A fixed %lld Months is grammatically wrong in Arabic/Polish/Russian/Ukrainian/etc. Make those keys String Catalog plural variations (variations.plural.{zero,one,two,few,many,other}, correct CLDR categories per language: ar uses all six; pl/ru/uk/cs/sk use one/few/many/other; CJK/Thai/Indonesian are other-only; Turkish is one/other — its nouns stay singular after numerals so the two values often coincide, but the catalog still needs both categories). No code change neededString(localized: "\(n) Months") auto-selects the category at runtime once the catalog has the variations. It compiles to a .stringsdict.
  • Compound counts need %#@name@ substitutions, not just plural variations — two counts in one key ("%lld of %lld photos processed") require one named substitution per count, each with its own plural variations. xcstringstool compile validates them (Phase 7).
  • Casing must be locale-aware. .uppercased() / .capitalized are locale-insensitive String methods (break Turkish dotless-i; force title-case onto non-English names like "voz de chipmunk"). Use the SwiftUI .textCase(.uppercase) modifier (honors the environment locale). Don't hand-join localized fragments with hardcoded ", " + .capitalized — make the whole sentence a localizable format.
  • One concept = one term across EVERY surface. A name (effect/feature/tier/badge names, proper nouns) appears in: the primary label, the widget's composed "Record · X" labels, AppIntents/Shortcuts descriptions ("Applies … (Chipmunk, Giant, Cyborg)"), the paywall perk list, and bare accessibility keys. Changing one and not the others is the most common consistency bug. Establish a canonical value per (concept, locale) and propagate it to all of them.
  • Spoken commands (Voice Control / Siri / Shortcuts) are special — localize them, but differently from read-aloud labels. A VoiceOver .accessibilityLabel is read aloud, so just translate it naturally. But a Voice Control "named" action (.accessibilityAction(named: Text("Reverse"))) and a Siri/Shortcuts trigger phrase ("${applicationName} record", "Reverse Audio") are words the user must say to invoke the action — so:
    1. the localized command MUST match the visible on-screen label for that control (the user says what they see) — keep the named action and its dial/button label the same localized term;
    2. use natural, easily-pronounced native words — machine-literal phrases can be awkward to speak and can break Voice Control / Siri matching;
    3. keep the app-name token (${applicationName}) as the brand, untranslated;
    4. don't .uppercased()/title-case the value — casing doesn't change speech, but store the plain localized word so the visible-label match holds;
    5. App Shortcut trigger phrases do NOT live in Localizable.xcstrings — they go in a dedicated AppShortcuts.xcstrings in the target that declares the AppShortcutsProvider, and every phrase must contain the ${applicationName} token. Intent titles/descriptions/parameter names use LocalizedStringResource (not LocalizedStringKey). These phrases do NOT auto-extract — add and translate them manually, preserving ${applicationName} in every locale. Flag every spoken command for native-speaker review (Phase 5).
  • Layout: avoid .fixedSize() on translatable text — it forces single-line ideal width, so long translations overflow the card/button. Use .fixedSize(horizontal: false, vertical: true) (wrap) or .lineLimit(1).minimumScaleFactor(…) (shrink).
  • Restructure interpolated sentences into format keys so translators can reorder: "\(price) per \(period)" → key "%@ per %@". Keep currency/number formatting native (StoreKit already localizes prices).
  • Comment every key: what it is, what each %@/%lld is, length limits, where it shows, whether it's uppercased, and the part of speech for ambiguous short words (see Phase 5). The translator (human or LLM) is only as good as the comment.

Phase 2 — Locale-correct BEHAVIOR (not just strings)

A whole class of shipped bugs is locale behavior — invisible to any string sweep:

  • Numeric INPUT parsing. Double(string) rejects , while .decimalPad produces it across most of Europe — German users typing 12,5 silently saved 0-kcal entries. Parse locale-aware. Beware over-fixing (a first fix stripped ordinary spaces as "grouping separators"). Verify with a small harness across locales.
  • Hardcoded weekday/month label arrays — wrong in every Monday-first locale. Rotate by calendar.firstWeekday; harness-verify against de_DE.
  • Fixed dateFormat strings — replace with localized templates / FormatStyle.
  • Untranslatable sentence construction — word-order-dependent concatenation. Test every composed string with: "could a translator reorder this?" If not, restructure into a format key (Phase 1).

Phase 3 — Glossary (terminology canon — gated)

If ~5+ concepts (effect/feature/tier/badge names, proper nouns) recur across 3+ surfaces, build LocalizationGlossary.json next to the catalogs, committed. Otherwise the Phase 4 policy bullets suffice. One entry per concept:

"effect.chipmunk": {
  "english": "Chipmunk",
  "policy": "native-word",
  "keys": ["Chipmunk", "Record · %@", "intent.applyEffect.description"],
  "terms": { "es": "Ardilla", "sk": "Veverička", "de": "Chipmunk" }
}

policy is one of native-word | loanword | brand-do-not-translate. keys is derived by grepping the English values for the term.

  • The prompt holds the rules; the glossary holds the decisions. The app name is a glossary entry with policy: "brand-do-not-translate" (plus shouldTranslate: false on pure-brand keys — Phase 4). Loanword-vs-native gets decided once per concept here, as data — not re-decided by each translation agent mid-sentence (sk "Čipmank" vs "Veverička" happened exactly that way).
  • Translate the glossary FIRST: one small agent wave over just the concept terms, then a native review of only those. It's the highest-leverage review in the whole job — these words repeat on every surface.
  • The filled glossary is binding input to every Phase 4/5 agent; pure-concept keys are applied mechanically in Phase 6; Phase 7 lints the catalog against it.
  • The glossary never becomes a strings file — .xcstrings stays the single source of truth. It is a forward-flowing constraint plus lint reference, reused on later passes (which is what prevents drift when a new surface is added).

HANDOFF: Save the glossary as LocalizationGlossary.json in the project root. This file MUST be passed to the Testing task as input for consistency validation.


Phase 4 — Translate (parallel agents)

  • Fan out agents grouped ~5 locales each; persona "professional iOS app localizer"; return STRICT JSON { "<locale>": { "<EnglishKey>": "<translation>", … }, … }, no prose/fences, exact keys.
  • Each agent's input payload must include, per key: the key, the English value, and the catalog comment (what it is, what each specifier means, where it shows, length limits, part of speech) — plus the filled glossary (Phase 3) as binding terminology. The comments were written for the translator (Phase 1) — a translation pass that receives bare key/value pairs throws that context away and reintroduces exactly the wrong-sense/homonym bugs Phase 5 exists to catch.
  • Hardcode absolute scratch paths in agent prompts — workflow args once failed to propagate and agents wrote to <repo>/undefined/.
  • Validate ONE group's JSON early (format, key count, specifier preservation) before the fleet finishes — a format bug caught late wastes 20 agents.
  • Plan for the slow group. Complex-script groups (the Dravidian set) finish long after the rest; state the policy up front: merge the completed groups and re-run only the straggler rather than blocking everything.
  • Rules every agent must obey:
    • Preserve format specifiers exactly (%@, %lld, %1$@…). May reorder positional specifiers for grammar; if reordering, use positional for ALL of them.
    • Natural/native, not literal; match UI tone; keep button/badge labels short.
    • Regional variants reflect real norms (es vs es-MX, pt-BR vs pt-PT, zh-Hans vs zh-Hant; he/ar RTL).
  • App name / brand rule: "This is the app name <X>. Do NOT translate it as the product name (e.g. the title). DO translate it when used descriptively in a sentence." Additionally mark keys that are purely the brand name with shouldTranslate: false in the catalog, so the rule is enforced mechanically, not just by translator instruction. (Keys using the name descriptively inside a sentence still get translated — don't mark those.)
  • Proper-noun / loanword policy: decide up front — native word everywhere it has one vs keep the loanword — then apply it consistently across locales AND duplicate keys. When the native word genuinely is the loanword (es "Robot"), that's correct; don't force a worse translation. For badges (PRO/MAX/BASIC) the loanword is usually right and length-constrained.

Phase 5 — REVIEW (native per-locale — a SEPARATE pass from translation)

A translation pass (human or LLM) produces real errors that only a native re-read catches. Fan out one reviewer per locale (or ~5/agent), give each the full key set (English + comment + that locale's value) plus the glossary (Phase 3) so cross-surface consistency is checked against a stated canon, not inferred. Have them flag ONLY problems as strict JSON. EVERY batch gets its review pass — including late drips. The initial 249-key batch got translate + review, but follow-on batches (loading messages, status enums, plurals, substitutions, a11y values) went translate-only and had to be confessed as a quality caveat three separate times. Rule: no batch merges without its review pass; keep a per-batch reviewed/unreviewed ledger. Real bug classes this catches:

  • Wrong-sense / part-of-speech homonyms — short UI words hide these: "Record" (verb vs noun), "Reverse" / "Clear" / "Format" / "Manage" / "Original" (verb vs noun vs adjective). Good comments prevent it; review confirms it.
  • Copy-paste / wrong-string mistranslations — e.g. "Review the app" rendered as "Reverse" in several locales: grammatical, but the wrong string entirely.
  • Typos / accents — e.g. Hebrew "כששתפים"→"כשמשתפים", Spanish "Repróducelo"→"Reprodúcelo".
  • Cross-surface / cross-variant inconsistency — es vs es-MX disagree; dial says one word, widget another; an AppIntents description still lists the English name.
  • Loanword-vs-native mistakes — sometimes the loanword is native (es "Robot"); sometimes a transliteration is worse than an existing native word (sk "Čipmank" vs the better "Veverička" already used elsewhere — prefer the term other surfaces already use).
  • Unspeakable / mismatched spoken commands — flag Voice Control named actions and Siri/Shortcuts trigger phrases that a native speaker wouldn't naturally say, that are hard to pronounce, or that don't match the visible on-screen label for the same control (see Phase 1).
  • Overflow / length — flag values much longer than the English for a tight slot.

Verify each flag against the catalog before applying (reviewers occasionally misread). Fix values AND, when the cause was ambiguity, improve the comment so it can't recur.


Phase 6 — Apply to the catalog

  • Write each cell as stringUnit { "state": "translated", "value": … }. Preserve key-level comment / extractionState and all untouched locales.
  • Never rely on build-time extraction to add keys — insert them explicitly (extractionState: manual where Xcode might drop them), and let the Phase 7.1 code-vs-catalog key diff be the source of truth for completeness.
  • Whenever ANY build rewrites the catalog (it happens — once as a 132k-line re-serialization), run a loss-check script before committing: 0 lost string units, 0 changed values, exactly the N expected added keys.
  • Keys that are purely a glossary concept (Phase 3): write the value mechanically from the glossary — no agent involved, no drift possible.
  • Don't touch knownRegions — the compiled .lproj directories alone make iOS offer the languages; planned pbxproj edits proved unnecessary.
  • Round-trip check: before editing, test whether json.dumps(json.load(file), ensure_ascii=False, indent=2)+"\n" reproduces the file byte-identically. It usually won't on an Xcode-written catalog (Xcode uses " : " separators, Python uses ": "). If it round-trips, scripted value-only edits give a minimal diff. If it doesn't, either (a) do targeted string-level edits on the raw text, or (b) accept a one-time whole-file reformat, then verify the change is value-only by comparing json.load of old vs new (identical except the intended values) — and expect Xcode to reformat it back on the next key-extraction build.
  • A build rewrites the .xcstrings when extraction detects a key delta (Xcode's " : " style vs json.dump's ":"); value-only edits don't trigger it. Expect a one-time reformat when you add/rename keys. Keep scripted edits minimal-diff; rebuild from HEAD + your delta if needed.
  • Never touch the source language or English variants (en-AU/CA/GB fall back to en).

Phase 7 — VERIFY — and BUILD (the part everyone skips)

7.0 — BUILD it — and re-build the FINAL committed state

Non-negotiable. A String Catalog build can pass on the build that extracts a new key and FAIL on the very next build (symbol generation, the all-specifier-key trap). So: build; if the build modified the catalog, build again (ideally clean then build) and confirm the on-disk state you're committing is green. One post-edit build is not enough. Build with build_project (Xcode MCP); use Bash only to inspect DerivedData output afterward. Most MCP servers and raw xcodebuild don't run string extraction the way an Xcode build does — don't fall back to xcodebuild "just this once."

7.1 — Key match

Every key the code generates must exist in the catalog or it silently shows English. Derive code keys, diff against catalog keys programmatically.

7.2 — Specifier safety (crash risk)

Every translation's format-specifier count must equal the source's — more specifiers than args garbles/crashes at runtime. Check every cell programmatically.

7.3 — xcstringstool compile

xcrun xcstringstool compile --output-directory /tmp/out Localizable.xcstrings

The same compiler Xcode uses; errors on specifier mismatches, validates plural variations (→ .stringsdict).

7.4 — Inspect the COMPILED bundle, not the source

swift build copies catalogs raw — only the Xcode app build compiles them. After building, confirm <locale>.lproj/Localizable.strings (and .stringsdict) exist with the real translated values (for a package, inside its *.bundle in the .app); plutil -p and verify it's the translation, not English.

(See Compiled Bundle Inspection in the Shared Preamble for canonical commands.)

7.5 — Runtime spot-check (launch in a locale)

Spot-check a few locales actually render translated (run, or read the compiled .strings). To run in a locale: set the scheme's App Language, or pass -AppleLanguages "(xx)" as a launch argument. .environment(\.locale, Locale(identifier: "xx")) in previews affects SwiftUI formatting only — a true bundle-language test needs the scheme/launch-argument route.

Note: Phase 7.5-7.10 are SPOT-CHECKS only — quick sanity checks before handoff to the Testing task. The Testing task (Section 2) performs COMPREHENSIVE runtime testing.

7.6 — Pseudolocalization sweep

Launch with -NSDoubleLocalizedStrings YES and walk every top-level screen — doubled text = localized, single = gap. This caught "Yesterday" and "Manual"/"Auto" that code sweeps missed. Note: en-XA does NOT work at runtime (it needs build-time generation) — don't lose a cycle discovering that.

xcrun simctl launch booted <BUNDLE_ID> -NSDoubleLocalizedStrings YES

7.7 — Mandatory RTL runtime spot-check

Arabic or Hebrew in addition to one LTR locale — the Arabic pass caught a Text(String) gap the Japanese pass didn't, and validates mirroring, Arabic-Indic numerals, and calendar rendering in one shot.

7.8 — Verify gated surfaces

Verify each gated surface in a real locale using the app's debug flags (e.g. --onboarding-only, --debug-paywall) — an onboarding stub bug was only caught on screen, after all catalog work was "done."

7.9 — Argument-order check

For keys with multiple same-type specifiers (%lld of %lld, %lld remaining): every translation must preserve source order or use positional specifiers throughout — otherwise numbers silently swap at runtime.

7.10 — Glossary lint

If Phase 3 ran: for each (concept, locale), every key listed in the glossary should contain that locale's canonical term. Flag, don't hard-fail — inflected languages legitimately decline the term mid-sentence; flags go back through review.


Phase 8 — Translation-quality validation (structure validated ≠ quality validated)

Everything above proves the mechanics; none of it proves the translations are good. Tiered, cheapest first — each tier narrows what the next (more expensive) tier must look at:

  1. Automated linters — glossary/term consistency across surfaces, placeholder/brand preservation, identical-to-English detection, length ratios.
  2. Cross-engine diff (DeepL/Google) as triage — large divergence from machine translation means look closer, not that either is right.
  3. Independent LLM-as-judge with a rubric, using a different model than the translator.
  4. In-context screenshot QA per locale (the Phase 8 sweep artifacts). Review each screenshot against the validation criteria: no English fallback, no truncation, no overflow, proper RTL mirroring, pseudolocalization shows doubled strings. Use the screenshot manifest CSV to track per-screenshot pass/fail. This is the visual proof that translations actually render correctly in context — not just that the catalog is structurally valid.
  5. Targeted native human review of everything flagged, plus high-stakes surfaces regardless (paywall, onboarding, tab names, errors) — prioritized by risk × market: morphologically rich locales (ru/uk/pl/cs/ar/he) and low-resource Indic locales first; human dollars on revenue-leading markets.

Phase 9 — Ship

  • Localized package consumed remotely: commit → tag a new version → push → bump the app's SPM dependency (pbxproj requirement + Package.resolved) → rebuild + re-verify (Phase 7.0).
  • App Store metadata is a SEPARATE effort (sight-words): app info, version "What's New", keywords, IAP/subscription display names & descriptions — may require creating a new app version first. Automating via App Store Connect: serial ASC queue, ~500ms pre-call delay, honor 429 Retry-After, validate completeness before committing.

Implementation-Specific Considerations

These topics are primarily IMPLEMENTATION concerns (what to do while localizing):

  • Font/script rendering for the Indic set — custom fonts usually lack Devanagari/Tamil/etc. glyphs, so those locales silently render in system-font fallback; tall scripts (Thai stacked diacritics, Devanagari) can clip in tight lineLimit(1) slots even when the text fits horizontally. Implementation fix: ensure fonts include Indic glyphs; use .lineLimit(1).minimumScaleFactor() instead of .fixedSize().
  • Bidirectional text composition — an LTR brand name or number inside an Arabic/Hebrew sentence needs bidi isolation or punctuation visually scrambles; naive string interpolation is the usual culprit. Implementation fix: use Text(verbatim:) with proper bidi isolation characters.
  • Measurement units — metric vs imperial is a region choice, not a language one (Measurement.FormatStyle); 12/24-hour time follows the same trap. Implementation fix: use FormatStyle APIs, not hardcoded strings.
  • Locale-aware collation and search — sorting with < or filtering with plain contains breaks for diacritics and non-Latin scripts. Implementation fix: use localizedStandardCompare and diacritic-insensitive predicates.
  • Speech synthesis — if the app speaks, AVSpeechSynthesizer voice/language selection must follow locale, and TTS output of localized strings is its own review surface. Implementation fix: select voice by locale identifier.
  • Scheduled local notifications — content is frozen at scheduling time, so notifications scheduled before a language change (or before localization shipped) fire in the old language until rescheduled. Implementation fix: persist stable identifiers, localize at render; reschedule on language change.
  • URLs and web content — help/support/privacy links pointing at English-only pages; in-app HTML; share-sheet composed text and mailto subject lines. Implementation fix: maintain locale-specific URL mappings.

HANDOFF TO TESTING TASK

Upon completing Implementation, pass the following artifacts to the Testing task:

  1. Screen Inventory (ScreenInventory.md or ScreenInventory.json) — from Phase 0 recon
  2. Completed String CatalogsLocalizable.xcstrings, InfoPlist.xcstrings, AppShortcuts.xcstrings, and any SPM package catalogs
  3. Glossary (LocalizationGlossary.json) — from Phase 3, if created
  4. Build Verification Results — from Phase 7 (build logs, xcstringstool output, compiled bundle inspection)
  5. Known Issues / Gaps — any strings that could not be localized, any screens that could not be verified, any skipped edge cases
  6. Skip List — any non-translatable keys filtered out (pure punctuation, format specifiers, emoji, unit keys)

The Testing task requires these inputs to perform comprehensive runtime validation.


SECTION 2 — TESTING/VALIDATION

This section covers everything about VERIFYING localization works at runtime — simulator testing, screenshot capture, visual analysis, and validation reporting. Use this section to power the "Test App Localization" template task.

Testing-specific rules:

  • Do NOT modify source code or project files — they are READ-ONLY for runtime testing
  • Do NOT change system language settings — use launch arguments instead
  • All screenshots, reports, and evidence should be saved to ~/localization-test-results/ (create if needed) or to a temporary folder you have available for this task
  • If a step requires user interaction (e.g., a system dialog), skip it and document the skip in the report
  • Be self-contained — clean up any temporary files after use

Prerequisites / Inputs (from Implementation Task)

This task requires the following inputs from the Implementation task:

  1. Screen Inventory — the numbered list of all screens/states from Phase 0 recon
  2. Completed String CatalogsLocalizable.xcstrings, InfoPlist.xcstrings, AppShortcuts.xcstrings, and any SPM package catalogs
  3. Glossary (LocalizationGlossary.json) — if created in Phase 3
  4. Build Verification Results — confirmation that the app builds green with all catalogs
  5. Known Issues / Gaps — any strings that could not be localized, any screens that could not be verified

If any of these inputs are missing, request them via request_help before proceeding.


⛔ IMAGE INPUT CAPABILITY GATE — READ BEFORE DOING ANYTHING ELSE

This task is contingent on BOTH the worker agent AND the acceptance validator models supporting image inputs (screenshots). The entire point of runtime localization testing is to capture screenshots of every app screen in various locales and visually analyze them for text truncation, overflow, English fallback, RTL mirroring, and other rendering issues. If image input is not supported, there is no way to validate the results.

Concrete check method (do this FIRST, before any other step)

  1. Boot a simulator and take a screenshot:
    xcrun simctl boot "iPhone 16" 2>/dev/null; sleep 5
    xcrun simctl io booted screenshot /tmp/gate-check.png
  2. Attach the screenshot to a task update (via attachment_paths).
  3. Attempt to describe what you see in the screenshot — e.g. "I see the iOS simulator home screen with app icons, a light/dark background, a status bar at the top showing time and battery."
  4. Evaluate the result:
    • If you CAN see and describe the screenshot contents (even roughly — "I see a grid of app icons on a dark background"), image input is working. Proceed with the task.
    • If you CANNOT describe the screenshot (empty description, error, or you get back metadata only with no visual understanding), then ESCALATE IMMEDIATELY. Use request_help with blocker: "IMAGE INPUT GATE FAILED: The worker agent cannot process image inputs. Runtime localization testing requires visual screenshot analysis. The task cannot proceed without image input capability."
  5. Validator check: The same gate applies to the acceptance validator. If the validator cannot process the screenshot images attached to the task results, it should reject the submission with the same reasoning. The prompt explicitly declares this contingency.

Do NOT proceed past this gate if image input is not working. Every subsequent step depends on it.


Runtime Testing Strategy (15 Steps)

This is the comprehensive runtime testing procedure. Execute these steps in order. Each step must be completed before moving to the next. All commands use placeholders — substitute your app's actual <BUNDLE_ID>, <APP_NAME>, <APP_PATH>, and <PROJECT_PATH>.

Step 1: Image Input Capability Gate Do this FIRST. Follow the gate check procedure in the IMAGE INPUT CAPABILITY GATE section above. If image input is not working, escalate via request_help immediately.

Step 2: Build the App

# Using Xcode MCP (capability-first — use any equivalent tool if not available):
#   set_run_destination → target "iPhone 16" (or similar)
#   build_project → build the app scheme

# After building, find the .app:
APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData -name "<APP_NAME>.app" -type d 2>/dev/null | sort -r | head -1)
echo "App path: $APP_PATH"

Verify the build succeeded with no errors. If the build fails, use get_build_errors to diagnose and fix. Do not proceed until the build is green.

Step 3: Boot a Simulator

xcrun simctl boot "iPhone 16" 2>/dev/null; sleep 5
xcrun simctl bootstatus booted
xcrun simctl list devices booted

Step 4: Compiled Bundle Inspection Before launching the app, verify the compiled bundle has translated strings:

# Compile the main catalog
xcrun xcstringstool compile --output-directory /tmp/xcstrings_verify <PROJECT_PATH>/Localizable.xcstrings

# Verify German strings exist
plutil -p /tmp/xcstrings_verify/de.lproj/Localizable.strings | head -20

# Verify Arabic strings exist
plutil -p /tmp/xcstrings_verify/ar.lproj/Localizable.strings | head -20

# Compile and verify the InfoPlist catalog (if it exists)
xcrun xcstringstool compile --output-directory /tmp/infoplist_verify <PROJECT_PATH>/<INFOPLIST_PATH>/InfoPlist.xcstrings
plutil -p /tmp/infoplist_verify/de.lproj/InfoPlist.strings
plutil -p /tmp/infoplist_verify/ar.lproj/InfoPlist.strings

# Compile and verify any SPM package catalogs
xcrun xcstringstool compile --output-directory /tmp/package_verify <PROJECT_PATH>/<PACKAGE_PATH>/Resources/Localizable.xcstrings
plutil -p /tmp/package_verify/de.lproj/Localizable.strings | head -20

Step 5: Install the App

xcrun simctl install booted "$APP_PATH"

Step 6: Primary Locale Test — German (de_DE) German is the recommended primary test locale because:

  • It's an LTR language with longer text than English (good for overflow testing)
  • It uses accented characters (ä, ö, ü, ß)
# Erase simulator for clean state (important for onboarding + empty states)
xcrun simctl erase "iPhone 16"
xcrun simctl boot "iPhone 16"; sleep 5
xcrun simctl install booted "$APP_PATH"

# Launch with German, force onboarding (if app supports --reset-onboarding)
xcrun simctl launch booted <BUNDLE_ID> -AppleLanguages "(de)" -AppleLocale "de_DE" --reset-onboarding

sleep 5

# Screenshot: onboarding welcome screen
xcrun simctl io booted screenshot ~/localization-test-results/screenshots/de/onboarding-welcome/01-default.png

# Navigate through onboarding:
# - Screenshot each onboarding screen
# - Select some options and screenshot
# - Complete onboarding

# After onboarding, app shows main tab view
# Screenshot each tab and screen
# For each screen, scroll to reveal all content and screenshot

Step 7: RTL Locale Test — Arabic (ar_SA) Arabic is the RTL test locale: right-to-left, testing layout mirroring, Arabic script.

xcrun simctl erase "iPhone 16"
xcrun simctl boot "iPhone 16"; sleep 5
xcrun simctl install booted "$APP_PATH"

xcrun simctl launch booted <BUNDLE_ID> -AppleLanguages "(ar)" -AppleLocale "ar_SA" --reset-onboarding
sleep 5

# Screenshot every screen in RTL
# Pay special attention to:
# - Layout direction (is the UI mirrored?)
# - Text alignment (is text right-aligned?)
# - Icon direction (do chevrons/arrows point the correct way?)
# - Tab bar order (is it mirrored?)
# - Navigation direction (swipe back should be from left)

Step 8: Additional LTR Locale — Japanese (ja_JP) Japanese tests a different script (CJK):

xcrun simctl erase "iPhone 16"
xcrun simctl boot "iPhone 16"; sleep 5
xcrun simctl install booted "$APP_PATH"

xcrun simctl launch booted <BUNDLE_ID> -AppleLanguages "(ja)" -AppleLocale "ja_JP" --reset-onboarding

Step 9: Pseudolocalization Sweep

# Launch with -NSDoubleLocalizedStrings YES (doubles all strings)
xcrun simctl launch booted <BUNDLE_ID> -NSDoubleLocalizedStrings YES --reset-onboarding

# Navigate EVERY screen and screenshot
# Doubled strings reveal:
# - Which strings are actually localized (doubled = localized, single = gap)
# - Which layouts overflow when text is longer
# - Which text truncates with ellipsis

Step 10: Dark Mode Test

# Try setting light mode (app may override to dark)
xcrun simctl ui booted appearance light
xcrun simctl launch booted <BUNDLE_ID> -AppleLanguages "(de)" -AppleLocale "de_DE"
# Screenshot to check if app respects light mode or forces dark
# Also set dark mode explicitly and verify translated text readability
xcrun simctl ui booted appearance dark

Step 11: Accessibility Content Size Test

# Set a larger content size
xcrun simctl ui booted content_size accessibility-extra-extra-extra-large

xcrun simctl launch booted <BUNDLE_ID> -AppleLanguages "(de)" -AppleLocale "de_DE"

# Screenshot every screen — look for text overflow, truncation, layout breakage
# Pay special attention to:
# - Tab bar labels
# - Button text
# - Navigation titles
# - Long text blocks (error messages, onboarding descriptions)

Step 12: Different Device Size Test

# Boot an iPhone SE (3rd generation) — small screen
xcrun simctl boot "iPhone SE (3rd generation)"; sleep 5
SE_UDID=$(xcrun simctl list devices | grep "iPhone SE (3rd generation)" | grep -o "[0-9A-F]\{8\}-[0-9A-F]\{4\}-[0-9A-F]\{4\}-[0-9A-F]\{4\}-[0-9A-F]\{12\}" | head -1)
xcrun simctl install "$SE_UDID" "$APP_PATH"
xcrun simctl launch "$SE_UDID" <BUNDLE_ID> -AppleLanguages "(de)" -AppleLocale "de_DE" --reset-onboarding

# Screenshot every screen on the smaller device
# Compare with iPhone 16 screenshots for overflow differences

Step 13: Camera Permission Dialog Test (if the app uses the camera)

# Erase simulator to reset permissions
xcrun simctl erase "iPhone 16"
xcrun simctl boot "iPhone 16"; sleep 5
xcrun simctl install booted "$APP_PATH"

# Launch with German
xcrun simctl launch booted <BUNDLE_ID> -AppleLanguages "(de)" -AppleLocale "de_DE"

# The system permission dialog should appear with localized NSCameraUsageDescription
# Screenshot the permission dialog immediately
# If the dialog doesn't appear automatically, reset permissions:
xcrun simctl spawn booted tccutil reset Camera <BUNDLE_ID>

Step 14: App Display Name on Home Screen

# Go to simulator home screen
osascript -e 'tell application "Simulator" to activate'
osascript -e 'tell application "System Events" to keystroke "h" using {command down, shift down}'

# Screenshot the home screen
xcrun simctl io booted screenshot ~/localization-test-results/screenshots/de/system/02-home-screen-icon.png

# The app name under the icon should be the localized CFBundleDisplayName

Step 15: Locale/Region Mismatch Test

# Language = German, Region = US
xcrun simctl launch booted <BUNDLE_ID> -AppleLanguages "(de)" -AppleLocale "en_US"

# Check if the app shows German strings with US date/number formatting
# This reveals locale/region mismatch issues

Step 16: Catalog All Screenshots and Analyze After capturing all screenshots:

  1. Organize them into the screenshot manifest directory structure (see Screenshot Manifest Format)
  2. Analyze each screenshot for the validation criteria (see Validation Criteria)
  3. Write a summary report (see Report Format)

Test Coverage

  • Extend the localization UI sweep beyond onboarding/paywall to the main UI and Settings (+ scrolled Settings for subscription/tier rows). Add a horizontal-overflow assertion (a label whose frame extends past the screen edge is clipped) on every captured screen — a real automatable clipping proxy. Navigate locale-independently: accessibility identifiers (not localized labels) + coordinate fallbacks.
  • Right-to-left (RTL) locales (ar, he, ur): verify mirrored layout, not just strings. Run the sweep at least once under an RTL locale and check: leading/trailing used instead of .left/.right, directional icons (chevrons, arrows, progress) mirror correctly, and text alignment follows layoutDirection. RTL screenshots go into the same visual review. Specifically verify: layout direction (is the UI mirrored?), text alignment (right-aligned?), icon direction (chevrons/arrows point the correct way?), tab bar order (mirrored?), navigation direction (swipe back should be from left).
  • VoiceOver proxy: an XCUITest asserting accessibility labels per locale is an automatable stand-in for VoiceOver output — in a localized run, assert the labels are the translated values, not English.
  • The home-screen widget and Share/Action extensions run out-of-process — the app's UI-test target can't launch or drive them; review their localized layouts in their own host contexts.
  • Limit to state honestly: frame-overflow is detectable; pixel-level ellipsis truncation within a frame still needs the screenshots for visual review.

Edge Cases

Beyond the basic locale tests, the following edge cases MUST be tested. These address gaps in runtime testing that experience has revealed:

Dark Mode + Locale

  • Verify: translated text is readable against dark backgrounds, no contrast issues with translated text, no text color changes that break translated text visibility.
  • If the app forces a specific color scheme (e.g. .environment(\.colorScheme, .dark)), note that it may not respect the simulator's appearance setting, but still verify text readability.
  • Test both xcrun simctl ui booted appearance dark and appearance light.

Accessibility Content Sizes + Locale

Localized text (especially long German or Hindi strings) may overflow at larger content sizes.

  • Test with xcrun simctl ui booted content_size accessibility-extra-extra-extra-large
  • Screenshot key screens: tab bar, settings rows, onboarding buttons, error messages
  • Look for: text truncation, button text overflow, layout breakage, tab bar label truncation

Different Simulator Device Sizes

  • Test on iPhone SE (3rd generation) — small screen (375pt wide)
  • Test on iPhone 16 Pro Max — large screen (430pt wide)
  • Compare the same screens across device sizes for overflow differences
  • Focus on: settings rows, onboarding option buttons, camera controls, navigation bars

Locale/Region Mismatch

  • Language = German (de), Region = US (en_US): simctl launch booted <BUNDLE_ID> -AppleLanguages "(de)" -AppleLocale "en_US"
  • This reveals: date formatting (should be US format but German text), number formatting, currency if applicable
  • Check: date section headers in list views, any formatted dates/numbers

Empty States

  • First launch / no data: Erase simulator, launch app, navigate to list views → empty states
  • No onboarding data: First launch shows onboarding
  • No subscription: If not entitled, paywall or "See options" in settings

Error States

  • Camera error: On simulator, attempt to take a photo — may show error alert
  • API failure: If the API is unreachable or returns an error, detail views show error states
  • No results found: If a search/identification returns nothing, the "no results" message
  • Screenshot every error state in the test locale

Plural Forms

The app may have strings like "Delete %lld Photos" or "%lld%% confidence". If any plural variations exist in the catalog:

  • Test with different counts (0, 1, 2, 5, 21) to verify CLDR plural rules
  • Check if plural strings render correctly in locales with complex plural rules (Arabic uses all 6 categories, Russian uses one/few/many/other)

Onboarding Flow Specifically

The onboarding package (if present) has its own Localizable.xcstrings, separate from the main app. These must be verified at runtime:

  • Force onboarding with appropriate launch arguments
  • Screenshot every onboarding screen in the test locale
  • Verify the onboarding package's strings render translated (not English)
  • The onboarding package uses NSLocalizedString with bundle: .module — verify the bundle resolution works at runtime

System Permission Dialogs (InfoPlist.xcstrings)

  • NSCameraUsageDescription (and other usage descriptions) are in InfoPlist.xcstrings, NOT Localizable.xcstrings
  • The system permission dialog should show this text translated
  • To trigger: erase simulator, launch app, navigate to the feature that requests the permission
  • Screenshot the system dialog in the test locale
  • Also check CFBundleDisplayName on the home screen

App Display Name on Home Screen

  • CFBundleDisplayName is in InfoPlist.xcstrings
  • Go to the simulator home screen and screenshot
  • The app name under the icon should be in the target locale (if a translation exists)
  • Test in German, Arabic, and Japanese

Background Animation Package Strings

If the app uses a background-animation or similar SPM package with its own Localizable.xcstrings, verify any visible strings from that package render translated.

Bidirectional Text

In RTL locales, check for:

  • LTR brand names or numbers inside RTL sentences (should be properly isolated)
  • Punctuation that visually scrambles in RTL context
  • Numbers that stay LTR within RTL text

Tall Scripts

For locales with tall scripts (Thai, Devanagari/Hindi), check:

  • Text clipping in lineLimit(1) slots
  • Vertical overflow in tight-height containers
  • These locales may show English if not yet translated — document this

Settings Toggles in Different Locales

If the app has settings toggles (e.g. "Show metric numbers", "Show scientific names"):

  • Verify toggle labels render translated
  • Toggling them affects the relevant views (scientific names, metric units)
  • Screenshot the affected views with toggles on and off in the test locale

Email / Link Actions

Settings may have "Contact Us" (opens mailto), "Terms of Use" and "Privacy Policy" (open URLs). These don't need to be fully exercised (they open external apps), but verify the button labels are translated in screenshots.


Screen Skipping Criteria

Screens may be skipped if:

  1. The screen requires user interaction that cannot be automated (e.g., biometric authentication, typing text into a field)
  2. The screen is a transient state (e.g., loading spinner, brief animation) that cannot be captured
  3. The feature is not available in the test build (e.g., paywall in debug build, server-dependent features with no test server)
  4. The screen is behind a permission gate that cannot be programmatically granted (e.g., HealthKit, HomeKit)

When skipping a screen, document the skip in the report with:

  • Screen ID
  • What was expected
  • Why it was skipped
  • What the expected result would have been

Validation Criteria

Per-Screenshot Pass/Fail Criteria

Each screenshot must be evaluated against these criteria. A screenshot FAILS if ANY of the following are true:

# Criterion Fail Condition How to Check
1 No English fallback Any user-facing string shows in English when a translation exists in the catalog for the target locale Compare visible text against the compiled .strings for that locale. If the catalog has a translation but the screen shows English, it's a fail.
2 No text truncation Text is cut off with "…" (ellipsis) or clipped at the edge where it wasn't in the English build Visually inspect for ellipsis characters or text that appears to be cut off mid-word
3 No horizontal overflow Text extends past the screen edge or container boundary Visually inspect — text should not be clipped at screen edges. Compare with pseudolocalization screenshots.
4 No overlapping text Text elements overlap each other or overlap icons/buttons Visually inspect for overlapping elements
5 Proper RTL mirroring (RTL locales only) Layout is not mirrored, text is not right-aligned, directional icons don't point the correct way Compare LTR vs RTL screenshots. In RTL: tab bar should be mirrored, navigation back arrow should point right, text should be right-aligned, HStack layouts should reverse.
6 Pseudolocalization shows doubled strings Any string that does NOT appear doubled under -NSDoubleLocalizedStrings YES is a localization gap In pseudo screenshots, all localized strings should appear doubled (e.g. "SettingsSettings"). Single strings = not localized.
7 All strings in target language Any string that should be translated shows in English Visually verify the screenshot shows text in the target language (German, Arabic, Japanese)

Screen-Level Pass Criteria

A screen PASSES only when ALL of these are true:

  • At least one screenshot of the screen exists in the primary test locale (German)
  • ALL screenshots of that screen pass the per-screenshot criteria
  • The screen was fully exercised (scrolled to bottom, all options shown, all states captured)

Overall Task Pass Criteria

The task PASSES only when ALL of these are true:

  1. Every screen in the inventory has at least one screenshot in the primary test locale (German)
  2. No screenshot shows English text where a translation exists in the catalog for that locale
  3. No screenshot shows text truncation (ellipsis or cut-off) that wasn't present in the English reference
  4. No screenshot shows horizontal overflow (text extending past screen edge)
  5. RTL screenshots show proper mirroring (layout direction, icon direction, text alignment)
  6. Pseudolocalization screenshots show doubled strings (proving localization works) AND reveal any overflow
  7. At least one screenshot of the system camera permission dialog in the test locale, showing NSCameraUsageDescription translated (if the app uses the camera)
  8. At least one screenshot of the app home screen showing the localized CFBundleDisplayName under the app icon (if a localized display name exists)
  9. A summary report (markdown) exists at ~/localization-test-results/reports/runtime-test-report.md listing:
    • Every screen tested, with pass/fail status
    • Any issues found (with screenshot references)
    • Any screens that could not be tested (with explanation of why)
    • Overall pass/fail determination

Screenshot Manifest Format

All screenshots must be saved to a well-organized directory structure with a consistent naming convention.

Directory structure

~/localization-test-results/
├── screenshots/
│   ├── de/                          # German (primary LTR locale)
│   │   ├── onboarding-welcome/
│   │   │   ├── 01-default.png
│   │   │   ├── 02-options-selected.png
│   │   │   └── 03-scrolled.png
│   │   ├── onboarding-features/
│   │   │   ├── 01-default.png
│   │   │   ├── 02-options-selected.png
│   │   │   └── 03-scrolled.png
│   │   ├── camera/
│   │   │   ├── 01-permission-granted.png
│   │   │   ├── 02-permission-denied.png
│   │   │   └── 03-error-alert.png
│   │   ├── photos/
│   │   │   ├── 01-empty.png
│   │   │   ├── 02-with-photos.png
│   │   │   └── 03-section-headers.png
│   │   ├── settings/
│   │   │   ├── 01-top.png
│   │   │   ├── 02-scrolled.png
│   │   │   └── 03-toggles.png
│   │   ├── errors/
│   │   │   ├── 01-api-error.png
│   │   │   └── 02-camera-error.png
│   │   └── system/
│   │       ├── 01-camera-permission-dialog.png
│   │       └── 02-home-screen-icon.png
│   ├── ar/                          # Arabic (RTL locale) — same sub-structure
│   │   └── ... (same screen IDs as de/)
│   ├── ja/                          # Japanese (CJK LTR locale)
│   │   └── ...
│   ├── pseudo/                      # Pseudolocalization (-NSDoubleLocalizedStrings YES)
│   │   └── ... (same screen IDs, English locale with doubled strings)
│   ├── de-accessibility-xxxl/       # German with largest accessibility content size
│   │   └── ... (key screens only)
│   ├── de-se/                       # German on iPhone SE (small screen)
│   │   └── ... (key screens only)
│   ├── de-light/                    # German in light mode (if app respects it)
│   │   └── ... (key screens only)
├── reports/
│   ├── screenshot-manifest.csv      # CSV listing every screenshot with locale, screen, state, pass/fail, notes
│   └── runtime-test-report.md       # Final summary report
└── compiled-bundles/                # Compiled .strings inspection output
    ├── main/
    │   ├── de.lproj/
    │   ├── ar.lproj/
    │   └── ...
    ├── infoplist/
    │   ├── de.lproj/
    │   └── ...
    └── packages/
        ├── de.lproj/
        └── ...

Naming convention

  • Locale directories: Use the Apple locale code (de, ar, ja, pseudo, de-accessibility-xxxl, de-se, de-light)
  • Screen directories: Use kebab-case screen IDs from the screen inventory (e.g. onboarding-welcome, camera, fish-info, settings)
  • Screenshot files: {number}-{state-description}.png (e.g. 01-default.png, 02-options-selected.png, 03-scrolled.png)
  • Numbering: Start at 01 for the default/first state, increment for additional states

Screenshot manifest CSV

Create a CSV file at ~/localization-test-results/reports/screenshot-manifest.csv with columns:

locale,screen_id,state_id,file_path,pass_fail,issues
de,onboarding-welcome,01-default,/Users/<USER>/localization-test-results/screenshots/de/onboarding-welcome/01-default.png,pass,
de,camera,01-permission-granted,/Users/<USER>/localization-test-results/screenshots/de/camera/01-permission-granted.png,fail,Text truncation on button label
ar,settings,01-top,/Users/<USER>/localization-test-results/screenshots/ar/settings/01-top.png,pass,
...

Report Format

The summary report (runtime-test-report.md) must follow this structure:

# Runtime Localization Test Report — <APP_NAME>

**Date:** YYYY-MM-DD
**Tester:** Agent Brown (automated)
**App:** <APP_NAME> (<BUNDLE_ID>)
**Build:** [build identifier or DerivedData path]

## Test Configuration
- Primary LTR locale: de (German, de_DE)
- RTL locale: ar (Arabic, ar_SA)
- Additional LTR locale: ja (Japanese, ja_JP)
- Pseudolocalization: -NSDoubleLocalizedStrings YES
- Simulator: iPhone 16 (iOS XX.X)
- Additional devices: iPhone SE (3rd generation)

## Summary
- Total screens tested: XX
- Screens passed: XX
- Screens failed: XX
- Screens skipped (with reason): XX
- Overall result: PASS / FAIL

## Per-Screen Results

### de (German)
| Screen ID | State | Screenshot | Result | Issues |
|-----------|-------|------------|--------|--------|
| onboarding-welcome | 01-default | screenshots/de/onboarding-welcome/01-default.png | PASS ||
| camera | 01-permission-granted | screenshots/de/camera/01-permission-granted.png | FAIL | Button text truncated |
| ... | ... | ... | ... | ... |

### ar (Arabic — RTL)
| Screen ID | State | Screenshot | Result | Issues |
|-----------|-------|------------|--------|--------|
| ... | ... | ... | ... | ... |

### ja (Japanese)
| Screen ID | State | Screenshot | Result | Issues |
|-----------|-------|------------|--------|--------|
| ... | ... | ... | ... | ... |

### pseudo (Pseudolocalization)
| Screen ID | State | Screenshot | Result | Issues |
|-----------|-------|------------|--------|--------|
| ... | ... | ... | ... | ... |

### Edge Case Tests
| Test | Screenshot | Result | Issues |
|------|------------|--------|--------|
| Dark mode + de | ... | ... | ... |
| Accessibility XXXL + de | ... | ... | ... |
| iPhone SE + de | ... | ... | ... |
| de + en_US region | ... | ... | ... |

## Issues Found
1. [ISSUE-001] Screen: camera, Locale: de — Button text truncated at ...
2. [ISSUE-002] Screen: settings, Locale: ar — Section not mirrored ...
3. ...

## Screens Skipped
1. [SKIP-001] Screen: processing-overlay — Transient state, could not capture mid-processing. Expected text: "Processing..."
2. ...

## Conclusion
[Overall assessment]

Output Deliverables (runtime testing)

The worker agent must produce the following deliverables upon completing runtime testing:

  1. Screenshots — organized in ~/localization-test-results/screenshots/{locale}/{screen-id}/ per the manifest format
  2. Screenshot manifest CSV — at ~/localization-test-results/reports/screenshot-manifest.csv
  3. Runtime test report — at ~/localization-test-results/reports/runtime-test-report.md (format in the Report Format section)
  4. Compiled bundle inspection output — at ~/localization-test-results/compiled-bundles/
  5. Task completion result — via task_complete with:
    • Summary of testing performed
    • Overall pass/fail determination
    • Key issues found (if any)
    • Screens skipped (with reasons)
    • All screenshots and the report attached via attachment_paths

Quick Reference — Command Cheatsheet

Build

# Via Xcode MCP (capability-first — use any equivalent): build_project
# Via command line (fallback ONLY):
# xcodebuild -project <PROJECT>.xcodeproj -scheme <SCHEME> -destination "platform=iOS Simulator,name=iPhone 16" build
# Note: Prefer Xcode MCP build_project for proper string extraction

Boot & install

xcrun simctl boot "iPhone 16"; sleep 5
APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData -name "<APP_NAME>.app" -type d 2>/dev/null | sort -r | head -1)
xcrun simctl install booted "$APP_PATH"

Launch with locale

# German (LTR)
xcrun simctl launch booted <BUNDLE_ID> -AppleLanguages "(de)" -AppleLocale "de_DE"

# Arabic (RTL)
xcrun simctl launch booted <BUNDLE_ID> -AppleLanguages "(ar)" -AppleLocale "ar_SA"

# Japanese
xcrun simctl launch booted <BUNDLE_ID> -AppleLanguages "(ja)" -AppleLocale "ja_JP"

# Pseudolocalization
xcrun simctl launch booted <BUNDLE_ID> -NSDoubleLocalizedStrings YES

# Locale/region mismatch
xcrun simctl launch booted <BUNDLE_ID> -AppleLanguages "(de)" -AppleLocale "en_US"

Screenshot

mkdir -p ~/localization-test-results/screenshots/de/camera
xcrun simctl io booted screenshot ~/localization-test-results/screenshots/de/camera/01-default.png

Appearance & accessibility

xcrun simctl ui booted appearance dark
xcrun simctl ui booted appearance light
xcrun simctl ui booted content_size accessibility-extra-extra-extra-large

Reset simulator

xcrun simctl shutdown booted
xcrun simctl erase "iPhone 16"
xcrun simctl boot "iPhone 16"; sleep 5

Compiled bundle inspection

xcrun xcstringstool compile --output-directory /tmp/verify <PROJECT_PATH>/Localizable.xcstrings
plutil -p /tmp/verify/de.lproj/Localizable.strings | head -30
plutil -p /tmp/verify/ar.lproj/Localizable.strings | head -30

Terminate app

xcrun simctl terminate booted <BUNDLE_ID>

APPENDIX: Apple localization.md (from Xcode Agent Skills)

The following is the latest localization.md exported from Xcode's agent skills at runtime. This is Apple's official SwiftUI localization guidance.

Note: This appendix was exported from Xcode 27.0 on 2026-07-21. Before using this prompt, export the latest version per the instructions in the Prerequisites section.


String Catalogs

Most projects localize through String Catalogs (.xcstrings). Each build syncs new strings from code into the catalog, but the catalog file must already exist — Xcode does not create one automatically. If a project already uses .strings or .stringsdict files, add new strings to the existing files rather than asking the user to migrate.

A project can use multiple String Catalogs and route strings to a specific one with the tableName parameter — useful when it makes sense to keep groups of strings separate (e.g., per feature or module).

Text("Explore", tableName: "Navigation",
     comment: "Tab bar item title for the Explore screen.")

Bundle for Swift Packages and Frameworks

Apps, app extensions, and XPC services are their own main bundle, so the bundle parameter can be omitted. Frameworks and Swift packages need an explicit bundle; without one, SwiftUI looks up strings from Bundle.main and the lookup fails silently — the string appears unlocalized at runtime.

// AVOID: Inside a framework or Swift package, this searches the app's catalog.
Text("Save to Favorites")
// PREFER: #bundle resolves to the current target's bundle.
Text("Save to Favorites", bundle: #bundle,
     comment: "Button to bookmark a recipe.")

#bundle is the preferred form; Bundle.module and Bundle(for: MyClass.self) work but are older patterns.

SwiftUI Views Localize String Literals Automatically

SwiftUI initializers that accept LocalizedStringKey (e.g., Text, Button, .navigationTitle) automatically treat string literals as localization keys. Do not wrap literals in NSLocalizedString, String(localized:), or LocalizedStringResource.

// AVOID: Text already treats literals as LocalizedStringKey; wrapping
// also resolves the string eagerly, ignoring \.locale overrides.
Text(NSLocalizedString("start_workout", comment: ""))
Text(String(localized: "start_workout"))
// PREFER: Pass the string literal directly.
Text("start_workout")

Both opaque keys ("start_workout") and natural-language strings ("Start Workout") work as LocalizedStringKey values. Choose whichever convention the project uses consistently — with opaque keys, the source-language text is set in the String Catalog directly, not at the call site.

Use Text(verbatim:) to opt out of localization for a string literal — most often a debug label that interpolates a runtime value (e.g., Text(verbatim: "Session: \(sessionID)")), where the literal would otherwise be treated as a localization key. When the argument is already a String variable, Text(value) calls the StringProtocol overload and skips localization on its own — no verbatim: needed.

Localizing Variables and Custom Types

When a String variable is passed to Text, the StringProtocol overload runs and the string is NOT localized. Wrapping the variable in LocalizedStringKey(_:) at the call site does not help either — Xcode cannot extract a literal from a runtime value, so the entry never lands in the catalog. To localize a value chosen from a known set of keys, model the set with a type that exposes LocalizedStringResource:

enum Category {
    case appetizers, mains, desserts
    var name: LocalizedStringResource {
        switch self {
        case .appetizers: "Appetizers"
        case .mains: "Mains"
        case .desserts: "Desserts"
        }
    }
}

Text(category.name)

When a view or view model exposes user-facing text, type the property as LocalizedStringKey or LocalizedStringResource instead of String. Every SwiftUI view that takes localized text accepts both, so deferring resolution costs nothing at the display site and preserves locale and bundle context end-to-end.

// AVOID: String properties lose localization context.
struct SectionHeader {
    let title: String
}
// PREFER: LocalizedStringResource keeps the string localizable.
struct SectionHeader {
    let title: LocalizedStringResource
}

String Interpolation vs Concatenation

String interpolation preserves LocalizedStringKey and produces a format string in the catalog (e.g., "Welcome, %@"). Concatenation with + produces a String — the result is not localized.

// AVOID: + produces String, not LocalizedStringKey. Not localized.
Text("Error: " + statusMessage)
// PREFER: Interpolation preserves LocalizedStringKey.
Text("Error: \(statusMessage)")

Never glue separately localized fragments to form a sentence — word order varies across languages.

// AVOID: Sentence assembly breaks in languages with different word order.
Text(String(localized: "Created by")) + Text(" ") + Text(authorName)
// PREFER: A single string lets translators rearrange the structure.
Text("Created by \(authorName)")

Casing

Bake the desired case into the string itself rather than transforming case at runtime via .textCase(_:), .localizedUppercase, or .localizedCapitalized. A runtime transform forces the same casing decision across all translations, leaving translators no way to adjust per language.

// AVOID: forces the same casing on every translation.
Text("Section Header").textCase(.uppercase)

// PREFER: provide the desired case in the string itself.
Text("SECTION HEADER")

This applies to localized strings. Strings the user typed in should display as-is; you don't know what casing they intended. If a transform is unavoidable, prefer .localizedUppercase / .localizedCapitalized, which honor the user's locale (Turkish dotted/dotless I, German ß, etc.).

Formatting Dates, Numbers, and Currencies

Use Text's format parameter or .formatted() instead of DateFormatter or NumberFormatter with hardcoded format strings. Format styles adapt to the user's locale; hardcoded format strings do not. These overloads localize through the format style — they're not a bypass of localization, and the value itself doesn't produce a catalog entry. When the value is interpolated into a localized literal (e.g., "Total: \(price, format: ...)"), the surrounding literal still accepts a comment: as usual.

// AVOID: Hardcoded format does not adapt to locale.
let formatter = DateFormatter()
formatter.dateFormat = "MM/dd/yyyy"
Text(formatter.string(from: workout.date))
// PREFER: Format styles adapt to the user's locale automatically.
Text(workout.date, format: .dateTime.month().day().year())

Date field components (.month(), .day(), .year()) enable which fields appear; the locale determines output order — the chain order doesn't lock layout.

// AVOID: Hardcoded currency formatting.
Text("$\(product.price, specifier: "%.2f")")
// PREFER
Text(product.price, format: .currency(code: store.currencyCode))

For lists of strings, Array.formatted() inserts locale-correct separators and conjunctions instead of a hardcoded joined(separator: ", ").

// AVOID
Text("Order: \(items.joined(separator: ", "))")
// PREFER
Text("Order: \(items.formatted())")

When DateFormatter is genuinely unavoidable, use setLocalizedDateFormatFromTemplate(_:) rather than assigning dateFormat directly — the template reorders fields per locale.

Layout for Localization

Use .leading and .trailing instead of .left and .right — they flip for right-to-left locales; .left and .right don't.

// AVOID: .left does not flip for RTL languages.
Text(recipe.title)
    .frame(maxWidth: .infinity, alignment: .left)
// PREFER: .leading flips to the trailing edge in RTL locales.
Text(recipe.title)
    .frame(maxWidth: .infinity, alignment: .leading)

Do not hardcode frame widths or heights for text — translations vary in length and scripts vary in height. Use ViewThatFits when a layout might not fit longer translations.

// PREFER: ViewThatFits picks the first layout that fits.
ViewThatFits {
    HStack { actionButtons }
    VStack { actionButtons }
}

Use SwiftUI's text styles instead of fixed point sizes. Text styles let line height adapt per script; fixed point sizes can clip glyphs in tall scripts.

// AVOID: fixed point size locks line height.
Text("Welcome").font(.system(size: 17))

// PREFER: text styles let line height adapt per script.
Text("Welcome").font(.body)

Reading the Current Locale

Use @Environment(\.locale) instead of Locale.current for locale-dependent logic in views — the environment respects preview overrides and per-view injection; Locale.current does not.

String(localized:) Outside SwiftUI Views

When you need a localized String outside of SwiftUI views, use String(localized:), not NSLocalizedString.

// AVOID
let title = NSLocalizedString("activity_summary", comment: "Dashboard header")
// PREFER
let title = String(localized: "activity_summary", comment: "Dashboard header")

Do not interpolate inside NSLocalizedString — Xcode extracts keys from literal strings at build time and cannot extract interpolated values. Use String(localized:) with interpolation instead; Xcode extracts the format string (e.g., "reminder_body %@") and treats interpolated values as runtime arguments.

Prefer String(localized:) over String(format:) and String.localizedStringWithFormat. String(format:) always renders digits as 0–9 regardless of locale and is unsuitable for user-facing text; String.localizedStringWithFormat works when paired with NSLocalizedString, but String(localized:) is the modern API and the right default.

LocalizedStringResource for Non-View Types

When a non-view type carries a user-facing string — a model object, a tip, a queued notification — use LocalizedStringResource instead of String. The string is resolved at display time, not creation time, so it honors the locale active when the value actually renders. Whenever a String would otherwise be passed between view models, modules, or into a view, LocalizedStringResource is the right type. Apply this when designing new types or changing user-facing text — don't sweep through existing String properties as part of unrelated edits.

// AVOID: Resolving at creation time loses the ability to display
// in a different locale later.
struct Tip {
    let headline: String
}
let tip = Tip(headline: String(localized: "Tip of the Day"))
// PREFER: LocalizedStringResource defers resolution to display time.
struct Tip {
    let headline: LocalizedStringResource
}
let tip = Tip(headline: "Tip of the Day")

Comments for Translators

Add a comment describing the UI element and its purpose, especially for ambiguous strings. For interpolated strings, describe each placeholder by position — translators don't see Swift variable names.

// AVOID: "Edit" could be a noun or a verb — different translations.
Text("Edit")
// PREFER
Text("Edit", comment: "Toolbar button that enters editing mode for the list.")
// PREFER: refer to placeholders by position, not by Swift name.
Text("Completed \(count) of \(count)",
     comment: "Progress label — the first variable is finished items, the second is the total.")

Comments can also live in the String Catalog (per-string Comment field), equivalent to passing comment: at the call site — keep one source of truth per string.


End of Localization Super-Prompt v4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment