10d79fc35d
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>
168 lines
6.1 KiB
JavaScript
168 lines
6.1 KiB
JavaScript
/*
|
||
* 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}.`);
|