From 4ecd45c4a9c27f97062187bf2578147f0f2e7681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 17 Aug 2026 01:15:32 +0000 Subject: [PATCH] themes get their own control, and the vimazing keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Dashboard/Layout/Header/Header.tsx | 2 + .../Dashboard/Layout/Header/ThemeSwitcher.tsx | 121 +++++++++++++++++ .../Dashboard/Layout/Header/UserMenu.tsx | 19 +-- .../components/ui/ThemeProvider.tsx | 2 +- src/workspaces/theme/src/index.ts | 1 + src/workspaces/theme/src/useThemeKeys.ts | 126 ++++++++++++++++++ 6 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx create mode 100644 src/workspaces/theme/src/useThemeKeys.ts 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]) => ( +
+ {entries.map((t) => ( + setTheme(t.id)} className="cursor-pointer"> + + {t.name} + + ))} +
+ ))} +
+
+ + {open && + createPortal( +
setOpen(false)} + > +
ev.stopPropagation()} + > +
    + {themes.map((t) => { + const isFocused = t.id === focused; + return ( +
  • + +
  • + ); + })} +
+

+ 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 }; +}