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:
Shantur Rathore
2026-02-03 16:49:42 +00:00
parent a2127a11ac
commit 17a3e43ac7
18 changed files with 288 additions and 117 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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}