Use when capturing or re-capturing UI screenshots for a marketing site (FeatureBlock images, email previews, hero posters, OG cards). Reads a YAML spec file that defines source URLs, capture targets, DOM tweaks, and output paths. Uses Playwright for native-browser element screenshots (no html2canvas, no oklab/bg-clip-text workarounds, no animation gotchas). Supports multiple auth strategies — static headers, pre-built storage state, interactive login, or minted JWT — and runs anonymously for public sites. Works headless in CI.
How this skill is triggered — by the user, by Claude, or both
Slash command
/marketing-screenshots:screenshotThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
A spec-driven element screenshotter. The user keeps a YAML spec file alongside their marketing site source; this skill runs it through a Playwright harness and writes PNG (+ WebP) outputs.
assets/auth/headers.mjsassets/auth/index.mjsassets/auth/interactive.mjsassets/auth/mint-jwt.mjsassets/auth/none.mjsassets/auth/storage-state.mjsassets/capture.mjsassets/examples/README.mdassets/examples/fastapi-docs-spec.yamlassets/package-lock.jsonassets/package.jsonassets/spec-loader.mjsassets/style-presets.mjsassets/target.mjsassets/tests/auth-interactive.test.mjsassets/tests/auth-mint-jwt.test.mjsassets/tests/auth-simple.test.mjsassets/tests/spec-loader.test.mjsassets/tests/style-presets.test.mjsassets/tests/target.test.mjsA spec-driven element screenshotter. The user keeps a YAML spec file alongside their marketing site source; this skill runs it through a Playwright harness and writes PNG (+ WebP) outputs.
If no spec exists, offer to bootstrap one from the bundled example (assets/examples/fastapi-docs-spec.yaml) or write a minimal one against the user's app.
On first use, install dependencies and the Chromium binary:
cd <plugin-assets>
npm run setup
<plugin-assets> is this skill's assets/ directory. When the plugin is installed via /plugin install marketing-screenshots@marketing-screenshots, that path is ~/.claude/plugins/.../plugins/marketing-screenshots/skills/screenshot/assets/.
cd <plugin-assets>
node capture.mjs /absolute/path/to/spec.yaml
The runner reads the spec, runs setup: commands, resolves auth, drives headless Chromium per viewport, writes PNG (+ WebP if enabled) to the spec's output_dir.
output_dir: /abs/path/to/public/marketing # required; absolute or relative-to-spec
webp: true # default: true
webp_quality: 82 # default: 82
device_scale_factor: 2 # default: 2
setup: # bash commands run before any captures
- cd ~/api && python scripts/reseed.py --reset
app:
base_url: https://app.example.test # Playwright baseURL
ignore_https_errors: true # default: false
auth:
strategy: none | headers | storage_state | interactive | mint_jwt
# … strategy-specific fields (see "Authentication" below)
viewports: # default: { desktop: { width: 1440, height: 900 } }
desktop: { width: 1440, height: 900 }
mobile: { width: 380, height: 800 }
shots:
- slug: hero
url: /docs/
wait_until: networkidle # default: networkidle. Use 'load' for sites whose CDNs keep connections open (e.g., MkDocs Material). Options: load | domcontentloaded | networkidle | commit
wait_for: 'h1:has-text("FastAPI")' # CSS selector (Playwright text= and :has-text supported)
settle_ms: 500
target:
selector: '#hero' # direct selector — no walk-up
# OR:
anchor: { tag: h1, text: 'FastAPI', leaf_only: true }
walk_up_until: { class_contains: 'hero' }
tweaks: [ ... ] # see "Tweak verbs" below
volatile_tweaks: [ ... ] # same verbs, rerun right before screenshot
style: marketing-card | portrait-phone | none
viewports: [desktop] # optional filter
mobile: # optional mobile-variant override
variant: constrained | viewport | skip
max_width: 380
padding: 8
force_single_column: true
Five strategies; pick by auth.strategy:
none — public sitesNo auth.* fields needed (or omit auth: entirely).
headers — static cookie / Bearer / PATauth:
strategy: headers
headers:
Cookie: 'session=abc123'
Authorization: 'Bearer xyz'
storage_state — pre-built Playwright stateauth:
strategy: storage_state
storage_state_in: /path/to/auth-state.json
interactive — headed login, save stateauth:
strategy: interactive
login_url: https://app.example.com/login
storage_state_out: /tmp/marketing-screenshots/auth-state.json
refresh_ttl_minutes: 50
First run: opens headed Chromium, you log in, press Enter — state saved. Subsequent runs reuse the state if it's fresh.
mint_jwt — sign a JWT and pretend you logged inFor local dev apps where you have the JWT signing secret. Fast, no human-in-the-loop, no browser needed for auth.
auth:
strategy: mint_jwt
secret_env: APP_JWT_SECRET # env var holding the signing secret
user_id: 666a16d4-...
user_email: [email protected]
storage_state_out: /tmp/marketing-screenshots/auth-state.json
storage:
key: my-app-session # localStorage key (or cookie name)
location: localStorage # or: cookie
jwt:
algorithm: HS256 # default: HS256
expires_in: 3600 # seconds, default: 3600
claims:
sub: '{{user_id}}' # interpolated from the fields above
email: '{{user_email}}'
aud: authenticated
role: authenticated
iat: '{{now}}'
exp: '{{exp}}'
For Supabase-shaped apps, additionally set the issuer and Supabase-specific claims:
claims:
iss: 'http://localhost:54321/auth/v1'
sub: '{{user_id}}'
email: '{{user_email}}'
aud: authenticated
role: authenticated
iat: '{{now}}'
exp: '{{exp}}'
app_metadata: { provider: email, providers: [email] }
user_metadata: {}
storage:
key: sb-supabase-auth-token # the conventional Supabase localStorage key
How the runner locates the element to screenshot:
# Direct selector — when an element has a stable ID or class
target:
selector: '#email-card'
# Anchor + walk-up — find a text node, walk up to an ancestor matching a condition
target:
anchor:
tag: h1 # or list: [h1, h2, h3]
text: 'Pick a new time'
leaf_only: true # default: true
walk_up_until:
class_contains: 'max-w-4xl'
# one or more of:
# parents: 3 # N parents up (fixed)
# class_contains: 'card' # ancestor has class containing string
# contains_text: 'Alyssa' # ancestor textContent contains
# has_descendant: 'input' # ancestor.querySelector succeeds
# until_sibling_matches: '/MAY|JUNE/i' # ancestor has a sibling matching regex
# js: 'target.classList.contains("foo")' # arbitrary JS condition
# Multiple conditions are ANDed.
Prefer content-based conditions over fixed parents: N — DOM shapes shift between releases. "Walk up until the container also has the customer's name AND a button" is robust; "walk up 6 parents" is fragile.
The tweaks: array runs sequentially after target identification, before styling. The volatile_tweaks: array runs the same verbs again right before screenshot — useful for text replacements React re-renders (countdown timers, animated counters).
| Verb | Args | Effect |
|---|---|---|
replace_text | { old | prefix | regex, new } | TreeWalker over target subtree; replace matching text nodes |
replace_link | { text_contains, new_text, new_href } | Rewrite <a> text + href |
hide_spans | { regex } (regex literal: /.../i) | Hide leaf spans whose text matches |
hide_extras | { selector, keep_first } | target.querySelector(selector).children — hide all after first N |
replace_inner | { seed_text, walk_up: { … }, html } | Find seed, walk up per descriptor, replace innerHTML |
clone_row | { seed_text, walk_up: { … }, rewrite: [ { old, new } ] } | Find row, clone, rewrite text via TreeWalker, append to parent |
force_single_column | true | Rewrite descendant grids with grid-cols-N to 1fr (mobile variants) |
inject_js | string | Wrap as (target) => { … }, eval in page — full escape hatch |
replace_inner walking up too far wipes the page. Use a content-based stop condition (e.g. until_sibling_matches: '/MAY|JUNE/' to stop at "the panel whose sibling is the calendar").clone_row walking up too far clones the whole list. Walk up only to the row whose siblings are the OTHER rows.volatile_tweaks instead of tweaks so it runs again right before screenshot.Two ways to capture a mobile-sized image:
mobile: { variant: viewport } — real responsive emulation. Requires a viewport named mobile in the top-level viewports:. Triggers md:/lg: Tailwind breakpoints when the app is responsive. Best for customer-facing flows.mobile: { variant: constrained, max_width: 380, padding: 8, force_single_column: true } — captures at the desktop viewport but constrains target.maxWidth and forces inner grids to single column. For desktop-only admin UIs you want to "look mobile" in marketing.mobile: skip (or omit mobile: entirely) — no mobile output.Output naming: <slug>.png for desktop, <slug>-<viewport>.png for other viewports, with <slug>-mobile.png always for any mobile viewport or constrained variant.
| Preset | When |
|---|---|
marketing-card | Wide product shots (default white card with 12px radius) |
portrait-phone | Phone-style portrait shots (white card with shadow, 28px radius, max-width 420) |
none | When the source page already styles the card (dedicated preview routes) |
After captures land, Read at least the first few PNGs to confirm visual correctness. The harness logs file sizes; a sudden 80% drop usually means the capture is mostly empty. Common visual-only issues:
SKILL.md — this fileassets/capture.mjs — orchestratorassets/spec-loader.mjs, target.mjs, walk-up.mjs, tweaks.mjs, style-presets.mjsassets/auth/{index,none,headers,storage-state,interactive,mint-jwt}.mjsassets/examples/fastapi-docs-spec.yaml — try-it-yourself exampleassets/package.json — runner depsProvides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.
npx claudepluginhub brandonhall/marketing-screenshots --plugin marketing-screenshots