The application no longer knows what it looks like. It imports one stylesheet and
mounts one provider; every colour, surface and radius lives in `src/workspaces/theme`.
Proven both directions, because either alone would be worthless:
Duck renders IDENTICALLY to before — checked against screenshots taken before
anything was touched, not asserted.
Plain renders as white with black ink, no component edits — panels turn white, the
teal avatar turns black, the grid fills vanish.
── What moved ──
`globals.css` held three unrelated things: Tailwind wiring, the token contract, and
the Duck brand's hex values. Split along those seams. `base.css` is structure and
names no colour; a theme file is values and nothing else, scoped to
`[data-theme='<id>']`. Adding a theme is a CSS file plus one registry line.
Light and dark are separate themes rather than a mode — the owner's call, and it
removes a concept from the app. Each is written out in full: a theme that only makes
sense as a diff against another cannot be authored by a stranger or sold to a
customer. `.dark` survives only as a per-theme flag, because 396 `dark:` utilities in
33 components resolve against it; retiring those retires the flag.
The `duck-*` utility names are kept for now and pointed at neutral variables, so a
non-Duck theme can define them sensibly. Renaming 1,470 occurrences in the same commit
as the extraction would make any visual difference impossible to attribute. Worth
recording that `duck-dark` was never a brand colour — `#14532d` in light, near-white
in dark, used by 946 components to mean "text". It is `--ink` now.
Surfaces are complete CSS values rather than HSL triplets: `--panel-bg` has to be able
to be a gradient or an image, or the glass the app already has would be unthemeable.
The old provider is a re-export, so its six call sites are untouched and the
extraction stays one variable.
── Bug found by the Plain theme ──
`useThemeSync` stamped the server's `appearance.colorMode` over the local theme on
every load, so choosing Plain and reloading silently reverted it. Diagnosed by asking
the page what it was wearing rather than reasoning: `stored` said `duck-dark` when the
script had written `plain`. The server value is now a starting point for a browser
that has never chosen, not a continuous instruction.
── Tools ──
Two, both of which lied to me before they worked. The snapshot script photographed
light twice and called it a clean run (one browser context, accumulated init scripts);
it now warns when a light/dark pair is identical. The preview server returned the
shell for missing assets, so the logo and wallpaper vanished and read as regressions
in the comparison — it falls through to upstream now.
Dead Tailwind v3 residue deleted: `components/ui/tailwind.{config.ts,css}`, referenced
by nothing, with content globs pointing at a directory that does not exist.
── Not done, deliberately ──
`THEMES-NOTES.md` has the full list. The headlines: the reference repo has not been
read, so the switching mechanism is deliberately the dumbest thing that works; the
wallpaper, panel radius and logo are still component-owned and Plain proves it; and
~985 raw Tailwind palette usages remain, worst in Soulseek.
tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
9.1 KiB
TypeScript
214 lines
9.1 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,
|
|
});
|
|
// `--theme` pins a named theme; without it the legacy colour-mode key is used, which is what an
|
|
// existing account has and therefore what the before/after comparison must exercise.
|
|
const forcedTheme = arg('theme');
|
|
await context.addInitScript(`
|
|
window.localStorage.setItem('BEARER_TOKEN', ${JSON.stringify(token)});
|
|
${forcedTheme ? `window.localStorage.setItem('officer-theme', ${JSON.stringify(forcedTheme)});` : ''}
|
|
window.localStorage.setItem('officer-color-mode', ${JSON.stringify(mode)});
|
|
document.documentElement.classList.toggle('dark', ${JSON.stringify(mode === 'dark' && !forcedTheme)});
|
|
`);
|
|
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.
|
|
if (!arg('theme')) {
|
|
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);
|
|
}
|