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
@@ -8,6 +8,7 @@ import type { DockItem } from '../Dock';
import { useIsTouch } from '../useIsTouch';
import { UserMenu } from './UserMenu';
import { JobsIndicator } from './JobsIndicator';
import { ThemeSwitcher } from './ThemeSwitcher';
import { RescanButton } from '../Rescan/RescanButton';
import { usePageTitle } from '@/state/usePageTitle';
import { useThemeAsset } from '@/components/ui/ThemeProvider';
@@ -98,6 +99,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
{/* <BugReportButton /> */}
<JobsIndicator />
<RescanButton />
<ThemeSwitcher />
<UserMenu />
</div>
</div>
@@ -0,0 +1,121 @@
import { createPortal } from 'react-dom';
import { Palette, Check } from 'lucide-react';
import * as Dropdown from '@/components/ui/dropdown-menu';
import type { Theme } from '@/components/ui/ThemeProvider';
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
const {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} = Dropdown;
/**
* Themes, out of the avatar menu and into the header.
*
* They were buried under an avatar next to "Sign out", which is where a preference goes when nobody has
* decided it matters. With fourteen of them it is a thing you use, so it gets its own control.
*
* ── The overlay ──
*
* `ctrl+alt+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
* you browse, so as little of it as possible should be covered.
*
* Ported from the owner's `vimazing`, keys and all.
*/
export const ThemeSwitcher = () => {
const { theme, themeId, setTheme, themes } = useTheme();
const { open, setOpen, focused, setFocused } = useThemeKeys();
// Grouped by family, so `Duck Light`/`Duck Dark` and the two Catppuccins sit together. The registry is
// readonly, so this builds mutable arrays rather than pushing into its own type.
const families = themes.reduce<Record<string, Theme[]>>((acc, t) => {
(acc[t.family] ??= []).push(t);
return acc;
}, {});
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title={`Theme: ${theme.name} · ctrl+alt+space for the list, ctrl+shift+h/l to cycle`}
aria-label="Theme"
className="flex h-8 w-8 items-center justify-center rounded-full transition-transform hover:scale-110 cursor-pointer"
style={{ color: 'var(--header-fg)' }}
>
<Palette className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-50 max-h-[70vh] w-56 overflow-y-auto">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
ctrl+alt+space · ctrl+shift+h/l
</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(families).map(([family, entries]) => (
<div key={family}>
{entries.map((t) => (
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer">
<Check className={`mr-2 h-4 w-4 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
{t.name}
</DropdownMenuItem>
))}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
{open &&
createPortal(
<div
className="fixed inset-0 z-[900] flex items-center justify-center"
// Deliberately light: the point is to see the app change behind it.
style={{ backgroundColor: 'color-mix(in srgb, var(--background) 55%, transparent)' }}
onClick={() => setOpen(false)}
>
<div
className="rounded-2xl border px-10 py-8 shadow-2xl"
style={{
backgroundColor: 'var(--popover)',
borderColor: 'var(--border)',
color: 'var(--popover-foreground)',
}}
onClick={(ev) => ev.stopPropagation()}
>
<ul className="flex flex-col gap-1">
{themes.map((t) => {
const isFocused = t.id === focused;
return (
<li key={t.id}>
<button
onMouseEnter={() => setFocused(t.id)}
onClick={() => {
setTheme(t.id);
setOpen(false);
}}
className={`w-full rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
}`}
>
{t.name}
{t.id === themeId && <span className="ml-2 align-middle text-xs opacity-70">active</span>}
</button>
</li>
);
})}
</ul>
<p className="mt-6 text-center text-xs opacity-60">
j/k move · l preview · h reset · enter keep · esc cancel
</p>
</div>
</div>,
document.body,
)}
</>
);
};
@@ -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" />
@@ -12,5 +12,5 @@
*
* New code should import from `theme` directly. This file exists to be deleted once nothing points here.
*/
export { ThemeProvider as ColorModeProvider, ThemeProvider, useTheme, useColorMode, useThemeAsset } from 'theme';
export { ThemeProvider as ColorModeProvider, ThemeProvider, useTheme, useColorMode, useThemeAsset, useThemeKeys } from 'theme';
export type { Theme, ThemeId } from 'theme';
+1
View File
@@ -6,5 +6,6 @@
*/
export { ThemeProvider, useTheme, useColorMode } from './ThemeProvider';
export { useThemeAsset } from './useThemeAsset';
export { useThemeKeys } from './useThemeKeys';
export { THEMES, DEFAULT_THEME, findTheme, isKnownTheme, isDarkTheme, themeFromLegacyColorMode } from './themes';
export type { Theme, ThemeId } from './themes';
+126
View File
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTheme } from './ThemeProvider';
import type { ThemeId } from './themes';
/**
* Keyboard theme switching, ported from the owner's `vimazing`.
*
* Two modes, and the second is the good one:
*
* ctrl+shift+l / ctrl+shift+h cycle forward / back, from anywhere, no UI
* ctrl+alt+space open the list, then browse it with j/k
*
* ── Why browsing applies the theme ──
*
* With the list open, `j`/`k` move the focus and `l` or `space` APPLIES the focused theme to the whole
* app behind the overlay. That is the point of it: you are not reading names, you are watching the
* application change. `h` puts back what you started with, `Enter` keeps what you are looking at, `Escape`
* restores and closes.
*
* The starting theme is captured when the list opens, so `h` and `Escape` can undo an entire browse
* regardless of how many previews happened in between.
*
* ── Not a UI ──
*
* This hook owns the keys and the selection; something else draws the list. It lives in the theme package
* because the behaviour belongs to themes, while a dialog would drag a component library in behind it.
*/
export function useThemeKeys() {
const { themeId, setTheme, themes } = useTheme();
const [open, setOpen] = useState(false);
const [focused, setFocused] = useState<ThemeId>(themeId);
const startedOn = useRef<ThemeId>(themeId);
// A ref, so the handler does not need re-binding on every theme change.
const state = useRef({ themeId, focused, open });
state.current = { themeId, focused, open };
const step = useCallback(
(from: ThemeId, delta: number): ThemeId => {
const ids = themes.map((t) => t.id);
const at = ids.indexOf(from);
return ids[(at + delta + ids.length) % ids.length]!;
},
[themes],
);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const { themeId: current, focused: focus, open: isOpen } = state.current;
// Toggle the list.
if (event.ctrlKey && event.altKey && event.code === 'Space') {
event.preventDefault();
setOpen((was) => {
if (!was) {
startedOn.current = current;
setFocused(current);
}
return !was;
});
return;
}
// Cycle from anywhere, list or no list.
if (event.ctrlKey && event.shiftKey && (event.key === 'L' || event.key === 'l')) {
event.preventDefault();
setTheme(step(current, 1));
return;
}
if (event.ctrlKey && event.shiftKey && (event.key === 'H' || event.key === 'h')) {
event.preventDefault();
setTheme(step(current, -1));
return;
}
if (!isOpen) return;
// Typing in a field should still be typing, even with the list up.
const target = event.target as HTMLElement | null;
if (target && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName))) return;
switch (event.key) {
case 'j':
case 'J':
case 'ArrowDown':
event.preventDefault();
setFocused((prev) => step(prev, 1));
return;
case 'k':
case 'K':
case 'ArrowUp':
event.preventDefault();
setFocused((prev) => step(prev, -1));
return;
case 'l':
case 'L':
case ' ':
// Apply what is focused, without closing — the browse.
event.preventDefault();
setTheme(focus);
return;
case 'h':
case 'H':
event.preventDefault();
setTheme(startedOn.current);
setFocused(startedOn.current);
return;
case 'Enter':
event.preventDefault();
setTheme(focus);
setOpen(false);
return;
case 'Escape':
event.preventDefault();
setTheme(startedOn.current);
setOpen(false);
return;
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [setTheme, step]);
return { open, setOpen, focused, setFocused };
}