ios-marketing-capture-automation

Automate multi-locale marketing screenshot capture for SwiftUI iOS apps with in-app capture system, demo data seeding, and element rendering.

Skill file

Preview skill file
---
name: ios-marketing-capture-automation
description: Automate multi-locale marketing screenshot capture for SwiftUI iOS apps with in-app capture system, demo data seeding, and element rendering.
triggers:
  - capture marketing screenshots for my iOS app
  - generate locale screenshots across all languages
  - render my widgets as isolated PNGs
  - automate App Store screenshot capture
  - create marketing assets for SwiftUI app
  - screenshot my app in all locales
  - render iOS app components as images
  - automate iOS app store screenshots
---

# iOS Marketing Capture Automation

> Skill by [ara.so](https://ara.so) — Marketing Skills collection.

## What This Skill Does

This skill helps you automate marketing screenshot capture for SwiftUI iOS apps by building an in-app capture system that:

- Adds a `#if DEBUG`-gated capture system with zero production footprint
- Seeds deterministic demo data so screenshots look populated and polished
- Navigates to each screen programmatically via step-based coordinator
- Snapshots full window including status bar, safe area, and presented sheets
- Renders isolated elements (cards, widgets, charts) via `ImageRenderer` at 3x with transparency
- Loops every locale automatically — one build, N relaunches with `-AppleLanguages`
- Works with any SwiftUI navigation: `TabView`, `NavigationStack`, `NavigationSplitView`

## Installation

### Using npx skills (recommended)

```bash
npx skills add ParthJadhav/ios-marketing-capture
```

Global install (available across all projects):

```bash
npx skills add ParthJadhav/ios-marketing-capture -g
```

Agent-specific install:

```bash
npx skills add ParthJadhav/ios-marketing-capture -a claude-code
```

### Manual installation

```bash
git clone https://github.com/ParthJadhav/ios-marketing-capture ~/.claude/skills/ios-marketing-capture
```

## Requirements Checklist

Before starting, verify:

- ✅ Xcode 16+ (synchronized folder groups support)
- ✅ iOS 17+ deployment target (for `ImageRenderer`, `@Observable`)
- ✅ A simulator runtime matching target iOS version
- ✅ Python 3 (for JSON parsing in shell script)
- ✅ SwiftUI-based app with defined navigation structure

## Core Concepts

### In-App Capture (Not XCUITest)

This approach uses in-app capture instead of XCUITest/Fastlane because:

- **No test target needed** — many projects lack one, adding means fragile pbxproj edits
- **Direct access** — ViewModels, SwiftData, `ImageRenderer`, `UIWindow.drawHierarchy`
- **Faster** — `xcodebuild build` once, then `simctl launch` per locale
- **Element renders require it** — `ImageRenderer` must run inside app process

### Step-Based Coordinator

Each screenshot is a self-contained `CaptureStep`:

```swift
struct CaptureStep {
    let name: String                         // "01-home"
    let navigate: @MainActor () -> Void      // put app in right state
    let settle: Duration                     // wait for animations
    let cleanup: (@MainActor () -> Void)?    // tear down before next step
}
```

## Implementation Pattern

### 1. Gather Requirements

When user asks to capture screenshots, collect:

1. **Screens to capture** — exact tab names or navigation paths
2. **Elements to render** — cards, widgets, charts to isolate
3. **Locales** — explicit list or "all locales in xcstrings"
4. **Device** — simulator model (e.g. "iPhone 17")
5. **Appearance** — light, dark, or both
6. **Seed data requirements** — what demo data needs to populate

### 2. Generated File Structure

Create this structure:

```
YourApp/
├── Debug/
│   └── MarketingCapture.swift      # Capture system (DEBUG-only)
├── ContentView.swift               # Modified — DEBUG hook
scripts/
└── capture-marketing.sh            # Build + locale loop
```

Output lands in:

```
marketing/
    en/
        01-home.png
        02-detail.png
        elements/
            card-item.png
            widget-small.png
    de/
        01-home.png
        ...
```

### 3. Core Capture System Code

```swift
#if DEBUG
import SwiftUI

struct CaptureStep {
    let name: String
    let navigate: @MainActor () -> Void
    let settle: Duration
    let cleanup: (@MainActor () -> Void)?
}

@Observable
final class MarketingCaptureCoordinator {
    var isCapturing = false
    var currentStep = 0
    
    private let steps: [CaptureStep]
    private let outputDir: URL
    private let elementsDir: URL
    
    init(steps: [CaptureStep], locale: String) {
        self.steps = steps
        
        let base = FileManager.default
            .urls(for: .documentDirectory, in: .userDomainMask)[0]
            .deletingLastPathComponent()
            .deletingLastPathComponent()
            .appendingPathComponent("marketing/\(locale)")
        
        self.outputDir = base
        self.elementsDir = base.appendingPathComponent("elements")
        
        try? FileManager.default.createDirectory(
            at: outputDir,
            withIntermediateDirectories: true
        )
        try? FileManager.default.createDirectory(
            at: elementsDir,
            withIntermediateDirectories: true
        )
    }
    
    @MainActor
    func startCapture() async {
        isCapturing = true
        currentStep = 0
        
        for step in steps {
            step.navigate()
            try? await Task.sleep(for: step.settle)
            captureWindow(name: step.name)
            step.cleanup?()
            currentStep += 1
        }
        
        isCapturing = false
        print("✅ Capture complete: \(outputDir.path)")
        exit(0)
    }
    
    private func captureWindow(name: String) {
        guard let window = UIApplication.shared
            .connectedScenes
            .compactMap({ $0 as? UIWindowScene })
            .first?.windows.first else { return }
        
        let renderer = UIGraphicsImageRenderer(bounds: window.bounds)
        let image = renderer.image { ctx in
            window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
        }
        
        let url = outputDir.appendingPathComponent("\(name).png")
        try? image.pngData()?.write(to: url)
    }
}

struct MarketingElementHarness {
    @MainActor
    static func renderElement<Content: View>(
        name: String,
        width: CGFloat,
        cornerRadius: CGFloat,
        background: Color,
        @ViewBuilder content: () -> Content
    ) {
        let renderer = ImageRenderer(content: content()
            .frame(width: width)
            .background(background)
            .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
        )
        renderer.scale = 3.0
        
        guard let uiImage = renderer.uiImage else { return }
        
        let base = FileManager.default
            .urls(for: .documentDirectory, in: .userDomainMask)[0]
            .deletingLastPathComponent()
            .deletingLastPathComponent()
        
        let locale = Locale.current.language.languageCode?.identifier ?? "en"
        let elementsDir = base
            .appendingPathComponent("marketing/\(locale)/elements")
        
        try? FileManager.default.createDirectory(
            at: elementsDir,
            withIntermediateDirectories: true
        )
        
        let url = elementsDir.appendingPathComponent("\(name).png")
        try? uiImage.pngData()?.write(to: url)
    }
}
#endif
```

### 4. Navigation Pattern Examples

#### TabView Navigation

```swift
@Observable
final class AppCoordinator {
    var selectedTab = 0
    
    func setTab(_ index: Int) {
        selectedTab = index
    }
}

// In MarketingCapture.swift:
func makeSteps(coordinator: AppCoordinator) -> [CaptureStep] {
    [
        CaptureStep(
            name: "01-home",
            navigate: { coordinator.setTab(0) },
            settle: .seconds(0.5),
            cleanup: nil
        ),
        CaptureStep(
            name: "02-shelf",
            navigate: { coordinator.setTab(1) },
            settle: .seconds(0.5),
            cleanup: nil
        )
    ]
}
```

#### NavigationStack with Router

```swift
@Observable
final class Router {
    var path: [Route] = []
    
    func push(_ route: Route) {
        path.append(route)
    }
    
    func popToRoot() {
        path.removeAll()
    }
}

// Steps:
CaptureStep(
    name: "03-detail",
    navigate: {
        router.popToRoot()
        router.push(.coffeeDetail(coffee))
    },
    settle: .seconds(0.8),
    cleanup: { router.popToRoot() }
)
```

#### NavigationSplitView

```swift
@Observable
final class SplitCoordinator {
    var sidebarSelection: SidebarItem?
    var detailSelection: DetailItem?
}

// Steps:
CaptureStep(
    name: "04-settings",
    navigate: {
        coordinator.sidebarSelection = .settings
        coordinator.detailSelection = nil
    },
    settle: .seconds(0.6),
    cleanup: nil
)
```

### 5. Demo Data Seeding

```swift
#if DEBUG
@MainActor
func seedMarketingData(modelContext: ModelContext) {
    // Clear existing
    try? modelContext.delete(model: Coffee.self)
    
    // Seed deterministic data
    let coffees = [
        Coffee(
            name: "Morning Blend",
            roaster: "Blue Bottle",
            origin: "Ethiopia",
            notes: ["Blueberry", "Chocolate", "Citrus"],
            roastDate: Date().addingTimeInterval(-7 * 86400)
        ),
        Coffee(
            name: "Dark Horse",
            roaster: "Stumptown",
            origin: "Colombia",
            notes: ["Caramel", "Nuts", "Brown Sugar"],
            roastDate: Date().addingTimeInterval(-3 * 86400)
        )
    ]
    
    coffees.forEach { modelContext.insert($0) }
    try? modelContext.save()
}
#endif
```

### 6. Integrating with ContentView

```swift
struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @State private var coordinator = AppCoordinator()
    
    #if DEBUG
    @State private var captureCoordinator: MarketingCaptureCoordinator?
    #endif
    
    var body: some View {
        TabView(selection: $coordinator.selectedTab) {
            HomeView()
                .tag(0)
                .tabItem { Label("Home", systemImage: "house") }
            
            ShelfView()
                .tag(1)
                .tabItem { Label("Shelf", systemImage: "books.vertical") }
        }
        #if DEBUG
        .task {
            if ProcessInfo.processInfo.environment["MARKETING_CAPTURE"] == "1" {
                seedMarketingData(modelContext: modelContext)
                
                let locale = Locale.current.language.languageCode?.identifier ?? "en"
                captureCoordinator = MarketingCaptureCoordinator(
                    steps: makeMarketingSteps(coordinator: coordinator),
                    locale: locale
                )
                
                try? await Task.sleep(for: .seconds(1))
                await captureCoordinator?.startCapture()
            }
        }
        #endif
    }
}
```

### 7. Element Rendering Examples

#### Widget Rendering

```swift
#if DEBUG
@MainActor
func renderWidgets() {
    let entry = PulseWidgetEntry(
        date: Date(),
        coffee: seedCoffee,
        recentBrew: seedBrew
    )
    
    // Small widget
    MarketingElementHarness.renderElement(
        name: "widget-pulse-small",
        width: 158,
        cornerRadius: 16,
        background: .white
    ) {
        PulseWidgetSmallView(entry: entry)
            .padding(16) // WidgetKit padding manually added
    }
    
    // Medium widget
    MarketingElementHarness.renderElement(
        name: "widget-pulse-medium",
        width: 338,
        cornerRadius: 16,
        background: .white
    ) {
        PulseWidgetMediumView(entry: entry)
            .padding(16)
    }
}
#endif
```

#### Card Rendering

```swift
#if DEBUG
@MainActor
func renderCards(coffees: [Coffee]) {
    for (index, coffee) in coffees.enumerated() {
        MarketingElementHarness.renderElement(
            name: "card-\(index + 1)",
            width: 380,
            cornerRadius: 20,
            background: Color(.systemBackground)
        ) {
            CoffeeCard(coffee: coffee)
                .padding(.horizontal, 16)
                .padding(.vertical, 12)
        }
    }
}
#endif
```

#### Chart Rendering

```swift
#if DEBUG
@MainActor
func renderCharts() {
    MarketingElementHarness.renderElement(
        name: "chart-cupping",
        width: 380,
        cornerRadius: 16,
        background: Color(.systemBackground)
    ) {
        CuppingRadarChart(scores: sampleScores)
            .frame(height: 300)
            .padding(20)
    }
}
#endif
```

### 8. Build and Launch Script

```bash
#!/bin/bash
# scripts/capture-marketing.sh

set -e

APP_NAME="YourApp"
SCHEME="YourApp"
DEVICE="iPhone 17"
IOS_VERSION="18.2"

LOCALES=("en" "de" "es" "fr" "ja")

SIMULATOR_ID=$(xcrun simctl list devices available | \
    grep "$DEVICE ($IOS_VERSION)" | head -1 | \
    grep -o '\[.*\]' | tr -d '[]')

if [ -z "$SIMULATOR_ID" ]; then
    echo "❌ Simulator '$DEVICE ($IOS_VERSION)' not found"
    exit 1
fi

echo "📱 Using simulator: $SIMULATOR_ID"

# Boot simulator
xcrun simctl boot "$SIMULATOR_ID" 2>/dev/null || true
sleep 2

# Build once
echo "🔨 Building..."
xcodebuild \
    -scheme "$SCHEME" \
    -destination "id=$SIMULATOR_ID" \
    -configuration Debug \
    -derivedDataPath ./build \
    build

APP_PATH=$(find ./build -name "${APP_NAME}.app" | head -1)
BUNDLE_ID=$(defaults read "$APP_PATH/Info.plist" CFBundleIdentifier)

echo "📦 Bundle ID: $BUNDLE_ID"

# Install once
xcrun simctl install "$SIMULATOR_ID" "$APP_PATH"

# Per-locale capture
for LOCALE in "${LOCALES[@]}"; do
    echo ""
    echo "📸 Capturing $LOCALE..."
    
    xcrun simctl launch \
        --terminate-running-process \
        "$SIMULATOR_ID" \
        "$BUNDLE_ID" \
        -AppleLanguages "($LOCALE)" \
        -MARKETING_CAPTURE 1
    
    sleep 8
done

# Copy to project
CONTAINER=$(xcrun simctl get_app_container "$SIMULATOR_ID" "$BUNDLE_ID" data)
cp -R "$CONTAINER/Documents/../../tmp/marketing" ./marketing/

echo ""
echo "✅ All locales captured → ./marketing/"
```

## Critical Gotchas (Baked Into Skill)

### 1. Live Activities Persist Across Launches

**Problem:** Next locale crashes on stale SwiftData references.

**Solution:** End all Live Activities in cleanup:

```swift
for activity in Activity<PulseAttributes>.activities {
    await activity.end(nil, dismissalPolicy: .immediate)
}
```

### 2. Re-Seeding Per Locale

**Problem:** CloudKit sync churn causes crashes.

**Solution:** Seed once at launch, not per step.

### 3. ViewModels Setup Before Seed

**Problem:** VMs hold stale empty snapshots.

**Solution:** Seed data → wait 1s → instantiate VMs → capture.

### 4. Setting Trigger Binding to nil

**Problem:** Doesn't dismiss `fullScreenCover` — captures wrong sheet.

**Solution:** Use dedicated dismiss closure:

```swift
.fullScreenCover(isPresented: $showTimer) {
    TimerView(onDismiss: { showTimer = false })
}
```

### 5. NavigationPath Can't Be Popped Externally

**Problem:** Pushing over existing path captures wrong stack.

**Solution:** Always `popToRoot()` before `push()` in navigate block.

### 6. `membershipExceptions` Is an INCLUSION List

**Problem:** Widget target membership goes backwards — widget renders fail.

**Solution:** Set `membershipExceptions` for DEBUG files to exclude widget target:

```swift
// In pbxproj or via Xcode:
// MarketingCapture.swift → Target Membership → Widget ❌
```

### 7. `ImageRenderer` + `ProgressView`

**Problem:** Renders as prohibited symbol without explicit style.

**Solution:** Force `.circular` style:

```swift
ProgressView()
    .progressViewStyle(.circular)
```

### 8. `.containerBackground` Outside WidgetKit

**Problem:** No-op — widget renders have no background.

**Solution:** Manually add background in render harness:

```swift
WidgetView(entry: entry)
    .background(Color.white)
```

### 9. iPhone 8 Plus Gone on iOS 18+

**Problem:** Legacy 6.5" simulator unavailable.

**Solution:** Use iPhone 17 Pro Max or latest available large device.

### 10. Locale Launch Argument Format

**Problem:** Locale ignored if parens missing.

**Solution:** Always use `"($LOCALE)"` format:

```bash
-AppleLanguages "(de)"  # ✅
-AppleLanguages "de"    # ❌
```

### 11. SwiftUI Animations in ImageRenderer

**Problem:** Captures frame 0, not animated state.

**Solution:** Render non-animated state or use explicit `.animation(nil)`.

## Common Workflows

### Capturing Timer Mid-Countdown

```swift
CaptureStep(
    name: "05-timer-active",
    navigate: {
        router.popToRoot()
        coordinator.startTimer(seconds: 180)
        // Timer view subscribes to coordinator.activeTimer
    },
    settle: .seconds(1.5), // Let timer animate in
    cleanup: {
        coordinator.stopTimer()
        router.popToRoot()
    }
)
```

### Capturing Presented Sheet

```swift
@State private var showSheet = false

// Step:
CaptureStep(
    name: "06-add-coffee",
    navigate: {
        showSheet = true
    },
    settle: .seconds(0.8),
    cleanup: {
        showSheet = false
    }
)

// View:
.sheet(isPresented: $showSheet) {
    AddCoffeeView()
}
```

### Rendering All Widgets

```swift
#if DEBUG
@MainActor
func renderAllWidgets() {
    let entries = [
        ("pulse-small", PulseWidgetSmallView.self, 158),
        ("pulse-medium", PulseWidgetMediumView.self, 338),
        ("pulse-large", PulseWidgetLargeView.self, 338)
    ]
    
    for (name, viewType, width) in entries {
        MarketingElementHarness.renderElement(
            name: "widget-\(name)",
            width: width,
            cornerRadius: 16,
            background: .white
        ) {
            viewType.init(entry: sampleEntry)
                .padding(16)
        }
    }
}
#endif
```

## Troubleshooting

### Capture exits immediately

**Check:** `MARKETING_CAPTURE=1` env var is set in launch args.

```bash
xcrun simctl launch ... -MARKETING_CAPTURE 1
```

### Locale not applied

**Check:** Parens in `-AppleLanguages`:

```bash
-AppleLanguages "(de)"  # ✅ Correct
```

### Widget renders blank

**Check:** Manual padding added (WidgetKit adds 16pt automatically):

```swift
WidgetView(entry: entry)
    .padding(16)  // Add this
```

### Screenshot captures wrong screen

**Check:** Settle duration long enough for animations:

```swift
settle: .seconds(0.8)  // Increase if needed
```

### SwiftData crash on second locale

**Check:** Live Activities ended in cleanup:

```swift
cleanup: {
    Task {
        for activity in Activity<Attrs>.activities {
            await activity.end(nil, dismissalPolicy: .immediate)
        }
    }
}
```

### Element renders with wrong size

**Check:** Frame width explicitly set:

```swift
content()
    .frame(width: 380)  // Must be explicit
```

## Configuration Reference

### Capture Step Properties

| Property | Type | Purpose |
|----------|------|---------|
| `name` | `String` | Filename without extension (e.g. "01-home") |
| `navigate` | `@MainActor () -> Void` | Put app in correct state |
| `settle` | `Duration` | Wait time for animations |
| `cleanup` | `(@MainActor () -> Void)?` | Tear down before next step |

### Script Variables

| Variable | Example | Purpose |
|----------|---------|---------|
| `APP_NAME` | `"YourApp"` | App target name |
| `SCHEME` | `"YourApp"` | Xcode scheme |
| `DEVICE` | `"iPhone 17"` | Simulator model |
| `IOS_VERSION` | `"18.2"` | iOS runtime |
| `LOCALES` | `("en" "de" "es")` | Locale codes |

### Environment Variables

Set in launch args or script:

```bash
MARKETING_CAPTURE=1        # Enable capture mode
```

## Post-Processing

Use [app-store-screenshots](https://github.com/ParthJadhav/app-store-screenshots) to composite captured PNGs into Apple-style marketing pages with device mockups, headlines, and gradients.

```bash
npx skills add ParthJadhav/app-store-screenshots
```

Then prompt:

```
Composite my marketing screenshots into App Store pages with device frames
```

## When to Use This Skill

✅ **Use when:**
- Capturing marketing screenshots for App Store
- Rendering isolated components (cards, widgets, charts)
- Multi-locale asset generation
- SwiftUI-based iOS app with defined navigation
- Need full control over demo data and app state

❌ **Don't use when:**
- UIKit-based app (requires different capture approach)
- XCUITest infrastructure already working well
- Single locale, manual capture sufficient
- App uses web views or external content (can't seed)

## Example: Complete Coffee App Capture

```swift
#if DEBUG
@MainActor
func makeMarketingSteps(
    coordinator: AppCoordinator,
    router: Router,
    viewModel: HomeViewModel
) -> [CaptureStep] {
    let coffees = viewModel.coffees
    
    return [
        // Tab 1: Home
        CaptureStep(
            name: "01-home",
            navigate: { coordinator.setTab(0) },
            settle: .seconds(0.5),
            cleanup: nil
        ),
        
        // Tab 2: Shelf
        CaptureStep(
            name: "02-shelf",
            navigate: { coordinator.setTab(1) },
            settle: .seconds(0.5),
            cleanup: nil
        ),
        
        // Detail: First coffee
        CaptureStep(
            name: "03-detail",
            navigate: {
                coordinator.setTab(0)
                router.popToRoot()
                router.push(.coffeeDetail(coffees[0]))
            },
            settle: .seconds(0.8),
            cleanup: { router.popToRoot() }
        ),
        
        // Timer: Active brew
        CaptureStep(
            name: "04-timer",
            navigate: {
                coordinator.setTab(0)
                router.popToRoot()
                coordinator.startTimer(seconds: 180)
            },
            settle: .seconds(1.2),
            cleanup: {
                coordinator.stopTimer()
                router.popToRoot()
            }
        ),
        
        // Settings
        CaptureStep(
            name: "05-settings",
            navigate: { coordinator.setTab(2) },
            settle: .seconds(0.5),
            cleanup: nil
        ),
        
        // Elements: Cards
        CaptureStep(
            name: "elements",
            navigate: {
                for (index, coffee) in coffees.enumerated() {
                    MarketingElementHarness.renderElement(
                        name: "card-\(index + 1)",
                        width: 380,
                        cornerRadius: 20,
                        background: Color(.systemBackground)
                    ) {
                        CoffeeCard(coffee: coffee)
                            .padding(.horizontal, 16)
                            .padding(.vertical, 12)
                    }
                }
                
                renderAllWidgets()
            },
            settle: .seconds(0.1),
            cleanup: nil
        )
    ]
}
#endif
```

Run:

```bash
chmod +x scripts/capture-marketing.sh
./scripts/capture-marketing.sh
```

Output:

```
marketing/
    en/
        01-home.png
        02-shelf.png
        03-detail.png
        04-timer.png
        05-settings.png
        elements/
            card-1.png
            card-2.png
            widget-pulse-small.png
            widget-pulse-medium.png
    de/
        [same structure]
    es/
        [same structure]
    fr/
        [same structure]
    ja/
        [same structure]
```

## Summary

This skill automates iOS marketing screenshot capture by:

1. **Gathering requirements** — screens, elements, locales, device, appearance
2. **Generating capture system** — DEBUG-gated coordinator + steps
3. **Seeding demo data** — deterministic, locale-agnostic
4. **Navigating programmatically** — TabView, NavigationStack, or SplitView
5. **Capturing window** — `drawHierarchy` for full screenshots
6. **Rendering elements** — `ImageRenderer` for isolated components
7. **Looping locales** — `simctl launch` with `-AppleLanguages`

The result: one build, N launches, complete multi-locale marketing assets ready for App Store or further compositing.

Source

Creator's repository · aradotso/marketing-skills

View on GitHub

Security

Security checks in progress
Results will appear here once audits complete
What this skill can do
Reads your filesConnects to the internetRuns code on your machine
Checked by 3 independent security firms
Does it try to trick the AI?Not yet checkedPending · Gen Agent Trust Hub
Does it sneak in hidden code?Not yet checkedPending · Socket
Does it have known bugs?Not yet checkedPending · Snyk