Files
platform/scripts/theme-snapshot.ts
T
pastilhasandClaude Opus 5 7980ee3761 photograph the app before touching how it looks
The themes work moves every colour out of the components and into a token layer, and
the entire value of that is being INVISIBLE. Nothing here could have told us whether
that held: the tests do not render, and comparing two screens from memory an hour
apart misses exactly the things this can break — a border colour, a radius, one mode
of two.

Eight routes, both colour modes, against the running app. Byte comparison rather than
a pixel-tolerance diff: a false alarm costs opening two images, a false all-clear
costs the refactor.

The first version was worthless and looked fine. It reused one browser context and
added an init script per mode — Playwright ACCUMULATES those, so the dark pass ran the
light script too, and the class toggle after `goto` was undone by the provider's own
effect. Six of eight routes came out byte-identical in light and dark, which reads as
a clean run. A context per mode fixes it, the class is asserted after settling rather
than before, and identical light/dark pairs now print a warning instead of passing
quietly.

Verified rather than assumed: 15 distinct images of 16, every route differs between
modes, and one was opened to confirm it is the authenticated app and not eight
variations of an error boundary.

Baselines are gitignored — 7MB of machine-specific PNGs. Regenerate with the script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:36:49 +00:00

206 lines
8.7 KiB
TypeScript

/**
* Photograph the application, so "the extraction changed nothing" can be checked instead of asserted.
*
* ── Why this exists ──
*
* The themes work moves every colour in the app out of the components and into a token layer. The whole
* value of that refactor is that it is INVISIBLE: same pixels, different source. Nothing else in this
* repository can tell us whether that held — the tests do not render, and a person comparing two screens
* from memory an hour apart will miss a border colour or a shifted radius, which are exactly the things
* this refactor can break.
*
* So: capture before, capture after, compare. A difference is either a bug or a decision, and both are
* worth seeing rather than discovering later in a screenshot from the owner.
*
* ── Usage ──
*
* bun scripts/theme-snapshot.ts --out .snapshots/before --base http://127.0.0.1:9000
* bun scripts/theme-snapshot.ts --out .snapshots/after --base http://127.0.0.1:9010
* bun scripts/theme-snapshot.ts --compare .snapshots/before .snapshots/after
*
* ── Authentication ──
*
* A minted owner token is written to `localStorage` before the first navigation, because `useClient` reads
* it from there (`BEARER_TOKEN`) and an unauthenticated app renders the login screen — one screen, over and
* over, which would compare perfectly clean while proving nothing at all.
*
* ── Both colour modes ──
*
* Light and dark are captured for every route. The dark values live in a separate block of the stylesheet,
* so a refactor can very easily carry one and drop the other, and only ever showing light would hide half
* of it.
*/
import { chromium, type Page } from 'playwright';
import { mkdirSync, readdirSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
/** The screens that carry the most distinct chrome, rather than the most features. */
const ROUTES = [
['home', '/'],
['chat', '/chat'],
['dashboards', '/dashboards'],
['jobs', '/jobs'],
['apps', '/apps'],
['skills', '/skills'],
['activity', '/activity'],
['system-monitor', '/system-monitor'],
] as const;
const MODES = ['light', 'dark'] as const;
const arg = (name: string): string | undefined => {
const i = process.argv.indexOf(`--${name}`);
return i === -1 ? undefined : process.argv[i + 1];
};
async function settle(page: Page): Promise<void> {
// `networkidle` alone is not enough: this app opens websockets that never go idle, and paints after
// React Query resolves. A short fixed settle after load is cruder and far more reliable here.
await page.waitForTimeout(2500);
// Kill anything that animates, or two captures of an identical page differ by a spinner's phase.
await page.addStyleTag({
content: `*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
caret-color: transparent !important;
}`,
});
await page.waitForTimeout(300);
}
async function capture(base: string, out: string, token: string): Promise<void> {
mkdirSync(out, { recursive: true });
const browser = await chromium.launch();
const failures: string[] = [];
/**
* A fresh context per colour mode, not one context reused.
*
* The first version added an init script per mode to a single context, and Playwright ACCUMULATES init
* scripts — so the dark pass ran the light script too, and toggling the class after `goto` was undone by
* the provider's own effect a moment later. The captures came out byte-identical in light and dark, which
* looks exactly like a clean run and proves nothing. A context that has only ever known one mode cannot
* have that argument with itself.
*/
for (const mode of MODES) {
const context = await browser.newContext({
viewport: { width: 1600, height: 1000 },
deviceScaleFactor: 1,
ignoreHTTPSErrors: true,
colorScheme: mode,
});
await context.addInitScript(`
window.localStorage.setItem('BEARER_TOKEN', ${JSON.stringify(token)});
window.localStorage.setItem('officer-color-mode', ${JSON.stringify(mode)});
document.documentElement.classList.toggle('dark', ${JSON.stringify(mode === 'dark')});
`);
const page = await context.newPage();
for (const [name, route] of ROUTES) {
const file = join(out, `${name}-${mode}.png`);
try {
await page.goto(`${base}${route}`, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page);
// Asserted AFTER settling, because the provider sets this class in an effect and would otherwise
// win the race. Re-checked rather than assumed: a wrong class here silently produces the bug above.
const applied = await page.evaluate((m) => {
document.documentElement.classList.toggle('dark', m === 'dark');
return document.documentElement.classList.contains('dark');
}, mode as string);
if (applied !== (mode === 'dark')) failures.push(`${name}-${mode}: colour mode did not apply`);
await page.waitForTimeout(400);
await page.screenshot({ path: file, fullPage: false });
console.log(` captured ${name}-${mode}`);
} catch (err) {
failures.push(`${name}-${mode}: ${err instanceof Error ? err.message : String(err)}`);
console.log(` FAILED ${name}-${mode}`);
}
}
await context.close();
}
await browser.close();
// A light and dark capture of the same route being identical means the mode never applied. That is a
// broken harness, not a clean result, and it must be loud — it already happened once.
const suspicious = ROUTES.map(([name]) => name).filter((name) => {
const a = join(out, `${name}-light.png`);
const b = join(out, `${name}-dark.png`);
return existsSync(a) && existsSync(b) && Buffer.compare(readFileSync(a), readFileSync(b)) === 0;
});
if (suspicious.length) {
console.log(`\n WARNING: light and dark are identical for: ${suspicious.join(', ')}`);
console.log(' Either those screens are genuinely colour-mode agnostic, or the capture is not switching modes.');
}
if (failures.length) {
console.log(`\n ${failures.length} problem(s):`);
for (const f of failures) console.log(` ${f}`);
}
}
/**
* Compare two capture directories by bytes.
*
* Deliberately not a pixel-tolerance diff: PNG equality is a coarse instrument, but a false ALARM here is
* cheap (look at two images) while a false all-clear is the entire failure mode this script exists to
* prevent. Anti-aliasing noise would matter if the two runs used different machines or browsers; they do
* not.
*/
function compare(before: string, after: string): void {
if (!existsSync(before) || !existsSync(after)) {
console.log(` missing directory: ${!existsSync(before) ? before : after}`);
process.exit(1);
}
const names = [...new Set([...readdirSync(before), ...readdirSync(after)])].filter((n) => n.endsWith('.png')).sort();
let same = 0;
const changed: string[] = [];
const missing: string[] = [];
for (const name of names) {
const a = join(before, name);
const b = join(after, name);
if (!existsSync(a) || !existsSync(b)) {
missing.push(name);
continue;
}
if (Buffer.compare(readFileSync(a), readFileSync(b)) === 0) same += 1;
else changed.push(name);
}
console.log(`\n identical : ${same}`);
console.log(` changed : ${changed.length}`);
for (const c of changed) console.log(` ${c}`);
if (missing.length) {
console.log(` only in one side: ${missing.length}`);
for (const m of missing) console.log(` ${m}`);
}
console.log(
changed.length === 0 && missing.length === 0
? '\n No visual change.'
: '\n Differences above are either a bug or a decision. Open both and decide which.',
);
}
const compareArgs = process.argv.indexOf('--compare');
if (compareArgs !== -1) {
compare(process.argv[compareArgs + 1]!, process.argv[compareArgs + 2]!);
} else {
const base = arg('base') ?? 'http://127.0.0.1:9000';
const out = arg('out') ?? '.snapshots/current';
// Taken as an argument rather than minted here on purpose: this script has to run against a checkout
// that has no `node_modules` (a fresh git worktree is exactly that), and importing the signer drags in
// the whole workspace graph. A string is portable.
const token = arg('token') ?? process.env.OFFICER_SNAPSHOT_TOKEN;
if (!token) {
console.log(' --token <jwt> is required (or OFFICER_SNAPSHOT_TOKEN). Mint one from a checkout with deps:');
console.log(` bun -e "const {sign}=await import('./src/servers/jwt');console.log(await sign({id:1,email:'you@example.com'}))"`);
process.exit(1);
}
console.log(` base ${base} -> ${out}`);
await capture(base, out, token);
}