diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 8761b71c..ad0466fa 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -20,7 +20,7 @@ import { useDialog } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; -import { useTheme } from "../context/ThemeContext"; +import { useTheme, THEME_META } from "../context/ThemeContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; @@ -67,7 +67,7 @@ export function Layout() { const lastMainScrollTop = useRef(0); const [mobileNavVisible, setMobileNavVisible] = useState(true); const [instanceSettingsTarget, setInstanceSettingsTarget] = useState(() => readRememberedInstanceSettingsPath()); - const nextTheme = theme === "dark" ? "light" : "dark"; + const isDarkTheme = THEME_META[theme].dark; const matchedCompany = useMemo(() => { if (!companyPrefix) return null; const requestedPrefix = companyPrefix.toUpperCase(); @@ -331,10 +331,10 @@ export function Layout() { size="icon-sm" className="text-muted-foreground shrink-0" onClick={toggleTheme} - aria-label={`Switch to ${nextTheme} mode`} - title={`Switch to ${nextTheme} mode`} + aria-label="Cycle theme" + title="Cycle theme" > - {theme === "dark" ? : } + {isDarkTheme ? : } @@ -389,10 +389,10 @@ export function Layout() { size="icon-sm" className="text-muted-foreground shrink-0" onClick={toggleTheme} - aria-label={`Switch to ${nextTheme} mode`} - title={`Switch to ${nextTheme} mode`} + aria-label="Cycle theme" + title="Cycle theme" > - {theme === "dark" ? : } + {isDarkTheme ? : } diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index e00afc84..6a5d8068 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -2,7 +2,7 @@ import { isValidElement, useEffect, useId, useState, type ReactNode } from "reac import Markdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; -import { useTheme } from "../context/ThemeContext"; +import { useTheme, THEME_META } from "../context/ThemeContext"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; interface MarkdownBodyProps { @@ -97,7 +97,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB pre: ({ node: _node, children: preChildren, ...preProps }) => { const mermaidSource = extractMermaidSource(preChildren); if (mermaidSource) { - return ; + return ; } return
{preChildren}
; }, @@ -140,7 +140,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
diff --git a/ui/src/context/ThemeContext.tsx b/ui/src/context/ThemeContext.tsx index 81c0b8ad..976f3721 100644 --- a/ui/src/context/ThemeContext.tsx +++ b/ui/src/context/ThemeContext.tsx @@ -8,7 +8,16 @@ import { type ReactNode, } from "react"; -type Theme = "light" | "dark"; +export type Theme = "catppuccin-mocha" | "tokyo-night" | "catppuccin-latte"; + +export const THEME_META: Record = { + "catppuccin-mocha": { label: "Catppuccin Mocha", dark: true, bg: "#1e1e2e", primary: "#89b4fa" }, + "tokyo-night": { label: "Tokyo Night", dark: true, bg: "#1a1b26", primary: "#7aa2f7" }, + "catppuccin-latte": { label: "Catppuccin Latte", dark: false, bg: "#eff1f5", primary: "#1e66f5" }, +}; + +const VALID_THEMES: Theme[] = ["catppuccin-mocha", "tokyo-night", "catppuccin-latte"]; +const DEFAULT_THEME: Theme = "catppuccin-mocha"; interface ThemeContextValue { theme: Theme; @@ -17,36 +26,47 @@ interface ThemeContextValue { } const THEME_STORAGE_KEY = "paperclip.theme"; -const DARK_THEME_COLOR = "#18181b"; -const LIGHT_THEME_COLOR = "#ffffff"; const ThemeContext = createContext(undefined); -function resolveThemeFromDocument(): Theme { - if (typeof document === "undefined") return "dark"; - return document.documentElement.classList.contains("dark") ? "dark" : "light"; +function isValidTheme(value: string | null): value is Theme { + return value !== null && VALID_THEMES.includes(value as Theme); +} + +function readStoredTheme(): Theme { + if (typeof document === "undefined") return DEFAULT_THEME; + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY); + return isValidTheme(stored) ? stored : DEFAULT_THEME; + } catch { + return DEFAULT_THEME; + } } function applyTheme(theme: Theme) { if (typeof document === "undefined") return; - const isDark = theme === "dark"; + const meta = THEME_META[theme]; const root = document.documentElement; - root.classList.toggle("dark", isDark); - root.style.colorScheme = isDark ? "dark" : "light"; + root.classList.toggle("dark", meta.dark); + root.classList.toggle("theme-tokyo-night", theme === "tokyo-night"); + root.style.colorScheme = meta.dark ? "dark" : "light"; const themeColorMeta = document.querySelector('meta[name="theme-color"]'); if (themeColorMeta instanceof HTMLMetaElement) { - themeColorMeta.setAttribute("content", isDark ? DARK_THEME_COLOR : LIGHT_THEME_COLOR); + themeColorMeta.setAttribute("content", meta.bg); } } export function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setThemeState] = useState(() => resolveThemeFromDocument()); + const [theme, setThemeState] = useState(() => readStoredTheme()); const setTheme = useCallback((nextTheme: Theme) => { setThemeState(nextTheme); }, []); const toggleTheme = useCallback(() => { - setThemeState((current) => (current === "dark" ? "light" : "dark")); + setThemeState((current) => { + const idx = VALID_THEMES.indexOf(current); + return VALID_THEMES[(idx + 1) % VALID_THEMES.length]; + }); }, []); useEffect(() => { @@ -54,7 +74,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { try { localStorage.setItem(THEME_STORAGE_KEY, theme); } catch { - // Ignore local storage write failures in restricted environments. + // Ignore localStorage write failures in restricted environments. } }, [theme]);