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