feat(07-01): extend ThemeContext to support three named themes with THEME_META export

- Expand Theme type to catppuccin-mocha | tokyo-night | catppuccin-latte
- Export THEME_META with label, dark boolean, bg hex, primary hex per theme
- applyTheme toggles .dark and .theme-tokyo-night classes correctly
- toggleTheme cycles all three themes (Mocha -> Tokyo Night -> Latte -> Mocha)
- readStoredTheme falls back to catppuccin-mocha for old localStorage values
- Fix Layout.tsx: replace theme === 'dark' comparison with THEME_META[theme].dark
- Fix MarkdownBody.tsx: replace theme === 'dark' comparisons with THEME_META[theme].dark
This commit is contained in:
Mikkel Georgsen 2026-03-31 14:15:27 +02:00 committed by Nexus Dev
parent a24658647f
commit 44be367ada
3 changed files with 44 additions and 24 deletions

View file

@ -21,7 +21,7 @@ import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
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";
@ -69,7 +69,7 @@ export function Layout() {
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
const nextTheme = theme === "dark" ? "light" : "dark";
const isDarkTheme = THEME_META[theme].dark;
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
const requestedPrefix = companyPrefix.toUpperCase();
@ -339,10 +339,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" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{isDarkTheme ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
@ -397,10 +397,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" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{isDarkTheme ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>

View file

@ -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 <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
return <MermaidDiagramBlock source={mermaidSource} darkMode={THEME_META[theme].dark} />;
}
return <pre {...preProps}>{preChildren}</pre>;
},
@ -140,7 +140,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
<div
className={cn(
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
theme === "dark" && "prose-invert",
THEME_META[theme].dark && "prose-invert",
className,
)}
>

View file

@ -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<Theme, { label: string; dark: boolean; bg: string; primary: string }> = {
"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<ThemeContextValue | undefined>(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<Theme>(() => resolveThemeFromDocument());
const [theme, setThemeState] = useState<Theme>(() => 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]);