Files
pig/scripts/screenshots.mjs
karti d7e0cbeccc
CI / verify (push) Successful in 2m42s
CI / publish (push) Has been skipped
Shoot the README screenshots, and make them regenerable
The screenshot section had been a placeholder since the shell was rebuilt as
three panes, because there was no cheap way to re-shoot and a stale image is
worse than no image. So this ships the capture, not just the captures:
scripts/screenshots.mjs takes all ten pages at 1440x900 and 393x852, in light
and dark, and screenshots-encode.py halves and re-encodes them to WebP — 16MB
of PNG becomes 1.9MB in the tree.

Two things would silently ruin a run, and the script exists to encode both.
Seeding localStorage['pig.themeMode'] is not enough: the appearance preference
is authoritative server-side and adopted after hydration, so every dark capture
snapped back to light a beat after first paint. The /api/me/profile response is
rewritten instead. And pig.sidebarOpen / pig.piggyDockOpen are per-device, so
whatever the last human left behind would otherwise leak in. The run also fails
on a wrong theme, a horizontal scrollbar or a console error — three things a
screenshot cannot show you.

Piggy is not pictured mid-conversation. It is off by default and no inference
credential exists, so such an image would be a staged transcript rather than a
capture. The README says that rather than implying the feature is missing.

Correcting what the README asserted while shooting against the running code:
read authorisation IS enforced — createReadGuardRoutes is mounted ahead of the
feature routes, and routes/learn.ts and routes/activities.ts are mounted too,
so only the HubSpot pair is still unreachable. The remaining read gap is that a
grant cannot be narrowed, there being no row-level team filter in the query
layer. Counts refreshed against the tree: 275 tests, ~47k lines, 14 migrations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 21:24:24 -07:00

168 lines
6.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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}.`);