Google Play Screenshots Generator (RIPDPI)
Overview
Build or update the Next.js page in play-store-screenshots/ that renders Google Play Store screenshots as designed store artwork with accurate app UI and exports them via html-to-image + Puppeteer batch capture at Google Play's required resolutions.
Google Play constraints:
- Max 8 screenshots per device type (phone, 7" tablet, 10" tablet)
- Text overlay must not exceed 20% of the screenshot area
- No promotional text (pricing, rankings, awards)
- 24-bit PNG or JPEG only (no alpha transparency)
- Max 8 MB per image
- Minimum 2 screenshots to publish, 4+ recommended for visibility
Core Principle
Each screenshot communicates one supported capability. Show the real app experience, and prioritize actual UI in the first three screenshots. Preserve the captured state, warnings and disabled controls. Use short headings to explain the capability without promising an unverified outcome. Do not invent settings, measurements or active connections.
Follow the Google Play preview-asset requirements. They take precedence over older marketing examples in this skill.
Existing Project Structure
The generator already exists at play-store-screenshots/. Check if it needs updating rather than scaffolding from scratch. For the current file layout, asset sources and resolutions, brand/logo state, and the two-stage raw-capture-to-rendered-output pipeline, read play-store-screenshots/AGENTS.md first -- it is the canonical, actively-verified source for those facts; do not re-derive them here.
Screenshot quality floor: only use the high-res Stage-1 captures in public/screenshots/ (see AGENTS.md for the current resolution and file list). The Roborazzi test screenshots under app/src/test/screenshots/ are too low-resolution for Play Store quality (420x900 to 720x920) -- if a screen is only available as a test screenshot, use a text-focused slide instead.
Step 1: Confirm RIPDPI Defaults with the User
Pre-Filled from DESIGN.md (confirm, don't ask from scratch)
All colors come from the project's design system (DESIGN.md / RipDpiExtendedColors). The Play Store screenshots use these tokens for brand consistency.
Brand-palette note: the repo-root DESIGN.md (alpha) is monochrome-first light (#FAFAFA background, #1A1A1A foreground). Marketing slides honor the design system: light, monochrome-first, restrained semantic color, no decorative gradients. BRAND_LIGHT is the canonical default; BRAND (the strict dark inversion) is kept for at most 1-2 rhythm-break slides per set and uses the same role mapping, not a separate visual identity.
| Item | Default |
|---|
| App name | RIPDPI |
| Design philosophy | Monochrome-first, semantic color for status only. Technical, precise, utilitarian. |
| Font families | Geist Sans (Latin copy), Vazirmatn v33.003 for fa, Geist Mono (values/configs); preserve the app fonts in raw captures |
Light Theme Tokens (canonical default)
const BRAND_LIGHT = {
bg: "#FAFAFA", // background
card: "#FFFFFF", // card
text: "#1A1A1A", // foreground
muted: "#F5F5F5", // muted
mutedFg: "#575757", // mutedForeground
accent: "#E8E8E8", // accent
border: "#E0E0E0", // border
success: "#047857", // success
warning: "#B45309", // warning
error: "#B91C1C", // destructive
info: "#1D4ED8", // info
restricted: "#6B7280", // restricted
} as const;
Dark Theme Tokens (strict inversion — same role mapping)
Use sparingly for at most 1-2 rhythm-break slides. Status colors are slightly
lighter than light-theme variants for dark-surface contrast — never bright.
const BRAND = {
bg: "#1A1A1A", // background
card: "#1F1F1F", // card
text: "#FAFAFA", // foreground
muted: "#262626", // muted
mutedFg: "#A3A3A3", // mutedForeground
accent: "#2A2A2A", // accent
border: "#2A2A2A", // border
success: "#10B981", // success
warning: "#D97706", // warning
error: "#DC2626", // destructive
info: "#3B82F6", // info
restricted: "#6B7280", // restricted
} as const;
Shape Tokens (from RipDpiShapeTokens)
| Token | Radius | Usage in screenshots |
|---|
xl | 16dp | Buttons, cards, text fields |
xlIncreased | 20dp | Expressive interactive surfaces |
xxl | 28dp | Pills, FABs |
full | 50% | Avatars, status dots |
Use these for screenshot frame corners (40px = ~3.7% of 1080px width) and UI element pills.
Typography Reference
| Element | Family | Weight | Current full-size text |
|---|
| Phone headline | Geist Sans or pinned locale font | 700 | 100px at 1080w |
| Phone explanation | Same locale font | 400 | At least 48px at 1080w |
| Banner wordmark | Geist Sans | 700 | 64px at 1024w |
| Banner value statement | Locale font | 400 | 56px at 1024w |
M3 Expressive principle: Prefer weight promotion (400->500->700) over size increase for emphasis.
| Feature list | 1. path optimization (proxy + VPN modes) 2. No root required 3. Advanced strategy controls (TCP, QUIC, DNS) 4. Encrypted DNS (DoH/DoT/DNSCrypt) 5. Integrated diagnostics & monitoring 6. Per-network policies 7. Works with AdGuard 8. Session telemetry & export |
| Style direction | Light, monochrome-first. Restrained semantic color (use info/success/warning only when the state itself matters). No decorative gradients on any slide — DESIGN.md forbids them in-app and the marketing surface honors the same rule. |
Confirm missing requirements
For an existing generator, keep its configured slide count and locales unless the user asks to change them. A request to implement banners includes the feature graphic. Ask only when a missing requirement could change the result; otherwise use the existing project defaults and proceed.
- Number of slides -- "How many screenshots do you want? (Google Play allows up to 8)"
- Feature Graphic -- "Shall I generate a Feature Graphic (1024x500)? It's required for Play Store listings."
- Localized screenshots -- "Do you want screenshots in multiple languages? If yes, which languages?"
- Additional instructions -- "Any specific requirements or preferences?"
IMPORTANT: If the user says "figure it out" or similar, use the defaults and proceed without asking.
Derived (do NOT ask)
- Background style: flat solid backgrounds only (no gradients).
BRAND_LIGHT.bg (#FAFAFA) for the canonical light slides; BRAND.bg (#1A1A1A) for the rare rhythm-break dark slide.
- Decorative elements: subtle low-opacity monochrome
Grid pattern is the only allowed decoration. No radial glow orbs, no circuit-board, no shield motifs — these read as consumer-dashboard flourish and violate the design system.
- Light vs dark slides: light-first (matches DESIGN.md). At most 1-2 of 6 slides may use the dark inversion for visual rhythm; never more.
- Screenshot placement: use
top positioning (not bottom + translateY) to precisely control where screenshots start below headlines
Step 2: Set Up / Update the Project
If project already exists
cd play-store-screenshots
bun install # or npm install
public/screenshots/ holds the raw Stage-1 app captures (see play-store-screenshots/AGENTS.md's Two-Stage Pipeline section). If a screen's UI changed, re-capture it from a device per that file's Stage 1 instructions -- do not copy from docs/screenshots/, which holds the rendered Stage-2 marketing outputs, not a source for raw captures.
If scaffolding new (only if the project does not already exist)
Package manager priority: bun > pnpm > yarn > npm
bunx create-next-app@latest play-store-screenshots --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*"
cd play-store-screenshots
bun add html-to-image
bun add -d puppeteer
bun pm trust puppeteer # allow postinstall to download Chromium
Copy the app icon, then seed public/screenshots/ with fresh Stage-1 device captures per play-store-screenshots/AGENTS.md:
mkdir -p public/screenshots
cp ../app/src/main/ic_launcher-playstore.png public/app-icon.png
# then follow AGENTS.md's Stage 1 capture steps to populate public/screenshots/*.png
Font Setup (Next.js 16+)
Next.js 16 ships Geist fonts natively via next/font/google:
// src/app/layout.tsx
import { Geist, Geist_Mono } from "next/font/google";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
Use var(--font-geist-sans) and var(--font-geist-mono) in slide styles.
Next.js 16 Caveats
useSearchParams requires Suspense: Wrap the main component in <Suspense> to avoid build failures during static prerendering.
- Production build for capture:
bun run build && bun run start -- the dev server HMR websocket causes Puppeteer and Chrome DevTools navigation timeouts.
Step 3: Plan the Slides
Screenshot Framework (Narrative Arc)
| Slot | Purpose | RIPDPI Suggestion |
|---|
| #1 | Connection | Complete Home frame with real service state |
| #2 | Diagnostics | Completed real run and its actual results |
| #3 | Relay configuration | Complete relay controls with a meaningful viewport |
| #4 | DNS options | Actual DNS settings screen |
| #5 | Packet strategies | Actual packet strategy controls |
| #6 | Backups | Actual backup and restore screen; one clear function |
Rules:
- Each slide sells ONE idea
- Vary layouts -- never repeat the same template structure in adjacent slides
- Light-first: at most 1-2 of 6 slides may be the dark inversion, for rhythm only
- Text overlay must not exceed 20% of the screenshot area
- Slides with no high-res screenshot available should be text-focused (feature cards, protocol pills, etc.)
Step 4: Write Copy FIRST
The Iron Rules
- One idea per headline. Never join two things with "and."
- Short, common words. 1-2 syllables. No jargon unless domain-specific.
- 3-5 words per line. Readable at thumbnail size.
- Line breaks are intentional. Control with
<br />.
- Max 20% text overlay.
Three Approaches (pick one per slide)
| Type | What it does | Example |
|---|
| Paint a moment | You picture yourself doing it | "Open any site. No extra steps." |
| State an outcome | What your life looks like after | "The internet you were promised." |
| Kill a pain | Name a problem and destroy it | "No more blocked pages." |
Step 5: Build the Page
Architecture
The entire generator is a single page.tsx file:
page.tsx
├── Constants (PHONE_W/H, FEATURE_GRAPHIC, BRAND tokens)
├── Screenshot component (frameless, 40px border-radius, bgColor prop)
├── Caption component (label + headline)
├── Decorative components (Glow, Grid, Pill)
├── Slide container component
├── Slide1..N components (one per slide)
├── FeatureGraphicSlide component (1024x500)
├── SLIDES array (registry)
├── ScreenshotPreview (ResizeObserver scaling + click-to-export)
├── ScreenshotsPage (grid + export logic)
└── Page wrapper (Suspense boundary)
Key Dimensions
const PHONE_W = 1080;
const PHONE_H = 1920;
const FEATURE_GRAPHIC = { w: 1024, h: 500 };
Screenshot Component (Frameless)
function Screenshot({ src, alt, style, bgColor = "#ffffff" }: {
src: string; alt: string; style?: React.CSSProperties; bgColor?: string;
}) {
return (
<div style={{ position: "relative", ...style }}>
<div style={{
width: "100%", height: "100%",
borderRadius: 40, // ~3.7% of 1080px, close to xl (16dp) scaled up
overflow: "hidden",
boxShadow: "0 12px 60px rgba(0,0,0,0.5)",
background: bgColor,
}}>
<img src={src} alt={alt}
style={{ display: "block", width: "100%", height: "100%",
objectFit: "cover", objectPosition: "top" }}
draggable={false} />
</div>
</div>
);
}
UI frame placement (Critical)
The current source frames are 1080x1800. Use their exact aspect ratio and scale the complete frame to fit below the headline. Use top positioning and check both bottom and side margins. Do not crop or stretch the real UI:
// Fit the complete frame below a compact heading
<Screenshot src="/screenshots/home-light.png" alt="Home"
style={{
position: "absolute",
top: 310, // precise control -- no overlap
left: "50%",
transform: "translateX(-50%)",
width: 840,
aspectRatio: "1080/1800",
}}
/>
Never use bottom: 0 + translateY(N%) for complete source screenshots -- the percentage math is hard to get right and leads to overlap or excessive gaps.
Single-Slide Mode
Support ?slide=N (1-6) and ?slide=fg for headless capture:
export default function Page() {
return (
<Suspense fallback={<div style={{ background: "#0a0a0a", minHeight: "100vh" }} />}>
<ScreenshotsPage />
</Suspense>
);
}
function ScreenshotsPage() {
const searchParams = useSearchParams();
const slideParam = searchParams.get("slide");
if (slideParam) {
if (slideParam === "fg") return <FeatureGraphicSlide />;
const idx = parseInt(slideParam) - 1;
const slide = SLIDES[idx];
if (slide) { const C = slide.component; return <C />; }
}
// ... grid view with export
}
Step 6: Export
Browser Export (interactive)
import { toJpeg } from "html-to-image";
const opts = { width: w, height: h, pixelRatio: 1, quality: 1, cacheBust: true, backgroundColor: "#FAFAFA" };
await toJpeg(el, opts); // warm-up call
const dataUrl = await toJpeg(el, opts); // actual JPEG capture
Puppeteer Batch Export (headless)
Prefer bun run capture:prod (see play-store-screenshots/AGENTS.md's Common Workflows table) -- it builds, boots the production server, runs the capture, and always tears the server down. The manual equivalent, against a production build (dev server HMR causes timeouts):
bun run build && bun run start -- -p 3099 &
node capture.mjs
// capture.mjs
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
for (const slide of SLIDES) {
await page.setViewport({ width: slide.w, height: slide.h, deviceScaleFactor: 1 });
await page.goto(`http://localhost:3099/?slide=${slide.param}`, { waitUntil: "load", timeout: 60000 });
await new Promise(r => setTimeout(r, 2000)); // fonts + images
await page.screenshot({ path: outPath, type: "png", clip: { x: 0, y: 0, width: slide.w, height: slide.h } });
}
Captured images go to docs/screenshots/ for README usage.
Key Export Rules
- Double-call trick for html-to-image: first call warms up fonts/images
- Formats: Browser downloads use opaque JPEG. Batch capture uses RGB PNG, checked by the strict asset validator. Background color alone does not change a canvas PNG from RGBA to RGB.
- Numbered filenames:
01-hero.png, 02-no-root.png, etc.
- Feature Graphic filename:
feature-graphic.png
- Production server only for Puppeteer -- dev server HMR websocket causes infinite loading
Step 7: Final QA Gate
Google Play Compliance
Visual Quality
Design System Alignment
Common Mistakes
| Mistake | Fix |
|---|
| Text overlaps phone screenshot | Use top positioning, not bottom + translateY |
| Screenshot clipped at edges | Use left/right: "4%" not negative values |
| Blank Puppeteer captures | Must use production build (bun run build && bun run start) |
| useSearchParams build error | Wrap component in <Suspense> |
| Low-res screenshots look bad | Use only validated Stage-1 captures from play-store-screenshots/public/screenshots/; never use rendered marketing output as raw input |
| Decorative gradients on slides | DESIGN.md forbids them in the app and they also break the marketing brand. Use solid backgrounds plus the Grid utility for subtle texture. No linear-gradient(...) or radial-glow orbs anywhere. |
| Bright saturated status accents | Use restrained DESIGN.md tokens (#1D4ED8 info, #047857 success, #B45309 warning), never bubblegum-bright variants. Reserve status color for slides where the state itself matters. |
| All slides look the same | Vary: centered phone, right-offset, left-offset, text-only, pills-only |
| Copy too complex | "One second at arm's length" test; 3-5 words per line |