# App Store Screenshots

> Use when building App Store screenshot pages, generating exportable marketing screenshots for iOS apps or iPad apps, or creating programmatic screenshot generators with Next.js. Triggers on app store, screenshots, marketing assets, html-to-image, device mockup, app-store-screenshots.

- Skill: `keremerkan/app-store-screenshots` (Agent Skill)
- Install (CLI): `npx skillmds@latest add keremerkan/app-store-screenshots`
- Raw SKILL.md: https://api.skillmd.com/api/skills/keremerkan/app-store-screenshots/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- Author: keremerkan (https://skillmd.com/u/keremerkan)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/keremerkan/app-store-screenshots

---


# App Store Screenshots Generator

## Overview

Build a Next.js page that renders App Store screenshots for **iPhone and iPad** as **advertisements** (not UI showcases) and exports them via `html-to-image` at Apple's required resolutions. Uses pre-framed device screenshots from `ascelerate screenshot frame` (Apple device bezels with pixel-perfect accuracy, including Dynamic Island and rounded corners). Screenshots are the single most important conversion asset on the App Store.

## Core Principle

**Screenshots are advertisements, not documentation.** Every screenshot sells one idea. If you're showing UI, you're doing it wrong — you're selling a *feeling*, an *outcome*, or killing a *pain point*.

## Step 1: Ask the User These Questions

Before writing ANY code, gather all required information. **Ask questions one at a time** — wait for the user's answer before asking the next question. Dumping all questions at once is overwhelming. If the user volunteers information about upcoming questions, acknowledge it and skip those when you get to them.

### Required

1. **Framed screenshots** — "Where are your framed device screenshots? (from `ascelerate screenshot frame` — typically `screenshots/framed/`)"
2. **App icon** — "Where is your app icon PNG?"
3. **Brand colors** — "What are your brand colors? (accent color, text color, background preference)"
4. **Font** — "What font does your app use? (or what font do you want for the screenshots?)"
5. **Feature list** — "List your app's features in priority order. What's the #1 thing your app does?"
6. **Number of slides** — "How many screenshots do you want? (Apple allows up to 10)"
7. **Style direction** — "What style do you want? Examples: warm/organic, dark/moody, clean/minimal, bold/colorful, gradient-heavy, flat. Share App Store screenshot references if you have any."

### Optional (ask after required questions are answered)

8. **Localization** — "Do you want screenshots in multiple languages? If so, which languages? (e.g. English, German, French). You'll get a language switcher in the preview to toggle between them."
9. **Component assets** — "Do you have any UI element PNGs (cards, widgets, etc.) you want as floating decorations? If not, that's fine — we'll skip them."
10. **Additional instructions** — "Any specific requirements, constraints, or preferences?"

### Derived from answers (do NOT ask — decide yourself)

Based on the user's style direction, brand colors, and app aesthetic, decide:
- **Background style**: gradient direction, colors, whether light or dark base
- **Decorative elements**: blobs, glows, geometric shapes, or none — match the style
- **Dark vs light slides**: how many of each, which features suit dark treatment
- **Typography treatment**: weight, tracking, line height — match the brand personality
- **Color palette**: derive text colors, secondary colors, shadow tints from the brand colors

**IMPORTANT:** If the user gives additional instructions at any point during the process, follow them. User instructions always override skill defaults.

## Step 2: Set Up the Project

### Requirements

- **Node.js 18+**
- **Framed screenshots** — generated by `ascelerate screenshot frame` (see [ascelerate](https://github.com/keremerkan/ascelerate)). The user should have framed device screenshots ready before starting.

### Project Location

Default location is `app-store-screenshots/` at the root of the user's working directory. Ask the user: "I'll create the project in `app-store-screenshots/`. Want me to use a different folder or a temporary location instead?" If the directory already exists with a Next.js project, reuse it.

**Git tracking:** After creating the project, check if the working directory is a git repo (`git rev-parse --is-inside-work-tree`). If so, suggest adding the project folder (or at minimum its `node_modules/`) to `.gitignore`.

### Detect Package Manager

Check what's available, use this priority: **bun > pnpm > yarn > npm**

```bash
# Check in order
which bun && echo "use bun" || which pnpm && echo "use pnpm" || which yarn && echo "use yarn" || echo "use npm"
```

### Scaffold (if no existing Next.js project)

Create the project inside `app-store-screenshots/`:

```bash
mkdir -p app-store-screenshots && cd app-store-screenshots

# With bun:
bunx create-next-app@latest . --typescript --app --src-dir --no-eslint --no-tailwind --import-alias "@/*"
bun add html-to-image jszip

# With pnpm:
pnpx create-next-app@latest . --typescript --app --src-dir --no-eslint --no-tailwind --import-alias "@/*"
pnpm add html-to-image jszip

# With yarn:
yarn create next-app . --typescript --app --src-dir --no-eslint --no-tailwind --import-alias "@/*"
yarn add html-to-image jszip

# With npm:
npx create-next-app@latest . --typescript --app --src-dir --no-eslint --no-tailwind --import-alias "@/*"
npm install html-to-image jszip
```

### File Structure

```
app-store-screenshots/
├── public/
│   ├── app-icon.png            # User's app icon
│   └── framed/                 # Framed screenshots from ascelerate
│       ├── en-US/              # Per-locale directories (matches ascelerate output)
│       │   ├── iPhone 17 Pro Max-01-home.png
│       │   ├── iPhone 17 Pro Max-02-settings.png
│       │   └── iPad Pro 13-inch (M5)-01-home.png
│       └── de-DE/
│           └── ...
├── src/app/
│   ├── copy.json               # Localized copy (editable without touching code)
│   ├── layout.tsx              # Font setup
│   └── page.tsx                # The screenshot generator (single file)
└── package.json
```

Copy or symlink the framed screenshots from ascelerate's output (`screenshots/framed/`) into `public/framed/`. The directory structure matches ascelerate's output: `{locale}/{device}-{name}.png`.

If the app UI is the same across languages (only marketing text changes), copy only one locale — the Device component's `onError` fallback handles missing locales.

**The generator is `page.tsx` + `copy.json`.** No routing, no extra layouts, no API routes. All text lives in `copy.json` so the user can update copy without editing components.

### Font Setup

```tsx
// src/app/layout.tsx
import { YourFont } from "next/font/google"; // Use whatever font the user specified
const font = YourFont({ subsets: ["latin"] });

export default function Layout({ children }: { children: React.ReactNode }) {
  return <html><body className={font.className}>{children}</body></html>;
}
```

## Step 2.5: Set Up Framed Screenshots

Before building the page, copy the framed device screenshots into the project. These are pre-generated by `ascelerate screenshot frame`, which composites raw screenshots into Apple device bezels with pixel-perfect accuracy (Dynamic Island, rounded corners, bezel fully intact).

### Copy Framed Screenshots

Based on the user's answer to question #8, copy or symlink the framed output:

```bash
# Copy framed screenshots from ascelerate's output into the Next.js public directory
cp -r /path/to/screenshots/framed/* public/framed/
```

The framed directory structure from ascelerate looks like:
```
screenshots/framed/
├── en-US/
│   ├── iPhone 17 Pro Max-01-home.png
│   ├── iPhone 17 Pro Max-02-settings.png
│   └── iPad Pro 13-inch (M5)-01-home.png
└── de-DE/
    └── ...
```

If the app UI doesn't change across languages (only marketing text differs), copy only one locale — the Device component's `onError` fallback handles missing locales.

### Important Notes

- Framing is configured per-device in `ascelerate/screenshot.yml` via `frameDevice` and `deviceBezel` — see [ascelerate docs](https://ascelerate.dev/docs/commands/screenshots)
- The framed images already contain the device bezel, Dynamic Island, and rounded corners — the Next.js page just renders them as `<img>` tags
- If the user hasn't framed their screenshots yet, they should run `ascelerate screenshot frame` first

## Step 3: Plan the Slides

### Screenshot Framework (Narrative Arc)

Adapt this framework to the user's requested slide count. Not all slots are required — pick what fits:

| Slot | Purpose | Notes |
|------|---------|-------|
| #1 | **Hero / Main Benefit** | App icon + tagline + home screen. This is the ONLY one most people see. |
| #2 | **Differentiator** | What makes this app unique vs competitors |
| #3 | **Ecosystem** | Widgets, extensions, watch — beyond the main app. Skip if N/A. |
| #4+ | **Core Features** | One feature per slide, most important first |
| 2nd to last | **Trust Signal** | Identity/craft — "made for people who [X]" |
| Last | **More Features** | Pills listing extras + coming soon. Skip if few features. |

**Rules:**
- Each slide sells ONE idea. Never two features on one slide.
- Vary layouts across slides — never repeat the same template structure.
- Include 1-2 contrast slides (inverted bg) for visual rhythm.

## Step 4: Write Copy FIRST

Get all headlines approved before building layouts. Bad copy ruins good design.

### The Iron Rules

1. **One idea per headline.** Never join two things with "and."
2. **Short, common words.** 1-2 syllables. No jargon unless it's domain-specific.
3. **3-5 words per line.** Must be readable at thumbnail size in the App Store.
4. **Line breaks are intentional.** Control where lines break with `<br />`.

### Three Approaches (pick one per slide)

| Type | What it does | Example |
|------|-------------|---------|
| **Paint a moment** | You picture yourself doing it | "Check your coffee without opening the app." |
| **State an outcome** | What your life looks like after | "A home for every coffee you buy." |
| **Kill a pain** | Name a problem and destroy it | "Never waste a great bag of coffee." |

### What NEVER Works

- **Feature lists as headlines**: "Log every item with tags, categories, and notes"
- **Two ideas joined by "and"**: "Track X and never miss Y"
- **Compound clauses**: "Save and customize X for every Y you own"
- **Vague aspirational**: "Every item, tracked"
- **Marketing buzzwords**: "AI-powered tips" (unless it's actually AI)

### Copy Process

1. Write 3 options per slide using the three approaches
2. Read each at arm's length — if you can't parse it in 1 second, it's too complex
3. Check: does each line have 3-5 words? If not, adjust line breaks
4. Present options to the user with reasoning for each
5. If localized: translate approved headlines into all requested languages. Respect the tone/formality of each language. Line breaks may need adjusting per language — German and French text is often longer than English.

### Reference Apps for Copy Style

- **Raycast** — specific, descriptive, one concrete value per slide
- **Turf** — ultra-simple action verbs, conversational
- **Mela / Notion** — warm, minimal, elegant

## Step 5: Build the Page

### Architecture

```
page.tsx
├── Constants (IPHONE_W, IPHONE_H, IPAD_W, IPAD_H, design tokens)
├── LOCALES (code, label, asc locale code for App Store Connect)
├── ASC_DEVICE (maps "iphone" → "APP_IPHONE_67", "ipad" → "APP_IPAD_PRO_3GEN_129")
├── COPY (imported from copy.json — localized strings per language, keyed by slide name)
├── Device component (renders a pre-framed device image — just an <img>)
├── Caption component (label + headline)
├── Decorative components (blobs, glows, shapes — based on style direction)
├── Screenshot1..N components (one per slide, with iPhone and/or iPad variants)
├── SCREENSHOTS array (registry, with device type tag)
├── ScreenshotPreview (ResizeObserver scaling + hover export)
└── ScreenshotsPage (grid + toolbar with language switcher + device filter + Export/Export All Languages)
```

### Export Sizes (Largest Only — Apple auto-scales to smaller displays)

App Store Connect accepts the largest screenshot size per device and automatically scales it for smaller displays. Only generate:

```typescript
// iPhone 6.9" — covers all iPhone sizes
const IPHONE_W = 1320;
const IPHONE_H = 2868;

// iPad 13" — covers all iPad sizes
const IPAD_W = 2064;
const IPAD_H = 2752;
```

### Rendering Strategy

Each screenshot is designed at full export resolution. Two copies exist:

1. **Preview**: CSS `transform: scale()` via ResizeObserver to fit a grid card
2. **Export**: Offscreen at `position: absolute; left: -9999px` at true resolution

If both iPhone and iPad are requested:
- **Separate sections**: Render iPhone and iPad in distinct sections with device headers (`<h2>iPhone</h2>`, `<h2>iPad</h2>`), each with its own grid. Do NOT mix them in a single grid.
- **Device filter**: The toolbar includes an All / iPhone / iPad toggle that shows/hides sections.
- Each section filters `SCREENSHOTS.filter(s => s.device === "iphone")` etc.

### Localization

If the user wants multiple languages, store all copy in a separate `copy.json` file so the user can edit text without touching code:

```
src/app/copy.json
```

```json
{
  "en": {
    "iphone-hero": { "headline": "Spam texts,<br/>silenced.", "pills": ["Smart", "Private"] },
    "iphone-privacy": { "label": "On-device AI", "headline": "Completely<br/>private.", "sub": "..." }
  },
  "de": {
    "iphone-hero": { "headline": "Spam-SMS,<br/>verstummt.", "pills": ["Intelligent", "Datenschutz"] },
    "iphone-privacy": { "label": "KI auf dem Gerät", "headline": "Vollständig<br/>privat.", "sub": "..." }
  }
}
```

Import and type-cast it in `page.tsx`:

```typescript
import COPY_JSON from "./copy.json";
const COPY = COPY_JSON as Record<Locale, Record<string, SlideCopy>>;
```

Each slide component receives the current locale and looks up its copy from `COPY[locale][slideName]`.

**Localized device images:** If app screenshots differ per language (localized UI), ascelerate's framed output is already organized by locale: `public/framed/en-US/`, `public/framed/de-DE/`, etc. Slide components use the current locale in the path: `src={`/framed/${ascLocale}/${deviceName}-${screenshotName}.png`}`. One locale is the required default — all others are optional and fall back via `onError` on the Device component.

### ScreenshotPreview Component

Each slide is previewed using CSS scale-down via ResizeObserver. Clicking a preview exports that single slide:

```tsx
function ScreenshotPreview({ def, index, locale, w, h }: {
  def: ScreenshotDef; index: number; locale: Locale; w: number; h: number;
}) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [scale, setScale] = useState(0.2);
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const ro = new ResizeObserver((entries) => {
      for (const entry of entries) setScale(entry.contentRect.width / w);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [w]);
  const Comp = def.component;
  return (
    <div ref={containerRef}
      style={{ position: "relative", overflow: "hidden", borderRadius: 12,
        border: "1px solid #e5e7eb", aspectRatio: `${w}/${h}`,
        cursor: "pointer", background: "#fafafa" }}>
      <div style={{ transformOrigin: "top left", transform: `scale(${scale})`, width: w, height: h }}>
        <Comp w={w} h={h} locale={locale} />
      </div>
      <div style={{ position: "absolute", bottom: 8, left: 8, background: "rgba(0,0,0,0.6)",
        color: "#fff", fontSize: 11, fontWeight: 600, padding: "3px 8px", borderRadius: 6 }}>
        #{index + 1} — {def.name}
      </div>
    </div>
  );
}
```

### Toolbar & Page Layout

The main page has a toolbar with a language switcher, device filter (if both iPhone and iPad), and export buttons. The grid shows preview cards. Offscreen containers hold full-resolution slides for export:

```tsx
export default function ScreenshotsPage() {
  const exportRefs = useRef<Map<number, HTMLDivElement>>(new Map());
  const [exporting, setExporting] = useState(false);
  const [locale, setLocale] = useState<Locale>("en");
  // If both iPhone and iPad: const [deviceFilter, setDeviceFilter] = useState<"all"|"iphone"|"ipad">("all");

  return (
    <div style={{ padding: 32, maxWidth: 1400, margin: "0 auto" }}>
      {/* Toolbar */}
      <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 32, flexWrap: "wrap" }}>
        <h1 style={{ fontSize: 24, fontWeight: 700, color: "#111", margin: 0 }}>App Screenshots</h1>
        <div style={{ flex: 1 }} />

        {/* Language Switcher */}
        <select value={locale} onChange={(e) => setLocale(e.target.value as Locale)}
          style={{ padding: "8px 12px", borderRadius: 8, border: "1px solid #e5e7eb",
            background: "#fff", color: "#333", fontWeight: 600, fontSize: 14, cursor: "pointer" }}>
          {LOCALES.map((l) => (
            <option key={l.code} value={l.code}>{l.label} ({l.code})</option>
          ))}
        </select>

        {/* Device filter (only if both iPhone and iPad) */}
        {/* <select value={deviceFilter} onChange={...}> All / iPhone / iPad </select> */}

        <button onClick={handleExportAll} disabled={exporting}
          style={{ padding: "8px 20px", borderRadius: 8, border: "none",
            background: exporting ? "#999" : BRAND_COLOR, color: "#fff",
            fontWeight: 600, fontSize: 14, cursor: exporting ? "not-allowed" : "pointer" }}>
          {exporting ? "Exporting..." : `Export (${locale.toUpperCase()})`}
        </button>
        <button onClick={handleExportAllLanguages} disabled={exporting}
          style={{ padding: "8px 20px", borderRadius: 8, border: "none",
            background: exporting ? "#999" : BRAND_DARK, color: "#fff",
            fontWeight: 600, fontSize: 14, cursor: exporting ? "not-allowed" : "pointer" }}>
          {exporting ? "Exporting..." : "Export All Languages"}
        </button>
      </div>

      {/* Screenshots Grid */}
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 20 }}>
        {SCREENSHOTS.map((def, i) => (
          <div key={def.name} onClick={() => handleExportSingle(i)}>
            <ScreenshotPreview def={def} index={i} locale={locale} w={IPHONE_W} h={IPHONE_H} />
          </div>
        ))}
      </div>

      {/* Offscreen Export Containers */}
      {SCREENSHOTS.map((def, i) => {
        const Comp = def.component;
        return (
          <div key={`export-${i}`}
            ref={(el) => { if (el?.firstElementChild) exportRefs.current.set(i, el.firstElementChild as HTMLDivElement); }}
            style={{ position: "absolute", left: -9999, fontFamily: '"SF Pro Display", "SF Pro", system-ui, sans-serif' }}>
            <Comp w={IPHONE_W} h={IPHONE_H} locale={locale} />
          </div>
        );
      })}
    </div>
  );
}
```

### Export Handlers

Three export functions: single slide, current language, and all languages:

```tsx
async function captureScreenshot(el: HTMLElement, w: number, h: number): Promise<string> {
  const wrapper = el.parentElement!;
  wrapper.style.left = "0px";
  wrapper.style.opacity = "1";
  wrapper.style.zIndex = "-1";
  const opts = { width: w, height: h, pixelRatio: 1, cacheBust: true };
  await toPng(el, opts); // warm-up
  const dataUrl = await toPng(el, opts); // actual capture
  wrapper.style.left = "-9999px";
  wrapper.style.opacity = "";
  wrapper.style.zIndex = "";
  return dataUrl;
}

// Export current language as zip
const handleExportAll = useCallback(async () => {
  setExporting(true);
  const zip = new JSZip();
  const ascLocale = LOCALES.find((l) => l.code === locale)!.asc;
  for (let i = 0; i < SCREENSHOTS.length; i++) {
    const def = SCREENSHOTS[i];
    const el = exportRefs.current.get(i);
    if (!el) continue;
    const prefix = String(i + 1).padStart(2, "0");
    const dataUrl = await captureScreenshot(el, IPHONE_W, IPHONE_H);
    zip.file(`${ascLocale}/${ASC_DEVICE}/${prefix}_${def.name}.png`, dataUrlToBlob(dataUrl));
    await new Promise((r) => setTimeout(r, 300));
  }
  const blob = await zip.generateAsync({ type: "blob" });
  const url = URL.createObjectURL(blob);
  downloadDataUrl(url, `screenshots-${ascLocale}.zip`);
  URL.revokeObjectURL(url);
  setExporting(false);
}, [locale]);

// Export ALL languages as single zip
const handleExportAllLanguages = useCallback(async () => {
  setExporting(true);
  const zip = new JSZip();
  const originalLocale = locale;
  for (const lang of LOCALES) {
    setLocale(lang.code);
    await new Promise((r) => setTimeout(r, 500)); // wait for re-render
    for (let i = 0; i < SCREENSHOTS.length; i++) {
      const def = SCREENSHOTS[i];
      const el = exportRefs.current.get(i);
      if (!el) continue;
      const prefix = String(i + 1).padStart(2, "0");
      const dataUrl = await captureScreenshot(el, IPHONE_W, IPHONE_H);
      zip.file(`${lang.asc}/${ASC_DEVICE}/${prefix}_${def.name}.png`, dataUrlToBlob(dataUrl));
      await new Promise((r) => setTimeout(r, 300));
    }
  }
  setLocale(originalLocale);
  const blob = await zip.generateAsync({ type: "blob" });
  const url = URL.createObjectURL(blob);
  downloadDataUrl(url, "appname-screenshots.zip");
  URL.revokeObjectURL(url);
  setExporting(false);
}, [locale]);

// Export single slide as PNG
const handleExportSingle = useCallback(async (idx: number) => {
  const def = SCREENSHOTS[idx];
  const el = exportRefs.current.get(idx);
  if (!el || !def) return;
  setExporting(true);
  const prefix = String(idx + 1).padStart(2, "0");
  const filename = `${prefix}-iphone-${def.name}-${locale}-${IPHONE_W}x${IPHONE_H}.png`;
  const dataUrl = await captureScreenshot(el, IPHONE_W, IPHONE_H);
  downloadDataUrl(dataUrl, filename);
  setExporting(false);
}, [locale]);
```

Helper functions:

```tsx
function downloadDataUrl(dataUrl: string, filename: string) {
  const link = document.createElement("a");
  link.download = filename;
  link.href = dataUrl;
  link.click();
}

function dataUrlToBlob(dataUrl: string): Uint8Array {
  const base64 = dataUrl.split(",")[1];
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes;
}
```

The "Export All Languages" zip structure is compatible with `ascelerate apps media upload <app> --folder screenshots.zip --replace` for direct upload to App Store Connect.

### Device Component

Since `ascelerate screenshot frame` produces complete device-framed images (bezel + screenshot composited together), the device component is just an `<img>` tag — no CSS clipping, no screen offset calculations, no border-radius hacks:

```tsx
function Device({ src, alt, style }: {
  src: string; alt: string; style?: React.CSSProperties;
}) {
  return (
    <img
      src={src}
      alt={alt}
      style={style}
      draggable={false}
      onError={(e) => {
        // Fall back to English if localized image is missing
        const img = e.currentTarget;
        const enSrc = img.src.replace(/\/(?!en\/)[a-z]{2}\//, "/en/");
        if (img.src !== enSrc) img.src = enSrc;
      }}
    />
  );
}
```

The framed images already contain the device bezel with the Dynamic Island, rounded corners, and screen content composited pixel-perfectly. The transparent areas around the device allow it to sit naturally on any background. The `onError` fallback ensures missing locale-specific images gracefully degrade to English.

### Typography (Resolution-Independent)

All sizing relative to canvas width W (use `IPHONE_W` or `IPAD_W` depending on device):

| Element | Size | Weight | Line Height |
|---------|------|--------|-------------|
| Category label | `W * 0.028` | 600 (semibold) | default |
| Headline | `W * 0.09` to `W * 0.1` | 700 (bold) | 1.0 |
| Hero headline | `W * 0.1` | 700 (bold) | 0.92 |

### Device Placement Patterns

Vary across slides — NEVER use the same layout twice in a row:

**Centered device** (hero, single-feature):
```
bottom: 0, width: "82-86%", translateX(-50%) translateY(12-14%)
```

**Two devices layered** (comparison):
```
Back: left: "-8%", width: "65%", rotate(-4deg), opacity: 0.55
Front: right: "-4%", width: "82%", translateY(10%)
```

**Device + floating elements** (only if user provided component PNGs):
```
Cards should NOT block the device's main content.
Position at edges, slight rotation (2-5deg), drop shadows.
If distracting, push partially off-screen or make smaller.
```

### iPad-Specific Layout Notes

iPad screenshots have a **landscape-ish aspect ratio** (2064×2752, closer to 3:4 vs iPhone's ~1:2.2). This affects layouts:

- The device image is wider relative to the canvas — use **narrower widths** (70-78% instead of 82-86%) to avoid crowding
- **Captions may need to sit above the device** rather than beside it, since the wider device leaves less horizontal space
- Two-device layouts work best with **smaller scaling** and more overlap
- iPad's larger visible screen area means the app UI is more readable — lean into showing the content

### "More Features" Slide (Optional)

Dark/contrast background with app icon, headline ("And so much more."), and feature pills. Can include a "Coming Soon" section with dimmer pills.

## Step 6: Export

### Why html-to-image, NOT html2canvas

`html2canvas` breaks on CSS filters, gradients, drop-shadow, backdrop-filter, and complex clipping. `html-to-image` uses native browser SVG serialization — handles most CSS faithfully.

**IMPORTANT: Use `filter: drop-shadow()` instead of `box-shadow`.** While `html-to-image` handles most CSS well, `box-shadow` renders with visible boxy artifacts in exports. Always use CSS `filter: drop-shadow()` for shadows on devices, floating elements, and cards. Note that `drop-shadow` blur radius is roughly half of `box-shadow` blur for a similar visual effect:

```css
/* BAD — renders boxy/corrupted in exports */
box-shadow: 0 16px 50px rgba(0,0,0,0.35);

/* GOOD — renders correctly in exports */
filter: drop-shadow(0 16px 25px rgba(0,0,0,0.35));
```

### Export Implementation

```typescript
import { toPng } from "html-to-image";

// CRITICAL: el must be the slide component's root div (position: relative),
// NOT the offscreen wrapper. Use ref on wrapper, then ref.firstElementChild.
// toPng must capture the element that owns the positioned children.
const wrapper = el.parentElement!;

// Before capture: move wrapper on-screen
wrapper.style.left = "0px";
wrapper.style.opacity = "1";
wrapper.style.zIndex = "-1";

const opts = { width: W, height: H, pixelRatio: 1, cacheBust: true };

// CRITICAL: Double-call trick — first warms up fonts/images, second produces clean output
await toPng(el, opts);
const dataUrl = await toPng(el, opts);

// After capture: move wrapper back off-screen
wrapper.style.left = "-9999px";
wrapper.style.opacity = "";
wrapper.style.zIndex = "";
```

Use `W = IPHONE_W, H = IPHONE_H` for iPhone slides and `W = IPAD_W, H = IPAD_H` for iPad slides.

**Offscreen container setup**: The wrapper div is `position: absolute; left: -9999px` with `fontFamily` set. The ref should resolve to the slide's root div inside it (via `firstElementChild`), not the wrapper itself:
```tsx
ref={(el) => { if (el?.firstElementChild) exportRefs.current.set(i, el.firstElementChild as HTMLDivElement); }}
```

### Key Rules

- **Double-call trick**: First `toPng()` loads fonts/images lazily. Second produces clean output. Without this, exports are blank.
- **On-screen for capture**: Temporarily move to `left: 0` before calling `toPng`.
- **Offscreen wrapper**: Use `position: absolute; left: -9999px` (not `fixed`). Do NOT set `width`/`height` on the wrapper — let the slide component define its own dimensions.
- **No resizing needed**: Each slide is already at the exact export resolution. No canvas scaling step.
- **Zip export (ascelerate compatible)**: Two export buttons. "Export ({LOCALE})" exports the current language. "Export All Languages" loops through every locale (sets state, waits for re-render, captures). Both create a zip compatible with [ascelerate](https://github.com/keremerkan/ascelerate). Structure:
  ```
  en-US/APP_IPHONE_67/01_hero.png
  en-US/APP_IPAD_PRO_3GEN_129/01_hero.png
  de-DE/APP_IPHONE_67/01_hero.png
  ```
  Device type mapping: `iphone → APP_IPHONE_67`, `ipad → APP_IPAD_PRO_3GEN_129`. Each locale entry needs an `asc` field for the App Store Connect locale code (e.g. `{ code: "en", label: "English", asc: "en-US" }`). Files are ordered alphabetically so use zero-padded prefixes: `01_name.png`.
- **Single-click export**: Downloads individual PNGs with device in filename: `01-iphone-hero-en-1320x2868.png`.
- 300ms delay between sequential exports.
- Set `fontFamily` on the offscreen container.
- Use `String(index + 1).padStart(2, "0")` for numbering.

## Common Mistakes

| Mistake | Fix |
|---------|-----|
| All slides look the same | Vary device position (center, left, right, two-device, no-device) |
| Decorative elements invisible | Increase size and opacity — better too visible than invisible |
| Copy is too complex | "One second at arm's length" test |
| Floating elements block the device | Move off-screen edges or above the device |
| Plain white/black background | Use gradients — even subtle ones add depth |
| Too cluttered | Remove floating elements, simplify to device + caption |
| Too simple/empty | Add larger decorative elements, floating items at edges |
| Device frame touches canvas edge | Always keep padding between the device and the canvas sides — touching the edge looks cheap and cramped |
| Headlines use "and" | Split into two slides or pick one idea |
| No visual contrast across slides | Mix light and dark backgrounds |
| Export is blank | Use double-call trick; move element on-screen before capture |
| Export has empty top, content at bottom | `toPng` must target the slide's root div (has `position: relative`), not the offscreen wrapper |
| Screenshots not framed | Run `ascelerate screenshot frame` first, then copy `screenshots/framed/` to `public/framed/` |
| Shadows look boxy/corrupted in export | Use `filter: drop-shadow()` instead of `box-shadow` — `html-to-image` doesn't render `box-shadow` correctly |
| Framed image looks wrong | Check bezel configuration in `ascelerate/screenshot.yml` — bezel must match the capture device |

