← all posts

Eight Languages, One False Claim, and a Toggle That Looked Like a Prompt

2026-08-24

Mind Journeys went live on the App Store, which I'd assumed was the finish line and turns out to be the starting gun. Almost everything since has been about the distance between shipping an app and shipping it somewhere — eight storefronts, eight languages, and a listing that has to survive being read by people who don't already know what the app is. Speakit got a quieter fortnight, but a good one: it finally asks learners to write something.

Eight Languages, and Everything the Extractor Can't See

Mind Journeys now ships in German, Japanese, French, Mexican Spanish, Korean, Brazilian Portuguese, Italian and Traditional Chinese. 165 keys in a String Catalog, all eight complete.

Most of the UI came free — SwiftUI's Text literals are already LocalizedStringKey. The actual work was every string the extractor can't see, and each one stayed invisible until a screenshot showed English sitting in the middle of a translated screen. Display names built out of enum rawValues, dressing up storage keys as UI text. The home greeting, assembled as "\(timeOfDay), \(name)" — a comma template that does not survive Japanese or Korean. And journey names, which live in Presets.json rather than in source, so they're translated in the loader and nowhere else: the same card renders bundled journeys and ones you built yourself, and localizing at the display site would cheerfully rename someone's own creation.

The store screenshots were their own project, since SF Compact carries no CJK and the headline compositor couldn't draw ja/ko/zh-Hant at all. Each script resolves its own face now — Heiti TC for Traditional Chinese rather than Hiragino, which is missing 你 and draws shared forms the Japanese way. The solver refuses to print sizes when a glyph is missing, because a missing glyph renders as a full-size empty box and survives review looking deliberate.

Then keyword research told me my premise was slightly wrong. "Binaural beats" is popularity 34 in the US and sits at or near the floor in all eight target markets, so a localized listing can't lead with it the way the US one does — white noise outranks my whole category in four of them. Japan is the glorious exception: バイノーラルビート is popularity 22 at difficulty 23, the best ratio in any market I track, while the English spelling there is a 6. The katakana isn't a nicety.

The Week I Deleted My Own Marketing Copy

The uncomfortable find: screenshot slot 5 said REAL RECORDED SCENES, in English and in every translation. Licenses.md records all seven ambiences as ElevenLabs sound-generation output, stitched from 30-second takes. The claim was false everywhere — including on the English page that was live at the time. Each locale now names scenes the app actually ships, which is both true and better for search: rain, ocean, fire and storm are things people type; provenance claims are not.

The Korean draft carried 자율신경 — autonomic nervous system — the exact phrase the no-efficacy-claims rule names. That's the trap a keyword-led translation walks into: the strongest Japanese term found was 自律神経を整える, a medical claim this app must never make.

Pushing the other way, there's now a Brainwave states screen for people who want more than a tagline. Every description says what a rhythm is, never what it will do for you, plus a note that the ranges are standard but the evidence is early and mixed. The journey lists are computed from Presets.json so they can't go stale, which surfaced something worth saying out loud: no bundled journey touches gamma. The library tops out at 16 Hz, so the screen says so. That honesty note got localized too — machine translation is precisely where "associated with" quietly becomes "improves."

Two Rejections That Were Really About Shape

App Review bounced build 28 under 5.1.2(i). The offending object was a Settings toggle labelled "Share anonymous usage data" — read as a custom App Tracking Transparency prompt. The app doesn't track: no advertising identifier, no attribution SDK, no ad network, PostHog with person profiles and session replay off and no identify() call anywhere. Implementing ATT would mean declaring tracking I don't do. So the toggle now sits under a Privacy header, is named for what it controls rather than for a permission being granted, and says outright there are no advertising identifiers. Same behaviour, different shape.

The other one nobody reported, because nobody could. The final onboarding CTA ran through gate(), which drops its feature block the moment a paywall reaches the screen — correct for a gate, catastrophic here. Closing that paywall returned you to the last step with onboarding still incomplete, and tapping again just re-presented it. There was no route into the app without buying, on the one screen every new install passes through. offer(_:then:) is the same presentation with the opposite resolution. A paywall may sell; it may not strand.

While checking a version bump took, I found MARKETING_VERSION and CURRENT_PROJECT_VERSION were decorative: XcodeGen writes Info.plist from project.yml, and with no version keys listed it filled in its own defaults, so every build produced 1.0 (1) regardless. The numbers on the store were being set somewhere else entirely — manageAppVersionAndBuildNumber was true, so exportArchive discarded whatever the archive carried. Both fixed. 1.0.1 was approved and released on 22 August; I'm on 1.2 (build 30) now.

Speakit Learns to Ask for Writing

Nothing in Speakit had ever asked a learner to write. No TextField in any activity renderer; all ten generated types were listen, tap, choose or speak. Meanwhile skill_focus offered "writing", so an activity could be tagged as writing practice while nothing was written. An A1 course that never asks for written production is missing a skill the level is defined by.

Two types now exist — fill-in-the-blank and translation prompts — but most of the design went into the grader, because that's what separates a good writing exercise from an infuriating one. It has to tell mistakes apart from typing friction. Case and punctuation are friction: "soy." and "Soy" are one answer. Kanji versus kana is friction at A1, so 日本語 and にほんご both pass, via the same tokenizer that powers furigana. A missing accent is a real error but a forgivable one, so it passes with the correct spelling named rather than silently. Autocorrect is off on both fields — an English keyboard silently "corrects" Spanish into English words, and then the learner is marked wrong for something they didn't type.

One bill came due. Last month I moved activity generation from structured output to a forced tool call to escape a grammar-compilation error. Tools are checked loosely, so the model started returning { "activities": "[{...}]" } — the whole array as a string of JSON. Going back isn't an option; that grammar wouldn't compile at all. So the boundary now unwraps top-level strings that parse, narrowly enough that genuinely malformed output still errors.

There's also an exploration written up for handwritten journal entries: write on paper, photograph the page, and everything downstream is the same code. Reading happens on-device via Vision and the learner confirms the text before analysis — analysing text they didn't write produces corrections for mistakes they never made. Which is roughly the theme of the fortnight: check what you actually sent before you go looking for what's wrong with it.

#ios#swift#app-store#localization#aso