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