/* * Re-shoot the documentation screenshots. * * pnpm run dev:api # :8920, against a seeded database * pnpm run dev:web # :5173, proxies /api to the above * node scripts/screenshots.mjs # writes docs/screenshots/*.webp * * Every page, at 1440×900 and at 393×852, in light and in dark — forty images. * The README section that these feed went stale once already, when the shell * was rebuilt and nobody could cheaply re-shoot; that is why this is a script * in the tree rather than a note about what to capture. * * Three things are forced rather than clicked: * * - The theme. Seeding localStorage is not enough on its own: the appearance * preference is stored server-side and adopted after hydration (see * apps/web/src/lib/theme.tsx), so every dark shot would snap back to light * a beat after first paint. The response is rewritten instead. * - The sidebar, expanded, and the Piggy dock, closed — both are per-device * states in localStorage, so whatever the last human left behind would * otherwise leak into the captures. * - The capacity matcher's results, by submitting the form. An empty form is * not a picture of a matcher. * * Captures are the viewport rather than the full scroll height: a README image * three thousand pixels tall is unreadable at the width a README renders in. * * The PNGs Playwright produces are downscaled from their 2×/3× capture and * re-encoded as WebP by scripts/screenshots-encode.py, which runs last. Forty * PNGs at capture scale are 16MB; the WebP set is under 2MB, which is the * difference between a repository people clone and one they do not. */ import { chromium } from 'playwright'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; const BASE = process.env.PIG_WEB_URL ?? 'http://127.0.0.1:5173'; const OUT = process.env.PIG_SHOTS_OUT ?? 'docs/screenshots'; const raw = mkdtempSync(join(tmpdir(), 'pig-shots-')); const PAGES = [ { slug: 'overview', path: '/' }, { slug: 'margin', path: '/margin' }, { slug: 'capacity', path: '/capacity' }, { slug: 'capacity-match', path: '/capacity', tab: 'Match a requirement', submit: 'Find capacity', }, { slug: 'growth', path: '/growth' }, { slug: 'calendar', path: '/calendar' }, { slug: 'demand', path: '/demand' }, { slug: 'supply', path: '/supply' }, { slug: 'accounts', path: '/accounts' }, { slug: 'contracts', path: '/contracts' }, ]; const VIEWPORTS = [ { name: 'desktop', width: 1440, height: 900, scale: 2 }, { name: 'mobile', width: 393, height: 852, scale: 3 }, ]; // channel:'chrome' reuses the system browser rather than downloading one. const browser = await chromium.launch({ channel: 'chrome' }); let failures = 0; for (const theme of ['light', 'dark']) { for (const vp of VIEWPORTS) { const ctx = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, deviceScaleFactor: vp.scale, colorScheme: theme, isMobile: vp.name === 'mobile', hasTouch: vp.name === 'mobile', }); await ctx.addInitScript(` try { localStorage.setItem('pig.themeMode', ${JSON.stringify(theme)}); localStorage.setItem('pig.accent', 'pig'); localStorage.setItem('pig.sidebarOpen', 'true'); localStorage.setItem('pig.piggyDockOpen', 'false'); } catch {} `); await ctx.route('**/api/me/profile', async (route) => { const response = await route.fetch(); let body = {}; try { body = await response.json(); } catch { /* An error body is fine to discard; the theme fields are what matter. */ } await route.fulfill({ response, json: { ...body, themeMode: theme, accentColor: 'pig' } }); }); const page = await ctx.newPage(); const problems = []; page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text().slice(0, 140)}`); }); for (const p of PAGES) { await page.goto(BASE + p.path, { waitUntil: 'networkidle' }); if (p.tab) { await page .getByRole('tab', { name: p.tab }) .or(page.getByText(p.tab, { exact: true })) .first() .click(); await page.waitForTimeout(600); } if (p.submit) { await page.getByRole('button', { name: p.submit }).first().click(); await page.waitForLoadState('networkidle').catch(() => {}); await page.waitForTimeout(900); } await page.waitForTimeout(1200); await page.screenshot({ path: `${raw}/${p.slug}-${vp.name}-${theme}.png` }); /* * Two checks a status code cannot make: a horizontal scrollbar (the * 393px failure mode), and the theme actually landing. Both would * otherwise be discovered by a human squinting at forty images. */ const [overflow, applied] = await page.evaluate(() => [ document.documentElement.scrollWidth - document.documentElement.clientWidth, document.documentElement.dataset.theme, ]); const ok = applied === theme && overflow === 0; if (!ok) failures += 1; console.log( `${ok ? ' ' : '!!'} ${p.slug.padEnd(15)} ${vp.name.padEnd(7)} ` + `want=${theme.padEnd(5)} got=${applied} overflow=${overflow}`, ); } if (problems.length) { failures += 1; console.log(' !! ' + [...new Set(problems)].slice(0, 4).join('\n !! ')); } await ctx.close(); } } await browser.close(); const encode = spawnSync('python3', ['scripts/screenshots-encode.py', raw, OUT], { stdio: 'inherit', }); rmSync(raw, { recursive: true, force: true }); if (encode.status !== 0) { console.error('encoding failed — is Pillow installed? (python3 -m pip install Pillow)'); process.exit(1); } if (failures) { console.error(`\n${failures} capture(s) had a theme, overflow or console problem — see above.`); process.exit(1); } console.log(`\nWrote ${PAGES.length * 4} images to ${OUT}.`);