browser-store-screenshots
Chrome Web Store Screenshots Generator
Overview
Build a Next.js page that renders Chrome Web Store screenshots as advertisements (not plain UI captures) and exports them via html-to-image at Chrome's required resolutions. Screenshots and promotional images are the primary conversion assets on the Chrome Web Store.
Supported image types out of the box:
- Screenshot (1280x800) -- Chrome Web Store listing page
- Screenshot Small (640x400) -- Chrome Web Store listing page (smaller variant)
- Small Promo Tile (440x280) -- Homepage, category pages, search results (REQUIRED)
- Marquee (1400x560) -- Featured carousel banner (OPTIONAL)
Core Principle
Screenshots are advertisements, not documentation. Every screenshot sells one idea. If you're just showing raw UI, you're doing it wrong -- you're selling a feeling, an outcome, or killing a pain point. Chrome Web Store screenshots that look like plain desktop captures get skipped. Your screenshots need to communicate the extension's value before the user even reads the description.
Step 1: Ask the User These Questions
Before writing ANY code, ask the user all of these. Do not proceed until you have answers:
Required
- Extension screenshots -- "Where are your extension screenshots? (PNG files of actual browser captures showing your extension in action)"
- Extension icon -- "Where is your extension icon PNG? (128x128 recommended)"
- Brand colors -- "What are your brand colors? (accent color, text color, background preference)"
- Font -- "What font does your extension/brand use? (or what font do you want for the screenshots?)"
- Feature list -- "List your extension's features in priority order. What's the #1 thing your extension does?"
- Number of slides -- "How many screenshots do you want? (Chrome Web Store allows up to 5)"
- Style direction -- "What style do you want? Examples: clean/minimal, dark/moody, bold/colorful, gradient-heavy, flat. Share Chrome Web Store screenshot references if you have any."
Optional
- Small Promo Tile -- "Do you want a Small Promo Tile (440x280)? This is REQUIRED for your listing and appears in search results and category pages."
- Marquee Image -- "Do you want a Marquee banner (1400x560)? This is optional and used if your extension is featured in the Chrome Web Store carousel."
- 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 extension aesthetic, decide:
- Background style: gradient direction, colors, whether light or dark base
- Decorative elements: subtle shapes, glows, 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
Detect Package Manager
Check what's available, use this priority: bun > pnpm > yarn > npm
# 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)
# With bun:
bunx create-next-app@latest . --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*"
bun add html-to-image
# With pnpm:
pnpx create-next-app@latest . --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*"
pnpm add html-to-image
# With yarn:
yarn create next-app . --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*"
yarn add html-to-image
# With npm:
npx create-next-app@latest . --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*"
npm install html-to-image
File Structure
project/
├── public/
│ ├── app-icon.png # User's extension icon
│ ├── mockup/ # Chrome browser mockup assets
│ │ ├── tab.png
│ │ ├── bottom-left.png
│ │ └── bottom-right.png
│ └── screenshots/
│ ├── feature-1.png
│ ├── feature-2.png
│ └── ...
├── src/app/
│ ├── layout.tsx # Font setup
│ └── page.tsx # The screenshot generator (single file)
└── package.json
The entire generator is a single page.tsx file. No routing, no extra layouts, no API routes.
Theme Presets
const THEMES = {
"clean-light": { bg: "#F6F1EA", fg: "#171717", accent: "#5B7CFA", muted: "#6B7280" },
"dark-bold": { bg: "#0B1020", fg: "#F8FAFC", accent: "#8B5CF6", muted: "#94A3B8" },
"warm-editorial": { bg: "#F7E8DA", fg: "#2B1D17", accent: "#D97706", muted: "#7C5A47" },
} as const;
type ThemeId = keyof typeof THEMES;
const [themeId, setThemeId] = useState<ThemeId>("clean-light");
const theme = THEMES[themeId];
Use theme tokens everywhere instead of hardcoded colors.
Font Setup
// src/app/layout.tsx
import { YourFont } from "next/font/google";
const font = YourFont({ subsets: ["latin"] });
export default function Layout({ children }: { children: React.ReactNode }) {
return <html><body className={font.className}>{children}</body></html>;
}
Step 3: Plan the Slides
Screenshot Framework (Narrative Arc)
Chrome Web Store allows up to 5 screenshots. Adapt this framework:
| Slot | Purpose | Notes |
|---|---|---|
| #1 | Hero / Main Benefit | Extension icon + tagline + main screen. This is the PRIMARY conversion driver. |
| #2 | Differentiator | What makes this extension unique vs competitors |
| #3 | Core Feature | The most-used feature, demonstrated in context |
| #4 | Workflow / Integration | How it fits into the user's daily browsing routine |
| #5 | Trust / Summary | Social proof, permissions transparency, or feature pills listing extras |
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.
- All screenshots are landscape (16:10 ratio) -- use horizontal composition.
Step 4: Write Copy FIRST
Get all headlines approved before building layouts. Bad copy ruins good design.
The Iron Rules
- One idea per headline. Never join two things with "and."
- Short, common words. 1-2 syllables. No jargon unless it's domain-specific.
- 3-5 words per line. Must be readable at thumbnail size in the store.
- 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 | "Summarize any page in one click." |
| State an outcome | What your life looks like after | "A clutter-free browser, every day." |
| Kill a pain | Name a problem and destroy it | "Never lose a tab again." |
What NEVER Works
- Feature lists as headlines: "Block ads, track time, and manage tabs"
- Two ideas joined by "and": "Save time and boost productivity"
- Vague aspirational: "Better browsing, reimagined"
- Marketing buzzwords: "AI-powered" (unless it's actually AI)
Bad-to-Better Headline Examples
| Weak | Better | Why it wins |
|---|---|---|
| Block ads and track browsing time | Browse without distractions | one idea, outcome-focused |
| Manage tabs with AI summaries | Find any tab, instantly | specific, sells the outcome |
| Save bookmarks with tags | Your bookmarks, actually organized | relatable pain point |
| Dark mode and custom themes | Make the browser yours | emotional, personal |
Copy Process
- Write 3 options per slide using the three approaches
- Read each at arm's length -- if you can't parse it in 1 second, it's too complex
- Check: does each line have 3-5 words? If not, adjust line breaks
- Present options to the user with reasoning for each
Chrome Web Store Copy Tips
- Extension screenshots are viewed at 1280x800 on desktop -- text can be slightly larger than mobile app screenshots
- Use action-oriented language: verbs first
- Keep it conversational -- Chrome extension users expect utility, not enterprise-speak
- Avoid mentioning "extension" or "Chrome" in copy -- the user already knows where they are
Step 5: Build the Page
Architecture
page.tsx
├── Constants (canvas dimensions, export sizes)
├── THEMES / COPY
├── Image preload cache (preloadAllImages + img() helper)
├── Browser frame component (shots.so-style Chrome mockup)
├── Minimal frame component (for small promo tile)
├── Caption component (label + headline, scales from canvasW)
├── Decorative components (blobs, glows -- based on style direction)
├── Slide components (Slide1..N for screenshots)
├── PromoTile component (440x280)
├── Marquee component (1400x560)
├── Slide registry (SCREENSHOT_SLIDES, PROMO_SLIDE, MARQUEE_SLIDE)
├── ScreenshotPreview -- ResizeObserver scaling + hover export
└── ScreenshotsPage -- grid + toolbar + export logic
Canvas Dimensions
Design at the largest required resolution. Smaller sizes are achieved by re-rendering at the target resolution on export.
// Screenshots
const SCREENSHOT_W = 1280;
const SCREENSHOT_H = 800;
const SCREENSHOT_SM_W = 640;
const SCREENSHOT_SM_H = 400;
// Promo images
const PROMO_TILE_W = 440;
const PROMO_TILE_H = 280;
const MARQUEE_W = 1400;
const MARQUEE_H = 560;
Export Sizes
const SCREENSHOT_SIZES = [
{ label: "Screenshot (recommended)", w: 1280, h: 800 },
{ label: "Screenshot (small)", w: 640, h: 400 },
] as const;
const PROMO_TILE_SIZES = [{ label: "Small Promo Tile (required)", w: 440, h: 280 }] as const;
const MARQUEE_SIZES = [{ label: "Marquee (optional)", w: 1400, h: 560 }] as const;
Image Type
type ImageType = "screenshot" | "promo-tile" | "marquee";
Browser Window Mockup (shots.so-style Chrome Light)
Use external shots.so assets for an authentic Chrome browser frame. The mockup has three layers:
- Window border: subtle double-border with outline
- Chrome UI: two-row layout (tab bar + address bar with controls)
- Content area: the screenshot/image drop zone
The Chrome UI uses shots.so PNG sprites for realistic tab bar and navigation controls, with a pure CSS address bar in between.
// Chrome light mockup assets (local -- no CORS issues)
const CHROME_TAB_URL = "./mockup/tab.png";
const CHROME_BOTTOM_LEFT = "./mockup/bottom-left.png";
const CHROME_BOTTOM_RIGHT= "./mockup/bottom-right.png";
function BrowserWindow({ src, alt, style }: { src: string; alt: string; style?: React.CSSProperties }) {
return (
<div style={{
width: "100%", height: "100%", position: "relative",
borderRadius: "0.56em", overflow: "hidden",
boxShadow: "rgba(0,0,0,0.16) 0px 0px 1em, rgba(0,0,0,0.36) 0px 1.25em 1.5em",
...style,
}}>
{/* Window border */}
<div style={{
position: "absolute", inset: 0, zIndex: 1,
border: "0.05em solid rgba(0,0,0,0.45)",
outline: "0.1em solid rgba(225,225,225,0.3)",
outlineOffset: "-0.15em", borderRadius: "0.56em",
pointerEvents: "none",
}} />
{/* Browser UI wrapper */}
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", overflow: "hidden" }}>
{/* Chrome UI */}
<div style={{ display: "flex", flexDirection: "column", width: "100%", overflow: "hidden" }}>
{/* Top bar (tab strip) */}
<div style={{
height: "calc(3.5em * 0.9)", position: "relative",
background: "#dfe1e5", display: "flex",
}}>
<div style={{ width: "3.5em", flexShrink: 0 }} /> {/* logo-title spacer */}
<img src={img(CHROME_TAB_URL)} alt=""
style={{ height: "100%", display: "block" }}
draggable={false} crossOrigin="anonymous" />
</div>
{/* Bottom bar (address bar + controls) */}
<div style={{
height: "calc(3.5em * 0.82)", display: "flex",
alignItems: "center", background: "#fff",
}}>
<img src={img(CHROME_BOTTOM_LEFT)} alt=""
style={{ height: "100%", flexShrink: 0 }}
draggable={false} crossOrigin="anonymous" />
{/* Address bar -- rounded pill */}
<div style={{
flex: 1, height: "80%", margin: "auto 0",
display: "flex", alignItems: "center",
borderRadius: "10em", background: "#f1f3f4",
padding: "0 1em",
}}>
<span style={{
fontSize: "0.85em", color: "#5f6368",
fontFamily: "monospace", whiteSpace: "nowrap",
overflow: "hidden", textOverflow: "ellipsis",
}}>
chrome-extension://...
</span>
</div>
<img src={img(CHROME_BOTTOM_RIGHT)} alt=""
style={{ height: "100%", flexShrink: 0 }}
draggable={false} crossOrigin="anonymous" />
</div>
</div>
{/* Content area (screenshot) */}
<div style={{
flex: 1, overflow: "hidden", position: "relative",
boxShadow: "rgba(0,0,0,0.5) 0em 0em 0.2em",
}}>
<img src={src} alt={alt} style={{
display: "block", width: "100%", height: "100%",
objectFit: "cover", objectPosition: "top",
}} draggable={false} />
</div>
</div>
</div>
);
}
Key details of the shots.so Chrome mockup:
--ui-height: 3.5emcontrols the overall chrome UI scale- Top bar (tab strip) is
0.9of ui-height, bottom bar is0.82 - Tab bar shows a realistic Chrome tab with the new-tab icon
- Bottom-left shows back/forward/refresh buttons and extension puzzle icon
- Bottom-right shows profile avatar and three-dot menu
- Address bar is a CSS rounded pill (
border-radius: 10em) with#f1f3f4background - Window border uses a double-border trick:
border+outlinewith negativeoutlineOffset - Shadow:
rgba(0,0,0,0.16) 0 0 1em, rgba(0,0,0,0.36) 0 1.25em 1.5em
Image Preload Must Include shots.so Assets
The shots.so URLs must be pre-loaded as data URIs just like local images:
const IMAGE_PATHS = [
"/app-icon.png",
"/screenshots/feature-1.png",
"/screenshots/feature-2.png",
// shots.so Chrome mockup assets -- MUST preload to avoid export failures
CHROME_TAB_URL,
CHROME_BOTTOM_LEFT,
CHROME_BOTTOM_RIGHT,
// ... all other images used in any slide
];
Minimal Browser Frame (for Promo Tile)
For the Small Promo Tile (440x280), the full browser mockup is too detailed. Use a minimal frame:
function MinimalFrame({ src, alt, style }: { src: string; alt: string; style?: React.CSSProperties }) {
return (
<div style={{
width: "100%", height: "100%", position: "relative", borderRadius: "0.3em",
overflow: "hidden",
boxShadow: "rgba(0,0,0,0.16) 0 0 0.5em, rgba(0,0,0,0.36) 0 0.6em 0.8em",
...style,
}}>
{/* Thin top bar with traffic lights */}
<div style={{
height: "1.5em", display: "flex", alignItems: "center",
padding: "0 0.5em", gap: "0.3em",
background: "#dfe1e5", borderBottom: "0.03em solid rgba(0,0,0,0.1)",
}}>
<div style={{ width: "0.5em", height: "0.5em", borderRadius: "50%", background: "#FF5F57" }} />
<div style={{ width: "0.5em", height: "0.5em", borderRadius: "50%", background: "#FEBC2E" }} />
<div style={{ width: "0.5em", height: "0.5em", borderRadius: "50%", background: "#28C840" }} />
</div>
<img src={src} alt={alt} style={{
display: "block", width: "100%", height: "calc(100% - 1.5em)",
objectFit: "cover", objectPosition: "top",
}} draggable={false} />
</div>
);
}
Rendering Strategy
Each image is designed at full resolution. Two copies exist:
- Preview: CSS
transform: scale()viaResizeObserverto fit a grid card - Export: Offscreen at
position: absolute; left: -9999pxat true resolution
Critical: Wrap the entire page in overflowX: "hidden" to prevent offscreen export elements from causing horizontal scroll:
<div style={{ minHeight: "100vh", background: "#f3f4f6", position: "relative", overflowX: "hidden" }}>
Slide Layout Patterns (Landscape)
Chrome Web Store screenshots are landscape (1280x800). Use these placement patterns -- NEVER repeat the same layout twice in a row:
Centered browser (hero, single-feature):
Browser centered, vertically fills most of the canvas
Caption above or overlaid on top of the browser
Left caption + right browser (feature showcase):
Caption: position: absolute, left: 5%, top: 50%, transform: translateY(-50%), width: 30%
Browser: position: absolute, right: 3%, top: 50%, transform: translateY(-50%), width: 55%
Right caption + left browser (alternate):
Browser: position: absolute, left: 3%, top: 50%, transform: translateY(-50%), width: 55%
Caption: position: absolute, right: 5%, top: 50%, transform: translateY(-50%), width: 30%
Full-width browser + overlay caption (immersive):
Browser: full width, slight bottom padding
Caption: position: absolute, top: 8%, left: 5%, with translucent background pill
Feature pills / summary (last slide):
No browser -- dark/contrast background
App icon + "And so much more."
Grid of feature pills (2-3 columns)
Small Promo Tile Layout (440x280)
The promo tile is small and appears in search results. It must communicate the extension's value at a glance:
function PromoTileSlide({ cW, cH }: { cW: number; cH: number }) {
return (
<div style={{
width: "100%", height: "100%", position: "relative", overflow: "hidden",
background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
{/* Extension icon */}
<img src={img("/app-icon.png")} alt="Icon"
style={{ width: cW * 0.18, height: cW * 0.18, borderRadius: cW * 0.035, flexShrink: 0 }}
draggable={false} />
{/* Tagline */}
<div style={{
fontSize: cW * 0.065, fontWeight: 700, color: "#fff", marginLeft: cW * 0.05,
lineHeight: 1.2, maxWidth: "60%",
}}>
Your tagline here.
</div>
</div>
);
}
Marquee Layout (1400x560)
The marquee is a wide banner for featured extensions. Use a cinematic layout:
function MarqueeSlide({ cW, cH }: { cW: number; cH: number }) {
return (
<div style={{
width: "100%", height: "100%", position: "relative", overflow: "hidden",
background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)",
display: "flex", alignItems: "center",
padding: `0 ${cW * 0.06}px`,
}}>
{/* Left: icon + name + tagline */}
<div style={{ display: "flex", alignItems: "center", gap: cW * 0.03, zIndex: 10 }}>
<img src={img("/app-icon.png")} alt="App Icon"
style={{ width: cW * 0.08, height: cW * 0.08, borderRadius: cW * 0.015 }}
draggable={false} />
<div>
<div style={{ fontSize: cW * 0.035, fontWeight: 800, color: "#fff", lineHeight: 1.1 }}>ExtensionName</div>
<div style={{ fontSize: cW * 0.018, color: "rgba(255,255,255,0.7)", marginTop: cW * 0.005 }}>Your tagline here.</div>
</div>
</div>
{/* Right: decorative element or browser preview */}
</div>
);
}
Device Dispatch
const { cW, cH, currentSizes, slides } = (() => {
if (imageType === "promo-tile") return { cW: PROMO_TILE_W, cH: PROMO_TILE_H, currentSizes: PROMO_TILE_SIZES, slides: [PROMO_SLIDE] };
if (imageType === "marquee") return { cW: MARQUEE_W, cH: MARQUEE_H, currentSizes: MARQUEE_SIZES, slides: [MARQUEE_SLIDE] };
return { cW: SCREENSHOT_W, cH: SCREENSHOT_H, currentSizes: SCREENSHOT_SIZES, slides: SCREENSHOT_SLIDES };
})();
Toolbar Layout
The toolbar has two sections: a scrollable controls area (left, flex: 1) and a fixed export button (right, always visible):
{/* Toolbar */}
<div style={{ position: "sticky", top: 0, zIndex: 50, background: "white", borderBottom: "1px solid #e5e7eb", display: "flex", alignItems: "center" }}>
{/* Scrollable controls */}
<div style={{ flex: 1, display: "flex", alignItems: "center", gap: 10, padding: "10px 16px", overflowX: "auto", minWidth: 0 }}>
<span style={{ fontWeight: 700, fontSize: 14, whiteSpace: "nowrap" }}>My Extension - Screenshots</span>
{/* Image type tabs */}
<div style={{ display: "flex", gap: 4, background: "#f3f4f6", borderRadius: 8, padding: 4, flexShrink: 0 }}>
{(["screenshot", "promo-tile", "marquee"] as ImageType[]).map(t => (
<button key={t} onClick={() => { setImageType(t); setSizeIdx(0); }}
style={{ padding: "4px 14px", borderRadius: 6, border: "none", cursor: "pointer", fontSize: 12, fontWeight: 600, whiteSpace: "nowrap", background: imageType === t ? "white" : "transparent", color: imageType === t ? "#2563eb" : "#6b7280" }}>
{t === "screenshot" ? "Screenshot" : t === "promo-tile" ? "Promo Tile" : "Marquee"}
</button>
))}
</div>
{/* Export size */}
<select value={sizeIdx} onChange={e => setSizeIdx(Number(e.target.value))} style={{ fontSize: 12, border: "1px solid #e5e7eb", borderRadius: 6, padding: "4px 10px" }}>
{currentSizes.map((s, i) => <option key={i} value={i}>{s.label} -- {s.w}x{s.h}</option>)}
</select>
</div>
{/* Export button -- always at right edge */}
<div style={{ flexShrink: 0, padding: "10px 16px", borderLeft: "1px solid #e5e7eb" }}>
<button onClick={exportAll} disabled={!!exporting}
style={{ padding: "7px 20px", background: exporting ? "#93c5fd" : "#2563eb", color: "white", border: "none", borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: exporting ? "default" : "pointer", whiteSpace: "nowrap" }}>
{exporting ? `Exporting... ${exporting}` : "Export All"}
</button>
</div>
</div>
Typography (Resolution-Independent)
All sizing relative to canvas width cW:
| Element | Size | Weight | Line Height |
|---|---|---|---|
| Category label | cW * 0.02 |
600 | default |
| Headline | cW * 0.045 to cW * 0.05 |
700 | 1.0 |
| Hero headline | cW * 0.055 |
700 | 0.95 |
| Promo tile tagline | cW * 0.065 |
700 | 1.2 |
| Marquee name | cW * 0.035 |
800 | 1.1 |
Note: Chrome Web Store screenshots are landscape and viewed on desktop -- text can be larger than mobile app screenshots, but still keep it concise.
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 all CSS faithfully.
Pre-load Images as Data URIs (CRITICAL)
html-to-image clones the DOM into an SVG <foreignObject>. During cloning it re-fetches every <img> src. These re-fetches are non-deterministic -- some hit the browser cache, some silently fail, causing transparent/black rectangles in exports.
Fix: Convert all images to base64 data URIs at page load. Use those as src everywhere. This includes the shots.so external assets.
const IMAGE_PATHS = [
"/app-icon.png",
"/screenshots/feature-1.png",
"/screenshots/feature-2.png",
// shots.so Chrome mockup assets -- MUST preload
CHROME_TAB_URL,
CHROME_BOTTOM_LEFT,
CHROME_BOTTOM_RIGHT,
// ... all other images
];
const imageCache: Record<string, string> = {};
async function preloadAllImages() {
await Promise.all(IMAGE_PATHS.map(async (path) => {
const resp = await fetch(path);
const blob = await resp.blob();
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
imageCache[path] = dataUrl;
}));
}
// Use in every <img> src:
function img(path: string): string {
return imageCache[path] || path;
}
Gate rendering on preload completion:
const [ready, setReady] = useState(false);
useEffect(() => { preloadAllImages().then(() => setReady(true)); }, []);
if (!ready) return <p>Loading images...</p>;
Export Implementation
import { toPng } from "html-to-image";
async function captureSlide(el: HTMLElement, w: number, h: number): Promise<string> {
el.style.left = "0px";
el.style.opacity = "1";
el.style.zIndex = "-1";
const opts = { width: w, height: h, pixelRatio: 1, cacheBust: true };
// CRITICAL: Double-call -- first warms up fonts/images, second produces clean output
await toPng(el, opts);
const dataUrl = await toPng(el, opts);
el.style.left = "-9999px";
el.style.opacity = "";
el.style.zIndex = "";
return dataUrl;
}
Export All (Bulk)
async function exportAll() {
if (imageType === "promo-tile" || imageType === "marquee") {
const el = exportRefs.current[0];
if (!el) return;
setExporting("...");
const size = currentSizes[sizeIdx];
const dataUrl = await captureSlide(el, size.w, size.h);
const a = document.createElement("a");
a.href = dataUrl;
a.download = `${imageType}-${size.w}x${size.h}.png`;
a.click();
setExporting(null);
return;
}
const size = currentSizes[sizeIdx];
for (let i = 0; i < slides.length; i++) {
setExporting(`${i + 1}/${slides.length}`);
const el = exportRefs.current[i];
if (!el) continue;
const dataUrl = await captureSlide(el, size.w, size.h);
const a = document.createElement("a");
a.href = dataUrl;
a.download = `${String(i + 1).padStart(2, "0")}-${slides[i].id}-${size.w}x${size.h}.png`;
a.click();
await new Promise(r => setTimeout(r, 300));
}
setExporting(null);
}
Key Export 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: 0beforetoPng-- offscreen elements don't render. - Offscreen container: Use
position: absolute; left: -9999px(notfixed) inside aoverflowX: hiddenwrapper. - 300ms delay between sequential exports -- prevents browser throttling.
- Numbered filenames: Zero-padded prefix so files sort correctly:
01-hero-1280x800.png. - Pre-loaded data URIs: Always use
img()helper. Never use raw file paths in slide components. shots.so assets MUST also be pre-loaded. - RGB source images: Ensure source screenshots are RGB (not RGBA). RGBA PNGs can produce transparent/black regions in exports.
- Full bleed, square corners: Chrome Web Store screenshots must have no rounded corners and no padding.
Chrome Mockup Assets
The Chrome browser mockup uses three local PNG assets served from public/mockup/:
public/
└── mockup/
├── tab.png # Chrome tab bar
├── bottom-left.png # Navigation controls (back, forward, refresh, extensions)
└── bottom-right.png # Profile avatar and three-dot menu
These assets are included in this skill's public/mockup/ directory and should be copied to the generated project's public/mockup/ folder. Always preload them into the image cache to ensure clean exports.
Step 7: Final QA Gate
Chrome Web Store Compliance
- 1280x800 or 640x400: Screenshots must be exactly these dimensions.
- Full bleed, square corners: No rounded corners, no padding, no white borders.
- Max 5 screenshots: Do not generate more than 5.
- Small Promo Tile (440x280): Must be provided -- this is REQUIRED by Chrome Web Store.
- No misleading claims: No "Editor's Choice", "Number One", etc.
- Consistent branding: Screenshots, icon, and promo tiles should share visual identity.
Message Quality
- One idea per slide: if a headline sells two ideas, split it or simplify it
- First slide is strongest: the hero slide must communicate the main benefit immediately
- Readable in one second: if you cannot parse it instantly, the headline is too complex
Visual Quality
- No repeated layouts in sequence: adjacent slides should not feel templated
- Browser mockup looks authentic: Chrome UI with tab bar, address bar, proper proportions
- Decorative elements support the story: add energy without covering the extension UI
- Visual rhythm exists: at least one contrast slide when the set is long enough
- Promo tile works at half size: the 440x280 tile appears small in search results
Export Quality
- No clipped text or assets after scaling to export size
- Screenshots correctly aligned inside the browser frame
- Chrome UI elements render (tab bar, address bar, navigation buttons) -- not blank
- Filenames sort correctly with zero-padded numeric prefixes
- All images export cleanly: no black rectangles, no missing images
- Theme tokens applied consistently across all slides in the same preset
Hand-off Behavior
When you present the finished work:
- briefly explain the narrative arc across the slides
- mention any slides that intentionally use contrast or different layout treatment
- call out any assumptions you made about brand tone, copy, or missing assets
- remind the user to upload the Small Promo Tile (440x280) -- it is REQUIRED for the listing
Common Mistakes
| Mistake | Fix |
|---|---|
| All slides look the same | Vary browser position (center, left, right, full-width, no-frame) |
| Copy is too complex | "One second at arm's length" test |
| Floating elements block the browser | Move off-screen edges or above the window frame |
| Plain white/black background | Use gradients -- even subtle ones add depth |
| Headlines use "and" | Split into two slides or pick one idea |
| Export is blank | Use double-call trick; move element on-screen before capture |
| Screenshots black in export | Images not inlined -- use preloadAllImages() + img() helper |
| Chrome UI blank in export | shots.so assets not preloaded -- add to IMAGE_PATHS |
| Some slides missing images | Non-deterministic fetch race -- same fix as above |
| Export button scrolls off toolbar | Split toolbar: scrollable controls left (flex: 1), fixed button right (flex-shrink: 0) |
| Page has horizontal scroll | Add overflowX: "hidden" on the outermost wrapper div |
| Missing promo tile | Always generate the 440x280 Small Promo Tile -- it is REQUIRED |
| Rounded corners on screenshots | Chrome Web Store requires full bleed, square corners |
| Wrong dimensions | Double-check: screenshots 1280x800 or 640x400, promo 440x280, marquee 1400x560 |
| Chrome UI assets missing | Copy public/mockup/ folder (tab.png, bottom-left.png, bottom-right.png) to the generated project |
More from hocgin/agent-skills
swift-composable-architecture
Use when building, refactoring, debugging, or testing iOS/macOS features using The Composable Architecture (TCA). Covers feature structure, effects, dependencies, navigation patterns, and testing with TestStore.
15swift-private-bundle
Use when working with private Swift Package Manager dependencies from github.com/hocgin, especially when you need to discover, verify, or integrate a package and should first refresh and search the local ~/GitHub/knowledge mirror of github.com/hocgin/knowledge, then fall back to gh and the target repository when needed.
6swift-sqlite-data
Use when working with SQLiteData library (@Table, @FetchAll, @FetchOne macros) for SQLite persistence, queries, writes, migrations, or CloudKit private database sync.
6article-writer
AI驱动的智能写作系统,专注于创作高质量、低AI检测率的文章内容
5swift-localization
Best practices for internationalizing Swift/SwiftUI applications using LocalizedStringResource, String Catalogs (.xcstrings), and type-safe localization patterns. Use when implementing multi-language support, adding new UI strings, or refactoring hardcoded text in Swift apps.
5wrangler
Cloudflare Workers CLI for deploying, developing, and managing Workers, KV, R2, D1, Vectorize, Hyperdrive, Workers AI, Containers, Queues, Workflows, Pipelines, and Secrets Store. Load before running wrangler commands to ensure correct syntax and best practices.
4