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
+185
View File
@@ -0,0 +1,185 @@
/*
THE STRUCTURE OF THE LOOK — never its values.
============================================================================================
This file says WHAT can be themed. A theme file says what it looks like. Nothing here names a colour,
and nothing here belongs to any particular theme: swapping every theme in the repository must not
require editing this file, and adding a variable here is a change to the contract that every theme has
to answer.
The split exists because it is the whole feature. Before it, `globals.css` mixed three things — Tailwind
wiring, the token contract, and the Duck brand's hex values — so "change how the app looks" and "change
how theming works" were the same edit, and neither could be done by anyone who did not own the app.
── How a value reaches a component ──
component .bg-surface
@theme (here) --color-surface: hsl(var(--surface))
theme file --surface: 0 0% 100%
The indirection through `var()` is load-bearing: Tailwind resolves `@theme` at BUILD time, so a theme
that set `--color-surface` directly could never be swapped without a rebuild. Resolving to a custom
property means the browser reads it at paint, and changing `data-theme` on <html> re-paints the app.
── Two spellings, and why ──
Colours are HSL TRIPLETS without the function (`0 0% 100%`), so `hsl(var(--x) / 0.5)` gives opacity for
free — that is what makes `bg-surface/50` work, and it is why every shadcn-derived token in this file
looks like that.
Surfaces (panel, dock, card, page) are COMPLETE CSS VALUES instead, because a theme needs to set things
a triplet cannot express: `rgba()`, a gradient, an image, `none`. A panel background that could only ever
be a flat colour would rule out the glass look the app has today.
*/
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
/*
Dark is still a CLASS, even though light and dark are now separate themes.
Two themes was the simplification the owner chose, and it is the right one — but 396 `dark:` utilities
are written into 33 components, and they all resolve against this variant. A theme that wants them
active declares `applyDarkClass` in its manifest and the provider adds the class; a theme that does not
simply never sees them. So the concept is gone from the app while the utilities keep working, and
removing those 396 becomes a later cleanup rather than a prerequisite.
*/
@custom-variant dark (&:is(.dark *));
@utility container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >= --theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 1400px) {
max-width: 1400px;
}
}
@theme {
/* ── Semantic colour, the whole vocabulary a component is allowed to use ──────────────────────── */
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-brand: hsl(var(--brand));
--color-brand-foreground: hsl(var(--brand-foreground));
--color-brand-muted: hsl(var(--brand-muted));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-warning: hsl(var(--warning));
--color-warning-foreground: hsl(var(--warning-foreground));
--color-info: hsl(var(--info));
--color-info-foreground: hsl(var(--info-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-cta: hsl(var(--cta));
--color-cta-foreground: hsl(var(--cta-foreground));
--color-ctahover: hsl(var(--ctahover));
--color-naturegreen: hsl(var(--naturegreen));
--color-naturegreen-foreground: hsl(var(--naturegreen-foreground));
/*
── The brand slots ────────────────────────────────────────────────────────────────────────────
These are the six utilities that were `duck-*`, kept under their old names for now so ~1,470 existing
usages keep compiling, but pointed at variables a theme owns rather than at brand hex.
They are DEPRECATED as spellings. `duck-dark` is the clearest case of why: it is `#14532d` in light and
near-white in dark, so it was never a brand green — it is ink, and 946 components are using it to mean
"text colour". A theme called anything other than Duck cannot sensibly define `--duck-dark`.
The rename to semantic names is the largest mechanical piece of this refactor and is deliberately not
done in the same change as the extraction: one of them can be verified by screenshot, and doing both at
once would make a visual difference impossible to attribute.
*/
--color-duck-yellow: var(--brand-accent);
--color-duck-orange: var(--brand-warm);
--color-duck-teal: var(--brand-primary);
--color-duck-forest: var(--brand-deep);
--color-duck-dark: var(--ink);
--color-duck-beige: var(--surface-warm);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out forwards;
--animate-accordion-up: accordion-up 0.2s ease-out forwards;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
/*
Tailwind v4 changed the default border colour to `currentcolor`. Kept so an element with a border and no
explicit colour looks as it did under v3 — removing it means auditing every bordered element in the app.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
* {
@apply border-border;
}
/*
The page itself is a themed surface, not just a colour.
`--page-bg` is a complete `background` value so a theme can be a photograph, a gradient, or nothing at
all. Duck is a wallpaper; Plain is white. Both are the same line here.
*/
body {
@apply text-foreground;
background: var(--page-bg);
background-attachment: fixed;
background-size: cover;
}
}