extract the visual layer into its own workspace
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>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Serve THIS worktree's frontend against the RUNNING platform's backend.
|
||||
*
|
||||
* The visual comparison needs the new bundle rendering real data. The obvious way — start a second full
|
||||
* platform on another port — is the one thing not to do tonight: it shares the database, the install root
|
||||
* and the sidecar registry with the live server the owner is currently talking to me through, and a second
|
||||
* instance racing it is exactly the SO_REUSEPORT class of problem that has already cost us an evening.
|
||||
*
|
||||
* So this serves static assets from the worktree's build and forwards everything else to the live server.
|
||||
* The frontend cannot tell the difference: same origin, same cookies, same API. Only the stylesheet and the
|
||||
* theme provider are new, which is precisely the variable under test.
|
||||
*
|
||||
* Read-only in practice — the snapshot script only navigates and screenshots — but it is a proxy, not a
|
||||
* sandbox: a POST would reach the real backend. Do not point anything destructive at it.
|
||||
*
|
||||
* bun scripts/theme-preview-server.ts --port 9010 --upstream http://127.0.0.1:9000
|
||||
*/
|
||||
const arg = (name: string, fallback: string): string => {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i === -1 ? fallback : (process.argv[i + 1] ?? fallback);
|
||||
};
|
||||
|
||||
const port = Number(arg('port', '9010'));
|
||||
const upstream = arg('upstream', 'http://127.0.0.1:9000');
|
||||
const dist = arg('dist', 'dist');
|
||||
|
||||
// The build emits `index.gen.html` (the generated entry), not `index.html`. Both are accepted so this
|
||||
// keeps working if that ever changes.
|
||||
const indexHtml =
|
||||
(await Bun.file(`${dist}/index.gen.html`)
|
||||
.text()
|
||||
.catch(() => null)) ??
|
||||
(await Bun.file(`${dist}/index.html`)
|
||||
.text()
|
||||
.catch(() => null));
|
||||
if (!indexHtml) {
|
||||
console.error(` no build found in ${dist}/ — run the frontend build first`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
idleTimeout: 60,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Anything the backend owns goes upstream, headers and body intact. `/api` covers REST; the rest are
|
||||
// the paths the SPA fetches that are not assets.
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/uploads/')) {
|
||||
const target = `${upstream}${url.pathname}${url.search}`;
|
||||
try {
|
||||
return await fetch(target, {
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.arrayBuffer(),
|
||||
redirect: 'manual',
|
||||
});
|
||||
} catch (err) {
|
||||
return new Response(`upstream unreachable: ${String(err)}`, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
// A built asset, if we have one.
|
||||
const asset = Bun.file(`${dist}${url.pathname}`);
|
||||
if (url.pathname !== '/' && (await asset.exists())) {
|
||||
return new Response(asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* A file we do NOT have goes upstream rather than falling through to the shell.
|
||||
*
|
||||
* `public/` — the logo, wallpapers, icons — is served by the platform from outside the build, so the
|
||||
* first version of this returned index.html for those and the page rendered with a broken logo and no
|
||||
* wallpaper. Both showed up in the comparison as if the extraction had removed them, which is exactly
|
||||
* the kind of false positive that gets a real regression waved through next to it.
|
||||
*
|
||||
* "Looks like a file" is the extension test: a client-side route never has one.
|
||||
*/
|
||||
if (/\.[a-z0-9]{2,5}$/i.test(url.pathname)) {
|
||||
try {
|
||||
const res = await fetch(`${upstream}${url.pathname}${url.search}`, { headers: req.headers });
|
||||
if (res.ok) return res;
|
||||
} catch {
|
||||
// Fall through to the shell — a missing asset should not take the page down.
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else is a client-side route: hand back the shell and let the router decide.
|
||||
return new Response(indexHtml, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(` preview on http://127.0.0.1:${server.port} (assets: ${dist}, api -> ${upstream})`);
|
||||
@@ -91,10 +91,14 @@ async function capture(base: string, out: string, token: string): Promise<void>
|
||||
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')});
|
||||
document.documentElement.classList.toggle('dark', ${JSON.stringify(mode === 'dark' && !forcedTheme)});
|
||||
`);
|
||||
const page = await context.newPage();
|
||||
|
||||
@@ -105,11 +109,13 @@ async function capture(base: string, out: string, token: string): Promise<void>
|
||||
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`);
|
||||
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}`);
|
||||
@@ -197,7 +203,9 @@ if (compareArgs !== -1) {
|
||||
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'}))"`);
|
||||
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}`);
|
||||
|
||||
Reference in New Issue
Block a user