Purpose & scope
- Records a slow-mo, narrated screenshot + video walkthrough of a working feature. Output:
<outDir>/<feature>/index.html(defaultdocs/showcases/<feature>/index.html). Disposable — safe to delete after viewing; the spec file is what gets committed. - Layers on the existing Playwright e2e harness — same stack wiring, same auth setup, same page objects and seed helpers. It is NOT a parallel test stack.
- Separate from CI and the e2e projects; never gates a pipeline. Regression detection stays in the e2e suite (your repo's e2e authoring skill, if any). Rendered artifacts are gitignored; the spec FILE is committed.
- Do NOT use for regression testing; do NOT modify the e2e Playwright config or any e2e project for showcase purposes.
Zero-config defaults
Everything below works with no configuration:
- package manager — from the lockfile in the repo root (
pnpm-lock.yaml,yarn.lock,bun.lockb,package-lock.json), else npm. - e2e directory — the first of
e2e/,test/,tests/e2e/that holds a Playwright config; the harness is bootstrapped there. - frontend URL —
FRONTEND_BASE_URLif set; elsehttp://localhost:<port>where the port isports.frontend, thenPORTin.env.local/.env, then-p/--portin thedevscript, then 3000. - run command —
<pm> showcase <feature>when the e2e package has ashowcasescript, elsenode <e2eDir>/scripts/run-showcase.mjs <feature>(equivalently<pm> exec playwright test --config <e2eDir>/playwright.showcase.config.tswithout the report). - output —
docs/showcases/<feature>/, served on port 8899.
Configuration
Per-repo overrides live in .claude/claude-skills.json under the showcase key. Every key is
optional. The runner reads them; the harness files receive them as SHOWCASE_* env vars.
| key | default | meaning |
|---|---|---|
outDir |
docs/showcases |
Root for rendered reports; the feature folder goes inside (<outDir>/<artifactNaming>/index.html). Relative to the repo root. Gitignore it. |
command |
auto-detect (see above) | The command the skill runs to record; set when the repo wraps the runner differently. |
e2eDir |
auto-detect e2e/, test/, tests/e2e/ |
Where the harness lives / is bootstrapped. |
ports |
{} |
Named ports: frontend (local dev server the browser opens) and report (LAN report server). SHOWCASE_PORT env overrides report. |
serverEnv |
{} |
Env passed to the Playwright process and its webServer — your app's env for a hermetic run (test mode flag, auth URL pinned to the showcase host, test DB routing, WEBSERVER_COMMAND). Explicit env on the command line wins. |
backendMode |
local |
local: Playwright boots the local frontend against the backend the e2e suite uses. remote: nothing is booted; the browser opens the deployed frontend at FRONTEND_BASE_URL. |
artifactNaming |
<feature> |
Per-feature folder name; <feature> and <date> are substituted. |
tutorialMode |
false |
Treat specs as retained tutorial recorders (see Two kinds of spec). |
{
"showcase": {
"e2eDir": "tests/e2e",
"ports": { "frontend": 3100, "report": 8899 },
"serverEnv": { "APP_MODE": "test", "AUTH_URL": "http://localhost:3100" },
"backendMode": "local"
}
}
The harness ships with this skill — bootstrap on first use
The showcase layer (fixture, config, runner, report renderer) is vendored under this skill's
harness/ directory — a working implementation, not a spec to build from scratch. The live copy
runs from the e2e package; the skill copy is the bootstrap template.
If the e2e directory has no showcase harness yet when this skill is invoked, bootstrap it:
- Copy the harness into the e2e package, preserving layout (
<skill>is this skill's directory,<e2eDir>the detected or configured e2e directory):cp -R <skill>/harness/showcase <e2eDir>/showcase cp -R <skill>/harness/scripts <e2eDir>/scripts cp <skill>/harness/playwright.showcase.config.ts <e2eDir>/ - Add the runner script to the e2e package's
package.json:"showcase": "node scripts/run-showcase.mjs". - Add
<outDir>/(defaultdocs/showcases/) to the root.gitignore— rendered artifacts are never committed; spec files are. - Resolve every
ADAPT:marker in the copied files. The markers are the harness's configuration mechanism: each is a seam where the generic harness meets your e2e suite. They live only inplaywright.showcase.config.tsandshowcase/helpers/entry.ts: (a) layer over the e2e base Playwright config once it exists (never fork it); (b) the frontend URL fallback, if the detected one is wrong; (c) wire the e2e auth setup project intodependencies:and its storage state intouse.storageState— reuse the e2e login, never hand-roll one; (d) thewebServerentry — the local frontend boot command, plus any sidecar the feature needs (a job runner, a mail catcher); (e) merge the e2e suite's extendedtest(page objects, seed helpers) intoentry.ts. - The fixture,
paths.ts,speed.ts,report.mjsandrun-showcase.mjsneed no changes — they resolve paths from their own location and the enclosing git repo, so cwd does not matter.
If the harness already exists in the e2e directory, use it as-is — do not re-copy over local adaptations.
Divergence policy: the e2e copy is authoritative, sync e2e → harness
Once bootstrapped, the copy under the e2e directory is the authoritative one: it is the copy
under the repo's lint/format gates; the skill's harness/ tree is outside their scope. Sync
direction is e2e → harness, never the reverse. After changing a shared file in the e2e copy —
showcase/helpers/showcase.ts, speed.ts, paths.ts, showcase/report.mjs,
scripts/run-showcase.mjs, showcase/specs/example.showcase.ts — copy it over the matching
harness/ file if the repo keeps a local copy of the skill. The two ADAPT templates
(playwright.showcase.config.ts, showcase/helpers/entry.ts) differ from their e2e counterparts
by design: port mechanical lint/format fixes to them while keeping their ADAPT seams.
How the app runs during a recording
Two run modes, chosen per feature with backendMode (or per run by setting FRONTEND_BASE_URL):
Mode local (default) — local frontend + real backend
Playwright boots the frontend dev server via the config's webServer entry (command from
WEBSERVER_COMMAND, default <pm> run dev; an already-running server on that URL is reused) and
points it at the backend the e2e suite uses — a compose stack, a shared dev backend, a mocked API,
whatever the e2e config already does. This records un-deployed frontend code against real
services. Boot only what the feature needs: if the repo is several apps, start the one app that
owns the feature.
FRONTEND_BASE_URL=http://localhost:3101 WEBSERVER_COMMAND="<pm> --filter admin dev" <pm> showcase roles
Mode remote — deployed frontend
Nothing is booted. The browser opens the deployed frontend at FRONTEND_BASE_URL; the e2e auth
setup logs in against it. Right when the feature is already deployed and you just want to prove it.
Hermetic runs
Showcases must be hermetic — every recording starts from a known state, same as e2e. Reusing data across runs accumulates fixtures, contaminates screenshots and produces non-reproducible recordings. Two levers, both borrowed from the e2e suite:
- Reset the same way e2e does. Wire a
globalSetupthat calls the e2e suite's reset routine against the database the showcase server actually reads (which may differ from the e2e test DB — pass the connection explicitly rather than trusting an env default). Truncate every test-data table in dependency order; do not scope the wipe to a naming prefix, which leaves strays. - Seed through the e2e helpers, or through a test-only seed/session endpoint if your app has one. If such an endpoint enforces a test-account naming pattern, honour it in specs. Read the feature's e2e spec for the exact seed shape rather than guessing.
Pin every required server env var in serverEnv / the config's webServer.env — a fresh
checkout's .env ships secrets empty, and a dev .env often points auth at a remote host. Session
validation must match the showcase host: pin the auth URL to http://localhost:<port> for the
hermetic server, or protected routes redirect to sign-in mid-recording. Leave optional vars absent
rather than empty. When a feature adds a required var, add it to the showcase env in the same
change.
Sidecars: if the feature fires a background job, sends mail, or pushes events, the showcase
must run that sidecar too (a second webServer entry). Forgetting it is the #1 cause of "the
showcase looks fine but the feature is broken" — the action returns success and the toast fires,
but nothing was listening.
Auth: reuse the e2e harness's auth/storage-state setup. If a run redirects to login, refresh the stored auth state via the e2e setup project and retry.
Commands
| Command | Effect |
|---|---|
<pm> showcase <feature> |
Record <feature>.showcase.ts; write <outDir>/<feature>/index.html |
<pm> showcase <feature> +50% |
1.5× pace (less slowMo, shorter pauses) |
<pm> showcase <feature> -30% |
0.7× pace |
<pm> showcase <feature> --speed=2 |
explicit 2× pace |
<pm> showcase <feature> --no-cursor |
disable the DOM pseudo-cursor overlay |
<pm> showcase <feature> --headed |
watch the run live (extra flags are forwarded to playwright test) |
<feature>is matched by basename substring againstshowcase/specs/**/*.showcase.ts.- Speed is clamped to
[0.1, 3.0]; bothslowMoandshowcase.pause()scale off it. - After the run the report path, an
open "…"command and the LAN URL(s) are printed.
Two kinds of spec: feature showcase vs tutorial recorder
Everything in this skill is written for the feature showcase: a disposable, reviewer-facing
walkthrough named <feature>.showcase.ts, recorded against the dev server. With tutorialMode: true the same harness also serves a tutorial recorder that produces published help videos.
The fixture always writes a captions.json v2 timing manifest (per-step startMs/endMs,
rect, voiceover, subtitle position) next to the video; in tutorial mode a caption burner of
your own turns it into a subtitled MP4 + .vtt. The differences that bite:
| Feature showcase | Tutorial recorder (tutorialMode) |
|
|---|---|---|
| Filename | <feature>.showcase.ts |
named for the help bundle it feeds |
| Build | dev server (fast) | production bundle mandatory — no dev badge, no cold-compile frames |
| Steps | as many as the feature needs | 3–6, matching the tutorial script 1:1 |
| Captions | English, reviewer-facing | telemetry; published subtitles come from the tutorial script at burn time |
| Fixture names | anything | role names only — it is published material |
| Output | <outDir> (gitignored) |
burned MP4 + VTT + poster, wherever your help content lives |
In tutorial mode return the acted-on Locator from a step so its bounding box lands in
captions.json (subtitle placement), and pass { voiceover, subtitlePosition: 'top' } when the
step's payoff sits in the bottom band. The _raw/ folder is the only place captions.json exists,
so burn before the next recording of the same feature overwrites it.
Creating a spec
- Name it
<feature>.showcase.tsundershowcase/specs/—<feature>is the id passed to the runner. Copyshowcase/specs/example.showcase.tsas the starting point. - Pick the variant: authenticated (the common case — inherits the e2e storage state) or
unauthenticated (a sign-in flow — compose the showcase fixture with whatever mail/OTP fixture
the e2e suite has via
mergeTests). - Import the showcase entry point (
../helpers/entry) so the spec gets both theshowcasefixture AND the e2e page objects. - Set up + tear down data via the e2e helpers. Every showcase cleans up after itself
(
afterAll), same as e2e. Unique IDs; read the feature's e2e spec for the exact seed shape. - Write each
showcase.step(caption, fn)as one narrative beat. Captions are English (they are for reviewers), even when the app UI is in another language. Use existing page-object methods and selectors — don't inline raw locators. Add 400–1000 msshowcase.pause()between scenes. - Warm up routes, then record.
Skeleton — authenticated
import { expect, test } from "../helpers/entry";
test.describe("<Feature> — <title>", () => {
test("happy path", async ({ page, showcase }) => {
showcase.describe("1–3 sentences stating what the video proves.");
// Pre-compile every route the spec visits so the video doesn't open on a blank frame.
await showcase.warmup("/app", "/app/items");
await showcase.step("Open the dashboard", async () => {
await page.goto("/app");
await expect(page.getByRole("heading", { name: /overview/i })).toBeVisible();
});
await showcase.pause(600);
await showcase.step("Create an item", async () => {
await showcase.click(page.getByRole("button", { name: /new item/i }));
await showcase.type(page.getByLabel(/name/i), "Quarterly report");
await showcase.click(page.getByRole("button", { name: /save/i }));
await expect(page.getByText(/saved/i)).toBeVisible();
});
await showcase.step("See it in the list", async () => {
const row = page.getByRole("row").filter({ hasText: "Quarterly report" });
await showcase.scrollTo(row); // below the fold → scroll before the screenshot
await expect(row).toBeVisible();
});
});
});
Skeleton — unauthenticated (sign-in flow, needs a mail fixture)
import { mergeTests } from "@playwright/test";
import { expect, test as showcaseTest } from "../helpers/showcase";
import { test as mailTest } from "<e2e fixtures>/mail"; // whatever the e2e suite provides
const test = mergeTests(showcaseTest, mailTest);
test.describe("Sign-in", () => {
test("happy path", async ({ page, showcase, mail }) => {
showcase.describe("Passwordless sign-in: visitor types email, follows the link, lands authenticated.");
const email = `showcase-${Date.now()}@example.com`; // honour your test-account pattern
await showcase.warmup("/sign-in");
await showcase.step("Open the sign-in page", async () => {
await page.goto("/sign-in");
await expect(page.getByLabel(/e-?mail/i)).toBeVisible();
});
await showcase.step("Request a link", async () => {
await showcase.type(page.getByLabel(/e-?mail/i), email);
await showcase.click(page.getByRole("button", { name: /send/i }));
await expect(page.getByText(/check your inbox/i)).toBeVisible();
});
await showcase.step("Follow the link and land authenticated", async () => {
await page.goto(await mail.waitForMagicLink(email));
await page.waitForURL(/\/app(\/|$)/);
await expect(page.locator("h1").first()).toBeVisible();
});
});
});
Gotchas
- Multi-actor walkthroughs = swap the page session mid-spec. The fixture records a single
page. To show actor A → actor B end-to-end, mint the next actor's session, clear cookies, add the new ones for the showcase host, and navigate — the same recorded page continues as the new actor. Warm routes once up front; compilation is per-route, not per-session. - Responsive dual layouts → strict-mode violations. A table that renders both a desktop
<table>and a mobile card layout makesgetByRole(..., { name })resolve to two elements. Scope to the row first (getByRole("row").filter({ hasText })…), mirroring the e2e selectors. - Plural forms. An e2e selector that asserts a count-interpolated string for one item will not match when the showcase seeds three. Match the plural-agnostic stem.
- Stale build cache after a branch switch can hang a route's on-demand compile indefinitely while the console sidecar stays empty (a pending request is silent). Clear the dev build cache or treat the first recording as a throwaway warm-up.
- Seed helpers may require the target user to exist before attaching roles; mint the session first, then attach.
Warm up routes before recording (avoid the first-load blank screen)
A dev server compiles a route on first navigation, and a cold app fetches its chunks — either way
the first goto inside a recorded step can open on a blank/compiling frame for many seconds.
Pre-compile every route the spec visits BEFORE the recorded steps via showcase.warmup(...): it
navigates each path with waitUntil: 'networkidle', swallows per-path errors (an auth redirect
still triggers the compile), then resets to about:blank. Call it once, after describe(). Routes
reached only by in-app clicks compile on click; warm those too if their first appearance is blank.
Human-look pointer motion (glide, dwell, type)
The fixture injects a DOM pseudo-cursor overlay — a dot that tracks the mouse plus a click-ripple ring scaled to the recording pace. To make the pointer watchable, use the fixture's motion helpers instead of raw locator actions wherever the viewer should be able to follow the mouse:
await showcase.click(locator)— glides the cursor from its last position along a curved, eased path, dwells briefly, then performs the real Playwright click (which fires the ripple).await showcase.hover(locator)— same glide + dwell, without the click.await showcase.type(locator, 'text')— motion-clicks the field, then types character by character at a natural cadence instead offill()'s instant paste. NOT for native date/color inputs — their segmented editing needsfill().
The glide is cosmetic and self-bounding: a missing or detaching target falls through to the real
action (which then fails with a proper timeout) instead of stalling the recording. Raw
locator.click() / page-object methods still work everywhere — reserve the motion helpers for the
beats the video is about.
Cursor overlay
- Injected via
context.addInitScript: a dot trackingmousemove, a pulse onmousedown;pointer-events: none, max z-index. - Visible after
click/hover/dragTo/page.mouse.*; NOT afterfill()/focus()(nomousemove). - Resets on navigation; re-arms on the next pointer action.
- Toggle:
--no-cursororSHOWCASE_CURSOR=off.
Writing reliable steps (avoid blank screenshots)
- The fixture screenshots the instant
fnreturns — no auto-wait.fnmust leave the page in the captioned state. goto+waitForURLresolves before paint → blank screenshot. Every screen-changing step needs one of:expect(...).toBeVisible(),waitForLoadState('networkidle'), orwaitForResponse(...). URL-only assertions also pass on an error overlay at the same URL.- Scrolling: if the target is below the fold,
await showcase.scrollTo(locator)inside the step before returning — scroll into view, then wait a frame so the screenshot settles.
Async-pipeline results — poll, don't seed, and poll out of frame
When the payoff arrives via a background job (a notification, a state push, a digest), the showcase stack runs the real pipeline (see Sidecars), so show the real result — don't fake it by inserting the row directly; in a fast environment the real pipeline often wins the race and a direct insert then doubles the row. The result is eventually consistent, so:
- Do the readiness poll in a NON-RECORDED pre-step, not inside a recorded
step(): a.toPass()reload-poll inside a recorded step captures every blank reloading frame and half the video is white. Poll out of frame (an API count, a DB query, orexpect(async () => { await page.reload(); await expect(x).toBeVisible() }).toPass()), then warm the routes, then record steps that run on an already-populated app. - Give the per-test timeout real headroom. A slow-mo multi-actor walkthrough plus a poll
blows past Playwright's default 30 s; the failure reads "Test timeout exceeded", not your
.toPasstimeout. The harness config setstimeout: 240_000for this reason.
Post-run verification (MANDATORY)
A green Playwright run ≠ the feature works. After the recording:
- Read every PNG in
<outDir>/<feature>/_raw/<run>/shots/*.pngwith theReadtool. - Scan for: framework error overlays or stack traces; HTTP error pages (500/404); missing UI
elements; untranslated i18n keys / raw
{variable}placeholders; wrong auth state. - Cross-check each caption against the pixels, and against the feature's acceptance criteria.
- Inspect the console-errors sidecar (
_raw/<run>/console-errors.jsonl— one JSONL line per browser-sideconsole.error/console.warn/ uncaughtpageerror/ non-abortedrequestfailed, anchored to the most recent screenshot viaafterStep). Empty file = clean run. This catches silent runtime errors and failed XHRs that never surfaced a banner.
jq -c '.kind' <outDir>/<feature>/_raw/*/console-errors.jsonl | sort | uniq -c
jq 'select(.kind=="pageerror")' <outDir>/<feature>/_raw/*/console-errors.jsonl
Reporting format
- If clean: "All N screenshots verified clean — no error overlays, captions match UI state."
- If issues found, report before declaring done, e.g.:
⚠ Showcase captured but has issues: Screenshot 04 (Open the item list): Problem: error overlay — "Cannot read properties of undefined (reading 'id')" Not a showcase bug — the feature is broken; the step's networkidle wait didn't catch it. - Do NOT fix the underlying product bug from this skill — that's a separate task.
- Every reply ends with the two printed lines, copied verbatim from the runner's stdout (the
report path and the
open "…"command), plus the LAN URL. Do not paraphrase or relativize.
Delivering the report
- Auto-served on the LAN after every run. The runner starts a static HTTP server over
<outDir>onports.report(default 8899;SHOWCASE_PORToverrides), detached so the one-shot command still exits, and printshttp://<lan-ip>:<port>/<feature>/index.html. The port is reused across runs (a second bind harmlessly fails). Needspython3; if absent the runner prints the manual command. The server is best-effort and unmanaged — kill it withpkill -f "http.server <port>". - The report is a folder (
index.html+_raw/), self-contained and openable directly. - Mobile / iOS: iOS can't play the VP8
.webmPlaywright records.report.mjstranscodes to H.264 MP4 whenffmpegis on PATH and adds it as the first<source>, so in-page playback works on a phone; or send the.mp4directly. If the reviewer reaches the dev box over a VPN and Safari times out while the server answers locally, the VPN route on the phone is usually inactive — toggle it and check for the VPN badge before touching the server. - Off-network reviewers: put a tunnelling tool (ngrok, a tailnet, …) in front of the same local server.
- Stale video after re-recording is the browser caching the
<video>URL; the report appends?v=<mtime>to the source so the URL changes every re-record — check it did.
Playwright truncates output-directory names
Playwright shortens long test-output directory names (specs-my-long-feature-na-09cff-…), so
any tool that locates a run by basename.includes(fullKey) fails on long spec names. Match on
progressively shorter prefixes and accept only a unique hit — truncation then resolves while
genuine ambiguity still errors. Prefer short feature ids.
After viewing
rm -rf <outDir>/<feature>— discard the rendered report (artifacts stay gitignored).- Keep the spec if it documents a shipped feature or feeds tutorial mode; delete it if it was a genuine throwaway. Regression detection still lives in the e2e suite.
Hard rules
- Showcase specs stay under
showcase/specs/— never touch the e2e spec dirs. - Never modify the e2e Playwright config or any e2e spec for showcase purposes; the showcase config is a layer over the base config.
- Never add showcase to CI.
- Never commit rendered artifacts under
<outDir>(keep it gitignored). - Every spec cleans up the data it creates (
afterAll), same as e2e. - Never skip post-run verification; never omit the final printed lines.
- Do NOT commit or push — the user does that.
Troubleshooting
| Symptom | Fix |
|---|---|
No spec found matching <feature> |
The basename substring didn't match any *.showcase.ts — check showcase/specs/. |
| Run redirects to the login page | Stale auth state — re-run the e2e auth setup project and retry; in local mode also check the auth URL is pinned to the showcase host. |
| Local app has no data / API errors | The local frontend still needs a backend — point it at the one the e2e suite uses (mode local), or record the deployed app (mode remote). |
| App crashes at boot on missing env | A required server var is unset for the hermetic server — pin it in serverEnv / webServer.env. |
| Video blank / 0 bytes | The web server wasn't up before the first step() — raise webServer.timeout or start it manually first (a running server is reused). |
| First recorded frame is blank/compiling | Add the route to showcase.warmup(...) before the recorded steps. |
| Screenshot blank | fn returned before paint — end the step with expect(...).toBeVisible() or waitForLoadState('networkidle'). |
| Half the video is blank reloads | A reload-poll ran inside a recorded step — move it to a non-recorded pre-step. |
| Feature "works" on video but not in production | A sidecar (job runner, mailer) wasn't running — add it as a second webServer entry. |
| Wrong runs in the report | Delete <outDir>/<feature>/_raw/ and re-record. |
| No mp4 / won't play on iOS | ffmpeg isn't installed — install it, then re-render with node showcase/report.mjs <feature>. |
| Route compile hangs after a branch switch | Stale dev build cache — clear it and re-record. |
To change this skill, do not edit this copy: use /dev-tools:update-skill, or see docs/updating-skills.md in mzvonar/claude-skills-public.