Files
platform/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx
T
pastilhasandClaude Opus 5 4ecd45c4a9 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>
2026-08-17 01:15:55 +00:00

122 lines
4.8 KiB
TypeScript

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,
)}
</>
);
};