diff --git a/.gitignore b/.gitignore index 57919df2..9ec1bada 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,6 @@ src/databases/officer_db/migrations/ # EXTRACTING-A-PLUGIN.md and anything beside it — stay tracked. The documentation about plugins belongs # to the platform; the plugins do not. /plugins/*/ + +# Visual baselines for the theme extraction — large, machine-specific, regenerate with scripts/theme-snapshot.ts +.snapshots/ diff --git a/scripts/theme-snapshot.ts b/scripts/theme-snapshot.ts new file mode 100644 index 00000000..fe97ad51 --- /dev/null +++ b/scripts/theme-snapshot.ts @@ -0,0 +1,205 @@ +/** + * 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 { + // `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 { + 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 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); +} diff --git a/src/workspaces/theme/package.json b/src/workspaces/theme/package.json new file mode 100644 index 00000000..7133950e --- /dev/null +++ b/src/workspaces/theme/package.json @@ -0,0 +1,11 @@ +{ + "name": "theme", + "version": "0.0.1", + "license": "MIT", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./css": "./css/index.css", + "./css/*": "./css/*" + } +}