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
@@ -1,10 +1,10 @@
import { Link } from 'react-router';
import * as Dropdown from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User, LogOut, Settings, Puzzle, Sun, Moon, Bot, UserCog } from 'lucide-react';
import { User, LogOut, Settings, Puzzle, Sun, Moon, Bot, UserCog, Palette } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
import { useColorMode } from '@/components/ui/ThemeProvider';
import { useColorMode, useTheme } from '@/components/ui/ThemeProvider';
import { useSettings } from 'state/useSettings';
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown;
@@ -13,6 +13,7 @@ export function UserMenu() {
const { user, isLoading } = useAuth();
const { t } = useTranslation();
const { colorMode, setColorMode } = useColorMode();
const { themeId, setTheme, themes } = useTheme();
const { settings, saveSettings } = useSettings();
if (isLoading) return null;
@@ -35,6 +36,18 @@ export function UserMenu() {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-10 w-48">
{/*
Every theme this build ships, listed. Deliberately the crudest possible control: the point tonight
is proving the app can wear a different skin at all, not designing a picker. A real one belongs in
an appearance settings screen, which does not exist yet.
*/}
{themes.map((theme) => (
<DropdownMenuItem key={theme.id} onClick={() => setTheme(theme.id)} className="cursor-pointer">
<Palette className={`mr-2 h-4 w-4 ${theme.id === themeId ? 'opacity-100' : 'opacity-30'}`} />
{theme.name}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/profile">
<User className="mr-2 h-4 w-4" />
+38 -5
View File
@@ -1,13 +1,46 @@
import { useEffect } from 'react';
import { useColorMode } from '@/components/ui/ThemeProvider';
import { useEffect, useRef } from 'react';
import { useColorMode, useTheme } from '@/components/ui/ThemeProvider';
import { useSettings } from 'state/useSettings';
/**
* Bring the account's saved appearance down from the server, once.
*
* ── Why "once" is the whole point ──
*
* This used to re-apply `settings.appearance.colorMode` whenever the settings query resolved, which was
* correct while colour mode was the ONLY appearance state — there was nothing else for it to overwrite.
* With themes there is: picking Plain and reloading put Plain in localStorage, and then this hook stamped
* `colorMode: 'dark'` back over it a moment later. The theme changed and then silently changed back, which
* looks like the picker not working.
*
* So the server's value is treated as what it is — a STARTING POINT for a browser that has never chosen —
* rather than as a continuous instruction. A local choice wins, because it is the more recent statement of
* what this person wants, and it is already persisted.
*
* The proper fix is for the server to store a theme id rather than a colour mode, at which point this hook
* becomes a one-line sync of the same field in both places. That needs a settings migration and is
* deliberately not in the extraction commit.
*/
const LOCAL_CHOICE_KEY = 'officer-theme';
export const useColorModeSync = () => {
const { settings } = useSettings();
const { setColorMode } = useColorMode();
const { themeId } = useTheme();
const applied = useRef(false);
useEffect(() => {
if (!settings) return;
setColorMode(settings.appearance.colorMode);
}, [settings?.appearance?.colorMode]);
if (!settings || applied.current) return;
applied.current = true;
// This browser has already been told what to wear. The server's colour mode is older information.
try {
if (window.localStorage.getItem(LOCAL_CHOICE_KEY)) return;
} catch {
// Storage unavailable — fall through and honour the server, which is the best guess left.
}
const saved = settings.appearance?.colorMode;
if (saved === 'light' || saved === 'dark') setColorMode(saved);
}, [settings, setColorMode, themeId]);
};
-353
View File
@@ -1,353 +0,0 @@
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
@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 {
/* Duck Landing Page Colors */
--color-duck-yellow: var(--duck-yellow);
--color-duck-orange: var(--duck-orange);
--color-duck-teal: var(--duck-teal);
--color-duck-forest: var(--duck-forest);
--color-duck-dark: var(--duck-dark);
--color-duck-beige: var(--duck-beige);
--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));
--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;
}
}
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
:root {
/* Duck brand hex colors */
--duck-yellow: #f4c430;
--duck-orange: #ea580c;
--duck-teal: #0891b2;
--duck-forest: #166534;
--duck-dark: #14532d;
--duck-beige: #e7d4b5;
/* Page background */
--pixel-grid-color: rgba(20, 83, 45, 0.1);
/* Glass surfaces */
--glass-bg: rgba(255, 255, 255, 0.1);
--glass-border: rgba(255, 255, 255, 0.2);
--dock-bg: rgba(0, 0, 0, 0.15);
--dock-border: rgba(255, 255, 255, 0.1);
--dock-tooltip-bg: rgba(0, 0, 0, 0.75);
/* Card component */
--card-bg: rgba(237, 231, 222, 0.9);
--card-grid-color: rgba(20, 83, 45, 0.08);
--cta: 211 74.77% 45.58%;
--cta-foreground: 0 0% 100%;
--naturegreen: 145 79% 38%;
--naturegreen-foreground: 0 0% 100%;
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 211 74.77% 45.58%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--brand: 145 79% 38%;
--brand-foreground: 0 0% 100%;
--brand-muted: 145 40% 90%;
--warning: 43 96% 56%;
--warning-foreground: 0 0% 0%;
/* The fifth state tone. "In progress" and "merged" are neither good news nor bad, and painting
them with --success makes a merged PR and an open one look like the same thing. Violet because
it has to stay legible next to success green and destructive red at pill size. */
--info: 263 70% 50%;
--info-foreground: 0 0% 100%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
/* Chart gradients */
--chart-primary: 211 82% 64%;
--chart-secondary: 211 82% 84%;
/* Status colors */
--status-success: 142 76% 36%;
--status-warning: 43 96% 56%;
--status-in-progress: 211 82% 64%;
/* Hover Effect */
--ctahover: 211 91.53% 34.82%;
}
.dark {
/* Duck brand — lightened for dark-mode contrast */
--duck-teal: #22d3ee;
--duck-dark: #f1f5f9;
--duck-forest: #4ade80;
--duck-beige: #292524;
/* Page background */
--pixel-grid-color: rgba(200, 230, 210, 0.08);
/* Glass surfaces */
--glass-bg: rgba(0, 0, 0, 0.2);
--glass-border: rgba(255, 255, 255, 0.1);
--dock-bg: rgba(0, 0, 0, 0.3);
--dock-border: rgba(255, 255, 255, 0.08);
--dock-tooltip-bg: rgba(0, 0, 0, 0.85);
/* Card component */
--card-bg: rgba(10, 20, 15, 0.9);
--card-grid-color: rgba(200, 230, 210, 0.06);
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 211 82% 64%;
--accent-foreground: 0 0% 100%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--brand: 145 60% 45%;
--brand-foreground: 0 0% 100%;
--brand-muted: 145 30% 25%;
--warning: 43 96% 56%;
--warning-foreground: 0 0% 0%;
/* Lifted well above the light value — 50% lightness violet on a near-black background reads as
muddy grey at 12px, which is exactly the size it is used at. */
--info: 263 90% 70%;
--info-foreground: 0 0% 100%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
/* Chart gradients */
--chart-primary: 211 82% 64%;
--chart-secondary: 211 82% 84%;
/* Status colors */
--status-success: 142 76% 36%;
--status-warning: 43 96% 56%;
--status-in-progress: 211 82% 64%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
[data-sonner-toaster] {
z-index: 700 !important;
}
/* File Browser — Context menu hover behavior */
/* Code Editor — thin dark scrollbars */
.code-editor-scrollable {
scrollbar-width: thin;
scrollbar-color: #444 transparent;
}
.code-editor-scrollable::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.code-editor-scrollable::-webkit-scrollbar-track {
background: transparent;
}
.code-editor-scrollable::-webkit-scrollbar-thumb {
background: #444;
border-radius: 3px;
}
.code-editor-scrollable::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Chat — the background-task strip. A default horizontal scrollbar is ~15px tall and is laid out INSIDE
the row, so it stole most of the chips' breathing room and made the tray read as squeezed. Thin, and
neutral enough to sit on either theme. */
.task-strip {
scrollbar-width: thin;
scrollbar-color: rgb(128 128 128 / 0.3) transparent;
}
.task-strip::-webkit-scrollbar {
height: 4px;
}
.task-strip::-webkit-scrollbar-track {
background: transparent;
}
.task-strip::-webkit-scrollbar-thumb {
background: rgb(128 128 128 / 0.3);
border-radius: 2px;
}
.task-strip:hover::-webkit-scrollbar-thumb {
background: rgb(128 128 128 / 0.5);
}
.file-grid:has([data-state='open']) [data-file-item]:not([data-state='open']) {
pointer-events: none;
}
.file-grid:has([data-state='open']) [data-file-item]:not([data-state='open']) .file-item-ellipsis {
opacity: 0 !important;
}
.file-grid [data-file-item][data-state='open'] .file-item-ellipsis {
opacity: 1 !important;
}
+11 -1
View File
@@ -1,2 +1,12 @@
@import './globals.css';
/*
The application's stylesheet: the theme layer, plus what is genuinely this app's own.
`globals.css` used to live here and held three unrelated things — Tailwind wiring, the token contract,
and the Duck brand's hex values. It has moved to the `theme` workspace and been split along those seams,
so "change how the app looks" and "change how theming works" stopped being the same edit.
`prose.css` stays: it styles rendered markdown in chat, and it already speaks in theme variables
(`hsl(var(--foreground))`, `--border`), so it follows whatever theme is active.
*/
@import 'theme/css/index.css';
@import './prose.css';