Keep a plugin/demo repo's screenshots in sync with its UX. When editor / UI / design-token code changes in an opted-in repo (one carrying .pulp/screenshots.toml), re-capture and refresh every place each shot is consumed — README images, web-demo Open Graph (og.png) images, and gallery thumbnails — consistently across the WAM and WCLAP demo sites. Two capture routes: native headless (render_to_png / pulp-screenshot, Skia) for README / native-editor shots, and live-web headless Chrome (playwrig...
Installs into .claude/skills of the current project.
Are you the author of Screenshot Sync?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/danielraffel-screenshot-sync)
---
name: screenshot-sync
description: >
Keep a plugin/demo repo's screenshots in sync with its UX. When editor / UI /
design-token code changes in an opted-in repo (one carrying
.pulp/screenshots.toml), re-capture and refresh every place each shot is
consumed — README images, web-demo Open Graph (og.png) images, and gallery
thumbnails — consistently across the WAM and WCLAP demo sites. Two capture
routes: native headless (render_to_png / pulp-screenshot, Skia) for README /
native-editor shots, and live-web headless Chrome (playwright-core) for
WAM/WCLAP demo OG + gallery images. Backfills demos missing OG images. This is
a PORTING / consistency tool — it re-shoots a repo's OWN UI, it never clones
another product's art.
requires:
tools:
- python3 # screenshot_sync_check.py (Python 3.11+ for tomllib)
- node # playwright-core (web capture route)
files:
- tools/scripts/screenshot_sync_check.py
- core/view/include/pulp/view/screenshot.hpp
- examples/web-demos/wclap-build/cloudflare/assemble-gallery.mjs
---
# screenshot-sync
Keep a repo's screenshots in lockstep with its UI. When the UX changes, the
README shot, the web-demo Open Graph preview (`og.png`), and the gallery
thumbnail all have to change with it — this skill re-captures them and the
`screenshot_sync_check.py` gate fails a PR that forgot to.
Read the [`screenshot`](../screenshot/SKILL.md) skill first for the raw capture
mechanics (backends, the image-compositing trap, the content-floor oracle,
vision-probe). This skill is the *sync/consume orchestration* on top of it.
## When this applies
- A repo carrying `.pulp/screenshots.toml` had a UX-path change and the
screenshot-sync gate (pre-push / CI) or PostToolUse hint fired, OR
- "update the screenshots / OG images / gallery thumbs", "backfill the WCLAP OG
images", "the demo preview image is stale / links don't unfurl".
## 0. Confirm opt-in
Screenshots are a **plugin-with-releases** concern, not a core/tooling one.
Check for `.pulp/screenshots.toml` at the repo root. **Absent → the repo is not
opted in; stop.** Do not invent screenshots for a library/tool/core repo. Opt-in
is per-repo by the file's presence — the same pattern
`~/.config/pulp/daw-smoke.toml` and `.shipyard.local/config.toml` use.
Default opt-in set: the `packaged-demo` and `packaged-examples` consumers
(gpu-nam, bendr, superconvolver, spectral-lab, tempo-sampler, classic-effects,
pulp-example-plugins). Everything else — pulp core included — stays off.
## The manifest: `.pulp/screenshots.toml`
One file per opted-in repo, at its root. It lists the UX paths that should
trigger a re-shoot and, for each screenshot **target**, its capture route and
every **place the image is consumed** — so one re-shoot fans out to README + OG
+ gallery and they cannot drift.
```toml
schema_version = 1
# UX paths whose change should trigger a re-shoot (repo-relative, gitignore-style
# globs — the same dialect skill_path_map.json uses). A design-token change
# reskins every widget, so a trigger hit invalidates ALL targets in the repo.
[trigger]
paths = [
"src/**/*_editor.cpp",
"ui/**/*.js",
"assets/design-system/**/*.css",
"assets/design-system/**/*.tokens.json",
]
# ── Native README / editor shot (capture route A) ──
[[target]]
id = "mono-synth-editor"
route = "native" # render_to_png / pulp-screenshot, Skia/gpu
tool = "pulp-mono-synth-shot" # per-editor dumper target (brew_shots.cpp pattern)
size = "720x460"
scale = 2.0 # match the native editor baseline (test_editors.cpp)
backend = "skia" # or "gpu" for requires_gpu_host() views
consumes = [
{ kind = "readme", path = "docs/img/mono-synth.png" },
]
# ── Web demo OG + gallery (capture route B) ──
[[target]]
id = "mono-synth-web"
route = "web"
url = "example-plugins/mono-synth/index.html"
device_scale_factor = 2
consumes = [
{ kind = "og", path = "example-plugins/mono-synth/og.png" },
{ kind = "gallery", path = "docs/gallery/mono-synth.png" },
{ kind = "og_meta", html = "example-plugins/mono-synth/index.html", rel = "./og.png" },
]
[[target]]
id = "example-gallery-web"
route = "web"
url = "example-plugins/index.html"
device_scale_factor = 2
# A target may narrow the global trigger to just the paths that affect IT:
trigger = [ "ui/**/*.js", "assets/design-system/**" ]
consumes = [
{ kind = "og", path = "example-plugins/og.png" },
{ kind = "og_meta", html = "example-plugins/index.html", rel = "./og.png" },
]
```
Schema:
- `schema_version` — currently `1`.
- `[trigger].paths` — global UX globs; a match invalidates every target unless a
target sets its own `trigger` list.
- `[[target]]` — `id` (required, unique), `route` (`native` | `web`, required),
optional `trigger` (per-target override), plus route-specific keys
(`tool`/`size`/`scale`/`backend` for native; `url`/`device_scale_factor` for
web) that document how to re-capture.
- `consumes[]` — `{ kind, path }` or `{ kind, html, rel }`:
- `readme` / `gallery` — the PNG a markdown/gallery references.
- `og` — the PNG the page's `og:image` / `twitter:image` point at.
- `og_meta` — the HTML head that must declare the OG/Twitter block pointing at
that og.png (this is what backfills a demo missing OG images).
The gate verifies the `readme` / `gallery` / `og` **PNG paths** were refreshed
when a trigger fired; `og_meta` is documentation for the head-template edit.
## 1. Identify stale targets
```bash
python3 tools/scripts/screenshot_sync_check.py --base origin/main --mode=report
```
It lists each `[[target]]` a diff invalidated. Read the hook output if it already
fired. A token change lists every target.
## 2. Route A — native README / editor shots
For a plugin's native editor rendered to a PNG with no window. See the
`screenshot` skill for backend selection; the essentials:
- Prefer a per-editor dumper target (the `pulp-bitches-brew` repo's
`ui/brew_shots.cpp` → `brew-shots` pattern; each plugin repo copies it as `pulp-<name>-shot`,
named by the manifest `tool =`), or `pulp-screenshot --demo` for SDK demos.
- **Skia backend for asset-heavy UIs** — the CoreGraphics canvas can't composite
file images and renders filenames as placeholder text (the classic
"broken import" that is really just the backend).
- **`gpu` backend (offscreen Dawn+Skia `HeadlessSurface`)** for
`requires_gpu_host()` views and any headless/CI/SSH capture — never the live
`WindowHost::capture_png()` headless (it needs an interactive WindowServer
session and hangs).
- Match the native editor baseline **scale 2.0** (`test_editors.cpp` in the
example repos) so README shots align with the visual-regression baselines.
- Assert `ScreenshotStats::passes_content_floor` — never commit a blank /
near-blank PNG (a "file written" check misses the empty-frame bug).
- Write each PNG to its `consumes.kind = readme|gallery` path.
`pulp-screenshot` is an Apple-only target; the per-editor dumper is the portable
route and must be added to each plugin repo.
## 3. Route B — WAM / WCLAP web demo OG + gallery
The WAM/WCLAP plugin UI is a WASM build running in a browser, so the faithful
capture is a real browser rendering the demo page — **not** native
`render_to_png`. Pulp already ships this path (playwright-core against the system
Chrome, no browser download); the OG generators reuse it.
**Convention (match WAM exactly).** WAM's OG image is a **device-scale-2
element screenshot of the demo page itself**, saved as a colocated `og.png`:
- per-plugin page → screenshot the started editor panel (`#panel`) — ~1280×1234
- gallery page → screenshot the card grid (`.wrap`) — ~1720×2002
The reference generators:
- WAM (GitHub Pages, single gallery): `web/gen-og-images.mjs` (renders each page,
presses the player's `window.__start()` seam, waits for `window.__demo.started`,
screenshots the element at `deviceScaleFactor: 2` with `--mute-audio
--autoplay-policy=no-user-gesture-required`) + `web/gen-og.mjs` (injects the
`og:image`/`twitter:card=summary_large_image` block only when a sibling `og.png`
exists) — both in `pulp-example-plugins` / `pulp-classic-effects`.
- WCLAP (Cloudflare Pages, combined `/example-plugins/` + `/classic-effects/`
galleries): generated by
`examples/web-demos/wclap-build/cloudflare/assemble-gallery.mjs` in pulp core.
Its `pluginPage()` / `galleryPage()` emit the OG head block; the sibling
`gen-og-images.mjs` renders each page's `og.png`. WCLAP pages need
**cross-origin isolation (COOP/COEP)** to instantiate the threaded-wasm shared
memory, so serve them with `cloudflare/serve-headers.mjs` (which applies the
`_headers` rules), never a plain static server.
Steps:
1. Build/assemble the demo Pages tree (`assemble-gallery.mjs` for WCLAP; the
per-plugin `.wasm` modules are the only heavyweight input — they can be
rebuilt with wasi-sdk or fetched from the live deploy).
2. Serve it (WCLAP: `serve-headers.mjs` for COOP/COEP).
3. Screenshot each page at `deviceScaleFactor: 2`, waiting on the ready
predicate. Do NOT resume audio for its own sake — the demos are
click-to-start; if a shot needs the started editor, drive the `__start()`
seam under `--mute-audio` (see the local-dev audio etiquette in CLAUDE.md).
4. Save as colocated `og.png`; assert it is non-blank before trusting it.
5. Ensure the page head declares `og:image` / `og:url` / `twitter:card` /
`twitter:image` → the colocated og.png (the `og_meta` consume). Edit the
shared page template once, not each page by hand.
### Landmine: on a PREVIEW deploy the `og:image` URL points at PRODUCTION, so the unfurl is stale
`assemble-gallery.mjs` stamps `og:image` / `og:url` from `SITE_BASE`, which DEFAULTS to the
production origin (`https://pulp-wclap-demos.pages.dev`). Deploy to a preview alias
(`wrangler pages deploy --branch <name>` → `<name>.pulp-wclap-demos.pages.dev`) and the
page's `og:image` still points at PRODUCTION — so a crawler unfurling the PREVIEW link
fetches the production og.png (the OLD image; your fresh capture sits on the preview alias
and is never seen). Symptom: you regenerate og.png, confirm it live at
`<preview>/…/og.png`, and the link preview STILL shows the old art — because the meta URL
and the image are on different origins. Fix: rebuild the preview with the alias as base —
`SITE_BASE=https://<name>.pulp-wclap-demos.pages.dev node assemble-gallery.mjs` — then
redeploy; verify with `curl -s <preview>/…/ | grep og:image`. Production needs no override
(default base is correct once merged). Separately, social caches (iMessage, Slack) hold the
OLD unfurl until they re-crawl — changing the og:image URL forces a re-fetch, but a client
may still show its cached card until the link is re-sent or its preview cache clears.
### Landmine: `assemble-gallery.mjs` + `wrangler deploy` does NOT refresh `og.png`
`og.png` is content-hash cached — `gen-og-images.mjs` writes an `og.png.key` beside each
image and SKIPS a page whose inputs (its own HTML + the assets it loads: wasm module,
player, `pulp-ui.js`) are unchanged, re-shooting only pages whose hash moved. The trap:
the normal deploy loop is `assemble-gallery.mjs` → `wrangler pages deploy`, and **neither
runs `gen-og-images.mjs`.** So after you change an editor and redeploy, the share card
keeps showing the OLD capture — a full-canvas editor shipped for a while with an og.png of
the pre-editor knob grid. To refresh: run `gen-og-images.mjs` (or delete the page's
`og.png` + `og.png.key` to force a re-shoot) BEFORE deploying. A full-canvas Skia editor
only renders under the ANGLE/GPU Chrome flags, and the shot is a DEFAULT-state capture — it
will not reflect interactive states (a knob moved, an engine toggled).
## 4. Consistency + backfill
- Both galleries mirror the SAME example-plugin repos, so keep the OG head block
and the og.png-generation step a **shared template** (WAM: `gen-og.mjs`'s
`ogBlock()`; WCLAP: `assemble-gallery.mjs`'s page emitters) so WAM and WCLAP
can't drift.
- Backfilling a demo that lacks OG images = adding the `og:image` / `og:url` /
`twitter:card=summary_large_image` / `twitter:image` lines to the page
template + generating the colocated og.png. Social crawlers fetch the raw
HTML and do NOT run JS, so the tags must be present at rest.
## 5. Verify + hand off
- Re-run `screenshot_sync_check.py --mode=report` → clean.
- Vision-probe before reading any capture for a visual judgment (screenshot skill).
- Commit the PNGs + any head-template edit in the SAME PR as the UX change, or
add a bypass trailer on the tip commit:
```
Screenshot-Sync: skip target=<id|all> reason="..."
```
Legitimate uses: a provably-invisible refactor, a docs-only touch that trips a
glob, or a batch whose shots regenerate in a follow-up. Same philosophy as
`Skill-Update: skip` / `Version-Bump: skip`.
## The gate (three layers, mirrors skill-sync)
`tools/scripts/screenshot_sync_check.py` reads the repo's `.pulp/screenshots.toml`,
diffs the change range against `[trigger].paths`, and checks each triggered
target's consumed PNGs were refreshed (or bypassed). It DETECTS staleness; it
does not capture (capturing is heavy + non-deterministic across machines — the
re-shoot is the explicit step above). Wired exactly like the skill-sync gate:
| Layer | Where | Mode |
|-------|-------|------|
| 1 hint | PostToolUse (`hooks/scripts/cli-plugin-sync.sh`) | `--mode=hint` — advisory |
| 2 report | `.githooks/pre-push` | `--mode=report` — enforcing (demote with `PULP_DISABLE_PREPUSH_GATES=1`) |
| 3 CI | `.github/workflows/version-skill-check.yml` | `--mode=report` — authoritative, no env demotion |
The check script ships from pulp core and is installed into consumer repos the
same way the githooks are (`tools/scripts/install-githooks.sh`); the trigger
map is repo-local (`.pulp/screenshots.toml`), because each plugin repo has its
own layout.
## Gotchas
- **A NEW page must be registered in `gen-og-images.mjs` by hand.** Only the
*galleries* auto-discover their demos (they read the `plugins = [...]` array back
out of the assembled `index.html`). Every non-gallery page — the standalone pages,
`super-convolver`, `super-convolver-gpu` — is a **hardcoded entry**. Add a page to
`assemble-gallery.mjs` without adding it there and `assemble-gallery.mjs` still
bakes an absolute `og:image` URL for it, pointing at a PNG that nobody ever
generated. And **Cloudflare serves a missing asset as its 404 page with HTTP 200**,
so this does not fail as a broken link — it fails as a **blank unfurl**, and a
naive `curl -w %{http_code}` check says `200` and confirms the lie. That is the
exact bug that shipped. `check-og-images.mjs` (file exists, real PNG magic bytes,
aspect ≈ 1.91:1) and `check-unfurl.mjs` (fetch the LIVE URL as a crawler and
follow the `og:image`) are the backstops — but the fix is to register the page.
Doubly easy to miss when the page is emitted **conditionally**, like
`super-convolver-gpu`, which only appears when its GPU DSP build tree exists.
- **Cache-bust the main-thread player entry ONLY — never the worklet processor
or the DSP module.** An AudioWorklet processor's *registered name* is derived
from the URL it was added from, so the main thread and the worklet must agree
on exactly one URL. A `?v=` on either side forks the name and the node never
constructs — the page loads, the UI paints, and there is simply no audio. The
corollary bites in the other direction too: **every module the `?v=` IS stamped
on must be an input to the hash** (`assemble-gallery.mjs`'s `playerVersion()`
hashes the shell, widgets, both adapters, and the UI entry). Add a bustable
module without adding it to the hash and its next change ships behind a stale
cache entry — an OG shot regenerated against a page that is still serving the
old bundle is the worst version of this, because it looks correct.
- **A page's UI module may be optional; a screenshot of the fallback is not a
bug.** The gallery assembler skips the both-ABI SuperConvolver section entirely
when the WAM build tree (`--wam-build`) is absent, and builds pages with **no
custom UI** when the wasm UI module (`--ui-build`) is absent — the player then
renders its generated parameter grid. A browser with no WebGL2 context lands on
that same grid at runtime. So before re-shooting a demo, confirm which UI the
page actually mounted; otherwise you will capture the parameter grid, commit it
as the plugin's og.png, and have "wrong" screenshots with a green build.
- **Wait for the player's SETTLE signal, not for the canvas element.**
`mountPulpUi` appends the canvas immediately and *then* fetches the wasm module,
so "the canvas exists" is true a full second before anything is drawn on it.
Waiting on it shoots the loading screen. The real signal is the loading class
being **gone** (`.pw-customui-loading`). `gen-og-images.mjs` additionally
**refuses to shoot** if that class is still present at timeout — a card is
worse than no card, because a blank unfurl looks like a broken product and
nothing in the build is red.
- **Check the og.png's PIXELS, not its existence.** A loading-screen card is a
well-formed PNG of the right size; only its content is wrong. `check-og-images.mjs`
decodes it and requires ≥2% bright pixels in the control band — the live broken
card measured **0.60%**, every real one 4.2–7.0%. And note Cloudflare serves a
**missing** asset as its 404 page with **HTTP 200**, so a fetch-and-check-status
probe confirms the lie; you have to look at the bytes.
- **The shot is expensive; cache it on a content hash.** Hash the page dir (plus
the player bundle) into `og.png.key` and skip when it matches: 78 s → 2 s across
a 30-page site when nothing moved. Verify the invalidation too — a cache that
never busts silently ships yesterday's UI.
- **A brotli `Content-Encoding` on a plain-copied file breaks the page, not just
the byte count.** The GPU page's 10.4 MB `.data` was copied raw while `_headers`
claimed brotli; the browser failed to decode it, the UI module never loaded, and
the only symptom anyone saw was an OG card reading "Loading editor…". One shared
`copyUiFile()` compresses every page's copy — don't hand-roll a second copy path.
- **CoreGraphics can't composite file images** → filename-as-text placeholders;
use the Skia backend for anything with assets.
- **Headless GPU** → the offscreen `gpu` backend, not the live host (which hangs
headless).
- **WCLAP needs cross-origin isolation** (COOP/COEP) to instantiate; WAM does
not — different serve step per route. Serve WCLAP with `serve-headers.mjs`.
- **Social crawlers don't run JS** — OG tags must be static in the head at rest.
- **Demo-site HTML/OG templates for WAM live in the consumer repos**
(`pulp-example-plugins` / `pulp-classic-effects` `web/gen-og*.mjs`); the WCLAP
combined-site template lives in pulp core (`cloudflare/assemble-gallery.mjs`).
- **This is a porting/consistency tool.** It re-shoots the repo's own UI. Never
fabricate or copy another product's screenshots.
## Landmine: the OG bake is a TWO-PASS assemble — pass BOTH passes the same build trees
`gen-og-images.mjs` shoots each page, then the site assembler runs **again** to bake the
`og:image` / twitter block into the HTML — a page gets its tags only when its `og.png` already
exists on disk, which is only true on that second pass.
Run the second pass **bare** (no `--wam-build` / `--ui-build` / `--gpu-build`) and the assembler
cannot find the build trees, so it **skips** every page that needs one — quietly, in a one-line
note — and therefore never rewrites their HTML, which is the only thing that pass exists to do.
`/super-convolver-gpu/`'s og.png was shot perfectly, then never referenced: the page shipped
with **no `og:image` at all** and unfurled bare. The image existing on disk proves nothing;
check the HTML.