#!/usr/bin/env node /** * captureScreens.mjs — retakes the screenshots in `src/data/screens.mjs`. * * PLAN.md §13 phase 9, D4 / D45. * * node scripts/captureScreens.mjs # every web screen * node scripts/captureScreens.mjs shard-status admin-users * RG_DEMO=http://localhost:3000 node scripts/captureScreens.mjs * * --------------------------------------------------------------------------------------- * AN AUTHORING TOOL, LIKE buildBrandAssets.mjs — NOT A CHECK * --------------------------------------------------------------------------------------- * This never runs in CI and CI never needs it: its output is committed, because the site * must build from a clean checkout with no game server, no database and no browser. What * CI runs is `checkScreens.mjs`, which only reads the files this produced. * * It exists because D4 asks for real screenshots of a real deployment, and the way real * screenshots rot is that the recipe for taking them lives in somebody's memory. The rig * is written down in PLAN.md §13; the framing — route, viewport, scroll offset, whether to * sign in — is written down in `screens.mjs`; and this turns the two into files. * * --------------------------------------------------------------------------------------- * WHY puppeteer-core AND NOT puppeteer * --------------------------------------------------------------------------------------- * `puppeteer` downloads its own Chromium — a hundred-odd megabytes fetched on every clean * install of a repository that needs a browser once per redesign. `puppeteer-core` drives * a Chrome that is already on the machine, which every machine that can look at this site * has. Point `RG_CHROME` at it if it is somewhere unusual. * * --------------------------------------------------------------------------------------- * WHY IT SIGNS IN THROUGH THE API RATHER THAN THE LOGIN FORM * --------------------------------------------------------------------------------------- * The administration screens need a session, and typing into the login form is the part of * a browser script most likely to break on a redesign — a moved field, a renamed button, a * React input that ignores synthetic typing. The session cookie is the only thing actually * wanted, so this asks the API for one from inside the page and lets the browser store it. * If that call stops returning 200 the script says so and stops, rather than quietly * screenshotting a login screen twelve times. */ import { existsSync, mkdirSync, readdirSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; import { screens, screensOf, WEB } from '../src/data/screens.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const OUT = path.join(HERE, '..', 'public', 'screens'); const BASE = (process.env.RG_DEMO || 'http://localhost:3000').replace(/\/+$/, ''); const USER = process.env.RG_ADMIN_USER || 'demoadmin'; const PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026'; /** Where Chrome usually is, per platform. First hit wins; `RG_CHROME` beats all of them. */ const CHROME_CANDIDATES = [ process.env.RG_CHROME, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/chromium', ].filter(Boolean); const wanted = process.argv.slice(2).filter((arg) => !arg.startsWith('-')); const todo = screensOf('web').filter((shot) => wanted.length === 0 || wanted.includes(shot.id)); if (todo.length === 0) { const known = screens.map((shot) => shot.id).join(', '); console.error(`Nothing to capture. Known ids: ${known}`); process.exit(1); } const chrome = CHROME_CANDIDATES.find((candidate) => existsSync(candidate)); if (!chrome) { console.error( 'No Chrome found. Set RG_CHROME to the browser executable — this script drives an\n' + 'installed Chrome rather than downloading one (see the header).', ); process.exit(1); } const puppeteer = (await import('puppeteer-core')).default; mkdirSync(OUT, { recursive: true }); const browser = await puppeteer.launch({ executablePath: chrome, headless: 'new', defaultViewport: { ...WEB.viewport, deviceScaleFactor: WEB.scale }, // Scrollbars are the browser's furniture, not the product's, and a colour profile that // is not sRGB makes the palette in a screenshot disagree with the palette on the page. args: ['--hide-scrollbars', '--force-color-profile=srgb'], }); /** * One page per privilege level rather than signing in and out around each shot: signing * out is the step that gets forgotten, and a public page captured with an admin session * shows a navigation bar the public never sees. */ const anon = await browser.newPage(); const admin = await browser.newPage(); await admin.goto(BASE, { waitUntil: 'domcontentloaded' }); const status = await admin.evaluate( async (username, password) => { const res = await fetch('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ username, password }), }); return res.status; }, USER, PASS, ); if (status !== 200) { console.error( `Could not sign in as "${USER}" at ${BASE} (HTTP ${status}).\n` + 'Seed the demo first — see PLAN.md §13 phase 9 and scripts/seedDemo.mjs.', ); await browser.close(); process.exit(1); } let failures = 0; for (const shot of todo) { const page = shot.admin ? admin : anon; const url = BASE + shot.route; try { await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 }); if (shot.scrollY) { await page.evaluate((y) => window.scrollTo(0, y), shot.scrollY); } // Live pages settle after their first paint: a shard panel fills in from an event // stream, a list re-sorts once its data lands. A second is cheap and the difference // between a screenshot of the product and a screenshot of its loading state. await new Promise((resolve) => setTimeout(resolve, 1200)); const png = await page.screenshot({ type: 'png' }); const file = path.join(OUT, `${shot.id}.webp`); // Quality 82 is where UI text stops visibly softening; the files land near 150 KB, // which is what makes a page with five of them still a page and not a download. await sharp(png).webp({ quality: 82 }).toFile(file); const meta = await sharp(file).metadata(); if (meta.width !== WEB.width || meta.height !== WEB.height) { console.error( ` ! ${shot.id}: got ${meta.width}x${meta.height}, expected ${WEB.width}x${WEB.height}`, ); failures++; continue; } console.log(` + ${shot.id.padEnd(18)} ${shot.route.padEnd(20)} ${meta.width}x${meta.height}`); } catch (err) { console.error(` ! ${shot.id}: ${err.message}`); failures++; } } await browser.close(); // A file left behind by a screen that has since been renamed or dropped is a file the // site still ships and nothing points at. Say so; do not delete somebody's work silently. if (wanted.length === 0) { const declared = new Set(screensOf('web').map((shot) => `${shot.id}.webp`)); const phones = new Set(screensOf('phone').map((shot) => `${shot.id}.webp`)); const orphans = readdirSync(OUT).filter((name) => !declared.has(name) && !phones.has(name)); if (orphans.length > 0) { console.log(`\nNot declared in screens.mjs, left alone: ${orphans.join(', ')}`); } } console.log(`\n${todo.length - failures} captured, ${failures} failed.`); process.exit(failures > 0 ? 1 : 0);