diff --git a/.agents/skills/screenshot/SKILL.md b/.agents/skills/screenshot/SKILL.md index d49c2350b..d7af9c32f 100644 --- a/.agents/skills/screenshot/SKILL.md +++ b/.agents/skills/screenshot/SKILL.md @@ -1,6 +1,6 @@ --- name: screenshot -description: Screenshot pages of the local dev server of any Next.js app here (dashboard, marketing web, status page) in light and dark, logged in where needed. Use after any visible change, when asked to "take a screenshot", "show me", or to verify a layout before reporting it done. +description: Screenshot pages of the local dev server of any Next.js app here (dashboard, marketing web, status page) on desktop and mobile, logged in where needed. Use after any visible change, when asked to "take a screenshot", "show me", or to verify a layout before reporting it done. --- # Screenshots @@ -9,7 +9,8 @@ The engine is [agent-browser](https://github.com/vercel-labs/agent-browser), a CLI that drives Chrome over CDP. Two ways in: - `scripts/screenshot.mjs` for the standard evidence shots. It logs in with - a seeded session, shoots light and dark at 2×, hides dev-only chrome, + a seeded session, shoots desktop (1400 px, 2×) and mobile (390 px, + 3×, iPhone user agent) in light, hides dev-only chrome, takes the element or the page, and writes a gallery. - `agent-browser` directly for a state the script cannot reach: a dropdown open, a dialog, a filled composer. See **Interactive shots**. @@ -22,7 +23,11 @@ node scripts/screenshot.mjs --app status-page /acme It prints the PNG paths and writes `.screenshots/index.html`, a gallery of every shot in the output directory. Read the PNGs with the Read tool and -judge against `DESIGN.md`. Both themes by default; `--theme light` for one. +judge against `DESIGN.md`. Files are `.desktop.png` and +`.mobile.png`, where the name is the path with every run of non-word +characters turned into `-` (`/incidents/1` gives `incidents-1`). Light only by +default; `--theme dark` or `--theme both` adds `..dark.png`. +`--device desktop` or `--device mobile` shoots one. ## Cloud session (nothing installed, nothing running) @@ -91,9 +96,10 @@ it and the script sets it itself when unset. No `agent-browser install`. | `--app` | `dashboard` | `dashboard`, `web` or `status-page`; sets base and whether to log in | | ` …` | required | one or more paths; on the status page the slug is the first segment in dev | | `--selector` | page | CSS selector; every visible match is shot on its own | -| `--theme` | `both` | `light` or `dark` | +| `--device` | `both` | `desktop` or `mobile` (390×844, 3×, iPhone 14 user agent) | +| `--theme` | `light` | `dark` or `both` | | `--out` | `.screenshots` | gitignored | -| `--width` | `1400` | viewport width, 2× device scale | +| `--width` | `1400` | desktop viewport width, 2× device scale | | `--full-page` | off | full-page capture when no selector | | `--base` | per app | override the dev server URL | | `--db` | `http://127.0.0.1:8080` | sqld HTTP endpoint for the session lookup | @@ -109,7 +115,8 @@ sequence below is what the script does, minus the gallery; `agent-browser skills get core` has the full command set. ```sh -agent-browser set viewport 1400 1000 2 # 2× like the script +agent-browser set viewport 1400 1000 2 # desktop, 2× like the script +# mobile: agent-browser set device "iPhone 14" && agent-browser set viewport 390 844 3 agent-browser set media dark # or light; next-themes follows it agent-browser cookies set authjs.session-token seed-session-max --url http://localhost:3001 agent-browser cookies set workspace-slug love-openstatus --url http://localhost:3001 @@ -124,8 +131,8 @@ agent-browser close # sessions outlive the shell - `wait --load networkidle` times out against the dev server (the HMR socket never goes idle). Wait for fonts, an element (`wait @ref`) or text. -- `--session ` isolates a browser; the script uses one per theme and - closes it. Close the ones you open. +- `--session ` isolates a browser; the script uses one per device and + theme, and closes it. Close the ones you open. - agent-browser shoots viewports, not elements. The script's `--selector` pins the match to the viewport's top-left at its own size and hides the rest; for an ad-hoc element shot, `scrollintoview` and shoot the viewport. @@ -152,15 +159,17 @@ re-seed to reset. - Each route is fetched once with the session cookies before the browser opens it, so a cold Turbopack compile (10s+) never trips a timeout. -- The dev-only Tailwind breakpoint badge (`[data-slot=tailwind-indicator]`) - and the Next.js dev indicator are hidden before the capture. +- The dev-only Tailwind breakpoint badge (`[data-slot=tailwind-indicator]`), + the Next.js dev indicator and the React Query devtools button (the beach + logo, `.tsqd-open-btn-container`) are hidden before the capture. - Not handled: avatar images from blocked hosts render as initials or alt text in the sandbox. Say so when it shows; do not chase it. ## Rules - Shoot the element, not the viewport, unless layout is the question. -- Shoot before and after, both themes, and compare. +- Shoot before and after, desktop and mobile, and compare. Add dark when + the change touches color. - Do not post, edit or delete on the fixtures unless the screenshot needs that state; say so when you do. - A redirect to `/login` means auth failed. Fix auth; do not screenshot the @@ -178,8 +187,8 @@ session runs. - **Cloud session:** nothing leaves the sandbox except git, so the files vanish with the VM. Publish the generated gallery with the Artifact tool: `file_path` `.screenshots/index.html`, `root` `.screenshots`, and every PNG - it shows passed in `files` under its own name (`{"incidents-1-light.png": - "incidents-1-light.png", ...}`). The gallery is written as a page fragment + it shows passed in `files` under its own name (`{"incidents-1.desktop.png": + "incidents-1.desktop.png", ...}`). The gallery is written as a page fragment (title, style, body) for exactly that. Put the link in the final message. Never commit screenshots to make them visible. - **Review evidence for a PR:** when a reviewer needs the image, publish it diff --git a/DESIGN.md b/DESIGN.md index f7989e5fd..1a4da6e52 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -273,7 +273,7 @@ lines, and a last row clipped mid-glyph. An agent cannot judge rendering from a diff. For any visible change, `scripts/screenshot.mjs` (the `screenshot` skill) shoots the element, not the -viewport, in both themes on the local dev server of the dashboard, the +viewport, on desktop and mobile (light; dark on request) on the local dev server of the dashboard, the marketing site or the status page, logging in with a seeded session where needed. Selectors differ per app: `[data-slot=timeline]` and the other dashboard slots, `main` or a section on the marketing site, the block slots diff --git a/scripts/screenshot.mjs b/scripts/screenshot.mjs index 8e6a5812d..e01005b89 100644 --- a/scripts/screenshot.mjs +++ b/scripts/screenshot.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Screenshot pages of a local Next.js dev server, in light and dark, through +// Screenshot pages of a local Next.js dev server, desktop and mobile, through // agent-browser (https://github.com/vercel-labs/agent-browser). // // node scripts/screenshot.mjs --app dashboard /incidents/1 --selector "[data-slot=timeline]" @@ -45,7 +45,7 @@ for (let i = 0; i < args.length; i++) { const app = APPS[flags.app ?? "dashboard"]; if (!app || paths.length === 0) { console.error( - "usage: screenshot.mjs --app dashboard|web|status-page [...] [--selector css] [--theme both|light|dark]", + "usage: screenshot.mjs --app dashboard|web|status-page [...] [--selector css] [--device both|desktop|mobile] [--theme light|dark|both]", ); process.exit(2); } @@ -54,14 +54,25 @@ const base = (flags.base ?? app.base).replace(/\/$/, ""); const db = flags.db ?? "http://127.0.0.1:8080"; const out = resolve(flags.out ?? ".screenshots"); const themes = - flags.theme === "light" || flags.theme === "dark" - ? [flags.theme] - : ["light", "dark"]; + flags.theme === "both" || flags.theme === "dark" + ? flags.theme === "both" + ? ["light", "dark"] + : ["dark"] + : ["light"]; const width = Number(flags.width ?? 1400); if (!Number.isFinite(width) || width <= 0) { console.error(`--width must be a positive number, got ${flags.width}`); process.exit(2); } +// `emulate` sets the mobile user agent; the viewport line pins size and scale. +const DEVICES = { + desktop: { width, height: 1000, scale: 2 }, + mobile: { width: 390, height: 844, scale: 3, emulate: "iPhone 14" }, +}; +const devices = + flags.device === "desktop" || flags.device === "mobile" + ? [flags.device] + : ["desktop", "mobile"]; const wantsAuth = app.auth || Boolean(flags.token || flags.state); mkdirSync(out, { recursive: true }); @@ -212,11 +223,12 @@ if (auth) { } } -// Dev-only chrome floats over the bottom-left corner: the apps' Tailwind -// breakpoint badge and the Next.js dev indicator. +// Dev-only chrome floats over the page corners: the apps' Tailwind +// breakpoint badge, the Next.js dev indicator and the React Query devtools +// button. const HIDE_DEV_CHROME = `(() => { const st = document.createElement("style"); - st.textContent = "[data-slot=tailwind-indicator],nextjs-portal{display:none!important}"; + st.textContent = "[data-slot=tailwind-indicator],nextjs-portal,.tsqd-open-btn-container{display:none!important}"; document.head.appendChild(st); return "ok"; })()`; @@ -255,11 +267,18 @@ const pinTheme = (theme) => `(() => { const LOADED = `document.readyState === "complete" && document.fonts.status === "loaded"`; const written = []; -for (const theme of themes) { - // One isolated browser per theme; sessions survive this process, so close it. - const session = `openstatus-shot-${theme}-${process.pid}`; +const runs = themes.flatMap((theme) => + devices.map((device) => ({ theme, device })), +); +for (const { theme, device } of runs) { + // One isolated browser per run; sessions survive this process, so close it. + const session = `openstatus-shot-${device}-${theme}-${process.pid}`; + const d = DEVICES[device]; + const viewport = [String(d.width), String(d.height), String(d.scale)]; + const suffix = `.${device}${theme === "dark" ? ".dark" : ""}.png`; try { - ab(session, "set", "viewport", String(width), "1000", "2"); + if (d.emulate) ab(session, "set", "device", d.emulate); + ab(session, "set", "viewport", ...viewport); ab(session, "set", "media", theme); for (const c of auth ?? []) ab(session, "cookies", "set", c.name, c.value, "--url", base); @@ -286,15 +305,22 @@ for (const theme of themes) { for (;;) { const box = evalJs(session, isolate(flags.selector, k)); if (!box.w) break; - ab(session, "set", "viewport", String(box.w), String(box.h), "2"); + ab( + session, + "set", + "viewport", + String(box.w), + String(box.h), + String(d.scale), + ); const file = join( out, - `${name}-${theme}${box.count > 1 ? `-${k}` : ""}.png`, + `${name}${box.count > 1 ? `-${k}` : ""}${suffix}`, ); ab(session, "screenshot", file); written.push(file); evalJs(session, RESTORE); - ab(session, "set", "viewport", String(width), "1000", "2"); + ab(session, "set", "viewport", ...viewport); if (++k >= box.count) break; } if (k === 0) { @@ -302,7 +328,7 @@ for (const theme of themes) { process.exitCode = 1; } } else { - const file = join(out, `${name}-${theme}.png`); + const file = join(out, `${name}${suffix}`); ab( session, "screenshot", @@ -333,10 +359,14 @@ function writeGallery() { if (shots.length === 0) return; const groups = new Map(); for (const s of shots) { - const m = s.file.match(/^(.*?)-(light|dark)(-\d+)?\.png$/); - const key = m ? `${m[1]}${m[3] ?? ""}` : s.file; + const m = s.file.match(/^(.*)\.(desktop|mobile)(\.dark)?\.png$/); + const key = m ? m[1] : s.file; const g = groups.get(key) ?? { key, shots: [], mtime: 0 }; - g.shots.push({ ...s, theme: m?.[2] ?? "" }); + g.shots.push({ + ...s, + device: m?.[2] ?? "", + label: m ? `${m[2]}${m[3] ? " dark" : ""}` : s.file, + }); g.mtime = Math.max(g.mtime, s.mtime); groups.set(key, g); } @@ -350,7 +380,9 @@ function writeGallery() { ["Taken", new Date().toISOString().slice(0, 16).replace("T", " ") + " UTC"], [ "Viewport", - `${width} px wide, 2× scale${flags["full-page"] ? ", full page" : ""}`, + devices + .map((k) => `${k} ${DEVICES[k].width} px at ${DEVICES[k].scale}×`) + .join(", ") + (flags["full-page"] ? ", full page" : ""), ], ]; const esc = (s) => @@ -371,7 +403,9 @@ function writeGallery() { dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 20px; margin: 0; font-size: 0.875rem; font-family: ui-monospace, Menlo, monospace; border-block: 1px solid var(--line); padding-block: 10px; } dt { color: var(--muted); } dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } section { display: grid; gap: 12px; } - figure { margin: 0; display: grid; gap: 8px; } + .row { display: flex; flex-wrap: wrap; gap: 16px; align-items: start; } + figure { margin: 0; display: grid; gap: 8px; flex: 1 1 480px; min-width: 0; } + figure.mobile { flex: 0 1 280px; } figcaption { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; color: var(--accent); } .frame { border: 1px solid var(--line); background: var(--panel); border-radius: 6px; overflow: hidden; } .frame img { display: block; width: 100%; height: auto; max-width: 100%; } @@ -385,15 +419,17 @@ ${[...groups.values()] .map( (g) => `

${esc(g.key)}

+
${g.shots - .sort((a, b) => a.theme.localeCompare(b.theme) * -1) + .sort((a, b) => a.label.localeCompare(b.label)) .map( - (s) => `
-
${esc(s.theme || s.file)}
-
${esc(g.key)} ${esc(s.theme)}
+ (s) => `
+
${esc(s.label)}
+
${esc(g.key)} ${esc(s.label)}
`, ) .join("\n")} +
`, ) .join("\n")}