diff --git a/.gitignore b/.gitignore
index 79a538f..b99ee86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,11 @@ data/
test-results/
playwright-report/
+# Demo recorder (dev-only, not part of the workspace)
+scripts/record-demo/node_modules/
+scripts/record-demo/out/
+scripts/record-demo/.tmp/
+
# macOS
.DS_Store
diff --git a/AGENTS.md b/AGENTS.md
index ef81ff2..1c8bab5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,6 +73,16 @@ CI (`.github/workflows/ci.yml`) and CHANGELOG.md's compare links must target `ma
`main`/`master` mismatch left CI structurally unable to fire on any PR. Check this whenever
adding or touching a workflow trigger or a branch-referencing doc link.
+## Regenerating the README demo (`docs/assets/demo.mp4`/`demo.gif`)
+
+`make demo` (or `scripts/record-demo/`'s own README) boots a disposable Postgres + the
+dashboard + `examples/demo-site`, drives the real product loop with Playwright, and converts
+the recording with ffmpeg. It runs the dashboard via `next start` against a production
+build, not `next dev` - the dev-mode build indicator overlay (`nextjs-portal`) intercepts
+Playwright's clicks and made the recorder flaky. `scripts/record-demo` is a standalone
+package outside `pnpm-workspace.yaml`'s `packages/*` glob (dev-only, never a product
+dependency).
+
## Maintaining this file
Keep this file for knowledge useful to almost every future agent session in this project.
diff --git a/Makefile b/Makefile
index ddf7777..558e6c5 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build dev test lint tidy clean
+.PHONY: build dev test lint tidy clean demo
build:
pnpm --filter @diffboard/core run build
@@ -23,3 +23,10 @@ tidy:
clean:
rm -rf packages/*/dist packages/server/.next node_modules packages/*/node_modules
+
+# Boots the real stack, records a genuine walkthrough, and converts it into
+# docs/assets/demo.mp4 + demo.gif for the README. See scripts/record-demo/README.md.
+demo:
+ cd scripts/record-demo && npm install
+ cd scripts/record-demo && npm run record
+ cd scripts/record-demo && npm run convert
diff --git a/README.md b/README.md
index af87ca7..607c2d3 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,15 @@ each change before the build goes green.
+## Demo
+
+
+
+A real run against the demo fixture in this repo: sign in, capture a baseline, ship a real CSS
+change to the CTA button, then triage the flagged diff in the review dashboard - approving the
+desktop screenshot (the intended rebrand) and rejecting the mobile one, where the same change
+shrinks the button below a usable tap target. Full quality: [docs/assets/demo.mp4](docs/assets/demo.mp4).
+
## What it does
- Captures screenshots of any URL or Storybook story across a viewport matrix, driven by a
@@ -36,12 +45,6 @@ each change before the build goes green.
- Runs entirely on infrastructure you already have: `docker compose up` gets you Postgres +
the dashboard, with no per-snapshot billing and no third party watching your screenshots
-
-
-*A real recording against the running app in this repo: a CSS change shrinks and washes out
-a button, `diffboard test` flags it as changed in CI, and the reviewer compares it on the
-dashboard before approving.*
-
## Why not Percy or Chromatic?
Percy and Chromatic are good products, billed per screenshot. Chromatic's free tier caps out
diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif
index 95ffe7e..d375d22 100644
Binary files a/docs/assets/demo.gif and b/docs/assets/demo.gif differ
diff --git a/docs/assets/demo.mp4 b/docs/assets/demo.mp4
new file mode 100644
index 0000000..f72299f
Binary files /dev/null and b/docs/assets/demo.mp4 differ
diff --git a/scripts/record-demo/README.md b/scripts/record-demo/README.md
new file mode 100644
index 0000000..d82cbbc
--- /dev/null
+++ b/scripts/record-demo/README.md
@@ -0,0 +1,61 @@
+# Demo recorder
+
+Boots the real diffboard stack from source and records a genuine walkthrough of the product
+loop: sign in, create a project, capture a baseline against `examples/demo-site`, ship a real
+visual change, and triage the resulting diff in the review dashboard (approve one screenshot,
+reject another). Produces `docs/assets/demo.mp4` and `docs/assets/demo.gif` for the README.
+
+This package is dev-only - it lives outside `pnpm-workspace.yaml`'s `packages/*` glob, so it
+is never pulled into the product build or `pnpm install` at the repo root.
+
+## Run it
+
+From the repo root:
+
+```bash
+make demo
+```
+
+Or step by step from this directory:
+
+```bash
+npm install
+npm run record # boots Postgres (docker), the dashboard, and demo-site; drives the browser; saves out/demo.raw.webm
+npm run convert # ffmpeg: out/demo.raw.webm -> docs/assets/demo.mp4 + demo.gif
+```
+
+## What `record.mjs` does
+
+1. Builds `@diffboard/core`, the CLI, and the server (`next build` - a **production** build;
+ `next dev`'s dev-mode overlay intercepts clicks and was flaky under this recorder).
+2. Starts a fresh, disposable Postgres container (`diffboard-demo-pg`, host port `5434` so it
+ doesn't collide with any dev Postgres on `5432`/`5433`), runs migrations, and seeds the
+ admin account.
+3. Starts the dashboard (`next start`, port `4300`) and `examples/demo-site` (port `3100`,
+ `DEMO_VARIANT=v1`).
+4. Drives a headless Chromium via Playwright (`recordVideo`, 1280x800,
+ `deviceScaleFactor: 2`) through the real UI: sign in, create a project, capture its token
+ from the page.
+5. Shells out to the real `diffboard` CLI twice - once against `v1` (baseline) and once
+ against `v2` (a real CSS change already in the fixture: a shrunk, washed-out CTA button) -
+ and navigates the browser to each resulting build.
+6. Reviews the second build's real diff in the dashboard: drags the slider, switches to
+ side-by-side and diff-overlay, approves the desktop screenshot (the rebrand is the
+ intended change) and rejects the mobile one (the same CSS change shrinks the button below a
+ usable tap target on a small screen - a genuine regression, not a fabricated one).
+7. Stops every child process and removes the Postgres container so the next run starts clean.
+
+Re-running against a freshly seeded stack produces the same walkthrough - there's no random
+data and no reliance on state left over from a previous run.
+
+## Ports
+
+Only `:4300` is a fixed requirement (the app). If `:4300` is already taken by something else,
+this fails fast rather than rewriting the committed port - free it and re-run. `:3100` (demo
+site) and `:5434` (recorder's own throwaway Postgres) can be changed at the top of
+`record.mjs` if they collide with something on your machine.
+
+## Debugging
+
+Child process output (the dashboard and demo-site) is captured to `.tmp/.log` instead
+of being discarded, since Next's production server is otherwise silent on success.
diff --git a/scripts/record-demo/convert.sh b/scripts/record-demo/convert.sh
new file mode 100755
index 0000000..a6c6bbd
--- /dev/null
+++ b/scripts/record-demo/convert.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# Converts the raw Playwright recording (out/demo.raw.webm) into the two
+# assets the README embeds: docs/assets/demo.mp4 (full quality, linked) and
+# docs/assets/demo.gif (embedded inline - must stay under 10 MB).
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$HERE/../.." && pwd)"
+RAW="$HERE/out/demo.raw.webm"
+MP4="$REPO_ROOT/docs/assets/demo.mp4"
+GIF="$REPO_ROOT/docs/assets/demo.gif"
+PALETTE="$HERE/out/palette.png"
+
+if [ ! -f "$RAW" ]; then
+ echo "No raw recording at $RAW - run 'npm run record' first." >&2
+ exit 1
+fi
+
+mkdir -p "$REPO_ROOT/docs/assets"
+
+echo "Encoding $MP4 ..."
+ffmpeg -y -i "$RAW" -vf "scale=1280:-2" -c:v libx264 -pix_fmt yuv420p -movflags +faststart "$MP4"
+
+echo "Building palette for the GIF ..."
+ffmpeg -y -i "$RAW" -vf "fps=12,scale=960:-2:flags=lanczos,palettegen" "$PALETTE"
+
+echo "Encoding $GIF ..."
+ffmpeg -y -i "$RAW" -i "$PALETTE" \
+ -lavfi "fps=12,scale=960:-2:flags=lanczos[x];[x][1:v]paletteuse" \
+ "$GIF"
+
+rm -f "$PALETTE"
+
+MP4_SIZE=$(du -h "$MP4" | cut -f1)
+GIF_SIZE=$(du -h "$GIF" | cut -f1)
+echo "Done: $MP4 ($MP4_SIZE), $GIF ($GIF_SIZE)"
+
+GIF_BYTES=$(wc -c < "$GIF")
+if [ "$GIF_BYTES" -gt 10485760 ]; then
+ echo "WARNING: $GIF is over 10 MB ($GIF_BYTES bytes) - drop fps or shorten the walkthrough." >&2
+ exit 1
+fi
diff --git a/scripts/record-demo/package-lock.json b/scripts/record-demo/package-lock.json
new file mode 100644
index 0000000..f443959
--- /dev/null
+++ b/scripts/record-demo/package-lock.json
@@ -0,0 +1,78 @@
+{
+ "name": "diffboard-record-demo",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "diffboard-record-demo",
+ "version": "0.0.0",
+ "devDependencies": {
+ "@playwright/test": "^1.62.1"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/scripts/record-demo/package.json b/scripts/record-demo/package.json
new file mode 100644
index 0000000..d19997e
--- /dev/null
+++ b/scripts/record-demo/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "diffboard-record-demo",
+ "private": true,
+ "version": "0.0.0",
+ "description": "Dev-only recorder that boots the real diffboard stack and captures docs/assets/demo.mp4 + demo.gif. Not part of the pnpm workspace or product build.",
+ "type": "module",
+ "scripts": {
+ "record": "node record.mjs",
+ "convert": "bash convert.sh"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.62.1"
+ }
+}
diff --git a/scripts/record-demo/record.mjs b/scripts/record-demo/record.mjs
new file mode 100644
index 0000000..c88e44b
--- /dev/null
+++ b/scripts/record-demo/record.mjs
@@ -0,0 +1,361 @@
+#!/usr/bin/env node
+// Boots the real diffboard stack (fresh Postgres, dashboard, demo-site fixture),
+// drives it through the real product loop with Playwright, and records a video.
+// Re-running this against a freshly seeded stack produces the same walkthrough.
+import { chromium } from "@playwright/test";
+import { spawn, execSync, execFileSync } from "node:child_process";
+import {
+ mkdirSync,
+ writeFileSync,
+ renameSync,
+ readdirSync,
+ rmSync,
+ existsSync,
+ openSync,
+} from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = join(HERE, "..", "..");
+const OUT_DIR = join(HERE, "out");
+const TMP_DIR = join(HERE, ".tmp");
+
+const PG_CONTAINER = "diffboard-demo-pg";
+const PG_PORT = 5434;
+const APP_PORT = 4300;
+const DEMO_PORT = 3100;
+
+const ADMIN_EMAIL = "admin@diffboard.dev";
+const ADMIN_PASSWORD = "DiffboardDemo123!";
+const SESSION_SECRET = "demo-recording-session-secret-not-for-production";
+const DATABASE_URL = `postgresql://diffboard:diffboard@localhost:${PG_PORT}/diffboard`;
+
+const children = [];
+
+function log(msg) {
+ console.log(`[record-demo] ${msg}`);
+}
+
+function sh(cmd, opts = {}) {
+ log(`$ ${cmd}`);
+ execSync(cmd, { stdio: "inherit", cwd: REPO_ROOT, ...opts });
+}
+
+function shOut(cmd, opts = {}) {
+ return execSync(cmd, { encoding: "utf8", cwd: REPO_ROOT, ...opts });
+}
+
+function spawnChild(name, cmd, args, opts = {}) {
+ const logPath = join(TMP_DIR, `${name}.log`);
+ const fd = openSync(logPath, "a");
+ const child = spawn(cmd, args, { stdio: ["ignore", fd, fd], ...opts });
+ children.push(child);
+ return child;
+}
+
+async function waitForHttp(url, timeoutMs = 60_000) {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ try {
+ const res = await fetch(url, { redirect: "manual" });
+ if (res.status < 500) return;
+ } catch {
+ // not up yet
+ }
+ await new Promise((r) => setTimeout(r, 500));
+ }
+ throw new Error(`Timed out waiting for ${url}`);
+}
+
+async function waitForDown(url, timeoutMs = 10_000) {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ try {
+ await fetch(url, { redirect: "manual" });
+ } catch {
+ return;
+ }
+ await new Promise((r) => setTimeout(r, 200));
+ }
+}
+
+function buildWorkspace() {
+ log("building @diffboard/core, the CLI, and the server (production build)");
+ sh("pnpm --filter @diffboard/core run build");
+ sh("pnpm --filter diffboard run build");
+ sh("pnpm --filter @diffboard/server run build", { env: { ...process.env, DATABASE_URL } });
+}
+
+function bootPostgres() {
+ log("booting fresh Postgres container for the recording");
+ try {
+ execSync(`docker rm -f ${PG_CONTAINER}`, { stdio: "ignore" });
+ } catch {
+ // didn't exist
+ }
+ sh(
+ `docker run -d --name ${PG_CONTAINER} -e POSTGRES_USER=diffboard -e POSTGRES_PASSWORD=diffboard -e POSTGRES_DB=diffboard -p ${PG_PORT}:5432 postgres:16-alpine`,
+ );
+ const deadline = Date.now() + 30_000;
+ while (Date.now() < deadline) {
+ try {
+ execSync(`docker exec ${PG_CONTAINER} pg_isready -U diffboard`, { stdio: "ignore" });
+ return;
+ } catch {
+ // not ready yet
+ }
+ }
+ throw new Error("Postgres never became ready");
+}
+
+function migrateAndSeed() {
+ log("running prisma migrate deploy + seed");
+ const serverDir = join(REPO_ROOT, "packages", "server");
+ const env = { ...process.env, DATABASE_URL };
+ execSync("npx prisma migrate deploy", { cwd: serverDir, env, stdio: "inherit" });
+ execSync("npx tsx prisma/seed.ts", {
+ cwd: serverDir,
+ env: { ...env, ADMIN_EMAIL, ADMIN_PASSWORD },
+ stdio: "inherit",
+ });
+}
+
+async function startServer() {
+ log(`starting dashboard on :${APP_PORT}`);
+ const serverDir = join(REPO_ROOT, "packages", "server");
+ spawnChild("server", "npx", ["next", "start", "-p", String(APP_PORT)], {
+ cwd: serverDir,
+ env: {
+ ...process.env,
+ DATABASE_URL,
+ SESSION_SECRET,
+ ADMIN_EMAIL,
+ ADMIN_PASSWORD,
+ STORAGE_DIR: join(TMP_DIR, "storage"),
+ PUBLIC_URL: `http://localhost:${APP_PORT}`,
+ PORT: String(APP_PORT),
+ },
+ });
+ await waitForHttp(`http://localhost:${APP_PORT}/login`);
+}
+
+function startDemoSite(variant) {
+ log(`starting demo-site (${variant}) on :${DEMO_PORT}`);
+ const demoDir = join(REPO_ROOT, "examples", "demo-site");
+ return spawnChild(`demo-site-${variant}`, "node", ["server.js"], {
+ cwd: demoDir,
+ env: { ...process.env, DEMO_VARIANT: variant, PORT: String(DEMO_PORT) },
+ });
+}
+
+function stopChild(child) {
+ if (!child || child.killed) return;
+ try {
+ child.kill("SIGTERM");
+ } catch {
+ // already gone
+ }
+}
+
+function writeCliConfig(token) {
+ const configPath = join(TMP_DIR, "diffboard.config.mjs");
+ writeFileSync(
+ configPath,
+ `export default ${JSON.stringify(
+ {
+ serverUrl: `http://localhost:${APP_PORT}`,
+ token,
+ baseBranch: "main",
+ threshold: 0.1,
+ viewports: [
+ { name: "desktop", width: 1280, height: 800 },
+ { name: "mobile", width: 390, height: 844 },
+ ],
+ targets: [{ name: "home", url: `http://localhost:${DEMO_PORT}/` }],
+ },
+ null,
+ 2,
+ )};\n`,
+ );
+ return configPath;
+}
+
+function runCli(configPath) {
+ const cliBin = join(REPO_ROOT, "packages", "cli", "dist", "bin.js");
+ log("running `diffboard test` against the running demo-site");
+ try {
+ return shOut(`node ${JSON.stringify(cliBin)} test --config ${JSON.stringify(configPath)}`);
+ } catch (err) {
+ // Exit code 1 just means "new or changed screenshots need review" - expected.
+ return err.stdout?.toString() ?? "";
+ }
+}
+
+function buildUrlFromCliOutput(output) {
+ const match = output.match(/http:\/\/localhost:\d+\/builds\/[^\s]+/);
+ if (!match) throw new Error(`Could not find build URL in CLI output:\n${output}`);
+ return match[0];
+}
+
+async function dragSlider(page) {
+ const frame = page.locator(".slider-wrap");
+ const box = await frame.boundingBox();
+ if (!box) return;
+ await page.mouse.move(box.x + box.width * 0.15, box.y + box.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(box.x + box.width * 0.85, box.y + box.height / 2, { steps: 24 });
+ await page.waitForTimeout(1100);
+ await page.mouse.move(box.x + box.width * 0.4, box.y + box.height / 2, { steps: 16 });
+ await page.waitForTimeout(900);
+ await page.mouse.up();
+}
+
+async function main() {
+ mkdirSync(OUT_DIR, { recursive: true });
+ mkdirSync(TMP_DIR, { recursive: true });
+
+ buildWorkspace();
+ bootPostgres();
+ migrateAndSeed();
+ await startServer();
+ startDemoSite("v1");
+ await waitForHttp(`http://localhost:${DEMO_PORT}/`);
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: 1280, height: 800 },
+ deviceScaleFactor: 2,
+ recordVideo: { dir: OUT_DIR, size: { width: 1280, height: 800 } },
+ });
+ const page = await context.newPage();
+
+ try {
+ // --- Sign in ---
+ await page.goto(`http://localhost:${APP_PORT}/login`);
+ await page.waitForTimeout(2650);
+ await page.locator("#email").fill(ADMIN_EMAIL);
+ await page.waitForTimeout(650);
+ await page.locator("#password").fill(ADMIN_PASSWORD);
+ await page.waitForTimeout(650);
+ await page.locator('button[type="submit"]').click();
+ await page.waitForURL(`http://localhost:${APP_PORT}/`);
+ await page.waitForTimeout(1750);
+
+ // --- Create the project (first-run form is shown automatically) ---
+ await page.locator("#name").fill("demo-site");
+ await page.waitForTimeout(1100);
+ await page.locator('button:has-text("Create project")').click();
+ await page.waitForSelector(".token-box");
+ await page.waitForTimeout(3950);
+
+ const tokenText = (await page.locator(".token-box").textContent())?.trim();
+ if (!tokenText) throw new Error("Could not read project token from dashboard");
+
+ await page.locator('button:has-text("Done")').click();
+ await page.waitForURL(`http://localhost:${APP_PORT}/`);
+ await page.waitForTimeout(1750);
+
+ await page.locator('a:has-text("demo-site")').click();
+ await page.waitForURL(/\/projects\/demo-site/);
+ await page.waitForTimeout(2200);
+
+ // --- Baseline run: capture the demo-site as it stands today ---
+ const configPath = writeCliConfig(tokenText);
+ const baselineOutput = runCli(configPath);
+ const baselineBuildUrl = buildUrlFromCliOutput(baselineOutput);
+
+ await page.goto(baselineBuildUrl);
+ await page.waitForSelector(".snapshot-row");
+ await page.waitForTimeout(2650);
+
+ // First run has no baseline yet - approve both to establish one.
+ for (const viewport of ["desktop", "mobile"]) {
+ await page.locator(`.snapshot-row:has-text("${viewport}")`).click();
+ await page.waitForTimeout(1300);
+ await Promise.all([
+ page.waitForResponse((r) => r.url().includes("/approve")),
+ page.locator('button:has-text("Approve")').click(),
+ ]);
+ await page.waitForTimeout(2000);
+ }
+
+ await page.goto(`http://localhost:${APP_PORT}/projects/demo-site`);
+ await page.waitForTimeout(2200);
+
+ // --- Make a real visual change to the demo site and check again ---
+ const v1 = children.pop();
+ stopChild(v1);
+ await waitForDown(`http://localhost:${DEMO_PORT}/`);
+ startDemoSite("v2");
+ await waitForHttp(`http://localhost:${DEMO_PORT}/`);
+
+ const changedOutput = runCli(configPath);
+ const changedBuildUrl = buildUrlFromCliOutput(changedOutput);
+
+ await page.goto(changedBuildUrl);
+ await page.waitForSelector(".snapshot-row");
+ await page.waitForTimeout(2650);
+
+ // Desktop: review the diff, then approve as the intended rebrand.
+ await page.locator('.snapshot-row:has-text("desktop")').click();
+ await page.waitForTimeout(1750);
+ await dragSlider(page);
+ await page.locator('.diff-toggle button:has-text("Side by side")').click();
+ await page.waitForTimeout(3100);
+ await page.locator('.diff-toggle button:has-text("Diff overlay")').click();
+ await page.waitForTimeout(3500);
+ await Promise.all([
+ page.waitForResponse((r) => r.url().includes("/approve")),
+ page.locator('button:has-text("Approve")').click(),
+ ]);
+ await page.waitForTimeout(2000);
+
+ // Mobile: the same CSS change shrinks the CTA below a usable tap target -
+ // flag it as a genuine regression instead of rubber-stamping it.
+ await page.locator('.snapshot-row:has-text("mobile")').click();
+ await page.waitForTimeout(1750);
+ await page.locator('.diff-toggle button:has-text("Slider")').click();
+ await page.waitForTimeout(650);
+ await dragSlider(page);
+ await page.locator('.diff-toggle button:has-text("Diff overlay")').click();
+ await page.waitForTimeout(3100);
+ await Promise.all([
+ page.waitForResponse((r) => r.url().includes("/reject")),
+ page.locator('button:has-text("Reject")').click(),
+ ]);
+ await page.waitForTimeout(2200);
+
+ await page.goto(`http://localhost:${APP_PORT}/projects/demo-site`);
+ await page.waitForTimeout(3950);
+ } finally {
+ await context.close();
+ await browser.close();
+ }
+
+ const rawPath = join(OUT_DIR, "demo.raw.webm");
+ if (existsSync(rawPath)) rmSync(rawPath);
+ const videoFiles = readdirSync(OUT_DIR).filter((f) => f.endsWith(".webm"));
+ if (videoFiles.length === 0) throw new Error("Playwright did not produce a video file");
+ renameSync(join(OUT_DIR, videoFiles[0]), rawPath);
+ for (const f of videoFiles.slice(1)) rmSync(join(OUT_DIR, f));
+ log(`raw recording saved to ${rawPath}`);
+
+ for (const child of children) stopChild(child);
+ try {
+ execSync(`docker rm -f ${PG_CONTAINER}`, { stdio: "ignore" });
+ } catch {
+ // already gone
+ }
+}
+
+main().catch((err) => {
+ console.error(err);
+ for (const child of children) stopChild(child);
+ try {
+ execFileSync("docker", ["rm", "-f", PG_CONTAINER], { stdio: "ignore" });
+ } catch {
+ // already gone
+ }
+ process.exitCode = 1;
+});