import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Palette, Check, Type } from "lucide-react"; import { Button } from "@nous-research/ui/ui/components/button"; import { ListItem } from "@nous-research/ui/ui/components/list-item"; import { BottomSheet } from "@nous-research/ui/ui/components/bottom-sheet"; import { Typography } from "@nous-research/ui/ui/components/typography/index"; import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint"; import { BUILTIN_THEMES, THEME_DEFAULT_FONT_ID, useTheme } from "@/themes"; import type { DashboardTheme, FontChoice, ThemeListEntry } from "@/themes"; import { useI18n } from "@/i18n"; import { cn } from "@/lib/utils"; /** * Compact theme picker mounted next to the language switcher in the header. * Each dropdown row shows a 3-stop swatch (background / midground / warm * glow) so users can preview the palette before committing. User-defined * themes from `~/.hermes/dashboard-themes/*.yaml` use their API-provided * definitions so they show real palette swatches just like built-ins. * * When placed at the bottom of a container (e.g. the sidebar rail), pass * `dropUp` so the menu opens above the trigger instead of clipping below * the viewport. On viewports below the `sm` breakpoint, `dropUp` uses a * bottom sheet portaled to `document.body` so the picker is not clipped by * the sidebar (same idea as a responsive Drawer). */ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitcherProps) { const { themeName, availableThemes, setTheme, fontId, fontChoices, setFont } = useTheme(); const { t } = useI18n(); const [open, setOpen] = useState(false); const wrapperRef = useRef(null); const dropdownRef = useRef(null); const narrowViewport = useBelowBreakpoint(640); const useMobileSheet = Boolean(dropUp && narrowViewport); const close = useCallback(() => setOpen(false), []); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [open, close]); useEffect(() => { if (!open || useMobileSheet) return; const onMouseDown = (e: MouseEvent) => { const target = e.target as Node; if (wrapperRef.current?.contains(target)) return; if (dropdownRef.current?.contains(target)) return; close(); }; document.addEventListener("mousedown", onMouseDown); return () => document.removeEventListener("mousedown", onMouseDown); }, [open, close, useMobileSheet]); const current = availableThemes.find((th) => th.name === themeName); const label = current?.label ?? themeName; const sheetTitle = t.theme?.title ?? "Theme"; return (
{useMobileSheet && (
)} {open && !useMobileSheet && (() => { const rect = wrapperRef.current?.getBoundingClientRect(); const dropdown = (
{sheetTitle}
); return dropUp ? createPortal(dropdown, document.body) : dropdown; })()}
); } function ThemeSwitcherOptions({ availableThemes, close, setTheme, themeName, }: ThemeSwitcherOptionsProps) { return ( <> {availableThemes.map((th) => { const isActive = th.name === themeName; const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition; return ( { setTheme(th.name); close(); }} role="option" > {paletteTheme ? ( ) : ( )}
{th.label} {th.description && ( {th.description} )}
); })} ); } const FONT_CATEGORY_LABEL_KEY: Record = { sans: "fontSans", serif: "fontSerif", mono: "fontMono", }; /** Font-override section rendered below the theme list. Lets the user pick * any catalog font independently of the active theme, or "Theme default" * to clear the override. Each row previews itself in its own font. */ function FontSection({ fontChoices, fontId, setFont }: FontSectionProps) { const { t } = useI18n(); const order: FontChoice["category"][] = ["sans", "serif", "mono"]; return ( <>
{t.theme?.fontTitle ?? "Font"}
{/* Theme-default (clears the override). */} setFont(THEME_DEFAULT_FONT_ID)} role="option" >
{t.theme?.fontDefault ?? "Theme default"} {t.theme?.fontDefaultHint ?? "Use the active theme's font"}
{order.map((cat) => { const fonts = fontChoices.filter((f) => f.category === cat); if (fonts.length === 0) return null; const catLabel = t.theme?.[FONT_CATEGORY_LABEL_KEY[cat]] ?? cat; return (
{catLabel}
{fonts.map((f) => { const isActive = f.id === fontId; return ( setFont(f.id)} role="option" >
{/* Preview the font in its own stack. */} {f.label}
); })}
); })} ); } function ThemeSwatch({ theme }: { theme: DashboardTheme }) { const [c1, c2, c3] = theme.swatchColors ?? [ theme.palette.background.hex, theme.palette.midground.hex, theme.palette.warmGlow, ]; return (
); } function PlaceholderSwatch() { return (
); } interface ThemeSwitcherOptionsProps { availableThemes: ThemeListEntry[]; close: () => void; setTheme: (name: string) => void; themeName: string; } interface FontSectionProps { fontChoices: FontChoice[]; fontId: string; setFont: (id: string) => void; } interface ThemeSwitcherProps { collapsed?: boolean; dropUp?: boolean; }