{"slug": "show-hn-storeshot-google-play-store-screenshots-built-for-agents", "title": "Show HN: Storeshot/Google Play store screenshots built for agents", "summary": "Storeshot, an open-source tool by Thiago Peres, generates Apple and Google Play store screenshots from a single command for native, web, or hybrid apps, enforcing store rules and working in CI. The MIT-licensed tool runs on Node 20+ with no browser dependency for native apps, using sharp for composition and supporting agent-driven config files.", "body_md": "Apple and Google Play store screenshots, built by agents, for agents.\n\n- ⚡\n**One command**—`npx storeshot`\n\ncaptures, frames and stages a whole set, in CI or locally. - 📱\n**Native, web, or hybrid**— one pipeline for the iOS Simulator, an Android emulator, a headless browser, or PNGs something else produced. The rest of the field picks a side. - 🌍\n**Locales are JSON**— captions wrap and rebalance to fit; nothing overflows. - 🎯\n**Store rules, enforced**— exact sizes, no alpha, under 8 MB, Apple bezels whole, Android free to bleed. A bad asset fails the run, not the upload. - 🤖\n**Agent-first**— the whole job is one config file, so a coding agent can add a screen or rewrite the copy and the diff lands as images in a pull request. - 🆓\n**Open source, no account**— MIT, runs on your machine, frames cached locally.\n\n|\n|\n\nDescribe the screens you want in a config file and get back store-ready assets:\nreal captures of your app, clipped into real device bezels, laid out under\nmarketing copy, at the exact pixel sizes Apple and Google accept, staged for\n`fastlane deliver`\n\nand `supply`\n\n.\n\nWorks with native and web apps alike. Captures come from the iOS Simulator, an Android emulator, a headless browser, or a directory of screenshots something else produced — everything after that point is identical.\n\nNo design tool in the loop. The whole set is one command, so it can run in CI, and an agent can change the copy or add a screen by editing one file.\n\nThe other ways of doing this all make you give something up:\n\n| Tool | The catch |\n|---|---|\n🐌 fastlane `frameit` |\nFragile ImageMagick and JSON layout, device frames that trail Apple's hardware, native-only |\n🖱️ Mockup makers |\nFree, browser-based, one frame at a time by hand — nothing to run in CI |\n🎨 Design templates |\nDrift the moment the UI ships; every device and locale is re-exported by hand |\n💸 Hosted generators |\nA subscription, an account, and your app's screens on someone else's servers |\n\nstoreshot gives up none of them.\n\n```\nnpm install --save-dev github:thiagoperes/storeshot\n```\n\nNode 20 or newer. Nothing else, and no browser: the marketing canvas is composed\nwith [sharp](https://sharp.pixelplumbing.com), so a native app never downloads a\nbrowser binary. Device bezels are fetched on first use and cached.\n\nCapturing from a browser is the one thing that needs more. Playwright is an optional peer dependency, so add it only for that:\n\n```\nnpm install --save-dev playwright\nnpx playwright install webkit chromium\n```\n\nComposition shapes text with Pango, so at least one font has to be installed. A\ndesktop or a normal CI runner already has plenty; a slim Linux image may need a\npackage such as `fonts-dejavu-core`\n\n. Name real families in your theme —\n`-apple-system`\n\nand other CSS keywords mean nothing outside a browser and are\nskipped over in the stack.\n\nThe examples below build the real App Store and Play sets for\n[Rally](https://getrally.com), a fleet expense platform that ships on both\nstores.\n\n`storeshot.ios.config.ts`\n\n— the App Store set, captured from the real build in\nthe Simulator. Requires macOS with Xcode.\n\n``` js\nimport { defineConfig, findTarget } from 'storeshot';\n\nexport default defineConfig({\n  // Rally's iOS app is iPhone-only, so the iPad target comes off. Apple scales\n  // the 6.9\" set down for every smaller iPhone, so one target covers them all.\n  targets: [findTarget('ios-iphone-6.9')],\n\n  capture: {\n    kind: 'ios-simulator',\n    // First name that exists wins, and an already-booted device is preferred.\n    device: ['iPhone 17 Pro Max', 'iPhone 16 Pro Max'],\n    bundleId: 'com.getrally',\n    appPath: 'ios/App/build/Debug-iphonesimulator/App.app',\n  },\n\n  // A launch and a route change need longer to settle than a browser does.\n  settleDelay: 2500,\n\n  screens: [\n    { id: 'platform', theme: 'dark' },\n    { id: 'transactions', theme: 'dark' },\n    { id: 'cards', theme: 'dark' },\n  ],\n\n  captions: {\n    en: {\n      platform: { kicker: 'Payments', title: 'Every fleet expense, one app' },\n      transactions: {\n        kicker: 'Transactions',\n        title: 'See every purchase as it happens',\n      },\n      cards: { kicker: 'Cards', title: 'Issue cards in seconds, not days' },\n    },\n  },\n});\nnpx storeshot --config storeshot.ios.config.ts\n```\n\nCaptures come out full-screen, with the device's own status bar, pinned to 9:41\non a full battery. Rally's screens are ordinary in-app routes with no URL scheme\nbehind them, so this run stops and asks you to navigate before each capture. Give\na screen a `deepLink`\n\n, or the config a `navigate`\n\nhook, to make it unattended —\nsee [Navigating a native app](#navigating-a-native-app).\n\n`storeshot.android.config.ts`\n\n— the Play set, from the release APK on an\nemulator. Requires the Android SDK platform tools on your `PATH`\n\n.\n\n``` js\nimport { defineConfig, findTarget } from 'storeshot';\n\nexport default defineConfig({\n  // Play asks for a phone and a tablet asset separately.\n  targets: [findTarget('android-phone'), findTarget('android-tablet')],\n\n  capture: {\n    kind: 'android-emulator',\n    appId: 'com.getrally.app',\n    apkPath: 'android/app/build/outputs/apk/release/app-release.apk',\n    // Booted when nothing is already attached. Omit to use `adb`'s only device.\n    avd: 'Pixel_5_API_35',\n  },\n\n  screens: [\n    {\n      id: 'platform',\n      deepLink: 'https://app.getrally.com/home',\n      theme: 'dark',\n    },\n    {\n      id: 'cards',\n      deepLink: 'https://app.getrally.com/home/cards',\n      theme: 'dark',\n    },\n  ],\n\n  captions: {\n    en: {\n      platform: { kicker: 'Payments', title: 'Every fleet expense, one app' },\n      cards: { kicker: 'Cards', title: 'Issue cards in seconds, not days' },\n    },\n  },\n});\nnpx storeshot --config storeshot.android.config.ts\n```\n\nA `deepLink`\n\nhere is anything `adb shell am start`\n\ncan open, so an app link like\nthe one above works without registering a custom scheme, as long as the app\ndeclares the intent filter. Play allows the device to bleed off the bottom edge,\nand these targets do, where the App Store ones above keep the bezel whole.\n\nRally is a Capacitor app, so the same screens can be captured headless in seconds without building either binary — WebKit for iOS targets, Chromium for Android ones, matching the web view each platform actually runs. This is Rally's default config, with the two native ones above kept for checking the native shell before a listing refresh.\n\n``` js\nimport { defineConfig } from 'storeshot';\n\nexport default defineConfig({\n  baseUrl: process.env.SCREENSHOT_BASE_URL ?? 'http://localhost:3000',\n  screens: [\n    {\n      id: 'platform',\n      path: '/home/acme',\n      theme: 'dark',\n      // Nothing is captured until these are on screen, so no skeletons.\n      waitFor: ['[data-slot=\"card\"]'],\n    },\n  ],\n  captions: {\n    // as above\n  },\n  // Signs in once; the session is reused across targets.\n  auth: ({ page }) => signIn(page),\n});\nnpx storeshot\n```\n\nWhichever route you take, assets land in\n`screenshots/framed/<locale>/<target>/`\n\nand are copied into `fastlane/`\n\nin the\nlayout `deliver`\n\nand `supply`\n\nexpect.\n\nCaptions wrap to fit and, at two lines, rebalance so the lines come out close to\nequal instead of leaving a stub on the second. Put a `\\n`\n\nin a title to break it\nsomewhere specific and that is used verbatim.\n\nFour targets are built in, chosen to satisfy both stores with the smallest possible set. Apple scales the 6.9\" iPhone and 13\" iPad sets down for every smaller device, so those two cover the App Store.\n\n| Target | Store | Screen | Output | Frame |\n|---|---|---|---|---|\n`ios-iphone-6.9` |\nApple | 1320 × 2868 | 1320 × 2868 | iPhone 17 Pro Max |\n`ios-ipad-13` |\nApple | 2048 × 2732 | 2064 × 2752 | iPad Pro 12.9\" |\n`android-phone` |\n1080 × 2340 | 1080 × 1920 | Pixel 5 | |\n`android-tablet` |\n1680 × 2440 | 1440 × 2560 | Neutral CSS bezel |\n\nA target describes its screen in points and a scale factor, which is the same arithmetic for both worlds: an iPhone 17 Pro Max is 440 × 956pt at 3x, or 1320 × 2868px, whether that comes from a simulator or a browser viewport. If your device's screenshot is a slightly different size but the same shape — the frame set's 12.9\" iPad cutout is 2048 × 2732 while the simulator shoots 2064 × 2752 — it is resampled. A genuinely different aspect ratio is refused rather than squashed.\n\nEvery asset is flattened to 24-bit PNG and checked before it is written: wrong dimensions, a leftover alpha channel, or a file over Google's 8 MB cap fails the run rather than the upload.\n\nStore rules are encoded, not left to the author. Apple requires product bezels to be shown uncropped with copy beside the device rather than on it, so App Store targets keep the device whole and use a background bloom instead of a drop shadow. Play has no such rule, so those targets may bleed the device off the bottom edge.\n\nCapture is a driver behind one interface, so where the pixels come from is a\nconfig choice and nothing downstream changes. Set `capture`\n\nonce for everything,\nor per platform when a native iOS build and a native Android build need different\ntooling. Individual targets can override it.\n\nDrives a real build in the Simulator through `xcrun simctl`\n\n. Requires macOS with\nXcode.\n\n```\ncapture: {\n  kind: 'ios-simulator',\n  device: ['iPhone 17 Pro Max', 'iPhone 16 Pro Max'],\n  bundleId: 'com.acme.App',\n  appPath: 'build/Debug-iphonesimulator/App.app',\n}\n```\n\nIt prefers a device that is already booted, boots one if not, and creates the simulator outright when no instance matches — which is the normal state of a fresh machine or CI runner. Before capturing it pins the status bar to Apple's canonical marketing state: 9:41, full battery, full signal. Screenshots are the whole screen, real status bar included.\n\nThe same idea over `adb`\n\n, against an emulator or an attached device.\n\n```\ncapture: {\n  kind: 'android-emulator',\n  appId: 'com.acme.app',\n  apkPath: 'build/outputs/apk/release/app-release.apk',\n  avd: 'Pixel_5_API_34', // booted only if nothing is attached\n}\n```\n\nSystemUI demo mode stands in for the iOS status bar override, giving a fixed clock, a full battery and no notification icons.\n\nFrames screenshots something else produced — XCUITest, `fastlane snapshot`\n\n,\nEspresso, or a designer's export.\n\n```\ncapture: {\n  kind: 'import',\n  dir: 'fastlane/screenshots',\n  // Defaults to `<target>/<screen>.png`.\n  file: ({ locale, screen }) => `${locale}/iPhone 17 Pro Max-${screen.id}.png`,\n}\n```\n\nThis is the shortest path if you already have UI tests that take screenshots: keep taking them however you like and adopt only the framing and delivery half of the pipeline.\n\nThe default. Playwright loads each screen at the device's exact viewport, using WebKit for iOS targets and Chromium for Android ones, so the render engine matches the web view the app will ship in. Suits anything served over HTTP, including Capacitor, Cordova and Electron wrappers.\n\nAnything else — a device farm, `idb`\n\n, an Appium session.\n\n```\ncapture: {\n  kind: 'custom',\n  includesStatusBar: true,\n  open: async ({ target, config }) => ({\n    capture: async (screen, locale) => myDeviceFarm.shoot(target.id, screen.id),\n    close: async () => myDeviceFarm.release(),\n  }),\n}\n```\n\nA native driver gets your app to each screen in one of three ways, in order of preference:\n\n**A deep link** on the screen —`deepLink: 'acme://cards'`\n\n— opened with`simctl openurl`\n\nor an`android.intent.action.VIEW`\n\nintent. Best option: fully unattended, and most apps that support universal links already have this.**A** in the config, which receives the screen and the device handle so it can shell out to`navigate`\n\nhook`idb`\n\n,`adb shell input`\n\n, or your own UI automation.**Nothing**, in which case it prompts you to drive the app by hand and press Enter before each capture. Slow, but it gets a listing shipped today and the captures are reusable —`--skip-capture`\n\nreframes them as often as you like.\n\nNative apps often need longer than the 900 ms default to settle after a launch or\na deep link. Raise `settleDelay`\n\nif a capture lands mid-transition.\n\n**Capture.** One of the sources above produces a PNG per screen.**Status bar.** Native captures already have a real one. A browser capture has none, so the page is captured shorter by exactly the status bar's height and a synthetic strip is stacked on top. The strip samples the colour of your app's top row so it disappears into the design, and no app content ends up hidden behind it.**Frame.** The capture is composited into a device bezel from, clipped through a mask traced from the frame's own alpha channel so the rounded corners and the Dynamic Island cut the screenshot exactly the way the hardware does.`fastlane/frameit-frames`\n\n**Compose.** The framed device and its caption are laid out at the store's output size. The backdrop is an SVG rasterised by librsvg, the type is shaped by Pango and measured before it is placed, and the device is resampled by sharp. Captions reflow and rebalance per locale rather than being baked into a fixed-width bitmap, and none of it needs a browser.**Deliver.** Assets are flattened, validated, and staged under`fastlane/`\n\n.\n\nEverything below `screens`\n\nand `captions`\n\nis optional.\n\n``` python\nimport { defineConfig } from 'storeshot';\n\nimport en from './captions/en.json' with { type: 'json' };\n\nexport default defineConfig({\n  // Browser captures only.\n  baseUrl: process.env.APP_URL ?? 'http://localhost:3000',\n\n  // Where output and caches go. Relative to the config file.\n  outDir: 'screenshots',\n  fastlaneDir: 'fastlane',\n\n  screens: [\n    {\n      id: 'dashboard',\n      // A path for browser captures, a deepLink for native ones.\n      path: '/app/dashboard',\n      theme: 'dark',\n      // Nothing is captured until these are visible, so no skeletons.\n      waitFor: ['[data-slot=\"card\"]'],\n      // Hidden before the shot, on top of the global `hide` list.\n      hide: ['.cookie-banner'],\n      // Skip a form factor the screen makes no sense on.\n      excludeTargets: ['ios-ipad-13'],\n    },\n  ],\n\n  captions: { en },\n\n  // Any subset. The rest falls back to a neutral built-in theme.\n  theme: {\n    dark: {\n      base: '#000000',\n      sweep: 'linear-gradient(176deg, #0F1053 0%, #070826 44%, #000000 100%)',\n      halo: '#1A2BC3',\n      title: '#FFFFFF',\n      kicker: '#A6D8FD',\n    },\n    // Real installed families. A `linear-gradient` above takes an angle or a\n    // `to <side>`, with hex, rgb() or rgba() stops.\n    sansFont: '\"Inter\", \"Helvetica Neue\", sans-serif',\n    showIndex: true,\n  },\n\n  // Browser captures: signs in once, and the session is reused per target.\n  async auth({ page }) {\n    await page.goto('/login');\n    await page.getByLabel('Email').fill(process.env.DEMO_EMAIL!);\n    await page.getByLabel('Password').fill(process.env.DEMO_PASSWORD!);\n    await page.getByRole('button', { name: 'Sign in' }).click();\n    await page.waitForURL((url) => !url.pathname.startsWith('/login'));\n  },\n\n  // Browser captures: runs before each screen navigates.\n  async prepare({ context, page, screen }) {\n    await context.addCookies([\n      { name: 'theme', value: screen.theme, domain: 'localhost', path: '/' },\n    ]);\n    await page.route('**/api/telemetry', (route) => route.abort());\n  },\n\n  // Native captures: drives the app when a screen has no deepLink.\n  async navigate({ screen, device, platform }) {\n    if (platform === 'ios') {\n      await myUiAutomation.tapTab(device, screen.id);\n    }\n  },\n});\n```\n\n`targets`\n\ncan be overridden too, if you need a device the defaults do not cover.\nImport `DEFAULT_TARGETS`\n\nand spread it, or write your own `TargetSpec[]`\n\n.\n\n```\nstoreshot [options]\n\n  --config <path>    Config file. Default: nearest storeshot.config.ts.\n  --base-url <url>   Override the app URL from the config.\n  --target <id>      Only this target (repeatable). Default: all.\n  --screen <id>      Only this screen (repeatable). Default: all.\n  --locale <code>    Caption locale (repeatable). Default: all configured.\n  --skip-capture     Recompose from the existing raw captures.\n  --skip-compose     Capture only.\n  --fresh-auth       Ignore the cached session and sign in again.\n```\n\n`--skip-capture`\n\nis the one to know: iterating on copy or colours reuses the\ncaptures already on disk, so the loop is seconds rather than minutes. That matters\nmore for native, where a capture pass means booting a simulator.\n\nTo capture the same app two ways — headless for speed, a real simulator to check\nfidelity before a listing refresh — keep a second config that overrides only\n`capture`\n\nand select it with `--config`\n\n.\n\nThe config is the whole interface, which makes this a good tool to hand to a coding agent. Useful things to ask for:\n\n- \"Add a screen for the billing page and write a caption for it.\"\n- \"Rewrite the captions to lead with the benefit, keep them under six words.\"\n- \"Try the halo in our accent colour and show me the iPhone set.\"\n\nEach is a small edit to one file followed by `storeshot --skip-capture`\n\n, and the\ndiff is reviewable as images in a pull request. The library API is exported too,\nif you would rather script the pipeline than shell out:\n\n``` js\nimport { loadConfig, parseOptions, run } from 'storeshot';\n```\n\nNote that the package ships TypeScript source and is loaded through\n[jiti](https://github.com/unjs/jiti), so importing it from plain Node without a\nTypeScript loader will not work. The CLI handles this for you.\n\n[fastlane](https://fastlane.tools) `snapshot`\n\nand `frameit`\n\ncover the same ground\nfor native apps, and if you already run them happily there is little reason to\nswitch. The differences that motivated this: capture is a driver rather than a\nrequirement, so native, web and pre-made screenshots go through one pipeline;\ncomposition is SVG and Pango through sharp instead of ImageMagick, so captions\nreflow and the layout uses your own design tokens; and it needs no UI-test suite\nto get started. Deliberately\nnot reimplemented is running your tests — `xcodebuild test`\n\nis fastlane's job, and\nthe `import`\n\ndriver consumes whatever it produces. The device bezels come from\nfastlane's own frame set.\n\nMIT", "url": "https://wpnews.pro/news/show-hn-storeshot-google-play-store-screenshots-built-for-agents", "canonical_source": "https://github.com/thiagoperes/storeshot", "published_at": "2026-07-28 17:28:47+00:00", "updated_at": "2026-07-28 17:52:54.301453+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Thiago Peres", "Storeshot", "Apple", "Google", "Rally", "fastlane", "Playwright", "sharp"], "alternates": {"html": "https://wpnews.pro/news/show-hn-storeshot-google-play-store-screenshots-built-for-agents", "markdown": "https://wpnews.pro/news/show-hn-storeshot-google-play-store-screenshots-built-for-agents.md", "text": "https://wpnews.pro/news/show-hn-storeshot-google-play-store-screenshots-built-for-agents.txt", "jsonld": "https://wpnews.pro/news/show-hn-storeshot-google-play-store-screenshots-built-for-agents.jsonld"}}