# Storescreens

> Set up and run storescreens-cli to automate App Store screenshot capture for iOS apps: render captioned/framed App Store-ready screenshots with device bezels, markdown captions, and panoramic backgrounds; upload screenshots + per-locale metadata (name, subtitle, description, keywords, what's new, promotional text) to App Store Connect via the official API; translate that metadata into other languages with a bring-your-own DeepL key; and archive + upload the app binary (.ipa) to App Store Connect / TestFlight via xcodebuild + altool with a pinned non-beta Xcode. Also supports targeted screenshots for quick visual checks during development. Use this skill when the user wants to install or configure storescreens-cli, set up screenshot automation for an Xcode project, add UI tests that capture screenshots, write or update ScreenshotTests.swift, run storescreens capture, render framed/captioned App Store screenshots, install device bezels, iterate on caption text and styling, take a quick screenshot of the simulat

- Skill: `ciscoriordan/storescreens` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add ciscoriordan/storescreens`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ciscoriordan/storescreens/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: ciscoriordan (https://skillmd.com/u/ciscoriordan)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ciscoriordan/storescreens

---


# storescreens

storescreens-cli runs XCUITest-based UI tests across simulators, extracts named `XCTAttachment` screenshots from the `.xcresult` bundle, and organizes them by device size. It also renders the raw captures into captioned, framed App Store Connect-ready screenshots (backgrounds, device bezels, markdown captions, per-slide highlights).

Follow the steps below in order. Each step checks state before acting - skip steps that are already complete.

The full workflow is:

1. Install CLI + MCP server (Steps 1-1b)
2. Detect project and generate `storescreens.yml` (Steps 2-3)
3. Set up UI test target + `ScreenshotTests.swift` (Steps 4-6)
4. Verify the build (Step 7)
5. Capture raw screenshots (Step 8)
6. **(optional) Render captioned, framed App Store-ready screenshots (Step 9)**
7. **(optional) Upload screenshots + metadata to App Store Connect (Step 10)**
8. **(optional) Archive + upload the app binary to App Store Connect / TestFlight (Step 10b)**
9. **(optional) Upload to storescreens.app for visual editing (Step 11)**

---

## Step 1: Check / Install storescreens-cli

Run:

```bash
storescreens --help 2>&1
```

Print the full output - it shows the available subcommands and options so the user knows what's available. If the command is not found, install it:

```bash
brew tap ciscoriordan/tap && brew install storescreens
# or, with mise (prebuilt, no Swift toolchain - installs storescreens + storescreens-mcp):
mise use -g github:ciscoriordan/storescreens-cli
# or, the install script (CLI only):
curl -fsSL https://raw.githubusercontent.com/ciscoriordan/storescreens-cli/main/install.sh | sh
```

Prefer Homebrew or mise for an automated install - both are prebuilt and need no toolchain. storescreens is also installable with [Mint](https://github.com/yonaskolb/Mint) (`mint install ciscoriordan/storescreens-cli storescreens`), but Mint builds from source and needs a Swift toolchain, so reach for it only if the user asks.

---

## Step 1b: MCP Server Setup (REQUIRED - do this before anything else)

**The storescreens MCP server is the preferred way to run captures.** It gives you structured tools (`capture`, `get_capture_status`, `list_screenshots`, `get_screenshot`, etc.) instead of parsing CLI output in Bash. **Always set this up.**

**Check if already connected:** Look at your available MCP tools. If you see `capture`, `get_capture_status`, `list_screenshots`, etc. from a `storescreens` MCP server, it's already working - skip to Step 2.

**If MCP is NOT connected, you MUST set it up now:**

1. Check if `.mcp.json` exists in the project root. If it does, read it.

2. **If `.mcp.json` doesn't exist**, create it:

```json
{
  "mcpServers": {
    "storescreens": {
      "command": "storescreens-mcp",
      "args": []
    }
  }
}
```

3. **If `.mcp.json` already exists** but doesn't have a `storescreens` entry, add the `storescreens` server to the existing `mcpServers` object. Do not overwrite other MCP servers.

4. **After creating or updating `.mcp.json`, STOP and tell the user:**

> **Action required: Please exit and relaunch Claude Code from this project directory.**
>
> I just created/updated `.mcp.json` to add the storescreens MCP server. Claude Code only reads project-level `.mcp.json` files on startup - running `/mcp` inside the current session won't pick it up.
>
> After relaunching, run `/storescreens` again and I'll continue from where we left off.

**Do not continue with further steps until MCP is confirmed working.** The MCP server provides `capture`, `get_capture_status`, `check`, `list_screenshots`, `get_screenshot`, `take_screenshot`, `read_config`, `write_config`, and other tools that make the entire workflow dramatically better. Without it, you're stuck parsing raw xcodebuild output in Bash.

---

## Step 2: Detect project

Find the `.xcworkspace` or `.xcodeproj` in the working directory. Identify the scheme name (usually matches the app name). You'll need both for `storescreens.yml` and the `xcodebuild` verify command later.

Also check for XcodeGen: if `project.yml` exists at the repo root, this project is XcodeGen-managed. The `.xcodeproj` is a generated artifact, so any target changes must go in `project.yml` and then `xcodegen generate` regenerates the project. Note this state (keep a note or variable like `usesXcodeGen = true`) and branch accordingly in Steps 4 and 7.

---

## Step 3: Check for existing `storescreens.yml`

**If `storescreens.yml` already exists, skip `storescreens init` and proceed directly to Step 4.** Do not re-run init or ask config questions unless the user explicitly asks to change devices, locales, or appearances.

Only run `storescreens init` when:
- `storescreens.yml` does not exist yet (fresh project), or
- the user explicitly asks to reconfigure or reinitialise

When you do run init:

```bash
storescreens init
```

Print the full output. If `.mcp.json` was created or updated, tell the user: **exit and relaunch Claude Code from this project directory** to pick up the new MCP config. Running `/mcp` inside the current session is not enough - project-level `.mcp.json` files are only read on startup.

After running `storescreens init`, read `storescreens.yml` and review the config with the user. Show the current values and ask:

1. **Devices** - Are these the right simulators? (Show the current list)
2. **Appearances** - Currently set to `[current value]`. Light only, or both light and dark?
3. **Locales** - Currently `[current value or "not set"]`. Do you need screenshots in multiple languages?

Make any requested changes by editing `storescreens.yml` directly, then continue to Step 4.

If `storescreens.yml` does not exist yet (fresh project), ask the user these questions before running `storescreens init`:

1. **iPhone devices** - Present these options and ask the user to choose:

   - **6.9" only (recommended)** - `iPhone 18 Pro Max`. iOS 27 runtimes (Xcode 27 or later) create it; iOS 26 runtimes create `iPhone 17 Pro Max` instead, which has the same screen and still works from Xcode 27 when an iOS 26 runtime is installed. Check `storescreens list` for which one is installed. App Store Connect auto-fills the 6.5" slot from 6.9" screenshots, so this single device covers both. This is all most apps need.
   - **6.9" + 6.5"** - Add `iPhone 11 Pro Max` or `iPhone Xs Max` if they want *distinct* screenshots in the 6.5" slot rather than the auto-scaled 6.9" ones. Note: no current model produces a 6.5" size (1242×2688 or 1284×2778) - only older simulators do.
   - **More sizes** - App Store Connect also has slots for 6.3", 6.1", 5.5", 4.7", 4", and 3.5". Ask if they want any of these. Corresponding simulators: `iPhone 18 Pro` (6.3"; `iPhone 17 Pro` on iOS 26 runtimes), `iPhone 17e` (6.1"), `iPhone 8 Plus` (5.5"), `iPhone SE (3rd generation)` (4.7"). Sizes smaller than 4.7" are very old and rarely needed.
   - **Skip iPhone for now** - valid choice; they can add iPhone later.

   Note: App Store Connect has **no 6.7" slot**. Its `APP_IPHONE_67` display type is the 6.9" slot, and `iPhone 16 Plus` (1290x2796) screenshots land there too, so do not suggest it next to the Pro Max. Likewise do not suggest `iPhone Air` as an extra size: its 1260x2736 screenshots also go to the 6.9" slot (storescreens still labels its files `iPhone 6.3"`). Two devices in one slot means `submit` uploads only one device's screenshots there (the one with the largest screen). Do not put `iPhone Duo` in the main config; it needs its own config (see Step 3c).

2. **Appearances** - Light mode only, or also dark mode?

Once you have the iPhone answers, write `storescreens.yml` with the iPhone devices. See `references/config-reference.md` for the full schema. The `test_target` and `test_class` should be `<AppName>UITests` and `ScreenshotTests` - you'll confirm these in Step 4.

**IMPORTANT: Always include `derived_data_path` in the config.** Without it, every capture run recompiles everything from scratch (including all SPM packages), which can add 3-5+ minutes to each run. Set it to `~/.storescreens-cache/<AppName>`:

```yaml
derived_data_path: ~/.storescreens-cache/<AppName>
```

If `storescreens.yml` already exists but is missing `derived_data_path`, add it now.

---

## Step 3b: iPad devices (separate question)

Ask the user separately whether they want iPad screenshots. Don't push it - it's fine to skip and add later.

App Store Connect iPad slots:
- **13"** (required when iPad is supported) → `iPad Pro 13-inch (M5)` - **recommend this as the starting point**
- **11"** → `iPad Pro 11-inch (M5)`
- **12.9"**, **10.5"**, **9.7"** - legacy slots requiring older simulator runtimes; rarely needed

If they want iPad, add the chosen simulators to `devices` in `storescreens.yml`. Also note: if their app has iPad support disabled in Xcode (Supported Destinations), they'll need to re-enable it there first (General tab → Supported Destinations → add iPad).

---

## Step 3c: iPhone Duo (optional, only if the user asks)

The iPhone Duo is a foldable with an outer display (1398x2034, folded) and an inner display (2007x2853, open). Only set it up when the user asks for it:

- Its simulator needs Xcode 27.1 beta or later plus the iOS 27.1 simulator runtime, which creates the `iPhone Duo` simulator. If they aren't installed, the user can run `xcodes install 27.1 Beta --experimental-unxip` in Terminal (it asks for their Apple ID and 2FA code, which you can't enter for them), then you can fetch the runtime with `DEVELOPER_DIR=/Applications/Xcode-27.1.0-Beta.app/Contents/Developer xcodebuild -downloadPlatform iOS` (no sign-in). storescreens uses whichever Xcode `xcode-select` or `DEVELOPER_DIR` selects, so keep the main config on the release Xcode and write a second config, e.g. `storescreens-duo.yml`, that copies `storescreens.yml` but lists only `- simulator: "iPhone Duo"` and sets its own `output_dir` (and `render.output_dir`, if rendering), because a successful capture replaces the previous output in its directory. Also remove the `search_preview` block from the copy: search previews never use Duo screenshots, so a Duo-only run would overwrite the main previews with empty tiles. Capture it with `--no-search-preview`, which guards against that even if the block is left in:

  ```bash
  DEVELOPER_DIR=/Applications/Xcode-27.1.0-Beta.app/Contents/Developer storescreens capture --config storescreens-duo.yml --no-search-preview
  ```

- The first launch of the Duo simulator can take several minutes (a known issue in Apple's beta). Don't treat a slow first boot as a hang.
- The pose (folded or open) can't be set from the command line or from a UI test; Apple exposes it only in Xcode's Device Hub. The simulator boots folded, so captures come out as outer-display screenshots (1398x2034) unless the user opens it in Device Hub. Screenshots come from whichever display is active, and storescreens labels them `iPhone Duo outer` or `iPhone Duo inner` by pixel size (files `iPhone_Duo_outer_<name>.png` / `iPhone_Duo_inner_<name>.png`).
- Tell the user App Store Connect doesn't accept iPhone Duo screenshots yet (Apple says later this year): `submit` skips them with a notice. The captures are still useful for checking the layout on both displays.

---

## Step 4: Check for a UI test target

Look for an existing UI test target by searching for `*UITests` directories or `.swift` files containing `XCTestCase` in a test target folder.

**If a UI test target already exists:**

Check whether `ScreenshotTests.swift` exists inside it. If it does, skip to Step 6. If it doesn't, proceed to Step 5 to write the test file.

**If no UI test target exists and the project is XcodeGen-managed (`project.yml` exists):**

Don't ask the user to create the target in Xcode. Editing the generated `.xcodeproj` directly is pointless because `xcodegen generate` wipes those changes. Add the target to `project.yml` yourself.

Append to the `targets:` block:

```yaml
  <AppName>UITests:
    type: bundle.ui-testing
    platform: iOS
    deploymentTarget: "<same as the app target>"
    sources:
      - path: <AppName>UITests
    dependencies:
      - target: <AppName>
    settings:
      PRODUCT_BUNDLE_IDENTIFIER: <app bundle id>.uitests
      GENERATE_INFOPLIST_FILE: YES
```

Also add the test target to the app scheme's `test:` block so `xcodebuild test` can discover it:

```yaml
schemes:
  <AppName>:
    build:
      targets:
        <AppName>: all
    test:
      targets:
        - <AppName>UITests
```

Then run `xcodegen generate` to regenerate the `.xcodeproj`. After that, proceed to Step 5.

**If no UI test target exists and the project is not XcodeGen-managed:**

Tell the user they need to create one manually in Xcode (this cannot be done from code):

> Please add a UI test target in Xcode:
> 1. File → New → Target → UI Testing Bundle
> 2. Name it `<AppName>UITests`
> 3. Set "Target to be Tested" to your app
> 4. Click Finish
>
> Xcode will generate a placeholder `<AppName>UITestsLaunchTests.swift` - delete that file.
> Then come back and I'll write `ScreenshotTests.swift` for you.

Wait for the user to confirm before continuing.

---

## Step 4b: Preflight check (run by the setup wizard)

The `storescreens setup` wizard automatically runs `storescreens check` before generating the test file. If it finds issues:

- **Errors** (e.g. unguarded CloudKit, missing `.toolbarVisibility` iPad guard): the wizard asks `Continue setup anyway? [y/N]`. Default is **No** - fix errors first, then re-run `storescreens setup`.
- **Warnings** (e.g. missing accessibility identifiers, unguarded review prompts): the wizard asks `Continue setup with warnings? [Y/n]`. Default is **Yes** - warnings are informational and don't block setup, but should be addressed before capture.

The `missing-accessibility-identifier` warning fires when a UI test file references an identifier (e.g. `waitForElement(id: "saveButton")` or `app.buttons["saveButton"]`) that has no corresponding `.accessibilityIdentifier("saveButton")` in the app source. Help the user add the missing identifier to their view before continuing.

The `localized-nav-button` warning fires when a test uses `navigationBars.buttons["Back"]` (or any title-cased label) to find a navigation bar button. Localized titles change per locale - "Back" becomes "Atrás" in Spanish, "Zurück" in German, etc. - causing test failures when running multi-locale screenshot capture. Fix: use `app.navigationBars.buttons.element(boundBy: 0)` for the system back button (it is always the first/leftmost button), or add an `.accessibilityIdentifier` to a custom button and query by that instead.

**When reporting findings, handle each warning/error category separately** - present one category, ask if the user wants to fix it now, fix it if yes, then move to the next category. Do not bundle multiple categories into a single question.

---

## Step 5: Check / configure screenshot mode in the app

Before writing the test file, check whether the app already handles a screenshot mode launch argument (search for `--uitesting` or `--uitesting` in the Swift source).

If it does not, explain what needs to be added and help the user add it to the appropriate place (app `init`, `@main` struct, or root view `.task`):

```swift
if ProcessInfo.processInfo.arguments.contains("--uitesting") {
    // Force premium/subscriber access - skip StoreKit verification
    // subscriptionService.isSubscribed = true

    // Disable animations for fast, deterministic captures
    UIView.setAnimationsEnabled(false)

    // Reset persisted UI state if needed
    // UserDefaults.standard.removeObject(forKey: "onboardingDone")
}
```

Also guard any `checkEntitlements()` or StoreKit calls so they don't run in screenshot mode. **Wrap the specific call - do not add a `guard...return` at the top of the function.** An early return silently skips everything below it, including state setup like `isInitialized = true`, which can leave the app in a broken state during tests.

```swift
// Good - wraps just the entitlement call:
if !ProcessInfo.processInfo.arguments.contains("--uitesting") {
    checkEntitlements()
}

// Bad - skips ALL code below this line, including isInitialized = true:
// guard !ProcessInfo.processInfo.arguments.contains("--uitesting") else { return }
```

Confirm with the user which (if any) subscription/entitlement service needs to be bypassed.

---

## Step 6: Write `ScreenshotTests.swift`

Ask the user to describe the screens they want to capture and the navigation flow between them. Use their description to write a tailored test file.

Key requirements for the test file:

- `app.launchArguments = ["--uitesting"]`
- Wait for the app to fully load before the first screenshot: `XCTAssertTrue(element.waitForExistence(timeout: 20))`
- Name screenshots with their meaningful identifier only: `"Home"`, `"Detail"`, `"MealPlan"`. No numeric prefixes. The capture pipeline stamps each output PNG's mtime and creationDate in the order of the `screenshots:` list in `storescreens.yml`, so `ls -t` and Finder's "Date Created" sort match the configured display order without needing `01_` / `02_` prefixes.
- Use accessibility identifiers for all element lookups - ask the user what identifiers exist or help them add them. Avoid fragile text/position-based queries.
- Always `waitForExistence(timeout:)` before tapping any element
- Clean up any state changed during the test (e.g. delete test permits/data added during the flow) at the end

After you decide the screens, write the same list under top-level `screenshots:` in `storescreens.yml`. That list is the single source of truth for display order: it drives capture filtering, HTML preview ordering, render order, and mtime stamping.

Use `assets/ScreenshotTests.swift.template` as a starting point. Place the file inside the `<AppName>UITests/` folder.

For XcodeGen-managed projects (`project.yml` exists): the file goes in `<AppName>UITests/` matching the `sources:` path in `project.yml`. No "Add Files to target" step is needed because XcodeGen picks up every `.swift` file under that directory on the next `xcodegen generate`. Run `xcodegen generate` after writing the file, then continue to Step 7.

For projects without XcodeGen, tell the user:

> Add `ScreenshotTests.swift` to the Xcode target:
> Right-click the `<AppName>UITests` group → Add Files to "[project]" → select `ScreenshotTests.swift` → confirm the target membership checkbox is checked.

---

## Step 7: Verify the build

If the project is XcodeGen-managed, run `xcodegen generate` first so the `.xcodeproj` reflects the new test target and any `ScreenshotTests.swift` additions. Any time you edit `project.yml` or add/remove test files, regenerate before invoking `xcodebuild`.

Pipe xcodebuild output to a log file so you can inspect errors:

```bash
xcodebuild build-for-testing \
  -workspace MyApp.xcworkspace \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 18 Pro' \
  2>&1 | tee build.log
```

Use a simulator name from the config, and check `storescreens list` for the names installed: iOS 27 runtimes create `iPhone 18 Pro`, iOS 26 runtimes `iPhone 17 Pro`. The Xcode version alone doesn't decide it, since Xcode 27 can still use an installed iOS 26 runtime.

If this fails, check `build.log` for the full error output and fix before running capture.

---

## Step 8: Capture

### Targeted screenshot (quick visual check)

When you need to verify a UI change visually, **do NOT run the full screenshot suite.** Use the `take_screenshot` MCP tool instead. It captures the current simulator screen in under a second - no build, no tests.

**When to use `take_screenshot`:**
- You edited a SwiftUI view and want to see the result
- You want to confirm a layout change before committing
- The simulator is already running your app

**When to use full `capture` instead:**
- You need final App Store screenshots
- You need multiple devices or locale variants
- The app is not running and you need it built from source

**Using the MCP tool:**

If the simulator is already running your app:

```
take_screenshot(simulator: "iPhone 18 Pro")
```

The image renders inline immediately. If no simulator name is given, it uses the first booted simulator.

If the simulator is not booted:

```
take_screenshot(simulator: "iPhone 18 Pro", boot: true)
```

**Using the CLI (if MCP is not available):**

```bash
storescreens screenshot --simulator "iPhone 18 Pro" --output screenshot.png
# Boot variant:
storescreens screenshot --simulator "iPhone 18 Pro" --boot --output screenshot.png
```

**If you need to navigate to a specific screen** that requires UI test interaction, the old approach still works: temporarily disable the main test, write a focused `testQuickVisual()` method, run `capture`, then clean up. But for a simple "what does the current screen look like" check, `take_screenshot` is much faster.

### Choosing the right screenshot tool

If Xcode 26.3+ is running and its MCP server is available (you'll have tools like `RenderPreview`, `BuildProject`, etc.), you have three options for visual checks:

| Tool | What it captures | When to use |
|------|-----------------|-------------|
| Xcode `RenderPreview` | A single SwiftUI `#Preview` | Checking an individual view's layout. No simulator needed. |
| storescreens `take_screenshot` | The full running app in a simulator | Checking the app with real data, navigation state, and system chrome. |
| storescreens `capture` | Full App Store screenshots across devices | Final screenshots for App Store Connect. Multiple devices, locales, appearances. |

Use `RenderPreview` when you want to check a single view in isolation (it renders SwiftUI previews, not the running app). Use `take_screenshot` when you need to see the actual app running with real state. Use `capture` for the final App Store screenshot suite.

### Full capture

**Use the MCP `capture` tool.** If the storescreens MCP server is not connected, **STOP - go back to Step 1b and set it up.** Do not proceed with Bash capture unless the user explicitly says they don't want MCP. The MCP tools provide structured progress, inline screenshot previews, and eliminate the need to parse raw xcodebuild output.

Check whether the `storescreens` MCP server is connected (you'll have tools like `capture`, `get_capture_status`, `list_screenshots` available). If it is, call the `capture` tool. Only fall back to Bash if the user explicitly declines MCP setup.

**If using MCP:**

1. Call the `capture` tool - it returns a `taskId` immediately and starts capture in the background.
2. **Poll interval: follow what `get_capture_status` tells you.** Each response ends with either "Wait 30 seconds" (build phase) or "Wait 5 seconds" (test phase). Always use the interval the server specifies - never switch on your own based on which devices have finished or what you see in the output.
   ```bash
   sleep 30   # when server says "Wait 30 seconds"
   sleep 5    # when server says "Wait 5 seconds"
   ```
   Then call `get_capture_status(task_id: "<taskId>")`. Repeat until `status: completed` or `status: failed`. Never call `get_capture_status` back-to-back without the sleep in between.
3. **What to show the user while polling:**
   - After each poll, output any **new** `✓ [Device] [Slot] screenshot_name` lines that weren't in the previous poll response. This is the primary progress signal - output them as-is so the user can see screenshots being captured in real time.
   - Also surface these meaningful transitions when they first appear: `Testing started`, `** TEST SUCCEEDED **`, `** BUILD FAILED **`, locale changes (`● Locale: ...`), and device completion lines (`✓ DeviceName: N screenshots`).
   - Do not repeat lines that were already shown in a previous poll. Track what you've output and only emit new lines.
   - **"last activity Xs ago" does NOT mean the run is stuck.** xcodebuild writes build output to stdout (which the MCP tracks), but once the build phase completes and tests start running, xcodebuild writes results directly to the `.xcresult` bundle on disk - **no stdout output is produced during test execution**. A "last activity 300s ago" status is normal and expected during the test phase. Keep polling - do not report a stall to the user unless the run exceeds 20 minutes total. **If the run HAS exceeded 20 minutes and all device tests have already passed** (you saw `Test case '...' passed` for every device in the status output), the MCP server is stuck in a post-test deadlock. Tell the user: "The capture process is stuck. Please run `pkill -f storescreens-mcp` in a terminal, then exit and relaunch Claude Code so the MCP server restarts, then re-run capture."
   - **Detect the stuck-buffer problem.** If the output is identical across any 2 consecutive polls **and** `last activity` has not advanced, immediately call `list_screenshots` AND check the file timestamps (via `ls -la` on the output directory) to verify the screenshots are from the *current* run - not a previous one. Compare the file modification times against when the current capture started. Only report screenshots as complete if they are newer than the capture start time. If timestamps are stale, the run is still in progress - keep polling.
   - **"0 screenshots" for a completed device is a red flag.** If a poll response shows a device reporting "Tests completed" or "0 screenshots", treat this as a likely failure and immediately inspect the per-device log. Do NOT continue polling silently. Read the log:
     ```bash
     cat <output_dir>/logs/test-<UDID-prefix>.log | grep -E "error:|FAILED|fatalError|Could not resolve"
     ```
     If errors are found, report them to the user immediately.
   - **Device failures in logs during polling:** `get_capture_status` will include a "Device failures detected in logs" section if it finds error patterns in any per-device log file. When this appears, immediately report the failure to the user - do not wait for the run to complete. If some devices have failed and others are still running, tell the user: "Device X failed (see error below). Other devices are still running. Do you want to abort or wait for them to finish?" Then wait for the user's answer before taking action.
4. On completion, report results: how many screenshots per device. Output a clickable preview link using the absolute path: `file://<absolute-path-to-project>/<output_dir>/preview.html`. On failure, read the log.

**If using Bash (MCP unavailable):**

```bash
storescreens capture
```

The CLI streams full xcodebuild output to stdout (auto-detected in non-TTY mode). The command output will be large (compile steps + test output).

**IMPORTANT - after the command finishes, ALWAYS print these two messages as your own text output (NOT inside a bash block):**

1. Tell the user they can expand the output block above to see the full xcodebuild build and test log.
2. Show the tail command for the log files using the absolute path to the project's output dir:

```
To follow logs in real time on future runs, open another terminal:
  tail -f <absolute-path-to-project>/<output_dir>/logs/test-*.log
```

Then report the results: how many screenshots captured, whether tests passed/failed. Output a clickable preview link using the absolute path: `file://<absolute-path-to-project>/<output_dir>/preview.html`.

If tests failed, read the log file to diagnose:

```bash
cat <absolute-path-to-project>/<output_dir>/logs/test-*.log
```

Output lands in `storescreens-output/` with device name as a filename prefix. Light and dark mode get separate directories:

```
storescreens-output/
├── preview.html           ← open: file://<absolute-path-to-project>/<output_dir>/preview.html
├── manifest.json
├── logs/
│   └── test-<device>.log  ← one per device
├── light/
│   ├── iPhone_6.9_Home.png
│   ├── iPhone_6.9_Detail.png
│   ├── iPhone_6.3_Home.png
│   └── ...
└── dark/
    ├── iPhone_6.9_Home.png
    ├── iPhone_6.9_Detail.png
    └── ...
```

Screenshot names are the meaningful identifier only (`Home`, `Detail`, `Search`). The capture pipeline stamps each output PNG's mtime + creationDate in the order of the top-level `screenshots:` list in `storescreens.yml`, so `ls -t storescreens-output/light/` and Finder's "Date Created" sort match the configured App Store display order without numeric prefixes.

---

## Step 9: Render captioned, framed screenshots (optional)

Raw captures from Step 8 are functional but plain - just the app UI with no marketing chrome. The render pipeline turns them into framed, captioned images ready to upload to App Store Connect: background, device bezel, images (logos, badges), laurel award overlays, marketing captions with markdown, per-slide highlights.

**When to offer this:** any time the user wants App Store Connect-ready screenshots with captions or device frames. The render is opt-in (off by default) but most users want it before shipping.

**Rendering runs automatically after `storescreens capture`** when `render.enabled: true` is in `storescreens.yml`. It can also be invoked standalone with `storescreens render`, which skips recapture and is the right loop for iterating on caption text, colors, fonts, etc. Pass `--no-render` to `storescreens capture` to skip rendering on one run.

### 9a. Design the narrative first

Before touching YAML, ask the user:

1. **What's the hero story?** Typically the first 2-3 slides in App Store Connect do 90% of the conversion work. What's the single most important thing to communicate? Often this becomes slide 1's title.
2. **What's the slide order?** Get a numbered list. This list goes verbatim into the top-level `screenshots:` key in `storescreens.yml` - the render pipeline walks it in this exact order (no alphabetical reordering anywhere).
3. **Which frame look?** `chrome.style: bezel` is the polished App Store look: Apple's real bezel artwork once the DMGs are imported, and until then it automatically falls back to `device`, a procedurally drawn generic frame (band, bezel ring, Dynamic Island/notch, buttons) that needs zero external assets. `chrome.style: device` uses the drawn frame permanently; `stroke` is a minimal rounded-rect outline. All of these work out of the box - only real Apple artwork requires the bezel install below.
4. **Light, dark, or both?** Most render fields (`background.image`, `background.color`, `images[].path`, `laurels[].color`) accept `{ light:, dark: }` variants.
5. **Any brand fonts?** Four tiers available: `system` (SF Pro), installed family name, local `.otf`/`.ttf` path, `{ google: "Inter" }` (auto-downloaded), or `{ regular:, bold:, italic:, bold_italic: }` bundle for correct markdown bold/italic.

### 9b. Write the screenshots list

Add the authoritative order at the top level of `storescreens.yml`:

```yaml
screenshots:
  - Home
  - Search
  - Detail
  # ...
```

This list is the single source of truth for display order. It drives capture filtering, render order, HTML preview order, and mtime stamping on the output PNGs (first in list = most recent mtime, so `ls -t` and Finder's "Date Created" both match). A panoramic background's left edge pins to the first entry here. An image with `placement: first_only` (also the default for `above_title`) draws on the first entry here.

### 9c. Install bezels (optional - upgrades `bezel` chrome to Apple's real artwork)

Without this install, `chrome.style: bezel` still renders fine using the drawn `device` frame (a warning in the render output says which slides used the fallback). Install the real bezels when the user wants Apple's own artwork.

Apple licenses the bezel PSDs for use with their products. StoreScreens does not redistribute them - the user downloads once, then the importer extracts what it needs.

1. Open <https://developer.apple.com/design/resources/> in a browser. Scroll to "Product Bezels". Download the DMG for each device family needed (iPhone, iPad, MacBook).
2. Double-click each downloaded DMG to mount it. They appear under `/Volumes/`.
3. Run:

```bash
storescreens bezels import
```

This auto-scans `/Volumes/` for Apple Design Resource DMGs, classifies PSDs by screen pixel dimensions, and writes one transparent-screen PNG + JSON sidecar per screen size and orientation to `~/Library/Application Support/storescreens/bezels/` (user-global, shared by every project). When several PSDs fit one size, it ranks them with fixed defaults: a file name that states the orientation first, then model (Pro Max, Pro, Air, mini, any other), then the newer iPhone generation, then colorway (Space Black, Black, Night Sky, Natural Titanium, Silver, Space Gray, Deep Blue, any other), then file name. It does not read `render.chrome.model_preference` / `colorway_preference`. For a different finish, copy that PSD into an empty folder and import with `--volume <folder>`; only the sizes it covers are replaced.

- The iPhone 18 DMG (18 Pro and Pro Max; Black, Silver, Glacier, Burgundy) imports like the iPhone 17 one. The screens are the same size, so when both DMGs are mounted the iPhone 18 artwork wins.
- The iPhone Duo DMG imports `Inner Open Portrait/Landscape` (inner display) and `Outer Closed Portrait/Landscape` (outer display). Its `Outer Open` file is ignored; the outer display uses `Outer Closed Portrait`. Colorways are Night Sky and Star White.

Flags:

- `--volume PATH` - scan this one path (a mounted DMG or any folder of PSDs) instead of auto-scanning `/Volumes/`.
- `--yes` - skip the "about to write N files, ok?" confirmation.

Inspect afterwards:

```bash
storescreens bezels check     # list installed bezels + canonical keys
storescreens bezels path      # print the install directory
```

Per-project override: drop PNG + JSON sidecar files into `./bezels/` next to `storescreens.yml`. Project-local bezels take precedence over user-global ones.

### 9d. Add a `render:` block - build it up incrementally

**Fast path - start from a named template.** If the user wants a sensible default look instead of hand-tuning every field, pick a built-in template. List them:

```bash
storescreens templates
```

Then seed the config:

```yaml
render:
  enabled: true
  template: sahara     # or: ascent, all_the_wiser, ethereal, midnight, pinecrest, blueprint, sunset_blvd
  output_dir: ./storescreens-framed
```

The template fills in `background`, `caption`, and `chrome` defaults (curated palette + typography + a procedural background pattern where appropriate). Anything the user writes explicitly in the same `render:` block still wins over the template - treat the template as "defaults I can override," not a lock-in. The sections below still apply when the user wants to tune individual fields.

Once picked, continue with per-slide captions (Step 9d's "slides:" block below). Most users won't need the background / scrim / chrome sections when using a template.

**Fast path - derive colors from the app itself.** When the user wants the store artwork to match their app's branding rather than a preset, run:

```bash
storescreens themes suggest          # analyzes the last capture; add --json for structured output
```

It returns up to three themes derived from the screenshots' dominant background and most vivid accent color (App Match, Brand Gradient, Soft Tint), each as a ready-to-paste `render:` snippet: `background.color` (solid or gradient), `caption.title.color`, and `chrome.device_colorway`, with legible contrast built in. The same analysis is available as the `suggest_themes` MCP tool. Present the options to the user, and always offer custom colors as an alternative. Apps with no saturated accent (grayscale UIs) get only App Match.

Don't dump a huge render block on the user and hope it renders well. Add fields one at a time, run `storescreens render` after each, and open `preview.html` to inspect.

Start with the minimum:

```yaml
render:
  enabled: true
  output_dir: ./storescreens-framed
  chrome:
    style: bezel          # drawn device frame out of the box; real Apple bezels once imported
```

Add a background (solid color or image, with optional light/dark variants):

```yaml
  background:
    color: "#1a1a2e"
    # or a vertical gradient, top → bottom:
    # color: ["#1a1a2e", "#4a1e5c"]
    # or an image (single wide image = panoramic, sliced across all slides):
    # image: ./marketing/panorama.jpg
    # fit: cover            # cover | contain | tile
    # align: center         # top | center | bottom
    # with appearance variants:
    # image:
    #   light: ./bg-light.png
    #   dark:  ./bg-dark.png
```

**Panoramic background**: if `background.image` is a single image wider than one slide, the renderer slices it across all slides in `screenshots:` order. Left edge of image pins to left edge of the first slide. Slides concatenate side-by-side in the App Store Connect gallery with a continuous image behind them.

Add a scrim to tame a busy background image:

```yaml
  scrim:
    color: "#000000"
    opacity: 0.35
    # or a vertical opacity gradient instead of flat opacity:
    # gradient:
    #   top_opacity: 0.0
    #   bottom_opacity: 0.6
```

Add image overlays (logos, badges - up to 2 per slide, dropped near the caption block):

```yaml
  images:
    - path: ./marketing/logo-wordmark.svg
      position: above_title    # above_title | below_title | above_subtitle | below_subtitle
      align: center            # left | center (default) | right
      max_height_pct: 6        # % of canvas height; default 8
      placement: first_only    # first_only | all | none
```

`position` defaults to `above_title` (matches the legacy logo placement). `placement` defaults to `first_only` for `above_title` and `all` for every other slot. Two entries in the same slot stack horizontally if they share an `align`, or place independently if they don't.

Optionally add laurel "award badge" overlays - left/right laurel SVGs around centered title/subtitle text:

```yaml
  laurels:
    - title: "Editors' Choice"
      subtitle: "App Store"
      color: "#FFD66B"
      position: below_subtitle
      max_height_pct: 11
```

`title` is bold by default and `subtitle` is regular; override with `title_style:` / `subtitle_style:` which accept the same fields as `caption.title`. Up to 2 per slide, same slot rules as `images`.

Backwards compatibility: the legacy `logo:` block (single path + placement) still renders, treated as a single image at `above_title`. New configs should prefer `images:`. Setting `images: []` explicitly suppresses the legacy fallback.

Add captions. Each role (`title`, `subtitle`) is optional. `min_font_size_pct` lets the renderer auto-shrink a long title to fit before it wraps:

```yaml
  caption:
    title:
      font: system                  # or "Helvetica Neue" / path / bundle / google
      weight: bold                  # thin|light|regular|medium|semibold|bold|heavy
      italic: false
      font_size_pct: 5.5
      min_font_size_pct: 3.0
      color: "#ffffff"
      align: center                 # left | center | right
    subtitle:
      font: system
      weight: regular
      font_size_pct: 3.2
      min_font_size_pct: 2.0
      color: "#ffffff"
      align: center
    spacing_pct: 1.0
    min_height_pct: 22              # reserved area at top of canvas for captions
    padding_pct: 5
```

Then fill in per-slide caption text:

```yaml
  slides:
    "Home":
      caption: "Your recipes, organized."        # shorthand: title only
    "Search":
      caption:                                    # array: strict line breaks
        - "Find anything"
        - "in *seconds*."
    "Detail":
      caption:                                    # full object
        title: "Every **detail**, at a glance."
        subtitle: "Powered by AI"
        highlights:
          - { match: "detail", color: "#feb909", weight: heavy, italic: true }
```

**Caption shorthand:**

| Form | Meaning |
|------|---------|
| `caption: "text"` | title only, auto-wraps at canvas width |


…(truncated)
