
Ios Marketing Capture
- 347 installs
- 253 repo stars
- Updated April 10, 2026
- parthjadhav/ios-marketing-capture
ios-marketing-capture is a Claude Code agent skill that helps iOS developers produce App Store marketing screenshots and promotional device captures for app launch and ASO listing updates.
About
ios-marketing-capture is an agent skill from parthjadhav/ios-marketing-capture aimed at iOS developers preparing App Store marketing assets. The skill guides capture workflows for device-framed screenshots, promotional imagery, and listing-ready marketing visuals so Swift and SwiftUI apps can ship polished App Store pages without manual design handoffs. Developers reach for ios-marketing-capture when an Xcode project is feature-complete and the team needs consistent screenshot dimensions, device frames, and marketing capture sequences for App Store Connect submission or ASO refreshes. Source documentation is minimal; the skill name and repository target mobile launch distribution tasks.
- ios-marketing-capture
Ios Marketing Capture by the numbers
- 347 all-time installs (skills.sh)
- +18 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,207 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/parthjadhav/ios-marketing-capture --skill ios-marketing-captureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 347 |
|---|---|
| repo stars | ★ 253 |
| Last updated | April 10, 2026 |
| Repository | parthjadhav/ios-marketing-capture ↗ |
How do you create App Store marketing screenshots for iOS?
Use ios-marketing-capture for development tasks
Who is it for?
iOS developers preparing App Store Connect listings who need agent-guided marketing screenshot and capture workflows.
Skip if: Android Play Store asset generation, in-app UI implementation, or full App Store metadata keyword research.
When should I use this skill?
User asks to capture iOS App Store screenshots, create marketing device frames, or prepare promotional images for App Store submission.
What you get
App Store-ready marketing screenshots, device-framed captures, and promotional image assets for iOS listing submission.
- App Store marketing screenshots
- device-framed promotional images
Files
iOS Marketing Capture
Overview
Automate reproducible marketing screenshot capture for a SwiftUI iOS app across multiple locales, with two parallel output streams:
1. Full-screen captures — every marketing-relevant screen, with deterministic seeded data, real status bar / safe-area chrome 2. Element captures — isolated renders of specific components (cards, widgets, charts) at any scale, with natural background inside rounded corners and transparency outside
This skill is the capture step. If the user also wants Apple-style marketing pages composited around the shots (device mockups, headlines, gradients), combine with the app-store-screenshots skill as a post-processing step.
Core Approach
In-app capture mode, not XCUITest. This is a hard decision that trades off against Fastlane snapshot / XCUITest conventions, and it wins for almost every real project.
Why in-app over XCUITest:
- No new test target. Adding a UI test target to an existing Xcode project is fragile pbxproj surgery. Many projects have zero test targets and no xcodegen — adding one by hand is error-prone.
- Faster iteration. A UI test takes 30s+ to launch per run. In-app capture is just a relaunch of the installed binary.
- No `xcodebuild test`. The whole flow is
xcodebuild buildonce, thensimctl launchper locale. No test-bundle overhead. - Access to real app state. You can call ViewModels, SwiftData, ImageRenderer, and
UIWindow.drawHierarchydirectly. XCUITest can only tap and read accessibility elements. - Element renders need in-process anyway.
ImageRendereron widget views or isolated components must run inside the app process — there's no XCUITest equivalent.
How it works:
1. A DEBUG-only MarketingCapture.swift file lives in the main app target 2. When launched with -MarketingCapture 1, the app seeds data, then a coordinator walks a list of CaptureSteps — each step navigates, waits for settle, snapshots, and cleans up 3. PNGs are written to the app's sandbox Documents/marketing/<locale>/ directory 4. A shell script builds once, installs, then loops locales by relaunching with -AppleLanguages (xx) -AppleLocale xx, pulling files out via simctl get_app_container
Process
Work through these steps in order. Do not skip ahead.
Step 1: Gather requirements
Ask the user these questions one at a time (do not batch them — each answer can invalidate later questions):
1. Screens to capture — "Which screens do you want? Give me the navigation path or the tab name for each." Get a concrete list, not "the main flows". 2. Isolated elements — "Any components you want rendered independently with transparent backgrounds? (carousel cards, widgets, hero tiles, charts, etc.)" 3. Locales — "Which locales? (a) all locales in your Localizable.xcstrings, (b) an App Store subset I'll specify, or (c) let me give you an explicit list." If (a), grep the .xcstrings file for locale codes:
python3 -c "import json; d=json.load(open('<path>/Localizable.xcstrings')); langs=set(); [langs.update(v.get('localizations',{}).keys()) for v in d['strings'].values()]; print(sorted(langs))"4. Device — "Which simulator? (6.1\" iPhone 17 recommended for iOS 26 design features)" — verify the device is available via xcrun simctl list devices available. 5. Appearance — "Light only, dark only, or both?" 6. Seed data — "How is demo data populated today? (a) fresh install seeds it automatically, (b) there's a debug 'Load Demo Data' button, (c) you add it manually, (d) no demo data exists yet." Then: "Is the existing data exhaustive enough that every screen you listed looks populated for marketing? Audit it with the user."
Step 2: Exploration
Before writing any code, explore the codebase enough to answer:
- Does the project use Xcode synchronized folder groups (Xcode 16+,
PBXFileSystemSynchronizedRootGroup)? If yes, new files auto-include in their target — no pbxproj edits needed. Check withgrep -c PBXFileSystemSynchronized <proj>.xcodeproj/project.pbxproj. - What is the root navigation pattern?
TabView(selection:)— most common. You need: the@State selectedTabbinding, tab indices, and which tabs have nestedNavigationStack.NavigationStack(single stack with a router) — you need: the path binding or router object, plus the set ofNavigationLink(value:)/.navigationDestinationtypes.NavigationSplitView— you need: the sidebar selection binding, detail column's navigation state.- Custom coordinator / UIKit host — you need: the coordinator's
navigate(to:)method or equivalent. - How are deep links routed? Find the
onOpenURLhandler and the enum/switch that maps URLs to navigation state. - Where are demo data seeders defined? Trace the code path from the debug button (if any) to the function that actually writes to
ModelContext. If no seeder exists, see "Creating a demo data seeder" below. - Do widgets live in a separate target? Are the widget view files and entry types in the main app target too? (Almost certainly no — they need to be added if you want to render them via ImageRenderer.)
- Does the app use Live Activities / ActivityKit? If yes, flag this as a known gotcha (see below).
- Does the app use SwiftData + CloudKit sync (
cloudKitDatabase: .automatic)? If yes, flag as a known gotcha. - Does any view need to be captured in a non-default state? (e.g. a timer mid-countdown, a form partially filled, a chart with specific values). If yes, each needs a
static varpriming mechanism (see "Priming view state" below).
Step 3: Present design to user
Before writing code, summarize your plan in this structure. Get explicit approval before proceeding:
1. Architecture (in-app capture mode, single file, DEBUG-gated) 2. File list (exact paths you'll create / modify) 3. Screen-by-screen capture plan (how each screen is reached — tab index, navigation path, sheet trigger) 4. Capture ordering rationale (which screens must come before others — see gotcha #5) 5. Element rendering approach (which components, how they'll be wrapped) 6. Output layout (folder structure, naming convention) 7. Known gotchas relevant to this project (flagged from Step 2) 8. Primed states needed (which views, what static vars)
Step 4: Implement
Use the templates in templates/ as starting points. They are reference patterns, not copy-paste scaffolding — every project has different navigation, models, and views. The templates show the building blocks; you compose them for the target app.
Key files to produce:
<AppName>/Debug/MarketingCapture.swift— the whole capture system, DEBUG-only. Contains:MarketingCaptureenum (launch arg parsing, output helpers, window snapshot, priming vars)MarketingCaptureCoordinatorclass (walks[CaptureStep]and snapshots each)MarketingElementHarnessenum (ImageRenderer renders of cards, widgets, charts)<AppName>/ContentView.swift(or wherever the root view lives) — DEBUG hook that seeds data and runs the coordinator.- Any views that need primed states — DEBUG-gated
.onAppearhooks and.onReceivedismiss listeners. scripts/capture-marketing.sh— build + install + per-locale loop..gitignore— addmarketing/.
Step 5: Verify iteratively
Do not hand the script to the user and wait. Run it yourself against a simulator and verify at least one locale before declaring done. Read the output PNGs with the Read tool to visually verify each screen shows what you expect. Common runtime issues are listed in "Known Gotchas" below.
When you find an issue, fix it, rerun the whole script (not just the failing locale — fixes can regress earlier locales), and re-verify visually.
Architecture: Step-Based Capture
The coordinator drives capture by walking a list of CaptureStep values. Each step is self-contained: it knows how to navigate to its screen, how long to wait, and how to clean up afterward.
struct CaptureStep {
let name: String // output filename, e.g. "01-home"
let navigate: @MainActor () -> Void // put the app in the right state
let settle: Duration // wait for animations/loads
let cleanup: (@MainActor () -> Void)? // tear down before next step
}The coordinator is a simple loop:
for step in steps {
step.navigate()
try? await Task.sleep(for: step.settle)
if let image = MarketingCapture.snapshotKeyWindow() {
MarketingCapture.writePNG(image, name: step.name)
}
step.cleanup?()
try? await Task.sleep(for: .milliseconds(400)) // cleanup animation
}Building steps for different navigation patterns
TabView app (most common):
// Simple tab switch — just set the index
CaptureStep(name: "01-home", navigate: { setTab(0) }, settle: .milliseconds(1800), cleanup: nil)
// Tab + presented sheet
CaptureStep(
name: "05-timer-setup",
navigate: {
setTab(3)
pendingBrewRecipe = someRecipe
},
settle: .milliseconds(2000),
cleanup: {
NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil)
pendingBrewRecipe = nil
}
)NavigationStack + router app:
// Push a route onto the stack
CaptureStep(
name: "02-detail",
navigate: { router.push(.itemDetail(item)) },
settle: .milliseconds(1800),
cleanup: { router.popToRoot() }
)NavigationSplitView app:
// Select sidebar item, then detail
CaptureStep(
name: "03-detail",
navigate: {
sidebarSelection = .recipes
detailSelection = recipes.first
},
settle: .milliseconds(1800),
cleanup: { detailSelection = nil }
)Ordering: the stacking rule
Capture any screen that needs a "clean" navigation state BEFORE screens that push onto the same stack. Nested NavigationPath / @State inside child views can't be popped from the coordinator. So:
Good: Shelf (clean list) → Coffee Detail (pushes onto shelf's stack)
Bad: Coffee Detail → Shelf (stack still has detail pushed)If two screens share a NavigationStack, capture the root-level view first.
Priming View State
Some screens need to be captured in a specific non-default state — a timer mid-countdown, a chart with particular values, a form half-filled. The pattern:
1. Add a static var to MarketingCapture for each priming value:
/// Set by the coordinator before presenting the timer view.
/// The view reads this in .onAppear to jump to a specific elapsed time.
static var pendingElapsedSeconds: Int?
/// Set to true to show the assessment overlay on the timer.
static var pendingShowAssessment: Bool = false2. In the target view, add a DEBUG-gated .onAppear that reads the priming value:
.onAppear {
#if DEBUG
if MarketingCapture.isActive, let elapsed = MarketingCapture.pendingElapsedSeconds {
phase = .active
timerVM.elapsedTime = TimeInterval(elapsed)
timerVM.start()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { timerVM.pause() }
}
#endif
}3. In the coordinator, set the var before navigating:
CaptureStep(
name: "06-timer-midway",
navigate: {
MarketingCapture.pendingElapsedSeconds = 75
openTimerSheet(someRecipe)
},
settle: .milliseconds(2400),
cleanup: {
MarketingCapture.pendingElapsedSeconds = nil
NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil)
}
)Creating a Demo Data Seeder
If the app has no existing demo data mechanism, create one. Place it in <AppName>/Debug/DemoDataSeeder.swift, wrapped in #if DEBUG.
Guidelines:
- Seed enough data that every captured screen looks populated. Audit the screen list against the seed.
- Use realistic content: real place names, plausible numbers, varied states (some items "running low", some "fresh", some with images, some without).
- If the app uses SwiftData, write directly to the
ModelContext. If Core Data, use the managed object context. If a REST backend, seed via the local cache/store layer. - Make seeding idempotent — check if data already exists before inserting. The store persists across simulator relaunches, and re-seeding per locale causes CloudKit sync churn and crashes.
- Include enough variety to fill different UI states: empty states should NOT appear unless they're a marketing screen.
Minimal shape:
#if DEBUG
enum DemoDataSeeder {
static func seedIfEmpty(in context: ModelContext) {
let existing = (try? context.fetchCount(FetchDescriptor<Item>())) ?? 0
guard existing == 0 else { return }
// Items with varied states
let items = [
Item(name: "...", status: .active, ...),
Item(name: "...", status: .lowStock, ...),
// ...enough to fill every screen
]
items.forEach { context.insert($0) }
try? context.save()
}
}
#endifElement Rendering
Elements are rendered via ImageRenderer at 3x scale with transparency outside rounded corners.
Cards / list rows
@MainActor
static func renderCards(items: [Item], theme: AppTheme) {
let cardWidth: CGFloat = 380
for item in items {
let card = ItemCard(item: item, theme: theme)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(width: cardWidth)
.background(theme.background)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
let renderer = ImageRenderer(content: card)
renderer.scale = 3
renderer.isOpaque = false
renderer.proposedSize = .init(width: cardWidth, height: nil)
guard let image = renderer.uiImage else { continue }
MarketingCapture.writePNG(image, name: "card-\(slugify(item.name))", subfolder: "elements")
}
}Widgets
Widget views require special handling because they normally run inside WidgetKit's process and rely on system-provided padding and backgrounds.
@MainActor
static func renderWidget(
name: String,
size: CGSize,
cornerRadius: CGFloat? = nil,
@ViewBuilder content: () -> some View
) {
let isAccessory = size.height <= 80
let radius = cornerRadius ?? (isAccessory ? 8 : 22)
let contentPadding: CGFloat = isAccessory ? 0 : 16
let view = content()
.padding(contentPadding)
.frame(width: size.width, height: size.height)
.background(theme.background)
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
.environment(\.colorScheme, .light)
let renderer = ImageRenderer(content: view)
renderer.scale = 3
renderer.isOpaque = false
renderer.proposedSize = .init(width: size.width, height: size.height)
guard let image = renderer.uiImage else { return }
MarketingCapture.writePNG(image, name: name, subfolder: "elements")
}
// Standard iPhone widget sizes (points, iPhone 14-17 size class)
enum WidgetSize {
static let small = CGSize(width: 170, height: 170)
static let medium = CGSize(width: 364, height: 170)
static let large = CGSize(width: 364, height: 382)
static let accessoryCircular = CGSize(width: 76, height: 76)
static let accessoryRectangular = CGSize(width: 172, height: 76)
static let accessoryInline = CGSize(width: 257, height: 26)
}
// Usage:
renderWidget(name: "widget-pulse-small", size: WidgetSize.small) {
PulseSmallView(entry: PulseEntry(
date: Date(),
count: 2,
streak: 5,
lastItemName: "Morning Routine"
))
}Charts / standalone views
Any SwiftUI view can be rendered as an element. Wrap it the same way — explicit size, background, corner clip:
@MainActor
static func renderChart() {
let chart = MyChartView(values: ChartData.sample)
.frame(width: 420, height: 420)
.background(theme.background)
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
let renderer = ImageRenderer(content: chart)
renderer.scale = 3
renderer.isOpaque = false
renderer.proposedSize = .init(width: 420, height: 420)
guard let image = renderer.uiImage else { return }
MarketingCapture.writePNG(image, name: "chart-overview", subfolder: "elements")
}Known Gotchas
These are all real bugs that bit a real project. Treat this list as load-bearing.
1. Live Activities persist across app launches
ActivityKit Live Activities outlive process termination. If your app starts a Live Activity during capture (e.g. via a timer's start()), then the next locale's relaunch will inherit it. Combined with a fresh seed that deletes the models the stale LA references, you get SwiftData persisted-property assertions.
Fix: call <ActivityManager>.shared.endImmediately() at the very start of the marketing capture block, before touching data. Also call timerVM.stop() (or whatever properly ends the LA) in the view's onDisappear when in capture mode.
2. Don't re-seed on every locale
Seeding SwiftData + CloudKit per locale causes sync churn and crashes. The SwiftData store persists across relaunches — the data is locale-agnostic demo content, so seed once on the first run and skip subsequent runs:
contentVM.fetchItems()
if contentVM.allItems.isEmpty {
DemoDataSeeder.seedIfEmpty(in: modelContext)
contentVM.fetchItems()
}3. ViewModels that setup before the seed hold stale snapshots
If the root view's onAppear calls someVM.setup(modelContext:) before the marketing seed runs, the VM holds a snapshot of the empty store. After seeding, call someVM.refresh() (or its equivalent fetch method) for every VM whose data you need.
4. Setting a trigger binding to nil does NOT dismiss a sheet
If a parent view presents a .fullScreenCover(item: $request) and request is driven by an internal @State, then setting the trigger binding (e.g. pendingItem = nil) does nothing to the cover. The cover stays up, and your next screenshot captures it instead of the screen you navigated to.
Fix: broadcast a dismiss signal via NotificationCenter, and have the presented view listen:
// MarketingCapture.swift
static let dismissSheetNotification = Notification.Name("MarketingCapture.dismissSheet")
// In presented view body
.onReceive(NotificationCenter.default.publisher(for: MarketingCapture.dismissSheetNotification)) { _ in
dismiss()
}Then in the step's cleanup, post the notification and allow at least 900ms for the cover animation to complete before the next step begins.
5. NavigationPath can't be popped from outside
If a child view holds @State private var navigationPath = NavigationPath() and a deep link pushes onto it, the coordinator can't reach in to pop. Solution: reorder your capture sequence so screens that push onto a stack come AFTER screens that need a clean stack. Example: capture Shelf first, then push into Coffee Detail — don't do it the other way around.
6. Widget views normally live in the extension target only
If the user's widget views are only in the widget extension target, you can't reference them from MarketingCapture.swift in the main app target. You need to either:
- (a) Add the widget view files (and their entry types and any shared helpers) to the main app target's membership. If the project uses synchronized folder groups, this means editing
PBXFileSystemSynchronizedBuildFileExceptionSet.membershipExceptions. CRITICAL GOTCHA: `membershipExceptions` is an INCLUSION list, not an exclusion list. Files listed there ARE members of the target, not excluded from it. Read this twice before editing. - (b) Skip widget rendering from the capture harness and let the user do them manually.
You'll also need to exclude <App>WidgetBundle.swift from the main app target (it has @main and conflicts with the app's @main).
7. ImageRenderer + ProgressView(value:total:) = prohibited symbol
Without an explicit style, ProgressView determinate renders as a red circle-with-slash when composited through ImageRenderer. Fix: .progressViewStyle(.linear) on the ProgressView. It's a no-op in normal rendering and fixes the render glitch.
8. .containerBackground(for: .widget) is a no-op outside widget context
When you render a widget view via ImageRenderer in the app, its .containerBackground does nothing — the widget's background is transparent, and pixels outside the content are bare. You must wrap the widget render with an explicit background color + rounded rect clip:
content()
.padding(16) // widget container normally provides this
.frame(width: size.width, height: size.height)
.background(theme.background)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))Home-screen widget corner radius on iPhone: ~22pt. Lock-screen accessory radius: ~8pt.
9. iPhone 8 Plus is gone on iOS 26
If the user asks for a "6.5\" iPhone" (legacy App Store size), note that iOS 26+ simulators don't include iPhone 8 Plus / iPhone 11 Pro Max. Options: (a) install an older iOS runtime via Xcode > Settings > Platforms, or (b) fall back to a modern 6.1\" like iPhone 17 for iOS 26 design features.
10. Locale launch arguments
Pass -AppleLanguages (xx) -AppleLocale xx at every simctl launch. The parens around the language code are mandatory (it's a plist array literal). Use Locale.current.language.languageCode?.identifier for folder naming — it's more robust than Locale.current.identifier which may include region suffixes like en_US.
11. SwiftUI animations in ImageRenderer
ImageRenderer captures a single frame — it doesn't wait for animations. If your component has an .onAppear animation (chart drawing, number counting up), the render may capture the initial state. Either disable the animation in capture mode or add an explicit delay before rendering:
try? await Task.sleep(for: .milliseconds(500)) // let onAppear animations finish
let renderer = ImageRenderer(content: view)Output Layout
marketing/
<locale>/ e.g. en, de, es, fr, ja
01-home.png
02-<screen>.png
...
NN-<screen>.png
elements/
card-<name>.png
widget-<family>-<size>.png
chart-<name>.pngPut marketing/ in .gitignore. These are outputs, not source.
Verification Checklist
Before declaring the capture pipeline done, verify:
- [ ] All locales produced N files (where N = screens + elements)
- [ ] File sizes differ between locales (confirms translations actually render — if
en/settings.pngandde/settings.pngare byte-identical, locale switching didn't take effect) - [ ] Read 2-3 screens visually for the primary locale and confirm they show the expected content
- [ ] Read the same screens for at least one other locale and confirm localized strings are present
- [ ] Read at least one widget render and one card render to verify backgrounds and corners look right
- [ ] No screenshot shows a screen from a different step (the most common bug — an undismissed sheet from the previous step)
Templates
templates/MarketingCapture.swift.template— skeleton of the capture file with step-based coordinator. Reference the body of this skill for the patterns to apply.templates/capture-marketing.sh.template— skeleton of the shell script. Replace the bundle ID, scheme name, and simulator name for each project.
# These are supported funding model platforms
github: parthjadhav
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
name: Bug report
description: Report a reproducible problem with the skill or generated capture system
title: "bug: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for filing a bug. Please include enough detail for someone else to reproduce it.
- type: input
id: environment
attributes:
label: Agent and environment
description: Which agent/editor/runtime were you using?
placeholder: Claude Code on macOS, Cursor on macOS, etc.
validations:
required: true
- type: input
id: install_method
attributes:
label: Install method
description: How did you install the skill?
placeholder: npx skills add ..., manual clone, etc.
validations:
required: true
- type: textarea
id: prompt
attributes:
label: Prompt used
description: Paste the prompt or request that triggered the issue
placeholder: Capture marketing screenshots for my app across all locales...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: repro
attributes:
label: Reproduction steps
description: Include crash logs, console output, generated files, or screenshots if relevant
validations:
required: true
blank_issues_enabled: false
name: Feature request
description: Suggest an improvement to the skill, templates, or workflow
title: "feat: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Please describe the user problem first, then the proposed solution.
- type: textarea
id: problem
attributes:
label: Problem to solve
description: What real workflow or quality gap does this feature address?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: What should change in the skill behavior or templates?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Any workarounds or other ideas you considered
- type: textarea
id: extra_context
attributes:
label: Additional context
description: Example prompts, screenshots, references, or prior art
Summary
- explain the problem this PR solves
- explain the change in skill/template behavior
Validation
- describe how you tested the change
- include the prompt or scenario you used if
SKILL.mdchanged
Checklist
- [ ] I checked open PRs/issues for overlap
- [ ] I kept
README.mdandSKILL.mdaligned where needed - [ ] I tested the changed workflow or documented why testing was not possible
Contributing
Thanks for contributing to ios-marketing-capture.
This repository is intentionally small, but changes still affect real agent behavior. Most contributions here change either:
README.md: how humans discover and install the skillSKILL.md: how coding agents actually behavetemplates/: the reference code patterns agents adapt for each project
What Makes a Good Contribution
- Fixes a real workflow problem for people capturing iOS marketing screenshots
- Documents a new gotcha discovered on a real project
- Improves support for different navigation patterns or app architectures
- Makes the skill easier to use across agents and environments
- Keeps the skill opinionated, but still project-agnostic
Scope Guidelines
Good fit:
- New gotchas from real capture failures
- Better navigation pattern coverage (e.g. custom coordinators, UIKit hosts)
- Improved element rendering reliability
- Better shell script portability
- Clearer installation or usage docs
Usually not a fit:
- Project-specific assumptions that don't generalize
- Hardcoded app names, bundle IDs, or view types in templates
- Large framework additions outside the skill's purpose
- Changes that make the skill significantly more verbose without improving outcomes
Before Opening a PR
1. Check open PRs to avoid overlapping work. 2. Read README.md, SKILL.md, and both templates. 3. Keep user-facing docs and skill behavior aligned when applicable.
Testing Changes
There is no automated test suite in this repository, so use a manual smoke-test checklist.
For README-only changes
- Confirm installation instructions still make sense
- Confirm example prompts and commands are copy-pasteable
For SKILL.md changes
Validate the skill against at least one realistic scenario:
1. Start with a SwiftUI app that has no marketing capture system 2. Ask an agent to capture marketing screenshots 3. Confirm the skill:
- asks the required discovery questions first
- explores the codebase before writing code
- produces a working
MarketingCapture.swiftadapted to the project - generates a shell script that runs end-to-end
- handles at least one gotcha correctly (e.g. sheet dismiss, seed-once)
For template changes
- Verify the template compiles when adapted to a real project
- Confirm placeholder comments are clear about what to replace
Strongly Recommended
Include in your PR description:
- the prompt you used to test the skill
- what behavior changed
- what stayed intentionally unchanged
Authoring Guidelines
- Prefer compact, high-signal instructions over long prose
- Keep examples realistic and production-oriented
- Avoid duplicating large blocks between
README.mdandSKILL.mdunless the duplication helps different audiences - Gotchas must include: what happens, why, and the fix
Pull Request Checklist
Before submitting, verify:
- the change solves a concrete problem
- the wording is clear for both humans and agents
- README and SKILL instructions do not contradict each other
- the PR description explains why the change is useful
MIT License
Copyright (c) 2026 Parth Jadhav
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

iOS Marketing Capture
A skill for AI-powered coding agents (Claude Code, Cursor, Windsurf, etc.) that automates marketing screenshot capture for SwiftUI iOS apps. It builds an in-app capture system, seeds demo data, snapshots every screen and UI element, and loops across all your locales automatically.
Built for and used by Bloom - https://apps.apple.com/us/app/bloom-coffee-shelf-recipe/id6759914524
What it does
- Adds a
#if DEBUG-gated capture system to your app target — zero production footprint - Seeds deterministic demo data so every screenshot looks populated and polished
- Navigates to each screen programmatically via a step-based coordinator
- Snapshots the full window including status bar, safe area, and presented sheets
- Renders isolated elements (cards, widgets, charts) via
ImageRendererat 3x with transparency - Loops every locale automatically — one build, N relaunches with
-AppleLanguages - Works with any SwiftUI navigation pattern:
TabView,NavigationStack,NavigationSplitView
Install
Using npx skills (recommended)
npx skills add ParthJadhav/ios-marketing-captureThis works with Claude Code, Cursor, Windsurf, OpenCode, Codex, and 40+ other agents.
Install globally (available across all projects):
npx skills add ParthJadhav/ios-marketing-capture -gInstall for a specific agent:
npx skills add ParthJadhav/ios-marketing-capture -a claude-codeManual (git clone)
git clone https://github.com/ParthJadhav/ios-marketing-capture ~/.claude/skills/ios-marketing-captureUsage
Once installed, the skill triggers automatically when you ask your agent to:
- Capture marketing screenshots for my iOS app
- Generate locale screenshots across all languages
- Render my widgets as isolated PNGs
- Automate App Store screenshot capture
Or just tell the agent what you need:
> Capture marketing screenshots for my app across all localesThe agent will ask you about your screens, elements, locales, device, appearance, and seed data before writing any code.
Example prompts
These are good starting prompts because they provide context while still leaving room for the skill to guide the process.
Coffee app
Capture marketing screenshots for my coffee tracking app.
I want Home, Shelf, Coffee Detail, Brew Timer, and Settings.
Also render coffee cards and all my widgets as isolated elements.
Capture across en, de, es, fr, ja.Habit tracker
Generate locale screenshots for my habit tracker app.
I need the dashboard, habit detail, streak view, and settings.
Light mode only, iPhone 17 simulator.
All 5 locales in my Localizable.xcstrings.Finance app
Capture marketing assets for my finance app.
I want the overview, transaction list, budget detail, and charts.
Render the spending chart and category cards as isolated elements.
English and German only.Fitness app with widgets
Automate App Store screenshot capture for my workout app.
Capture the main dashboard, workout detail mid-session, and history.
Render all my WidgetKit widgets (small, medium, lock screen) as isolated PNGs.Better prompt tips
- List the exact screens you want captured by tab name or navigation path
- Mention any components you want rendered independently (cards, widgets, charts)
- Specify locales explicitly or say "all locales in my xcstrings"
- Say which simulator and iOS version you want
- Mention if any screen needs to be captured in a non-default state (e.g. timer mid-countdown)
- Say light only, dark only, or both
How it works
In-app capture mode, not XCUITest
The skill uses an in-app capture approach instead of XCUITest / Fastlane:
- No test target surgery — many projects have none, and adding one means fragile pbxproj edits
- Direct access to everything — ViewModels, SwiftData,
ImageRenderer,UIWindow.drawHierarchy - Faster —
xcodebuild buildonce, thensimctl launchper locale (no test-bundle overhead) - Element renders require it —
ImageRendereron widget views must run inside the app process
Step-based coordinator
Each screenshot is a self-contained CaptureStep:
struct CaptureStep {
let name: String // "01-home"
let navigate: @MainActor () -> Void // put the app in the right state
let settle: Duration // wait for animations
let cleanup: (@MainActor () -> Void)? // tear down before next step
}The coordinator is a simple loop — no hardcoded screen sequences. The agent composes steps for your specific navigation architecture.
Navigation patterns
The skill covers three navigation architectures:
| Pattern | How steps drive it |
|---|---|
TabView(selection:) | setTab(index) |
NavigationStack + router | router.push(.route) / router.popToRoot() |
NavigationSplitView | Set sidebar + detail selection bindings |
Element rendering
Isolated components are rendered via ImageRenderer at 3x scale with natural background inside rounded corners and transparency outside:
MarketingElementHarness.renderElement(
name: "card-morning-blend",
width: 380,
cornerRadius: 20,
background: theme.background
) {
CoffeeCard(coffee: coffee, theme: theme)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}Widget rendering handles the quirks of rendering WidgetKit views outside the widget process (missing containerBackground, missing padding, ProgressView rendering bugs).
What gets generated
The skill guides the agent to create:
YourApp/
├── Debug/
│ └── MarketingCapture.swift # Capture system (DEBUG-only)
├── ContentView.swift # Modified — DEBUG hook for seed + coordinator
├── Views/.../TimerView.swift # Modified — primed state hooks (if needed)
scripts/
└── capture-marketing.sh # Build + install + per-locale loopOutput layout
marketing/
en/
01-home.png
02-detail.png
03-settings.png
elements/
card-morning-blend.png
widget-pulse-small.png
chart-cupping.png
de/
...
es/
...Known gotchas
The skill documents 11 real bugs discovered during development. These are all baked into the skill's guidance so the agent avoids them automatically:
| # | Gotcha | What happens |
|---|---|---|
| 1 | Live Activities persist across launches | Next locale crashes on stale SwiftData references |
| 2 | Re-seeding per locale | CloudKit sync churn causes crashes |
| 3 | VMs setup before seed | Hold stale empty snapshots |
| 4 | Setting trigger binding to nil | Doesn't dismiss fullScreenCover — wrong screenshot |
| 5 | NavigationPath can't be popped externally | Must capture clean stack before pushed detail |
| 6 | membershipExceptions is an INCLUSION list | Widget target membership goes backwards |
| 7 | ImageRenderer + ProgressView | Renders as prohibited symbol without explicit style |
| 8 | .containerBackground outside WidgetKit | No-op — widget renders have no background |
| 9 | iPhone 8 Plus gone on iOS 26 | Legacy 6.5" simulator unavailable |
| 10 | Locale launch argument format | Parens are mandatory: (xx) not xx |
| 11 | SwiftUI animations in ImageRenderer | Captures frame 0, not the animated state |
Pairs well with
Use app-store-screenshots as a post-processing step to composite the captured PNGs into Apple-style marketing pages with device mockups, headlines, and gradients.
Requirements
- Xcode 16+ (synchronized folder groups support)
- iOS 17+ deployment target (for
ImageRenderer,@Observable) - A simulator runtime matching your target iOS version
- Python 3 (used by the shell script for JSON parsing)
Contributing
Contributions are welcome, especially around:
- Support for additional navigation patterns
- New gotcha documentation from real projects
- Cross-agent compatibility improvements
- Clearer docs and onboarding
License
MIT
#!/usr/bin/env bash
#
# Marketing screenshot capture — builds once, installs to a simulator,
# then relaunches the binary per locale with -MarketingCapture 1 and
# pulls PNGs from the app sandbox into ./marketing/<locale>/.
#
# TEMPLATE — replace the values in the configuration block below.
# The rest of the script is generic and should work for any SwiftUI app.
set -euo pipefail
# ============================================================================
# CONFIGURATION — edit these for your project
# ============================================================================
LOCALES=(en de es fr ja) # language codes from Localizable.xcstrings
BUNDLE_ID="com.example.App" # your app's bundle identifier
SCHEME="App" # Xcode scheme name
PROJECT="App.xcodeproj" # .xcodeproj path (or use WORKSPACE below)
# WORKSPACE="App.xcworkspace" # uncomment if using .xcworkspace instead
SIM_NAME="iPhone 17" # simulator device name
TIMEOUT=90 # seconds to wait per locale before failing
# ============================================================================
# INTERNALS — no edits needed below this line
# ============================================================================
DERIVED="build/marketing-dd"
APP_PATH="$DERIVED/Build/Products/Debug-iphonesimulator/${SCHEME}.app"
OUT_ROOT="marketing"
cd "$(dirname "$0")/.."
# Determine project flag
if [ -n "${WORKSPACE:-}" ]; then
PROJECT_FLAG="-workspace $WORKSPACE"
else
PROJECT_FLAG="-project $PROJECT"
fi
echo "==> Ensuring $SIM_NAME simulator exists and is booted"
DEVICE_ID=$(xcrun simctl list devices available -j 2>/dev/null \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
for runtime, devices in data['devices'].items():
if 'iOS' not in runtime:
continue
for d in devices:
if d['name'] == '$SIM_NAME':
print(d['udid'])
sys.exit()
" || true)
if [ -z "${DEVICE_ID:-}" ]; then
echo "Creating $SIM_NAME simulator"
RUNTIME=$(xcrun simctl list runtimes -j | python3 -c "
import json, sys
data = json.load(sys.stdin)
for r in data['runtimes']:
if r.get('platform') == 'iOS' and r.get('isAvailable'):
print(r['identifier']); sys.exit()
")
DEVICE_TYPE=$(xcrun simctl list devicetypes -j | python3 -c "
import json, sys
data = json.load(sys.stdin)
for d in data['devicetypes']:
if d['name'] == '$SIM_NAME':
print(d['identifier']); sys.exit()
")
DEVICE_ID=$(xcrun simctl create "$SIM_NAME" "$DEVICE_TYPE" "$RUNTIME")
fi
xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true
open -a Simulator --args -CurrentDeviceUDID "$DEVICE_ID" || true
echo "==> Building $SCHEME (Debug) for $SIM_NAME"
xcodebuild \
$PROJECT_FLAG \
-scheme "$SCHEME" \
-configuration Debug \
-destination "id=$DEVICE_ID" \
-derivedDataPath "$DERIVED" \
build \
-quiet
if [ ! -d "$APP_PATH" ]; then
echo "Build did not produce $APP_PATH" >&2
echo "Check that SCHEME and PROJECT are correct." >&2
exit 1
fi
echo "==> Installing $APP_PATH"
xcrun simctl install "$DEVICE_ID" "$APP_PATH"
mkdir -p "$OUT_ROOT"
for L in "${LOCALES[@]}"; do
echo "==> Capturing locale: $L"
xcrun simctl terminate "$DEVICE_ID" "$BUNDLE_ID" 2>/dev/null || true
sleep 0.5
# Clear any prior sandbox output for this locale — the _done sentinel
# check needs to be unambiguous between runs.
SBOX=$(xcrun simctl get_app_container "$DEVICE_ID" "$BUNDLE_ID" data)
rm -rf "$SBOX/Documents/marketing/$L"
# AppleLanguages requires the plist-array literal format with parens.
xcrun simctl launch "$DEVICE_ID" "$BUNDLE_ID" \
-MarketingCapture 1 \
-AppleLanguages "($L)" \
-AppleLocale "$L" > /dev/null
# Wait for the coordinator's _done sentinel.
WAITED=0
SENTINEL="$SBOX/Documents/marketing/$L/_done"
while [ ! -f "$SENTINEL" ]; do
sleep 1
WAITED=$((WAITED + 1))
if [ "$WAITED" -gt "$TIMEOUT" ]; then
echo "Timeout waiting for $SENTINEL after ${TIMEOUT}s" >&2
echo "The app may have crashed. Check:" >&2
echo " ~/Library/Logs/DiagnosticReports/ for crash logs" >&2
echo " Console.app filtered to the app's process name" >&2
exit 1
fi
done
# Pull output
rm -rf "$OUT_ROOT/$L"
cp -R "$SBOX/Documents/marketing/$L" "$OUT_ROOT/$L"
rm -f "$OUT_ROOT/$L/_done"
echo " -> $(find "$OUT_ROOT/$L" -name '*.png' | wc -l | tr -d ' ') PNGs written to $OUT_ROOT/$L"
done
xcrun simctl terminate "$DEVICE_ID" "$BUNDLE_ID" 2>/dev/null || true
echo ""
echo "Done. Output in $OUT_ROOT/"
find "$OUT_ROOT" -name "*.png" | sort
// TEMPLATE — reference patterns for building a marketing capture system.
//
// This is NOT copy-paste scaffolding. Every project has different navigation,
// models, and views. Read the SKILL.md for the reasoning behind each pattern,
// then compose the pieces that apply to your project.
//
// The three components:
// 1. MarketingCapture enum — launch arg parsing, output, window snapshot
// 2. MarketingCaptureCoordinator — step-based screen capture loop
// 3. MarketingElementHarness — ImageRenderer for cards, widgets, charts
//
// See SKILL.md "Known Gotchas" for the reasoning behind every #if DEBUG hook.
#if DEBUG
import SwiftUI
import UIKit
// MARK: - Core utilities
enum MarketingCapture {
// MARK: Launch argument
static var isActive: Bool {
ProcessInfo.processInfo.arguments.contains("-MarketingCapture") &&
value(for: "-MarketingCapture") == "1"
}
private static func value(for key: String) -> String? {
let args = ProcessInfo.processInfo.arguments
guard let idx = args.firstIndex(of: key), idx + 1 < args.count else { return nil }
return args[idx + 1]
}
// MARK: Locale
/// Folder name for per-locale output. Uses language code only to avoid
/// "en_US" vs "en" divergence from -AppleLocale.
static var localeFolder: String {
Locale.current.language.languageCode?.identifier
?? Locale.current.identifier
}
// MARK: Priming vars
//
// Add one static var per view state you need to prime. The coordinator
// sets these before navigating, and the target view reads them in
// .onAppear under #if DEBUG. Reset them in the step's cleanup closure.
//
// Examples:
// static var pendingElapsedSeconds: Int?
// static var pendingShowOverlay: Bool = false
// MARK: Dismiss broadcast
//
// Used to force-dismiss a presented fullScreenCover / sheet whose
// item binding is held as @State inside an intermediate parent.
// See SKILL.md gotcha #4.
static let dismissSheetNotification = Notification.Name("MarketingCapture.dismissSheet")
// MARK: Output
static var outputRoot: URL {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let root = docs
.appendingPathComponent("marketing", isDirectory: true)
.appendingPathComponent(localeFolder, isDirectory: true)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
return root
}
static func writePNG(_ image: UIImage, name: String, subfolder: String? = nil) {
var dir = outputRoot
if let subfolder {
dir = dir.appendingPathComponent(subfolder, isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
let url = dir.appendingPathComponent("\(name).png")
guard let data = image.pngData() else {
print("[MarketingCapture] failed to encode \(name)")
return
}
do {
try data.write(to: url, options: .atomic)
print("[MarketingCapture] wrote \(url.path)")
} catch {
print("[MarketingCapture] write failed: \(error)")
}
}
/// Writes a sentinel file so the shell script knows this locale is done.
static func writeSentinel() {
let url = outputRoot.appendingPathComponent("_done")
try? Data().write(to: url)
}
// MARK: Window snapshot
/// Captures the key UIWindow including any presented modals / sheets.
@MainActor
static func snapshotKeyWindow() -> UIImage? {
guard let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.flatMap({ $0.windows })
.first(where: { $0.isKeyWindow })
else { return nil }
let renderer = UIGraphicsImageRenderer(bounds: window.bounds)
return renderer.image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
}
}
// MARK: - Step-based coordinator
/// Each step represents one screenshot: navigate → wait → snap → cleanup.
struct CaptureStep {
let name: String // output filename, e.g. "01-home"
let navigate: @MainActor () -> Void // put the app in the right state
let settle: Duration // wait for animations/data loads
let cleanup: (@MainActor () -> Void)? // tear down before next step
init(
name: String,
settle: Duration = .milliseconds(1800),
navigate: @escaping @MainActor () -> Void,
cleanup: (@MainActor () -> Void)? = nil
) {
self.name = name
self.navigate = navigate
self.settle = settle
self.cleanup = cleanup
}
}
@MainActor
final class MarketingCaptureCoordinator {
static let shared = MarketingCaptureCoordinator()
private init() {}
/// Override per-step settle time with a floor. Useful if you find
/// 1800ms isn't enough for heavy screens (charts, large lists).
var minimumSettle: Duration = .milliseconds(1800)
func run(steps: [CaptureStep], then elements: () async -> Void) async {
print("[MarketingCapture] run for locale=\(MarketingCapture.localeFolder)")
for step in steps {
step.navigate()
let settle = max(step.settle, minimumSettle)
try? await Task.sleep(for: settle)
guard let image = MarketingCapture.snapshotKeyWindow() else {
print("[MarketingCapture] snapshot failed: \(step.name)")
continue
}
MarketingCapture.writePNG(image, name: step.name)
if let cleanup = step.cleanup {
cleanup()
// Allow cleanup animations (sheet dismiss, navigation pop)
try? await Task.sleep(for: .milliseconds(900))
}
}
// Element renders (cards, widgets, charts)
await elements()
MarketingCapture.writeSentinel()
print("[MarketingCapture] done for locale=\(MarketingCapture.localeFolder)")
}
}
// MARK: - Element harness
enum MarketingElementHarness {
// MARK: Cards
/// Renders a SwiftUI view as an isolated PNG element with background
/// and rounded corners. Use for list rows, carousel cards, tiles.
@MainActor
static func renderElement<V: View>(
name: String,
width: CGFloat,
height: CGFloat? = nil,
cornerRadius: CGFloat = 20,
background: Color,
@ViewBuilder content: () -> V
) {
let view = content()
.frame(width: width, height: height)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
let renderer = ImageRenderer(content: view)
renderer.scale = 3
renderer.isOpaque = false
renderer.proposedSize = .init(width: width, height: height)
guard let image = renderer.uiImage else {
print("[MarketingCapture] element render failed: \(name)")
return
}
MarketingCapture.writePNG(image, name: name, subfolder: "elements")
}
// MARK: Widgets
//
// Standard iPhone widget sizes (points, iPhone 14-17 size class).
// Adjust if targeting iPad or a specific device family.
enum WidgetSize {
static let small = CGSize(width: 170, height: 170)
static let medium = CGSize(width: 364, height: 170)
static let large = CGSize(width: 364, height: 382)
static let accessoryCircular = CGSize(width: 76, height: 76)
static let accessoryRectangular = CGSize(width: 172, height: 76)
static let accessoryInline = CGSize(width: 257, height: 26)
}
/// Renders a widget view with the padding and corner radius that
/// WidgetKit normally provides. See SKILL.md gotchas #7, #8.
@MainActor
static func renderWidget<V: View>(
name: String,
size: CGSize,
cornerRadius: CGFloat? = nil,
background: Color,
@ViewBuilder content: () -> V
) {
let isAccessory = size.height <= 80
let radius = cornerRadius ?? (isAccessory ? 8 : 22)
let contentPadding: CGFloat = isAccessory ? 0 : 16
let view = content()
.padding(contentPadding)
.frame(width: size.width, height: size.height)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
.environment(\.colorScheme, .light)
let renderer = ImageRenderer(content: view)
renderer.scale = 3
renderer.isOpaque = false
renderer.proposedSize = .init(width: size.width, height: size.height)
guard let image = renderer.uiImage else {
print("[MarketingCapture] widget render failed: \(name)")
return
}
MarketingCapture.writePNG(image, name: name, subfolder: "elements")
}
// MARK: Helpers
static func slugify(_ s: String) -> String {
s.lowercased()
.replacingOccurrences(of: " ", with: "-")
.filter { $0.isLetter || $0.isNumber || $0 == "-" }
}
}
#endif
// ============================================================================
// INTEGRATION GUIDE
// ============================================================================
//
// 1. ROOT VIEW (.onAppear hook)
//
// Add this to your root view's .onAppear, AFTER existing VM setup calls:
//
// #if DEBUG
// if MarketingCapture.isActive {
// // (a) End stale Live Activities (skip if app doesn't use ActivityKit)
// ActivityManager.shared.endImmediately()
//
// // (b) Seed data only if empty (store persists across relaunches)
// itemVM.fetch()
// if itemVM.items.isEmpty {
// DemoDataSeeder.seedIfEmpty(in: modelContext)
// itemVM.fetch()
// }
//
// // (c) Refresh any VM that setup() before the seed
// otherVM.refresh()
//
// // (d) Suppress onboarding
// showOnboarding = false
//
// // (e) Build steps and run
// Task {
// try? await Task.sleep(for: .milliseconds(500))
//
// let steps: [CaptureStep] = [
// // Tab switch — no cleanup needed
// CaptureStep(name: "01-home") { setTab(0) },
//
// // Presented detail on tab 0
// CaptureStep(name: "02-detail") {
// setTab(0)
// showDetail = true
// } cleanup: {
// showDetail = false
// },
//
// // Clean tab BEFORE pushing onto its stack (gotcha #5)
// CaptureStep(name: "03-list") { setTab(1) },
//
// // Push onto tab 1's stack — after clean capture
// CaptureStep(name: "04-item-detail") {
// setTab(1)
// deepLinkItem = items.first
// } cleanup: {
// deepLinkItem = nil
// },
//
// // Sheet with primed state
// CaptureStep(name: "05-timer", settle: .milliseconds(2400)) {
// MarketingCapture.pendingElapsedSeconds = 75
// pendingSheetItem = someItem
// } cleanup: {
// MarketingCapture.pendingElapsedSeconds = nil
// NotificationCenter.default.post(
// name: MarketingCapture.dismissSheetNotification,
// object: nil
// )
// pendingSheetItem = nil
// },
//
// // Last tab — no stack state to worry about
// CaptureStep(name: "06-settings") { setTab(4) },
// ]
//
// await MarketingCaptureCoordinator.shared.run(steps: steps) {
// // Element renders
// let theme = themeManager.theme(for: .light)
// for item in itemVM.items {
// MarketingElementHarness.renderElement(
// name: "card-\(MarketingElementHarness.slugify(item.name))",
// width: 380,
// cornerRadius: 20,
// background: theme.background
// ) {
// ItemCard(item: item, theme: theme)
// .padding(.horizontal, 16)
// .padding(.vertical, 12)
// }
// }
// // Widgets (requires widget files in main target — gotcha #6)
// MarketingElementHarness.renderWidget(
// name: "widget-pulse-small",
// size: MarketingElementHarness.WidgetSize.small,
// background: theme.background
// ) {
// PulseSmallView(entry: .preview)
// }
// }
// }
// }
// #endif
//
//
// 2. PRESENTED VIEWS (dismiss listener)
//
// Any view presented via .fullScreenCover or .sheet that the coordinator
// needs to dismiss between steps must listen for the broadcast:
//
// #if DEBUG
// .onReceive(NotificationCenter.default.publisher(
// for: MarketingCapture.dismissSheetNotification
// )) { _ in
// // Stop any ongoing work (timers, animations)
// timerVM.stop()
// dismiss()
// }
// #endif
//
//
// 3. PRIMED VIEWS (.onAppear hook)
//
// Views captured in a non-default state read priming vars on appear:
//
// .onAppear {
// #if DEBUG
// if MarketingCapture.isActive,
// let elapsed = MarketingCapture.pendingElapsedSeconds {
// phase = .active
// timerVM.elapsedTime = TimeInterval(elapsed)
// timerVM.start()
// DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
// timerVM.pause()
// }
// }
// #endif
// }
//
// .onDisappear {
// #if DEBUG
// if MarketingCapture.isActive { timerVM.stop() }
// #endif
// }
Related skills
FAQ
What does ios-marketing-capture help iOS developers produce?
ios-marketing-capture helps iOS developers generate App Store marketing screenshots, device-framed promotional captures, and listing-ready visual assets for App Store Connect submission.
When should teams invoke ios-marketing-capture?
ios-marketing-capture fits post-build launch prep when a Swift or SwiftUI app needs consistent App Store screenshots and promotional imagery before App Store Connect upload or ASO updates.