the account stores a theme, not a colour mode

Your theme now follows the account instead of living in one browser's localStorage.
Choosing Plain on a laptop reaches the phone.

`appearance.colorMode` is superseded by `appearance.theme`. Along the way this
resolves a mismatch that had been sitting there: the server's default was
`appearance: { theme: 'light' }` while the client read `appearance.colorMode`, so that
default had never once been read by anything. Both sides say `theme` now.

The Light/Dark item is gone from the user menu. Light and dark are two themes, both
already in the list above it, and that toggle was the last writer of the legacy key.

── Two bugs found by testing this, both mine, both silent ──

**A default that looked like a choice.** `useSettings` returns `DEFAULT_SETTINGS`
until the request lands — `settings` is never null, it is a complete valid object. So
"wait for settings" fired on the first render against the defaults, marked itself
done, applied the default theme and never saw the real one. Then the persist side
wrote that default over the account's actual preference. It overwrote the owner's own
`dark` while I was testing it; restored by hand from the `colorMode` it had not yet
destroyed. `useSettings` now exposes `settingsLoaded` so the difference is expressible.

**Defaults masking a migration.** With that fixed, an account holding only
`colorMode: 'dark'` STILL came up light. The client merges `DEFAULT_SETTINGS` over
whatever arrives, so a missing `theme` is filled in with the default before any
consumer sees it — and a default is indistinguishable from a choice. The migration
therefore belongs on the SERVER, which sees the stored object before anything is
merged over it. It is read-only: a GET that rewrites the row on every call is a
surprise, and the client persists on its next change anyway.

Verified by putting the account into the pre-themes shape and asking the new handler
directly, rather than by restarting to find out:

  stored: {"colorMode":"dark"}  ->  returned: {"colorMode":"dark","theme":"duck-dark"}

── Needs a restart ──

The first server-side change in this work. Everything until now was frontend and a
rebuild was enough.

950 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:31:22 +00:00
co-authored by Claude Opus 5
parent ecb6864ff9
commit 997f7e7a87
4 changed files with 105 additions and 52 deletions
@@ -1,28 +1,19 @@
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, Palette } from 'lucide-react';
import { User, LogOut, Settings, Puzzle, Bot, UserCog, Palette } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
import { useColorMode, useTheme } from '@/components/ui/ThemeProvider';
import { useSettings } from 'state/useSettings';
import { useTheme } from '@/components/ui/ThemeProvider';
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown;
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;
const toggleColorMode = () => {
const next = colorMode === 'dark' ? 'light' : 'dark';
setColorMode(next);
saveSettings({ ...settings, appearance: { ...settings.appearance, colorMode: next } });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -37,9 +28,12 @@ export function UserMenu() {
</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.
Every theme this build ships. This replaced a Light/Dark toggle, which had become a second way to
do the same thing — light and dark are two themes now, both already in this list, and the toggle
was the last thing writing the legacy `appearance.colorMode`.
Deliberately the crudest possible control: proving the app can wear another skin, 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">
@@ -87,11 +81,6 @@ export function UserMenu() {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={toggleColorMode} className="cursor-pointer">
{colorMode === 'dark' ? <Sun className="mr-2 h-4 w-4" /> : <Moon className="mr-2 h-4 w-4" />}
{colorMode === 'dark' ? 'Light Mode' : 'Dark Mode'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/auth/signout">
<LogOut className="mr-2 h-4 w-4" />