Shoot the README screenshots, and make them regenerable
CI / verify (push) Successful in 2m42s
CI / publish (push) Has been skipped

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>
This commit is contained in:
2026-08-13 21:15:45 -07:00
parent e82d5a90bf
commit d7e0cbeccc
44 changed files with 563 additions and 33 deletions
+167
View File
@@ -0,0 +1,167 @@
/*
* 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}.`);