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:
@@ -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" />
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user