diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx
index 39fdc5e8..0e492af9 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx
@@ -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) {
{/* */}
+
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx
new file mode 100644
index 00000000..b8a01d41
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx
@@ -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>((acc, t) => {
+ (acc[t.family] ??= []).push(t);
+ return acc;
+ }, {});
+
+ return (
+ <>
+
+
+
+
+
+
+ ctrl+alt+space · ctrl+shift+h/l
+
+
+ {Object.entries(families).map(([family, entries]) => (
+
+ j/k move · l preview · h reset · enter keep · esc cancel
+
+
+
,
+ document.body,
+ )}
+ >
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx
index 00b836a0..8b9c1b22 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx
@@ -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() {
- {/*
- 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) => (
- setTheme(theme.id)} className="cursor-pointer">
-
- {theme.name}
-
- ))}
-
diff --git a/src/workspaces/components/ui/ThemeProvider.tsx b/src/workspaces/components/ui/ThemeProvider.tsx
index e61b5d13..213eaf3f 100644
--- a/src/workspaces/components/ui/ThemeProvider.tsx
+++ b/src/workspaces/components/ui/ThemeProvider.tsx
@@ -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';
diff --git a/src/workspaces/theme/src/index.ts b/src/workspaces/theme/src/index.ts
index e17c722e..5642611e 100644
--- a/src/workspaces/theme/src/index.ts
+++ b/src/workspaces/theme/src/index.ts
@@ -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';
diff --git a/src/workspaces/theme/src/useThemeKeys.ts b/src/workspaces/theme/src/useThemeKeys.ts
new file mode 100644
index 00000000..61c4cef0
--- /dev/null
+++ b/src/workspaces/theme/src/useThemeKeys.ts
@@ -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);
+ const startedOn = useRef(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 };
+}