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
+116
View File
@@ -0,0 +1,116 @@
# Themes decoupling — working notes
Branch `themes-decoupling`, worktree `platform-themes`. Nothing here is on master.
Read this before the diff. It is the state of the work, the decisions taken and why, and the things
deliberately left alone.
---
## Where it got to
The visual layer is out of the app and into `src/workspaces/theme`. The application no longer contains any
colour: it imports one stylesheet and mounts one provider.
Proven by the two things that matter:
- **Duck renders identically to before.** Panels, wallpaper, grid fills, glass, logo — checked against
screenshots taken before anything was touched.
- **Plain renders as white with black ink**, with no component edits. Panels turn white, the teal avatar
turns black, grid fills disappear. That is the decoupling working.
Switch themes from the avatar menu, top right.
---
## The shape
```
src/workspaces/theme/
css/base.css structure — Tailwind, the @theme contract, element defaults. NO values.
css/themes/duck-light.css the app's original appearance, moved unchanged
css/themes/duck-dark.css ditto, the old .dark block
css/themes/plain.css white/black — an instrument, not a design
css/chrome.css scrollbars and overlays, now in theme variables
css/index.css the single import an app makes
src/themes.ts the registry: id, name, family, applyDarkClass. Nothing about appearance.
src/ThemeProvider.tsx sets data-theme, toggles .dark, persists the choice
```
A theme is a CSS file scoped to `[data-theme='<id>']` plus one line in the registry. Nothing else.
---
## Decisions, and why
**Light and dark are separate themes, not a mode.** The owner's call, and it removes a concept from the
app. `Duck Light` and `Duck Dark` are siblings. Written out in full rather than as a diff — a theme that
only makes sense in the presence of another cannot be authored by a third party or sold to a customer.
**`.dark` survives as a compatibility flag.** 396 `dark:` utilities are written into 33 components and they
resolve against that class. A theme sets `applyDarkClass` and they keep working. Shim with a known end
date; removing those 396 retires it.
**`duck-*` utility names are kept, pointed at neutral variables.** ~1,470 occurrences across 108 files.
Renaming them and extracting in the same change would make a visual difference impossible to attribute.
They now resolve through `--brand-primary`, `--ink`, and friends, so a non-Duck theme can define them
sensibly even while the spelling is wrong.
**`duck-dark` is `--ink`.** It was never a brand colour: `#14532d` in light, near-white in dark. 946
components use it to mean "text". That misnaming is why it is the most-used token in the app.
**Surfaces are complete CSS values, not HSL triplets.** `--panel-bg` can be `rgba()`, a gradient or an
image. A panel background that could only be a flat colour would rule out the glass the app has now.
**The old provider file is a re-export.** Six call sites keep working untouched, so the extraction is one
variable.
---
## Blockers and open questions — for the morning
**1. The reference repo (`vimazing`) has not been read yet.** The switching mechanism (`data-theme` on
`<html>`) and the packaging format are deliberately the dumbest thing that works, because that repo will
dictate them. Everything above is independent of that choice.
**2. Leaks the Plain theme exposed.** These are components painting themselves rather than asking the
theme. The screenshots in `.snapshots/plain/` are the punch list:
- **The wallpaper.** Still renders under Plain, so it is set by a component, not `--page-bg`. Probably the
dashboard layout. Needs to become a theme-owned surface.
- **Panel radius.** `--panel-radius: 0` had no effect — panels hardcode `rounded-*`. Shape is themeable in
principle and is not yet.
- **The logo.** A fixed image. Fine for Duck, wrong for white-labelling: a customer's build needs its own.
- **~985 raw Tailwind palette usages** (`text-emerald-400`, `bg-black/10`) and **~119 hex literals**. The
worst concentration by far is `officerdev/src/apps/Soulseek/` (~285). `MetricCard.tsx` is the
highest-leverage single fix — a shared component hardcoding its palette.
**3. Server settings still store `appearance.colorMode`, not a theme id.** `useThemeSync` used to stamp the
server's colour mode over the local theme on every load — picking Plain and reloading silently reverted it.
Now the server value is only honoured when the browser has never chosen. The proper fix is storing a theme
id, which needs a settings migration.
**4. A pre-existing bug, unrelated but adjacent.** The server's default is `appearance: { theme: 'light' }`
while the client reads `appearance.colorMode` — so a fresh account's server default has never been read.
Left alone.
**5. `rebuildFrontend()` reports `"Bundle failed"` and discards the bundler's actual errors.** Cost twenty
minutes tonight. Same shape as the agent bugs from earlier — a failure that refuses to say why.
**6. Landing and marketplace are untouched.** They have their own copies of the palette; `duck-teal` has
three different definitions across the three apps. Folding them into this layer is a natural follow-up and
was not in tonight's scope.
---
## Tools built
- `scripts/theme-snapshot.ts` — captures 8 routes × 2 modes, or a pinned theme with `--theme`. Compares two
directories byte-for-byte. Its first version silently photographed light twice; it now warns when a
light/dark pair is identical.
- `scripts/theme-preview-server.ts` — serves this worktree's build against the RUNNING platform's API, so
the new frontend can be seen with real data without starting a second platform beside the live one.
Byte comparison is too strict for before/after across two builds: this worktree has no plugins, so the dock
differs on every page, and live data moves between captures. Use it to find *which* screens changed, then
look at them.