Files
CodeNomad/packages/ui/src/lib/theme.tsx
Shantur Rathore d98d519fd3 feat(ui): persist theme preference
Persist system/light/dark theme mode in app config and default new installs to system so the UI follows OS theme unless overridden.
2026-02-03 19:42:24 +00:00

225 lines
6.4 KiB
TypeScript

import { createContext, createEffect, createMemo, createSignal, onMount, useContext, type JSX } from "solid-js"
import { createTheme, ThemeProvider as MuiThemeProvider } from "@suid/material/styles"
import CssBaseline from "@suid/material/CssBaseline"
import { useConfig } from "../stores/preferences"
export type ThemeMode = "system" | "light" | "dark"
interface ThemeContextValue {
isDark: () => boolean
themeMode: () => ThemeMode
setThemeMode: (mode: ThemeMode) => void
cycleThemeMode: () => void
}
const ThemeContext = createContext<ThemeContextValue>()
function applyThemeMode(mode: ThemeMode) {
if (typeof document === "undefined") return
if (mode === "system") {
document.documentElement.removeAttribute("data-theme")
return
}
document.documentElement.setAttribute("data-theme", mode)
}
interface ResolvedPaletteColors {
backgroundDefault: string
backgroundPaper: string
primary: string
primaryContrast: string
textPrimary: string
textSecondary: string
divider: string
}
const lightPaletteFallbacks: ResolvedPaletteColors = {
backgroundDefault: "#ffffff",
backgroundPaper: "#f5f5f5",
primary: "#0066ff",
primaryContrast: "#ffffff",
textPrimary: "#1a1a1a",
textSecondary: "#666666",
divider: "#e0e0e0",
}
const darkPaletteFallbacks: ResolvedPaletteColors = {
backgroundDefault: "#1a1a1a",
backgroundPaper: "#2a2a2a",
primary: "#0080ff",
primaryContrast: "#1a1a1a",
textPrimary: "#cfd4dc",
textSecondary: "#999999",
divider: "#3a3a3a",
}
const readCssVar = (token: string, fallback: string, rootStyle: CSSStyleDeclaration | null) => {
if (!rootStyle) return fallback
const value = rootStyle.getPropertyValue(token)
if (!value) return fallback
const trimmed = value.trim()
return trimmed || fallback
}
const resolvePaletteColors = (dark: boolean): ResolvedPaletteColors => {
const fallbackSet = dark ? darkPaletteFallbacks : lightPaletteFallbacks
const rootStyle = typeof window !== "undefined" ? getComputedStyle(document.documentElement) : null
return {
backgroundDefault: readCssVar("--surface-base", fallbackSet.backgroundDefault, rootStyle),
backgroundPaper: readCssVar("--surface-secondary", fallbackSet.backgroundPaper, rootStyle),
primary: readCssVar("--accent-primary", fallbackSet.primary, rootStyle),
primaryContrast: readCssVar("--text-inverted", fallbackSet.primaryContrast, rootStyle),
textPrimary: readCssVar("--text-primary", fallbackSet.textPrimary, rootStyle),
textSecondary: readCssVar("--text-secondary", fallbackSet.textSecondary, rootStyle),
divider: readCssVar("--border-base", fallbackSet.divider, rootStyle),
}
}
export function ThemeProvider(props: { children: JSX.Element }) {
const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null
const { themePreference, setThemePreference } = useConfig()
const [isDark, setIsDarkSignal] = createSignal(true)
const [themeRevision, setThemeRevision] = createSignal(0)
const themeMode = () => themePreference() as ThemeMode
const resolveDarkTheme = () => {
const mode = themeMode()
if (mode === "dark") return true
if (mode === "light") return false
return mediaQuery?.matches ?? false
}
const applyResolvedTheme = () => {
const mode = themeMode()
const dark = resolveDarkTheme()
if (mode === "system") {
applyThemeMode("system")
} else {
applyThemeMode(mode)
}
setIsDarkSignal(dark)
if (typeof window !== "undefined") {
requestAnimationFrame(() => setThemeRevision((v) => v + 1))
} else {
setThemeRevision((v) => v + 1)
}
}
createEffect(() => {
applyResolvedTheme()
})
onMount(() => {
if (!mediaQuery) return
const handleSystemThemeChange = () => {
applyResolvedTheme()
}
mediaQuery.addEventListener("change", handleSystemThemeChange)
return () => {
mediaQuery.removeEventListener("change", handleSystemThemeChange)
}
})
const setThemeMode = (mode: ThemeMode) => {
setThemePreference(mode)
}
const cycleThemeMode = () => {
const current = themeMode()
const next: ThemeMode = current === "system" ? "light" : current === "light" ? "dark" : "system"
setThemeMode(next)
}
const muiTheme = createMemo(() => {
themeRevision()
const paletteColors = resolvePaletteColors(isDark())
return createTheme({
palette: {
mode: isDark() ? "dark" : "light",
primary: {
main: paletteColors.primary,
contrastText: paletteColors.primaryContrast,
},
secondary: {
main: paletteColors.primary,
},
background: {
default: paletteColors.backgroundDefault,
paper: paletteColors.backgroundPaper,
},
text: {
primary: paletteColors.textPrimary,
secondary: paletteColors.textSecondary,
},
divider: paletteColors.divider,
},
typography: {
fontFamily: "var(--font-family-sans)",
},
shape: {
borderRadius: 8,
},
components: {
MuiIconButton: {
styleOverrides: {
root: {
color: "inherit",
"&.Mui-disabled": {
color: "var(--text-muted)",
opacity: 0.55,
},
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
backgroundColor: "var(--surface-secondary)",
color: "var(--text-primary)",
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: "var(--surface-secondary)",
color: "var(--text-primary)",
boxShadow: "none",
borderBottom: "1px solid var(--border-base)",
zIndex: 10,
},
},
},
MuiToolbar: {
styleOverrides: {
root: {
minHeight: "56px",
},
},
},
} as any,
})
})
return (
<ThemeContext.Provider value={{ isDark, themeMode, setThemeMode, cycleThemeMode }}>
<MuiThemeProvider theme={muiTheme()}>
<CssBaseline />
{props.children}
</MuiThemeProvider>
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within ThemeProvider")
}
return context
}