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
+130
View File
@@ -0,0 +1,130 @@
/*
PLAIN — white ground, black ink, visible borders
============================================================================================
Not a design. An instrument.
Every value here is white, black, or a grey, and every surface is flat and square. So when the app is
rendered under this theme, ANY colour still on the screen is a component painting itself instead of
asking the theme — a hardcoded `text-emerald-400`, a `bg-black/10`, a hex literal. The leaks light up.
That makes it the acceptance test for the whole decoupling effort, and a much better one than reading
code: the survey found ~985 raw Tailwind palette usages and ~119 hex literals, and no amount of grepping
tells you which of those are load-bearing. A screenshot does.
It is also the honest starting point for a third-party author — copy this, and every value you change is
one you have decided about, with nothing inherited from someone else's brand.
Deliberately NOT beautiful. A pleasant neutral theme would hide small leaks in its own greys; stark black
on stark white hides nothing.
*/
[data-theme='plain'] {
/* ── Brand slots ──────────────────────────────────────────────────────────────────────────────
All black. The `duck-*` utilities resolve through these, so anything still using them renders as flat
black rather than teal — visible as "this should have been a semantic token" without being colourful
enough to be mistaken for intent. */
--brand-primary: #000000;
--brand-accent: #000000;
--brand-warm: #000000;
--brand-deep: #000000;
--ink: #000000;
--surface-warm: #ffffff;
/* ── The page ─────────────────────────────────────────────────────────────────────────────────
Flat white and no grid. If a pixel grid or a wallpaper appears under this theme, something is drawing
it from outside the theme layer. */
--page-bg: #ffffff;
--pixel-grid-color: transparent;
/* ── Surfaces ─────────────────────────────────────────────────────────────────────────────────
Opaque white with a hard black border, and square corners. The app's panels are translucent with
rounded corners and a grid fill, so any panel that still looks like that under Plain is styling itself
rather than reading `--panel-*`. */
--glass-bg: #ffffff;
--glass-border: #000000;
--panel-bg: #ffffff;
--panel-border: #000000;
--panel-radius: 0;
--panel-header-bg: #f2f2f2;
--panel-header-fg: #000000;
--panel-accent: #000000;
--panel-grid-color: transparent;
--dock-bg: #ffffff;
--dock-border: #000000;
--dock-tooltip-bg: #000000;
--card-bg: #ffffff;
--card-grid-color: transparent;
/* ── Semantic colour ──────────────────────────────────────────────────────────────────────────
Greys only. The state tones (success/warning/destructive/info) are greys too, which is wrong for a
real theme and right for this one: it proves the app is asking for a token rather than reaching for a
green it likes. A theme that wanted real state colour would simply set them. */
--background: 0 0% 100%;
--foreground: 0 0% 0%;
--card: 0 0% 100%;
--card-foreground: 0 0% 0%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 0%;
--primary: 0 0% 0%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 95%;
--secondary-foreground: 0 0% 0%;
--muted: 0 0% 95%;
--muted-foreground: 0 0% 35%;
--accent: 0 0% 90%;
--accent-foreground: 0 0% 0%;
--destructive: 0 0% 25%;
--destructive-foreground: 0 0% 100%;
--success: 0 0% 25%;
--success-foreground: 0 0% 100%;
--brand: 0 0% 0%;
--brand-foreground: 0 0% 100%;
--brand-muted: 0 0% 90%;
--warning: 0 0% 45%;
--warning-foreground: 0 0% 100%;
--info: 0 0% 35%;
--info-foreground: 0 0% 100%;
--border: 0 0% 0%;
--input: 0 0% 0%;
--ring: 0 0% 0%;
--cta: 0 0% 0%;
--cta-foreground: 0 0% 100%;
--ctahover: 0 0% 20%;
--naturegreen: 0 0% 0%;
--naturegreen-foreground: 0 0% 100%;
/* Square everything. Rounded corners surviving under Plain means a hardcoded `rounded-xl`, which is a
shape leak rather than a colour one — worth seeing too. */
--radius: 0;
/* ── Data visualisation and status ────────────────────────────────────────────────────────────── */
--chart-primary: 0 0% 20%;
--chart-secondary: 0 0% 60%;
--status-success: 0 0% 25%;
--status-warning: 0 0% 45%;
--status-in-progress: 0 0% 35%;
/* ── Chrome ───────────────────────────────────────────────────────────────────────────────────── */
--scrollbar-thumb: #000000;
--scrollbar-thumb-hover: #333333;
--scrollbar-thin-thumb: rgb(0 0 0 / 0.4);
--scrollbar-thin-thumb-hover: rgb(0 0 0 / 0.7);
}