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:
2026-08-16 22:00:38 +00:00
co-authored by Claude Opus 5
parent 7980ee3761
commit 105766a4eb
21 changed files with 1165 additions and 533 deletions
+15 -7
View File
@@ -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}`);