feat(ui): add system/light/dark theme toggle
Add a 3-state theme toggle in folder selection and instance tabs, and update tokens/styles so light mode has readable contrast. Sync MUI surfaces and Shiki highlighting to CSS variables to prevent stale colors when switching themes.
This commit is contained in:
@@ -3,22 +3,24 @@ import { createTheme, ThemeProvider as MuiThemeProvider } from "@suid/material/s
|
||||
import CssBaseline from "@suid/material/CssBaseline"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
export type ThemeMode = "system" | "light" | "dark"
|
||||
|
||||
interface ThemeContextValue {
|
||||
isDark: () => boolean
|
||||
toggleTheme: () => void
|
||||
setTheme: (dark: boolean) => void
|
||||
themeMode: () => ThemeMode
|
||||
setThemeMode: (mode: ThemeMode) => void
|
||||
cycleThemeMode: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>()
|
||||
|
||||
function applyTheme(dark: boolean) {
|
||||
function applyThemeMode(mode: ThemeMode) {
|
||||
if (typeof document === "undefined") return
|
||||
if (dark) {
|
||||
document.documentElement.setAttribute("data-theme", "dark")
|
||||
if (mode === "system") {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
document.documentElement.setAttribute("data-theme", mode)
|
||||
}
|
||||
|
||||
interface ResolvedPaletteColors {
|
||||
@@ -76,24 +78,45 @@ const resolvePaletteColors = (dark: boolean): ResolvedPaletteColors => {
|
||||
|
||||
export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null
|
||||
const { themePreference, setThemePreference } = useConfig()
|
||||
const { themePreference } = useConfig()
|
||||
const [isDark, setIsDarkSignal] = createSignal(true)
|
||||
const [themeMode, setThemeModeSignal] = createSignal<ThemeMode>(themePreference())
|
||||
const [hasUserOverride, setHasUserOverride] = createSignal(false)
|
||||
const [themeRevision, setThemeRevision] = createSignal(0)
|
||||
|
||||
const resolveDarkTheme = () => {
|
||||
themePreference()
|
||||
return true
|
||||
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)
|
||||
applyTheme(dark)
|
||||
if (typeof window !== "undefined") {
|
||||
requestAnimationFrame(() => setThemeRevision((v) => v + 1))
|
||||
} else {
|
||||
setThemeRevision((v) => v + 1)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
applyResolvedTheme()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const preference = themePreference()
|
||||
if (hasUserOverride()) return
|
||||
setThemeModeSignal(preference)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!mediaQuery) return
|
||||
const handleSystemThemeChange = () => {
|
||||
@@ -107,15 +130,21 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
}
|
||||
})
|
||||
|
||||
const setTheme = (_dark: boolean) => {
|
||||
setThemePreference("dark")
|
||||
const setThemeMode = (mode: ThemeMode) => {
|
||||
setHasUserOverride(true)
|
||||
setThemeModeSignal(mode)
|
||||
// Persistence is intentionally implemented later.
|
||||
// When we wire it up, this should call `setThemePreference(mode)`.
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(true)
|
||||
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: {
|
||||
@@ -144,21 +173,32 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
borderRadius: 8,
|
||||
},
|
||||
components: {
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: "inherit",
|
||||
"&.Mui-disabled": {
|
||||
color: "var(--text-muted)",
|
||||
opacity: 0.55,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: paletteColors.backgroundPaper,
|
||||
color: paletteColors.textPrimary,
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: paletteColors.backgroundPaper,
|
||||
color: paletteColors.textPrimary,
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
color: "var(--text-primary)",
|
||||
boxShadow: "none",
|
||||
borderBottom: `1px solid ${paletteColors.divider}`,
|
||||
borderBottom: "1px solid var(--border-base)",
|
||||
zIndex: 10,
|
||||
},
|
||||
},
|
||||
@@ -175,7 +215,7 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
})
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>
|
||||
<ThemeContext.Provider value={{ isDark, themeMode, setThemeMode, cycleThemeMode }}>
|
||||
<MuiThemeProvider theme={muiTheme()}>
|
||||
<CssBaseline />
|
||||
{props.children}
|
||||
|
||||
Reference in New Issue
Block a user