One morning I opened App Store Connect and noticed that the English screenshots for a wallpaper app I had been shipping for five years were still showing a UI from six months ago. I had quietly refreshed the Japanese set after the iOS 26 redesign, but the English locale had drifted four versions behind. I was running Apple Search Ads against that English listing at the time, so the 18% month-over-month drop in CTR was not exactly mysterious.
If you run several apps in parallel on your own, this drift is close to guaranteed. Code has CI to tell you when it breaks. Store assets can sit stale indefinitely without making a sound. When I started using Antigravity's Background Agent for release-time monitoring, I decided to hand it the multilingual screenshot refresh as well. This article is a five-week operations log: what I let it do, what I kept on my own desk, and why.
Why I Reduced the Scope From "End-to-End" to "Prep Work Only"
In the first week I got greedy and wrote a job that would pick the lead screenshot, translate the copy, render every locale, and push everything straight to Connect. I rolled that back almost immediately. Design judgment — which cut goes in slot 1, whether the English copy's rhythm matches the Chinese — is not something I'm ready to fully delegate to an agent yet.
Work where judgment quality matters and work that is merely high in volume are easy to conflate. Automate them together and you get the speed of the second producing failures in the first.
After five weeks the boundary settled here:
- Prep work — per-device renders, layer alignment, character-overflow detection per locale — goes to the Background Agent
- Lead visual selection, copy tone, and final approval stay with me
Once I drew that line, my own time on this task collapsed to roughly 90 minutes on a Sunday morning. I've written more broadly about where that line belongs in What I Delegated to an AI Agent — And What I Should Have Kept. This case is a concrete instance where it landed just before the render step.
The Skeleton of the Job
The definition I'm passing to Background Agent looks roughly like this. I've trimmed it to the load-bearing parts; the real one is close in shape.
# antigravity/agents/appstore-screenshots-weekly.yaml
name: appstore-screenshots-weekly
schedule: "0 4 * * SUN" # Sun 04:00 JST
inputs:
apps:
- { bundle_id: jp.dolice.wallpaper.zen, locales: [ja, en, zh-Hans, zh-Hant, ko, es-MX, fr, de] }
- { bundle_id: jp.dolice.wallpaper.dark, locales: [ja, en, zh-Hans, zh-Hant, ko, es-MX, fr, de] }
- { bundle_id: jp.dolice.wallpaper.mind, locales: [ja, en, zh-Hans, zh-Hant, ko] }
- { bundle_id: jp.dolice.wallpaper.kid, locales: [ja, en, zh-Hans, ko] }
tools:
- fs.read_master_psd
- text.translate # uses my glossary
- validate.caption_width # added in week 3 (see below)
- image.render_screenshot # renders PNG with device frame
- asc.list_localizations
- notify.slack
steps:
- id: detect_master_diff
run: fs.read_master_psd($app.master) -> compare(prev_snapshot)
- id: skip_if_no_change
when: detect_master_diff.changed == false
run: notify.slack("$app: no master change, skipping")
- id: regenerate_per_locale
foreach: $app.locales
run: |
text.translate(source=$app.master.copy, target=$locale, glossary=$app.glossary)
-> validate.caption_width(locale=$locale, devices=$app.devices)
-> image.render_screenshot(device=[iPhone-6.9, iPhone-6.5, iPad-13])
- id: report
run: notify.slack(diff_summary + warnings)The shape is: only act on weeks where the master PSD changed, then report to Slack so a human decides whether to push to Connect. The asc.list_localizations step is there to pull the current locale list for diffing; the write-back step (asc.upload_screenshot) is intentionally absent. The next section explains why.
Three Reasons I Pulled Auto-Upload Out
I had auto-upload turned on in week one. By week two, an English caption shipped with a half-finished phrase ending; I caught it on the metadata screen before review, but only just. I removed the write-back step right after, and the reasons I wrote down were:
- Copy rhythm still belongs on the human side. How well a one-line caption lands is a different axis from whether the translation is correct. A glossary can guarantee correctness; everything past that isn't ready to hand off.
- Rollback on Connect is expensive. Once a screenshot is overwritten, reverting requires a manual re-upload. Catching a bad overnight job in the morning becomes a heavy chore.
- Apple Search Ads timing matters. When I change a screenshot, I often want to nudge the corresponding keyword bid at the same time. Reviewing both as one change makes attribution easier to read later.
What Actually Broke Across Five Weeks
A few concrete numbers. Four apps × up to eight locales = 32 sets, run five times across five weeks, so 160 generated sets total. Of those, 121 were used as-is and 39 needed a human touch-up.
| Type of fix | Count | Absorbed by automation? |
|---|---|---|
| Chinese simplified / traditional overflow on portrait devices | 18 | Yes (validation added in week 3) |
| Korean font baseline offset overlapping the lead visual | 9 | Partly — detectable, but the fix needs judgment |
| Spanish register (Mexico vs. Spain) not absorbed by the glossary | 8 | No |
| Japanese master line-height not respected by the renderer | 4 | Yes (a setting error on the master side) |
I worked down the list by frequency, and the first row is where the leverage was.
The Caption-Width Validation I Added in Week 3
Trying to catch Chinese overflow by character count will always miss. The same 12 characters render at roughly double the width in Simplified Chinese versus Latin script, and the same locale behaves differently once the device frame width changes. So I inserted a step that measures actual rendered width — after translation, before rendering.
# antigravity/tools/validate_caption_width.py
from PIL import ImageFont
# Usable text width inside the device frame, in px.
# Measured from the text box width in the master PSD.
FRAME_TEXT_WIDTH = {
"iPhone-6.9": 1080,
"iPhone-6.5": 1010,
"iPad-13": 1520,
}
# Must match the fonts the renderer actually uses, or the measurement is meaningless.
LOCALE_FONT = {
"ja": ("NotoSansJP-Bold.otf", 64),
"zh-Hans": ("NotoSansSC-Bold.otf", 64),
"zh-Hant": ("NotoSansTC-Bold.otf", 64),
"ko": ("NotoSansKR-Bold.otf", 60),
"en": ("Inter-Bold.ttf", 64),
"es-MX": ("Inter-Bold.ttf", 64),
"fr": ("Inter-Bold.ttf", 64),
"de": ("Inter-Bold.ttf", 64),
}
# Headroom for hinting differences between the offline renderer and a real device.
# 0.98 let three cases slip through, so I lowered it to 0.94.
TOLERANCE = 0.94
def measure_width(text: str, locale: str) -> float:
"""Return the rendered width in px for the given locale's font."""
font_name, size = LOCALE_FONT[locale]
font = ImageFont.truetype(font_name, size)
left, _top, right, _bottom = font.getbbox(text)
return right - left
def validate_caption(text: str, locale: str, devices: list[str]) -> list[str]:
"""Return a warning per device that won't fit. Empty list means it passes."""
warnings = []
for device in devices:
limit = FRAME_TEXT_WIDTH[device] * TOLERANCE
width = measure_width(text, locale)
if width > limit:
over = width / limit
warnings.append(
f"{locale}/{device}: {width:.0f}px > {limit:.0f}px ({over:.0%})"
)
return warnings
if __name__ == "__main__":
devices = ["iPhone-6.9", "iPhone-6.5", "iPad-13"]
print(validate_caption("每天换一张,让桌面安静下来", "zh-Hans", devices))
# => ['zh-Hans/iPhone-6.5: 1043px > 949px (110%)']The reason to measure with getbbox rather than count characters is that the question isn't "how many characters" but "does it leave the frame." Obvious in hindsight — my first version set a per-locale character limit, and that's exactly what let all 18 cases through.
TOLERANCE = 0.94 is empirical. Width measured in an offline renderer and how a caption reads on an actual device differ by a few percent. I ran at 0.98 initially; three captions still looked cramped on device, so I dropped it to 0.94. Tightening it means more translation rework, so I treat it as a threshold for showing a human, not a threshold for blocking.
Across the two weeks after this step went in, manual fixes from Chinese overflow dropped from 18 to 2. Both remaining cases had raised a warning that I waved through myself — so not a detection miss.
Why the Korean Baseline Issue Stayed a Manual Check
The nine Korean incidents were a different animal. Nothing overflowed horizontally; the text sat at the wrong vertical position and collided with the lead visual. The cause is in the font metrics.
from PIL import ImageFont
for name, size in [("NotoSansKR-Bold.otf", 60), ("Inter-Bold.ttf", 64)]:
font = ImageFont.truetype(name, size)
ascent, descent = font.getmetrics() # em-box top and bottom
top = font.getbbox("Ag한국어")[1] # actual y where drawing starts
print(f"{name}: ascent={ascent} descent={descent} bbox_top={top}")
# NotoSansKR-Bold.otf: ascent=78 descent=21 bbox_top=13
# Inter-Bold.ttf: ascent=79 descent=21 bbox_top=17The ascent values are nearly identical, but bbox_top differs by 4px. Because the renderer centers vertically against the em box, the optical center shifts per language. On a single line that's noise. The moment the caption wraps to two lines it compounds, and the lower line grazes the edge of the lead visual.
Detection alone is easy — put a threshold on the bbox_top delta. I wrote it. But when that threshold trips, whether the right answer is "raise the text," "lower the lead visual," or "don't let it wrap at all" depends on looking at that specific frame. Auto-nudging produces images that technically fit and visually feel cramped. So the rule became: warn, don't fix.
Automation discussions tend to treat "detectable" as "automatable." These nine cases were the textbook counterexample — a judgment call sits between detection and repair.
Why I Kept Reporting on a Single Slack Channel
I kept notifications to one channel because every extra destination becomes another "I'll check it later" pile. For a job that runs once a week, a buried notification is indistinguishable from no notification at all.
I also told Background Agent to keep reports short: bullet only what changed, and send "no changes" when the diff is empty. That last part matters — silence shouldn't be ambiguous between "nothing to report" and "the job never started." A system where you can't tell those apart fails quietly. I go deeper on that failure mode in Catching "Running but Doing Nothing" Antigravity Subagents — A 3-Layer Observability Pattern.
The report format settled here after a few rewrites:
[Sun 04:18 JST] appstore-screenshots-weekly
- zen: ja=OK en=⚠️ copy end zh-Hans=⚠️ overflow zh-Hant=OK ko=OK es-MX=OK fr=OK de=OK
- dark: ja=OK en=OK zh-Hans=OK zh-Hant=OK ko=⚠️ font height es-MX=OK fr=OK de=OK
- mind: no master change, skipped
- kid: ja=OK en=OK zh-Hans=OK ko=OKOne line per app with locales laid out horizontally means the ⚠️ markers stack vertically, and after a few weeks you can see which languages are chronically weak. Two weeks in, it was obvious that zh-Hans and ko were the repeat offenders — which is what prompted adding the validation step.
Where Five Weeks Left Me
Week one with full auto-upload was fast and accident-prone. From week two onward, with write-back removed, my own time dropped to 90 minutes a week and the accidents stopped. Adding caption-width validation in week three pulled the touch-up rate well below the 39-per-160 pace it started at.
Looking back, the order that worked was: narrow what you delegate first, then thicken the inspection inside that narrower scope. Reversed, I suspect I'd still be cleaning up after it. The broader design of running jobs overnight is something I've laid out in Letting Antigravity Be Your Night-Shift Engineer: A Solo Dev Operating Model.
If you want to try this, start by dropping the 30-line equivalent of validate_caption_width.py into the manual workflow you already run. Until you can articulate what you want inspected, there's no basis for deciding what to delegate. In my case, the boundary this whole article describes was effectively settled the moment those 30 lines existed.
The next thing I want to wire together is a single diff view for Apple Search Ads keyword bid changes and screenshot updates, so the two move in step. If that experiment runs long enough to be worth writing about, I'll post a follow-up.