themes get their own control, and the vimazing keys

Fourteen themes was too many to keep under an avatar next to "Sign out" — that is where
a preference goes when nobody has decided it matters. It now has its own button in the
header, between the controls and the avatar, grouped by family.

── The keys, ported from the owner's vimazing ──

  ctrl+shift+l / ctrl+shift+h   cycle forward / back, from anywhere, no UI
  ctrl+alt+space                open the list
  j / k                         move the focus
  l or space                    apply the focused theme to the app BEHIND the list
  h                             back to where you started
  enter                         keep what you are looking at
  escape                        restore and close

The browse is the point, and it is why the overlay is a portal over a 55% backdrop
rather than a modal: you are not reading names, you are watching the application change.
The theme you started on is captured when the list opens, so `h` and `escape` undo a
whole browse however many previews happened inside it.

Typing is still typing: the list ignores j/k/l/h while focus is in an input, textarea,
select or anything contenteditable.

── Where it lives ──

The key handling is in the theme package — it is behaviour that belongs to themes, and
it needs no UI. The list and the button are Officer's, because a dialog would drag a
component library into a package that currently has none.

Verified by driving the real keyboard in a browser:

  start duck-dark · ctrl+shift+l -> plain · ctrl+alt+space -> overlay
  j, l -> tokyo-night (previewed) · escape -> plain, closed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 01:15:55 +00:00
co-authored by Claude Opus 5
parent ba0c8600b8
commit 4ecd45c4a9
6 changed files with 252 additions and 19 deletions
@@ -1,17 +1,15 @@
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, Bot, UserCog, Palette } from 'lucide-react';
import { User, LogOut, Settings, Puzzle, Bot, UserCog } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
import { useTheme } from '@/components/ui/ThemeProvider';
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown;
export function UserMenu() {
const { user, isLoading } = useAuth();
const { t } = useTranslation();
const { themeId, setTheme, themes } = useTheme();
if (isLoading) return null;
return (
@@ -27,21 +25,6 @@ export function UserMenu() {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-10 w-48">
{/*
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">
<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" />