[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-post-localizing-a-swiftui-app-the-translating-was-the-easy-part":3},{"content":4,"created_at":5,"description":6,"id":7,"keywords":8,"reading_time":12,"slug":13,"title":14,"updated_at":15,"ok":16},"# Localizing a SwiftUI app: the translating was the easy part.\n\nOne subscription, one screen, two prices: `19,99 $` on the card at the top and `19.99 $` in the row right below it. Same number, two decimal separators. One went through Foundation's formatter, the other through a `String(format:)` written back when the app only spoke English. Every test was green.\n\nThat's localization in one screenshot. The text itself is the easy part now: AI agents translated all of Recurred in a few hours, from two JSON files and a glossary. The hard part is all the code that quietly assumed English without ever saying so.\n\n[Recurred](https:\u002F\u002Fgozman.space\u002Fapps\u002Frecurred) shipped in English in July. Version 1.5.0 went out on 25 September with the first nine languages: English, German, Spanish, French, Italian, Brazilian Portuguese, Russian, Ukrainian and Hindi. About 1,000 strings in the app and 80 in the widgets, each in every language, with every plural form each language needs. From the first line of the plan to the App Store took about a week.\n\nMost of that week went to things nobody warned me about. Enums that turned out to be database keys. Strings Xcode can't see. Tests that were green while checking nothing. And Hindi, which broke twice: once because of a letter-spacing modifier, and once because Apple's on-device model won't run in it.\n\nI wrote about [shipping Recurred in the first place](https:\u002F\u002Fgozman.space\u002Fblog\u002Frecurred-shipping-my-first-swiftui-app) a couple of months ago. This one is narrower: a how-to and a list of mistakes, mostly mine.\n\n## The goal: localization like a website.\n\nWhat I wanted, written at the top of the plan before any code:\n>As simple as localization on websites. Just a set of files I can give to AI to translate and ship without verification.\n\n\"Without verification\" is the dangerous part. You only get to skip checking every screen in every language if the layout doesn't care how long a string is. So most of the work turned out to be making the app **immune to string length**, plus tests that catch the places where it isn't.\n\nPretty much everything below comes from that one sentence.\n\n## Translation happens on my Mac, not on your phone.\n\nThe first decision, and the one I'm most sure about: there's no runtime translation in this app.\n\nApple ships two runtime options. The `Translation` framework downloads language packs and covers a limited set of pairs. `FoundationModels` needs iOS 26 and Apple Intelligence hardware, which rules out every device at my iOS 18 floor and a lot of the EU. Recurred already uses `FoundationModels` for its \"describe your subscriptions in words\" import, so it's tempting to reach for it again.\n\nDon't. A String Catalog (`.xcstrings`) is compiled into the app at build time. An iPhone on iOS 18 with no on-device model reads the same table as the newest hardware. There's nothing to download and nothing to check for. And a `.xcstrings` file is plain JSON, which happens to be the \"set of files I can give to AI\" I asked for.\n\nI ended up with two catalogs, one per target: the app and the widget extension. The widget runs in its own process and resolves strings against its own bundle, so a shared string has to be translated in both. A test compares the two. Otherwise a key translated in one and not the other shows English in the widget forever, and the widget is the one place nobody looks.\n\n## English first, alone.\n\nThe order I shipped in:\n1. English only. Move every string into the catalog. Nothing changes for users.\n2. German, because it has the longest strings and stress-tests layout hardest.\n3. The other seven at once.\n\nStep one matters most. It changes nothing a user can see, so it can ship the day it's green, and every language after it becomes a data change. Do extraction and nine languages in one go and you find every bug at the end, nine times over.\n\nGerman landed a day after the English milestone. The other seven took two more days.\n\n## Your enums are database keys.\n\nThis was the first landmine, and it doesn't crash or warn. Recurred has enums like this:\n\n```swift\nenum Category: String, Codable {\n    case entertainment = \"Entertainment\"\n    case devTools = \"Dev tools\"\n    \u002F\u002F ...\n}\n```\n\nThat `rawValue` was doing four jobs. It was stored in SwiftData and CloudKit. It was shown on screen. It went into the on-device AI prompt as a hint. And it was indexed for icon search.\nLocalize it in place and you orphan every row already saved on every user's phone, and break iCloud sync while you're at it. So every enum like this got two accessors next to its frozen `rawValue`:\n- `displayName`, localized, for the UI only.\n- `promptTerm`, always English. Small on-device models follow English instructions best, and a Russian category hint makes matching worse.\n\nThe `rawValue` itself is the storage key and is never shown or translated. One test pins every `rawValue` literally, and another scans `Views\u002F` for a `rawValue` ending up in `Text`, `Label` or `accessibilityLabel`.\n\nThere was a sneakier version too. Home recovered a category from a group header like this:\n\n```swift\nCategory.allCases.first { $0.rawValue == group.title }\n```\n\nLocalize the title and that returns `nil`, and the group header silently loses its colour. A grouping key should carry the enum, not its rendered title. Same reason a chart's `id:` stays the `rawValue` while only the label localizes: if the id changes with the language, every animation restarts.\n\n## Half the strings aren't in `Text(\"…\")`.\n\nThe obvious way to size the job is to grep for `Text(\"`. That found about 480 sites.\n\nIt missed a whole second population: display text returned as a plain `String` from models and services. `\"Expired on …\"`, `\"Trial ends today\"`, `\"Lifetime\"`, notification bodies, exchange-rate errors. The plan guessed about 300 of those. The real inventory came back at 1,132 entries across 83 files, and almost 400 of the `String`-returning ones were sitting in `Views\u002F`, where nobody had counted them.\n\nThat's the harder half. Those strings need `String(localized:)`, or `LocalizedStringResource` when resolution has to wait (widget configuration, notifications). They hold almost all the plurals, and VoiceOver reads a lot of them aloud.\n\nGenerated symbols helped here. With `STRING_CATALOG_GENERATE_SYMBOLS` on, a key with arguments becomes a static function, so `String(localized: .renewsInDays(days))` gets checked by the compiler. One gotcha: you can't guess the symbol name from the key. `l10n.smoke.test` generates `.l10NSmokeTest`, not `.l10nSmokeTest`. Let the compiler tell you.\n\n## A key is a context, not a string.\n\nThe catalog has about 1,000 keys and around 830 distinct English values. So roughly 170 keys look like duplicates: \"Done\" ten times, \"Cancel\" eight times. Merging them is very tempting.\n\nDon't. With just German added, eight of those shared values already translated differently. Save is Sichern on a button and Sparen when it means saving money. Lifetime is Einmalig as a billing period and Einmalkauf as a purchase type. \"Every N months\" splits on grammatical gender. Russian and Ukrainian inflect by case, so that number only goes up.\n\nKeys are stable paths like `home.upnext.title`, never the English text. If the key is the text, a copy edit quietly creates a new key and leaves the old translation behind.\n\n**Never concatenate localized text, and never do grammar in Swift.** This one got me more than anything else. Code like this:\n\n```swift\nif days == 1 { return \"Tomorrow\" } else { return \"In \\(days) days\" }\n```\n\nis the English plural rule, written in Swift. Russian needs день, дня and дней, and that branch reads wrong at 2, 5 and 21 whatever the translator does. The fix is one key with plural variations, and deleting the branch.\n\nThe same bug turned up in a few other shapes. A savings line was three `Text`s in an `HStack` (\"Save\", an amount, \"a year\"), which no translator can reorder. It's one format string now. The Add screen's title was \"Add %@\" with a subscription type dropped in, so German, Russian and Ukrainian got a nominative noun where the grammar wanted an accusative. Three reviewers flagged that one separately, which is usually a sign the problem is in the code. And somewhere the code lowercased a localized noun and appended `\"s\"` to make it plural. English grammar, applied to German.\n\n## Foundation already speaks every language.\n\nA lot of strings shouldn't be in the catalog at all.\n\n`Currency` used to hardcode about 150 English currency names. `Locale.current.localizedString(forCurrencyCode:)`has all of them in every language, for free. Same for language names. That's 150 strings times eight languages I didn't have to translate.\nDates were sneakier. Four cached formatters used literal patterns like `\"MMM d, yyyy\"`. That translates the month name but keeps US order, so a German user got `Jan. 5, 2026` instead of `5. Jan. 2026`. `setLocalizedDateFormatFromTemplate(\"MMMdy\")` lets the locale pick the order.\n\nNumbers had the worst one, the two prices from the top of this post. Sixteen places built money with `String(format: \"%.2f\")`, which hardcodes the US decimal point and skips grouping. No test could see it, because the literal never touches the catalog and it fits its slot fine. They all go through the shared cached formatters now.\n\nPercentages have the same problem. German and French write `42 %` with a space, Turkish writes `%42`. If the `%` sign lives in your Swift code, no translation can fix it. Use `.percent` and pass a fraction.\n\nA few more only showed up once real languages were on screen.\nCompact amounts like `1.1K` were built by gluing a suffix onto a number. Russian wants `1,1 тыс.` with a space, and it turns out English is the odd one here: almost every language's CLDR pattern has the space. The suffix is a pattern with a placeholder now.\n\nHindi doesn't count in millions. The app already printed Indian grouping, `₹12,00,000`, and then labelled the same number `1.2M`. Hindi uses हज़ार, लाख (100,000) and करोड़ (10 million). The fix reads Indian numbering off the formatter itself (`secondaryGroupingSize == 2`) instead of the language code, so it follows the region and should cover Bengali or Marathi whenever they show up.\n\nAnd dates used as titles. Foundation returns `сентябрь 2026 г.` and `septiembre de 2026` in lowercase, which is right in the middle of a sentence and wrong as a calendar header. Six of the nine languages had it. The fix capitalizes the first character of the whole string rather than \"the month\", because `21 сентября` starts with a digit and has to stay lowercase.\n\n## Make length not matter.\n\nThis is the actual core of \"ship without verification\". A test tells you a translation broke a screen. A layout policy keeps it from breaking in the first place. Mine, in order of preference:\n1. Wrap. Growing vertically is the only way to degrade without losing anything.\n2. `ViewThatFits` over whole rows. It measures instead of guessing, so it works at widths no device has yet. Give it whole rows, not one label inside an `HStack`, or it picks the long variant and then gets squeezed.\n3. A hard line limit, only where the height is fixed for a reason (a widget card, a calendar cell), with a comment saying what breaks without it.\n4. Shrinking the font isn't on the list. `minimumScaleFactor` is a number tuned for one width, and on the Mac the window resizes all the time.\n\n**Text wraps, rows reflow, nothing shrinks.**\nSome places can't wrap: widget rows, chips, badges, segmented controls. Those keys get a `tight.` prefix and a character budget the translator knows about up front, 1.35× the English length. I had to fix the budget twice. A ratio is wrong for short words: \"Keep\" got 5 characters and German's \"Behalten\" needs 8, so the budget got a floor. Then on the non-tight side it started blocking correct translations by one to three characters, on full-width rows with plenty of room. At that point the budget was making translations worse. The fitting tests already know whether a string fits, so the budget now only catches a translation that has clearly run away.\n\nWhen a string still didn't fit, the fix was usually the English. A donut chart said \"**12** subs\" in the middle because the hole was small, and that pushed Spanish, Portuguese, French, Italian and Hindi into abbreviating their word for \"subscription\". Making the chart a bit bigger let English say \"subscriptions\" and fixed five languages at once, plus whatever languages come next.\n\n## A fitting test that runs without a simulator.\n\nChecking layout usually means screenshots. Nine languages, a few devices, a few text sizes: that's a lot of PNGs, and nobody reads them.\n\nFitting is just geometry, though, and iOS 18 lets you measure it headlessly. `TextRenderer` gets called with the real laid-out lines and their widths, and `ImageRenderer` forces a layout pass with no simulator UI. Put them together:\n\n```swift\nstruct FitProbe: TextRenderer {\n    let report: FitReport\n    let available: CGFloat\n\n    func draw(layout: Text.Layout, in context: inout GraphicsContext) {\n        report.note(lines: layout.count)\n        for line in layout {\n            report.note(lineWidth: line.typographicBounds.rect.width, available: available)\n            context.draw(line)\n        }\n    }\n}\n```\n\nAttach it at the root of a screen (`.textRenderer` reaches every `Text` below it), render at some width, and you know if any line overflowed. The matrix runs every screen at 10 widths, 4 text sizes and 9 languages. Two of the widths match no current device, a 300pt folded one and a 960pt unfolded one, because sooner or later a new form factor arrives with a width nobody tested. A real iPhone Duo canvas got added later, so that bet more or less paid off.\n\nIt only writes a PNG when something fails. A green run writes nothing, and a red run gives you exactly the images worth opening.\n\nIt has limits, and I hit three of them. `Text.Layout` can't give you back the string it measured, only geometry, so the harness reports the screen, width, size and language instead. A `NavigationStack` inside `ImageRenderer` crashes the test process, so navigation titles never get measured. Worse, a `List`, `Form` or `.searchable` screen lays out zero text headlessly, so Settings and Search rendered nothing and passed every time. The suite now fails any screen that measured zero lines. And it measures each line against its own frame, so a label truncated in a narrow column reports a smaller width than its string and looks fine. The Add sheet's category column was 210pt where six translations needed up to 232, and its lazy grids don't render any cells headlessly at all. Screenshots caught that one.\n\n## Green tests that checked nothing.\n\nIf I could send one section back to myself before starting, it's this one. A localization test's natural state is passing while measuring nothing. I found five of them.\n\nThe fitting matrix was measuring English. It set the language with `.environment(\\.locale, …)`, which reaches `Text` but not `String(localized:)`, and about five hundred strings resolve against the process language instead. A long German test string measured 529pt one way and 104pt (English) the other, on the same screen. Now each language runs in its own test process, launched with `-testLanguage`.\n\nThe catalog parser couldn't see plurals. It read the top-level `stringUnit`, and plural strings don't have one. About 10% of the catalog was invisible to the length budget, the truncation check and the \"nothing left in review\" check, including the plural that shows \"4 charges\" on the smallest widget.\nThe width check pointed at five keys that didn't exist, so it checked an empty set and passed.\n\nThe pseudolanguage sweep never ran. `make pseudo` launched under `en-XA`, Xcode's accented pseudolanguage, which is supposed to make unextracted strings stand out. Nothing in the build creates an `en-XA.lproj`, though, so iOS fell back to English and an unextracted string looked exactly like a translated one. The version that works runs in English with `-NSShowNonLocalizedStrings YES`, which shows any string that misses its catalog lookup in ALL CAPS. I checked it could fail by planting a fake key and watching `ZZZ.CONTROL.UNEXTRACTED` appear.\n\nAnd every failure screenshot was 126 bytes. Above 8,192px, `ImageRenderer.cgImage` returns an image with no pixel data, so ImageIO wrote a PNG header and nothing else.\nNone of these failed loudly. **Every gate needs a second test that proves it's looking at something real**: a planted failure, a non-zero count, a check that the set isn't empty.\n\n## Reading the app finds a different kind of bug.\n\nThe automated checks cover completeness, plurals, length and fit. None of them can tell you whether a string reads right.\n\nSo after translation there was a second pass: one AI reviewer per language, separate from the agent that translated it, reading all ~1,080 entries against the English and the translator notes. I read the languages I know myself. A native speaker reading every language would be the proper way to do the rest. For a free app I can't afford that, and the AI review turned out to be good enough for most of what matters. It caught things no test can:\n- Spanish and Hindi used the app's own word for Delete to mean \"deselect\", on the screens where you cancel subscriptions.\n- German called a free trial a `Test`, which is an exam.\n- The Ukrainian Optimizer screens advertised «стеження», surveillance, right after onboarding promised «Без стеження».\n- Italian spoke as \"io\" in a few system strings, and Italian marks gender there.\n\nWhat worked: give each reviewer one flat document per language (key, comment, English, translation) instead of the 2 MB catalog. Apply the fixes at the JSON level with an ordered dict, so Xcode's formatting round-trips byte for byte. And check the current value before writing each fix. That check caught eight fixes aimed at the wrong key before any of them reached the file.\n\nThen I read screenshots, which found yet another kind of bug. A catalog review sees wording but not placement:\n- `.kerning` breaks Devanagari. Letter-spacing on the small heading labels pulled apart the line that connects Hindi letters, and the headings came out as loose fragments. Spacing is chosen by the script now.\n- A destructive button truncated to \"Ausgewählte kün…\" in three languages because it shared the row 50\u002F50 with \"Back\".\n- The timeline chart clipped its first month label in every language. English lost 1.3pt and nobody noticed. Russian `Июнь` lost 6pt and read as `Люнь`.\n- The Mac sidebar cut off \"Оптимизат…\". SwiftUI's column width modifier didn't reach a `.sidebarAdaptable` tab view, so that fix went through AppKit.\n\nEach of the three checks has a blind spot the others cover. The catalog review can't see a string that never made it into the catalog. The non-localized strings sweep can't see a literal with no letters in it, so `\"70–100%\"` sailed through. The fitting matrix can't see a truncated label. I needed all three.\n\n## Product names in other alphabets.\n\nThe glossary said `Optimizer` is a product name, so keep it as is. That's fine in German, Latin letters next to Latin letters. In Russian it gave `Optimizer подписок`, a Latin word in a Cyrillic tab bar. In Hindi, `सब्सक्रिप्शन Optimizer`.\n\nSo the rule depends on the script now. Latin-alphabet languages keep `Optimizer`. Other scripts transliterate it (Оптимизатор, Оптимізатор, ऑप्टिमाइज़र) and decline it like any other noun. The test classifies languages by the script it measures in their strings, not by a list of codes, so a tenth language gets the right rule automatically.\n\nRelated: formal or informal (vous or tu, вы or ты) is a product decision you want to make before the first string is translated, because it ends up in a thousand strings and is painful to undo. I followed whatever Apple's own platform does in each language instead of forcing one house voice.\n\n## The language setting is a link, not a picker.\n\nThe default needs no code: iOS picks the app language from the system language. For letting people change it, the common SwiftUI trick is a locale manager that writes `\\.locale` into the environment. That reaches `Text` and that's it. Not `String(localized:)`, not the widget's separate process, not a notification that fires three months later.\n\niOS already has the right screen. Once an app declares two or more languages, Settings → Recurred gets a Preferred Language row. My settings screen got one row that links there.\nThe link didn't work at first. `app-settings:` is resolved against the app that opens it, and SwiftUI's `openURL` doesn't pass that along, so every settings link in the app opened the root of Settings. Only `UIApplication.shared.open` lands on the app's own page. My own `CLAUDE.md` had been telling agents to use `openURL`.\n\nI also skipped a first-launch language prompt. If the only way to change language is during onboarding, one wrong tap leaves you stuck. Apple's own apps don't ask either.\n\n## Region is not language.\n\nThe only negative feedback the localized release has gotten so far wasn't about a translation. It was about money.\n\nRecurred guesses your base currency from the device region, not the language. A Ukrainian user wrote in, upset that the app spoke Ukrainian to him and then showed everything in Russian rubles. He took it personally. The funny part is that his device region was set to Russia. The app did what his phone told it to.\n\nI kept the behaviour. Language tells you what someone reads; region is a better guess for what they pay in, and it's right most of the time. The alternatives are worse. Guessing from language is wrong for every Spanish speaker outside Spain, and USD isn't the neutral default some people think it is. So the guess only sets a starting value, and onboarding asks for the base currency anyway.\nAnything you derive from locale is a guess about a person, and sometimes it's a guess they won't like. I'd rather show it clearly, make it easy to change, and ask when it matters.\n\n## Notifications freeze their language.\n\nRecurred schedules repeating notifications that keep firing for months without the app opening. Build the text in English, switch the phone to Russian, and the reminders stay English until something reschedules them.\nThe iOS answer is `NSString.localizedUserNotificationString(forKey:arguments:)`, which looks the string up at delivery time. It doesn't exist on macOS, and the notification code is shared between the two. That fix passes `make build`and fails `make build-mac`.\n\nWhat works on both: stamp the schedule with the language it was built in. On launch, if the app's language doesn't match the stamp, rebuild everything and skip the usual six-hour throttle.\n\n## Input has no language.\n\nDisplay strings follow the locale. Input doesn't. A CSV export from a Russian bank has Russian headers and no locale metadata. A Russian speaker on an English phone types Russian.\nSo the import vocabulary (column names, cadence words, category names) is a union of all nine languages at once, and it lives in Swift tables on purpose. A catalog lookup returns one language and would break the other eight.\n\nWorking on this turned up two bugs that were there before localization. The CSV parser knew `MM\u002Fdd\u002Fyyyy` but not `dd\u002FMM\u002Fyyyy`, so a European `03\u002F07\u002F2026` became 3 March. It gets better: `DateFormatter` ignores separators, so `dd.MM.yyyy` will happily parse `03\u002F07\u002F2026`. The parser reads the separator itself now. And CSV dates were parsed at UTC midnight and then read back in the local calendar, so every import west of Greenwich landed a day early. Nobody had reported that one.\n\nParsing \"every 2 months\" got lead words in nine languages (`раз в`, `tous les`, `alle`, `हर`), and later a table of number words (две, zwei, dos). Spanish once (eleven) is left out on purpose, because English \"once monthly\" would otherwise parse as every eleven months.\n\nTwo small text details. German typists often write `ae` for `ä`, and folding `vierteljährlich` gives `vierteljahrlich`, not `vierteljaehrlich`, so those spellings need their own entries. And Devanagari must never be diacritic-folded. Folding strips the vowel signs and merges different words, काम (work) into कम (less). For Russian, folding ё into е is exactly what you want.\n\n## The on-device model won't run in Hindi.\n\nRecurred's \"describe your subscriptions in words\" import runs on Apple's on-device model through `FoundationModels`. Under a Hindi app language, it doesn't run at all.\n\nThe first sign was a Hindi user staring at a greyed-out card that said \"Preparing on-device intelligence…\". That's a progress message, and this was never going to finish. On iOS 27 under Hindi, `SystemLanguageModel` reports `.modelNotReady` and a real session fails, whatever the region. I tried every combination I could think of: `hi`, `hi` with English as a fallback, `hi-IN` with `en-IN`. All dead. The model follows the **app's** language, not the device's, so being on an English phone doesn't help either.\nThe odd part is that it doesn't look like a capability problem. Russian isn't on Apple's supported list either, and it runs fine. macOS 27 runs Hindi. And the model reads Hindi text perfectly well when the app is in English: I typed a Hindi paragraph into an English (India) app and got 4 of 4 subscriptions back, rupees included. My guess is iOS just doesn't load the model for that locale yet. There's no lever to pull, either: `SystemLanguageModel` takes no locale parameter, and `supportsLocale()` only reports.\n\nSo the fix is a bit of a hack. The card no longer pretends something is loading. It says the feature isn't available in this language, explains that switching the app to English makes it work and that you can keep writing in Hindi, and opens the app's settings page where the language row lives. iOS relaunches the app after that change, which re-checks availability. The App Store screenshots for Hindi do the same thing: the app runs in English (India) and the text typed into it is Hindi.\n\nThe obvious alternative was plugging in an external LLM for languages Apple doesn't cover. I didn't want to. The whole point of that feature is that your subscriptions never leave the phone, and a free app with no account has no business shipping your text to someone's server. An honest \"not in this language yet, here's the workaround\" is the better deal. If Apple adds Hindi, the check is Apple's own `supportsLocale()`, so the card should start working without an update.\n\n## The test infrastructure tax.\n\nPart of the week went to tooling problems that have nothing to do with translation and only show up once there are nine languages.\nUI tests found elements by their English label. In German, 9 of 13 flows failed before taking a single screenshot. Everything is queried by `accessibilityIdentifier` now. Two catches there: a date cell or weekday header matched by its formatted text breaks, because the app and the test runner format dates in different locales. And SwiftUI sometimes puts the same identifier on two nested elements, so you need `.firstMatch`, or the query comes back ambiguous, which looks a lot like missing.\n\nAfter each pass, xcodebuild tried to collect simulator diagnostics and could hang for its full ten-minute timeout, after the tests had already passed. Across nine languages that's 90 minutes of nothing. With `-collect-test-diagnostics never`the whole nine-language fitting matrix runs in 4.5 minutes.\n\n`-AppleLanguages` sets the language but not the region. Every screenshot kept my Mac's Georgian region, so Hindi's lakh and crore code never ran. Pass `-AppleLocale` too.\n\n`-testLanguage` sticks to the simulator. One sweep left the shared test simulator in `hi_IN`, and the next morning 119 unrelated unit tests failed. A sweep that changes language now gets its own simulator, and so does every agent running in parallel. Agents sharing one simulator produced a test crash that looked a lot like a real bug, until I ran the same command without my changes and got the same failure.\n\n## Screenshots: from hours to minutes.\n\nNine languages means nine sets of App Store screenshots for iPhone, iPad and Mac. My first sweep took two to three hours for the latest-iOS iPhone and iPad passes plus the Mac build, with UI tests tapping through the app in every language.\n\nVery little of that time was taking screenshots. Each pass relaunched the app ten times, waited for live exchange rates from the network every time, slept between taps, and marked Optimizer usage one day at a time. And the App Store deck uses 20 screenshots per locale, while I was taking 52.\n\nThe new path has three parts. Exchange rates come from a fixture (`-uiFixedRates`), so there's no network and prices are identical across every run and language. Scenes (`-uiScene optimizer-summary`) are a debug-only launch argument that opens the app straight into the state a screenshot needs, through the same routes widgets and notifications use. The destination screen raises a `scene.ready` marker once its content is there, or `scene.failed` with a reason if it can't get there, so it never quietly shoots the wrong screen. And the list of captures comes from the deck itself, so the runner takes only what the slides use and fails by name on a slide with no scene.\n\nThe navigation-based tests stay as the integration suite that proves you can reach each screen. The store path just stops proving it 180 times in a row.\n\nThe same set now takes about fifteen minutes, build included. I never timed either run properly, but you don't need a stopwatch to tell two hours from fifteen minutes.\n\n## How AI fit into this.\n\nPeople always ask, so: AI agents did the translation, one agent per language in its own git worktree, working directly on the JSON with the glossary and the budgets. Separate AI reviewers read every language, and I read the ones I speak. No paid translators. Most of the code changes were also agents, working through a plan with a dependency graph in waves.\n\nThe agents were the easy part to get right. What took thought was around them. The plan wrote down the landmines before anyone touched code; the enum one alone would have corrupted user data. I only trusted gates I'd seen fail. Decisions stayed with me: raising a budget, adding an exemption, relaxing a rule. An agent told to make a failing test pass will happily do any of those. Catalogs got merged at the JSON level, never with git, because seven branches that each touch all thousand entries conflict on every single one. And agents committed early. Rate limits killed five of them mid-run, and the ones that had committed lost nothing.\n\nThe plan itself had bugs too, and the agents caught them. One test asserted the wrong set of billing cycles; \"fixing\" it by deleting the missing case would have orphaned every daily subscription. Another read `cgImage` twice and doubled every count. Both turned up because every agent had to watch its test fail before making it pass.\n\nIf there's one opinion I'd take from this, it's the same one I ended up with in the [Temporal post](https:\u002F\u002Fgozman.space\u002Fblog\u002Ftemporal-workflows-in-golang-the-three-things-that-bite-in-production): a deterministic check beats an AI judgment every time you can have one. The AI translated, reviewed and wrote most of the code, and it was good at all three. But the reason I'm comfortable shipping eight languages I can't fully read is the boring part: a fitting matrix, a plural check and a budget that give the same answer on every run. The AI was most useful building those, not replacing them.\n\n## Wrapping up.\n\nIf you're about to localize an app, start with the release nobody will notice: every string moved into the catalog, English only. It's the one that makes every language after it cheap. Before you translate anything, go looking for the places where your code speaks English without saying so: the persisted `rawValue`, the `String` returned from a model, the `if count == 1`, the `String(format:)` and the hand-typed `%`. Then make length stop mattering, with wrapping rows and a fitting test that runs without a simulator.\n\nAnd distrust every green test until you've seen it go red. Mine stayed green while measuring English, an empty set of keys and 126-byte screenshots. The translation was never the risky part. The tests telling me it was fine were.\n\nThe translating took hours and the plumbing took the week. The plumbing is done now, so new languages are mostly a data change, and more are coming.\n- Recurred: [gozman.space\u002Fapps\u002Frecurred](https:\u002F\u002Fgozman.space\u002Fapps\u002Frecurred)\n- App Store: [apps.apple.com\u002Fapp\u002Fid6783705092](https:\u002F\u002Fapps.apple.com\u002Fapp\u002Fid6783705092)\n\nSee you in the next post 👾\nSources:\n- Localizing and varying text with a string catalog: [developer.apple.com](https:\u002F\u002Fdeveloper.apple.com\u002Fdocumentation\u002Fxcode\u002Flocalizing-and-varying-text-with-a-string-catalog)\n- TextRenderer: [developer.apple.com](https:\u002F\u002Fdeveloper.apple.com\u002Fdocumentation\u002Fswiftui\u002Ftextrenderer)\n- SystemLanguageModel: [developer.apple.com](https:\u002F\u002Fdeveloper.apple.com\u002Fdocumentation\u002Ffoundationmodels\u002Fsystemlanguagemodel)\n- CLDR language plural rules: [unicode.org](https:\u002F\u002Fwww.unicode.org\u002Fcldr\u002Fcharts\u002Flatest\u002Fsupplemental\u002Flanguage_plural_rules.html)","2026-10-08T05:54:38.911009Z","AI agents translated Recurred into its first nine languages in hours. The week around it went to enums that were secretly database keys, strings Xcode can't see, tests that passed while measuring nothing, and Hindi breaking in two unexpected ways. A how-to and a list of mistakes, from extraction to App Store screenshots.",13,[9,10,11],"recurred","swift","apple",1301,"localizing-a-swiftui-app-the-translating-was-the-easy-part","Localizing a SwiftUI app: the translating was the easy part","2026-10-08T06:06:25.726172Z",true]