Localizing a SwiftUI app: the translating was the easy part.
One 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.
That'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.
Recurred 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.
Most 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.
I wrote about shipping Recurred in the first place a couple of months ago. This one is narrower: a how-to and a list of mistakes, mostly mine.
The goal: localization like a website.
What I wanted, written at the top of the plan before any code:
As simple as localization on websites. Just a set of files I can give to AI to translate and ship without verification.
"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.
Pretty much everything below comes from that one sentence.
Translation happens on my Mac, not on your phone.
The first decision, and the one I'm most sure about: there's no runtime translation in this app.
Apple 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.
Don'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.
I 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.
English first, alone.
The order I shipped in:
- English only. Move every string into the catalog. Nothing changes for users.
- German, because it has the longest strings and stress-tests layout hardest.
- The other seven at once.
Step 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.
German landed a day after the English milestone. The other seven took two more days.
Your enums are database keys.
This was the first landmine, and it doesn't crash or warn. Recurred has enums like this:
enum Category: String, Codable {
case entertainment = "Entertainment"
case devTools = "Dev tools"
// ...
}
That 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.
Localize 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:
displayName, localized, for the UI only.promptTerm, always English. Small on-device models follow English instructions best, and a Russian category hint makes matching worse.
The rawValue itself is the storage key and is never shown or translated. One test pins every rawValue literally, and another scans Views/ for a rawValue ending up in Text, Label or accessibilityLabel.
There was a sneakier version too. Home recovered a category from a group header like this:
Category.allCases.first { $0.rawValue == group.title }
Localize 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.
Half the strings aren't in
The obvious way to size the job is to grep for Text(". That found about 480 sites.
It 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/, where nobody had counted them.
That'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.
Generated 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.
A key is a context, not a string.
The 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.
Don'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.
Keys 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.
Never concatenate localized text, and never do grammar in Swift. This one got me more than anything else. Code like this:
if days == 1 { return "Tomorrow" } else { return "In \(days) days" }
is 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.
The same bug turned up in a few other shapes. A savings line was three Texts 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.
Foundation already speaks every language.
A lot of strings shouldn't be in the catalog at all.
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.
Dates 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.
Numbers 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.
Percentages 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.
A few more only showed up once real languages were on screen.
Compact 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.
Hindi 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.
And 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.
Make length not matter.
This 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:
- Wrap. Growing vertically is the only way to degrade without losing anything.
ViewThatFitsover whole rows. It measures instead of guessing, so it works at widths no device has yet. Give it whole rows, not one label inside anHStack, or it picks the long variant and then gets squeezed.- 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.
- Shrinking the font isn't on the list.
minimumScaleFactoris a number tuned for one width, and on the Mac the window resizes all the time.
Text wraps, rows reflow, nothing shrinks.
Some 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.
When 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.
A fitting test that runs without a simulator.
Checking layout usually means screenshots. Nine languages, a few devices, a few text sizes: that's a lot of PNGs, and nobody reads them.
Fitting 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:
struct FitProbe: TextRenderer {
let report: FitReport
let available: CGFloat
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
report.note(lines: layout.count)
for line in layout {
report.note(lineWidth: line.typographicBounds.rect.width, available: available)
context.draw(line)
}
}
}
Attach 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.
It only writes a PNG when something fails. A green run writes nothing, and a red run gives you exactly the images worth opening.
It 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.
Green tests that checked nothing.
If 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.
The 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.
The 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.
The width check pointed at five keys that didn't exist, so it checked an empty set and passed.
The 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.
And 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.
None 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.
Reading the app finds a different kind of bug.
The automated checks cover completeness, plurals, length and fit. None of them can tell you whether a string reads right.
So 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:
- Spanish and Hindi used the app's own word for Delete to mean "deselect", on the screens where you cancel subscriptions.
- German called a free trial a
Test, which is an exam. - The Ukrainian Optimizer screens advertised «стеження», surveillance, right after onboarding promised «Без стеження».
- Italian spoke as "io" in a few system strings, and Italian marks gender there.
What 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.
Then I read screenshots, which found yet another kind of bug. A catalog review sees wording but not placement:
.kerningbreaks 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.- A destructive button truncated to "Ausgewählte kün…" in three languages because it shared the row 50/50 with "Back".
- The timeline chart clipped its first month label in every language. English lost 1.3pt and nobody noticed. Russian
Июньlost 6pt and read asЛюнь. - The Mac sidebar cut off "Оптимизат…". SwiftUI's column width modifier didn't reach a
.sidebarAdaptabletab view, so that fix went through AppKit.
Each 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.
Product names in other alphabets.
The 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.
So 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.
Related: 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.
The language setting is a link, not a picker.
The 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.
iOS 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.
The 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.
I 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.
Region is not language.
The only negative feedback the localized release has gotten so far wasn't about a translation. It was about money.
Recurred 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.
I 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. Anything 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.
Notifications freeze their language.
Recurred 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.
The 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 buildand fails make build-mac.
What 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.
Input has no language.
Display 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. So 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.
Working on this turned up two bugs that were there before localization. The CSV parser knew MM/dd/yyyy but not dd/MM/yyyy, so a European 03/07/2026 became 3 March. It gets better: DateFormatter ignores separators, so dd.MM.yyyy will happily parse 03/07/2026. 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.
Parsing "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.
Two 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.
The on-device model won't run in Hindi.
Recurred'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.
The 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.
The 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.
So 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.
The 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.
The test infrastructure tax.
Part of the week went to tooling problems that have nothing to do with translation and only show up once there are nine languages.
UI 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.
After 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 neverthe whole nine-language fitting matrix runs in 4.5 minutes.
-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.
-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.
Screenshots: from hours to minutes.
Nine 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.
Very 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.
The 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.
The 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.
The 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.
How AI fit into this.
People 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.
The 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.
The 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.
If there's one opinion I'd take from this, it's the same one I ended up with in the Temporal post: 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.
Wrapping up.
If 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.
And 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.
The 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.
- Recurred: gozman.space/apps/recurred
- App Store: apps.apple.com/app/id6783705092
See you in the next post 👾 Sources:
- Localizing and varying text with a string catalog: developer.apple.com
- TextRenderer: developer.apple.com
- SystemLanguageModel: developer.apple.com
- CLDR language plural rules: unicode.org
8 Oct
2026