Shotlist guide
Captions and composing
Writing captions in a String Catalog, review states, drafting translations with Apple Translation or Claude, composing with templates and layouts, text fitting and overflow, content rules, and creative assets with Apple's safe areas.
Captions and composing
shotlist compose turns raw captures into the screenshots you upload: each capture placed on a canvas with its caption, at the slot’s exact size. It also draws the creative assets: the product page header art and the search results art.
The order of work is:
- Write captions in your source language.
- Draft the other languages (
shotlist captions draft), or write them yourself. - Have someone who reads each language check the drafts, then mark them reviewed (
shotlist captions review). - Compose (
shotlist compose). - Verify (
shotlist verify).
Composing needs a subscription; the captions commands are free. See Subscription.
The captions catalogue
Captions live in Shotlist/Captions.xcstrings, a String Catalog. Xcode’s own editor works on it, so translators and tools that handle String Catalogs can too. shotlist init creates an empty one, with English (en) as its source language.
Keys
| Key | Required | Used for |
|---|---|---|
scene.<id>.caption |
Yes, for every scene in sequence, in every locale |
The headline on that scene’s screenshot. |
scene.<id>.subtitle |
No | A smaller line below the caption. |
creative.header.title |
Yes, if creative.header is set |
The header art’s headline. |
creative.header.subtitle |
No | A line below it. |
creative.search.title |
No | The search art’s headline. Falls back to the search scene’s caption. |
<id> is the scene id: scene.library.caption.
Which entry a locale uses
For a locale such as de-DE, Shotlist looks for:
- an entry for
de-DEexactly; - then one for the language,
de; - then, for a regional variant of the source language (such as
en-GBoren-USwhen the source isen), the source entry.
So one German entry covers de-DE, de-AT and de-CH, and an en-GB entry can override en where British wording differs. Norwegian nb and no are treated as the same language.
What it looks like
{
"sourceLanguage" : "en",
"strings" : {
"scene.library.caption" : {
"extractionState" : "manual",
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Your whole shelf, in one place" } },
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Dein ganzes Regal an einem Ort" } }
}
},
"scene.library.subtitle" : {
"extractionState" : "manual",
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Track what you're reading and what's next" } }
}
}
},
"version" : "1.0"
}
Edit it in Xcode (File › Open, then choose the file), or with any JSON editor. Don’t add it to your app target: it’s for Shotlist, not the app.
A caption can contain line breaks. Use them to control where a long caption wraps.
Review states
Each entry has a state:
| State in the file | Shotlist calls it | Meaning |
|---|---|---|
translated |
Reviewed | Written or checked by a person. Ready to publish. |
needs_review |
Awaiting review | A machine draft nobody has checked. verify and publish refuse it. |
new |
Awaiting review | Xcode’s state for an entry that hasn’t been translated or checked. Treated like needs_review. |
Captions you write yourself, in Xcode or by hand, are normally translated. Every machine draft is saved as needs_review, and stays that way until someone marks it reviewed.
shotlist captions status
shotlist captions status
✓ ready en-GB 4 reviewed · 0 awaiting review · 0 missing
! review de-DE 0 reviewed · 4 awaiting review · 0 missing
✗ missing fr-FR 0 reviewed · 0 awaiting review · 4 missing
It counts each scene’s caption (not subtitles or creative titles) for every locale in shotlist.json. It exits 1 until every locale is ready, so CI can gate on it. shotlist captions on its own runs status. With --json it prints, for each locale, the counts and the missing keys.
Drafting translations
shotlist captions draft
draft fills every missing caption, subtitle and creative title in every locale whose language differs from the catalogue’s source language. It translates from the source entries, so write those first. It never changes an entry that already exists.
For each locale it uses:
- Apple’s on-device Translation, when the language pair is installed on this Mac. Add languages in System Settings › General › Language & Region › Translation Languages. Nothing leaves the Mac.
- Otherwise, the Claude API, with your own credential: the
ANTHROPIC_API_KEYenvironment variable, or a profile fromant auth login. The source captions, the target language and your--contextare sent to Anthropic’s API.
If neither is available for a locale, it’s skipped, and draft says why.
drafted de-DE: 8 captions with Apple Translation (on device), marked needs_review
skipped fr-FR: no on-device en→fr translation is installed, and no Claude API credential (ANTHROPIC_API_KEY or `ant auth login`)
Review the drafts in Xcode (Captions.xcstrings), then `shotlist captions review --locales <locale>`.
| Option | Effect |
|---|---|
--locales <list> |
Only these locales. |
--context "<one line>" |
A line about the app, to help the translation: --context "A reading tracker for book clubs". Used by the Claude API. |
--json |
Print the drafted counts, the engine per locale and the skipped locales as JSON. |
Drafts are saved under the language code (de, not de-DE), so one draft serves every region of a language.
The Claude API is asked to keep each caption short and natural for its market, to keep product and brand names and line breaks, and not to add prices, URLs or claims the original doesn’t make. Read every draft anyway: that’s what the review step is for.
In the Mac app, Draft Missing Captions in the Compose inspector does the same.
Marking drafts reviewed
When someone who reads a language has checked its drafts (and corrected them in the catalogue where needed):
shotlist captions review --locales de-DE
reviewed de-DE: 8 captions
This marks every needs_review or new entry with text, for that locale and its language, as reviewed. --locales is required, and takes a comma-separated list.
Only run it once the drafts have really been checked. It’s the step that lets them be published.
In the Mac app, Mark Reviewed… beside a locale in the Compose inspector asks for confirmation first. You can also change an entry’s state in Xcode’s String Catalog editor.
Composing
shotlist compose
For every locale and slot, compose makes one screenshot per sequence entry, in order:
Shotlist/Output/composed/<locale>/<slot>-NN.png
NN counts from 01 in sequence order. A scene skipped on a slot (one that doesn’t list the iPhone Duo posture, say) takes no number there. The folder follows fastlane’s <locale>/ layout, so tools that read fastlane screenshot folders can read it.
Each screenshot uses:
- the capture in the entry’s
appearanceif it has one, or else in the first appearance inappearances; - the template named by the entry’s
template, orcaption-top; - the caption and subtitle for that scene and locale.
Compose renders at the slot’s exact pixel size, flattens to opaque sRGB with no alpha, writes the PNG, then reads it back and checks its size and alpha before counting it done.
32 composed · 0 unchanged · 10 creative files · 0 failed
! machine-drafted captions not yet reviewed: de-DE
| Option | Effect |
|---|---|
--slots <list>, --locales <list> |
Compose only these. |
--changed-only |
Re-render only screenshots whose capture, caption, subtitle, template or partner scene changed since they were last composed. |
--debug |
Also write each creative asset with Apple’s safe area drawn on, as …-safe-area.png. |
--json |
Print the summary as JSON. |
Without --changed-only, compose first removes every composed file for each slot and locale it’s composing, then renders them all. With it, unchanged screenshots are left alone, and files numbered past the end of a shorter sequence are removed, so nothing from an old sequence is left to be uploaded.
Compose doesn’t refuse unreviewed drafts: it renders them so you can see them, and lists the locales. verify and publish refuse them.
Compose exits 1 if anything failed: a missing capture, a missing caption, an overflow or a missing template. Each failure names the locale, slot and scene. Other screenshots are still written.
In the Mac app, Compose (⌘B) does the same, with Changed Only on by default.
Templates
A template is one layout plus a style kit: background, caption and subtitle styles, the device treatment and margins.
Built-in templates
| Template | Layout | Look |
|---|---|---|
caption-top |
caption-top |
Light warm gradient. Bold dark caption above the device. The default. |
caption-bottom |
caption-bottom |
Dark green gradient. Bold white caption below the device. |
full-bleed |
full-bleed |
The capture fills the canvas, with a dark scrim at the bottom and a heavy white caption over it, aligned to the leading edge. No device. |
split |
split |
Two scenes side by side under one caption. For iPad and the Duo’s inner display. |
header-brand |
header |
For header art: a dark green gradient with a heavy white title and subtitle, centred in Apple’s safe area. |
search-hero |
search |
For search art: the title beside a capture, inside Apple’s safe area. |
Layouts
| Layout | Arrangement |
|---|---|
caption-top |
Caption and subtitle in the top part of the canvas; the device below takes device.height of the canvas height. |
caption-bottom |
The device at the top; caption and subtitle below. |
full-bleed |
The capture fills the canvas edge to edge; the caption sits at the bottom over a gradient scrim. The caption area is 30% of the canvas height. |
split |
Caption across the top; two devices side by side below: this entry’s scene and the scene named in with. |
header |
Creative header art: text only by default, centred in the header safe area. |
search |
Creative search art: headline on the leading side, device on the trailing side, inside the search safe area. |
split needs room for two devices, so it’s used only on ipad-13 and iphone-duo-inner. On every other slot, an entry with split is composed with caption-top (your own caption-top.json if you have one). Name the second scene with with:
{ "scene": "planner", "template": "split", "with": "library" }
The with scene must also be in sequence, so that it’s captured; it’s composed as its own screenshot too. If it has no capture for that slot, locale and appearance, the entry’s own capture appears twice.
Your own templates
Put a template in Shotlist/Templates/<name>.json and name it in a sequence entry’s template. A file with a built-in’s name replaces that built-in for this project.
A template file is complete in itself; it doesn’t inherit from a built-in. Start from a built-in’s values below and change what you need:
{
"layout": "caption-top",
"background": { "type": "gradient", "colors": ["#FFF8EE", "#F2E3CC"], "angle": 90 },
"caption": { "weight": "heavy", "size": 0.07, "minSize": 0.05, "color": "#2B1A0E", "maxLines": 3, "alignment": "center" },
"subtitle": { "weight": "regular", "size": 0.036, "minSize": 0.028, "color": "#6B5443", "maxLines": 2, "alignment": "center" },
"device": { "frame": "frameless", "cornerRadius": 0.11, "shadow": true, "height": 0.74 },
"padding": 0.07
}
Sizes are fractions of the canvas’s shorter side, so one template works in portrait, in landscape (the Duo’s inner display) and on the wide creative canvases. A caption size of 0.072 on iphone-6.3 (1206 pixels wide) is about 87 pixels.
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
layout |
string | yes | caption-top, caption-bottom, full-bleed, split, header or search. |
|
background.type |
string | yes | color, gradient or image. |
|
background.color |
string | no | #000000 |
For color, and the fallback for image. |
background.colors |
array of strings | no | black to #333333 |
For gradient: two or more colours. |
background.angle |
number | no | 90 | For gradient: degrees. 0 runs left to right, 90 top to bottom. |
background.image |
string | no | For image: a file in Shotlist/Templates/, scaled to fill the canvas. |
|
caption.size |
number | yes | Starting point size, as a fraction of the shorter side. | |
caption.minSize |
number | no | 75% of size |
The smallest it may shrink to before it counts as an overflow. |
caption.color |
string | yes | Text colour. | |
caption.weight |
string | no | regular |
regular, medium, semibold, bold, heavy or black. Always SF Pro. |
caption.maxLines |
number | no | 3 | The most lines the caption may take. |
caption.alignment |
string | no | center |
leading, center or trailing. |
subtitle |
object | no | none | The same keys as caption. Without it, subtitles aren’t shown. |
device.frame |
string | with device |
frameless: the capture with rounded corners and a soft shadow. The only frame for now. |
|
device.cornerRadius |
number | with device |
Corner radius as a fraction of the capture’s width. | |
device.shadow |
boolean | with device |
Draw a soft shadow. | |
device.height |
number | with device |
The share of the canvas height the device may take. | |
device |
object | no | none | Leave it out for no device (as full-bleed and header-brand do). |
padding |
number | yes | The margin, as a fraction of the shorter side. |
Colours are #RRGGBB or #RRGGBBAA, in sRGB. The result is flattened, so a translucent colour blends with what’s beneath it.
If a template file isn’t valid, compose stops: “Templates/
The built-ins’ values
| caption-top | caption-bottom | full-bleed | split | header-brand | search-hero | |
|---|---|---|---|---|---|---|
| Background | gradient #F5F2EA → #E4DECF, 90° |
gradient #1F3A2E → #0E1A14, 90° |
#000000 |
gradient #F5F2EA → #E4DECF, 90° |
gradient #1F3A2E → #3E6B4F, 20° |
gradient #1F3A2E → #3E6B4F, 30° |
| Caption | bold 0.072 (min 0.05), #0E1A14, 3 lines, centre |
bold 0.072 (min 0.05), #FFFFFF, 3 lines, centre |
heavy 0.078 (min 0.054), #FFFFFF, 3 lines, leading |
bold 0.05 (min 0.036), #0E1A14, 2 lines, centre |
heavy 0.1 (min 0.06), #FFFFFF, 2 lines, centre |
heavy 0.08 (min 0.045), #FFFFFF, 3 lines, leading |
| Subtitle | regular 0.038 (min 0.03), #4A5A52, 2 lines |
regular 0.038 (min 0.03), #C9D6CF, 2 lines |
medium 0.04 (min 0.03), #E8E8E8, 2 lines |
regular 0.028 (min 0.022), #4A5A52, 2 lines |
medium 0.044 (min 0.03), #DCE7E0, 2 lines |
none |
| Device | corner 0.11, shadow, 0.74 | corner 0.11, shadow, 0.74 | none | corner 0.06, shadow, 0.76 | none | corner 0.11, shadow, 0.82 |
| Padding | 0.07 | 0.07 | 0.07 | 0.05 | 0.05 | 0.06 |
In the Mac app, the Compose inspector shows the selected scene’s template, and Show Templates in Finder opens the folder.
Text fitting
A caption is never clipped. Compose fits each one to its box:
- It starts at the template’s
size. - It lays the text out with TextKit at 96% of the box width, so SwiftUI’s own wrapping can’t disagree at the edge.
- If it doesn’t fit, it shrinks in steps of 2% of the starting size, down to
minSize. - If it still doesn’t fit at
minSize, that screenshot fails with an overflow, naming the locale, the scene and the reason. Nothing is written for it.
A caption doesn’t fit when:
- a single word is wider than the box. A long German compound, say. It would have to break mid-word;
- it needs more lines than
maxLines; - it’s taller than its share of the caption area. The caption gets 70% of the area’s height and the subtitle 30%.
caption overflow in de-DE, scene 'library': "Leseclubterminverwaltung leicht gemacht" doesn't fit: the word "Leseclubterminverwaltung" is wider than the caption box even at the minimum size (60 px). Shorten it for de-DE in Captions.xcstrings, or add a line break.
To fix an overflow:
- write a shorter caption for that locale;
- add a line break, or break a long compound with a hyphen and a line break;
- or use a template with a smaller
minSize, moremaxLinesor a smaller device.
In the Mac app’s Compose screen, a locale that overflows is outlined and labelled with the reason.
Right-to-left languages
Locales whose language is Arabic (ar), Hebrew (he), Persian (fa) or Urdu (ur) are composed right to left: the layout mirrors, and leading alignment becomes the right-hand edge.
Content rules
Apple’s rules for creative assets forbid prices and discounts, URLs, copyright symbols, mentions of other platforms or stores, and Apple recognitions. They’re good sense for screenshot captions too. Shotlist checks every caption, subtitle and creative title in every locale:
| Rule | Catches |
|---|---|
| Price or currency | A currency symbol or code next to a number: £5, 9 €, USD 10, 20 kr. |
| Percentage with a price word | A percentage near words such as off, sale, save, discount, deal or free, in English, German, French, Spanish, Swedish, Danish, Norwegian, Dutch and Italian: 50% off, Sparen Sie 30 %. |
| URL | Web addresses and domains: https://…, www.…, myapp.app. |
| Copyright or trademark symbol | ©, ®, ™, (c), (r), (tm). |
| Another platform or store | Android, Google Play, Play Store, Galaxy Store, Samsung, Huawei, AppGallery, Windows, Microsoft Store, Amazon Appstore, F-Droid. |
| Apple recognition | Editors’ Choice, App of the Day, Game of the Day, App of the Year, Game of the Year, Apple Design Award, Featured by Apple, As seen on the App Store. |
Matching ignores case. Compose lists findings as warnings (! content: de-DE scene.shop.caption: price or currency: “€5”); verify fails on them.
Creative assets
iOS and iPadOS 27 show two kinds of art on the App Store: a product page header above your screenshots, and search results art in search. Shotlist draws both from your captions and captures. Configure them under creative in shotlist.json (see Configuration):
"creative": {
"header": { "template": "header-brand", "localised": true },
"search": { "scene": "library", "template": "search-hero" }
}
What compose writes
| File | Size | Notes |
|---|---|---|
creative/<locale>/header.png |
3840 × 1646 | One per locale. With "localised": false, one only, in creative/shared/, from the first locale’s captions. |
creative/<locale>/header-16x9.png |
5244 × 2950 | The 16:9 alternative. Left out with "universal": false. |
creative/<locale>/search.png |
3840 × 2560 | One per locale. |
creative/<locale>/search-16x9.png |
5244 × 2950 | The 16:9 alternative. |
creative/<locale>/search-preview.png |
384 × 256 | The search art at about a tenth of its size, as it shows in results. |
…-safe-area.png |
as above | With --debug: a copy with Apple’s safe area outlined. |
All are opaque PNGs with no alpha channel.
Text and captures
- Header:
creative.header.title, withcreative.header.subtitlebelow if you give one. Header art has no screenshot by default, because the screenshots sit directly below it on the product page. Setheader.sceneto include one. - Search:
creative.search.title, or the search scene’s caption if there’s none, beside a capture ofsearch.scene. The capture comes fromsearch.slot, or the firstiphone-6.xslot inslots. That scene must be insequence, so it’s captured.
Safe areas
Apple’s templates leave a safe area in the middle of each canvas; outside it, art can be cropped on some devices. Shotlist keeps all text and captures inside it:
| Canvas | Safe area |
|---|---|
| Header, 3840 × 1646 | x 1097 to 2743, y 493 to 1154 |
| Search, 3840 × 2560 | x 836 to 3004, y 765 to 1795 |
| 16:9, 5244 × 2950 | The same proportions as the canvas it’s an alternative to. Apple publishes no separate numbers for it. |
Run shotlist compose --debug to write copies with the safe area outlined, or turn on Safe Areas in the Mac app’s Creative screen.
Legibility at search size
Search art shows about 365 points wide on an iPhone: roughly a tenth of its pixels. search-preview.png shows it at that size. If the headline would be smaller than 14 pixels there, compose warns:
! search art headline in de-DE is 12 px at search-results size; 14 px or more reads reliably
Use a shorter headline for that locale, or a template with a larger caption.
Uploading
publish --apply uploads header.png and search.png and places them on each localisation. The 16:9 alternatives are written for you to use elsewhere or upload by hand; publish doesn’t upload them. Creative assets are reviewed with the app version. See App Store Connect.
Failures
| Message | Fix |
|---|---|
no creative.header.title caption in <locale> |
Add the key for that locale. |
no creative.search.title (or caption for '<scene>') in <locale> |
Add either. |
no capture of '<scene>' to put in the search art |
The scene isn’t in sequence, or its capture failed for that slot and locale. |
title overflow in <locale>: … |
As for captions: shorten it or add a line break. |