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:
@@ -29,4 +29,10 @@ export const appMessages = {
|
||||
|
||||
"releases.uiUpdated.title": "UI updated",
|
||||
"releases.uiUpdated.message": "UI is now updated to {version}.",
|
||||
|
||||
"theme.mode.system": "System",
|
||||
"theme.mode.light": "Light",
|
||||
"theme.mode.dark": "Dark",
|
||||
"theme.toggle.title": "Theme: {mode}",
|
||||
"theme.toggle.ariaLabel": "Theme: {mode}",
|
||||
} as const
|
||||
|
||||
@@ -91,7 +91,7 @@ async function getOrCreateHighlighter() {
|
||||
|
||||
// Create highlighter with no preloaded languages
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ["github-light", "github-dark"],
|
||||
themes: ["github-light", "github-light-high-contrast", "github-dark"],
|
||||
langs: [],
|
||||
})
|
||||
|
||||
@@ -242,9 +242,9 @@ async function runLanguageLoadQueue() {
|
||||
}
|
||||
|
||||
function setupRenderer(isDark: boolean) {
|
||||
if (!highlighter || rendererSetup) return
|
||||
|
||||
currentTheme = isDark ? "dark" : "light"
|
||||
if (!highlighter) return
|
||||
if (rendererSetup) return
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
@@ -296,10 +296,10 @@ function setupRenderer(isDark: boolean) {
|
||||
// Use highlighting if language is loaded, otherwise fall back to plain code
|
||||
if (loadedLanguages.has(langKey)) {
|
||||
try {
|
||||
const html = highlighter!.codeToHtml(decodedCode, {
|
||||
lang: langKey,
|
||||
theme: currentTheme === "dark" ? "github-dark" : "github-light",
|
||||
})
|
||||
const html = highlighter!.codeToHtml(decodedCode, {
|
||||
lang: langKey,
|
||||
theme: currentTheme === "dark" ? "github-dark" : "github-light-high-contrast",
|
||||
})
|
||||
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}${html}</div>`
|
||||
} catch {
|
||||
// Fall through to plain code if highlighting fails
|
||||
|
||||
@@ -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