feat: add System theme option that follows OS color scheme

Adds a 'System' option to the theme switcher that automatically uses
light or dark theme based on the OS prefers-color-scheme setting.
Dynamically updates when the OS preference changes (e.g. scheduled
dark mode). i18n labels added for EN/FR.
This commit is contained in:
Nicolas Varrot
2026-02-13 04:12:54 +00:00
parent aa37d7b313
commit 2157d7ebd5
5 changed files with 28 additions and 6 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ dist/
.env
.env.local
*.local
meta.json

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { Palette, Sun, Moon, Monitor, Check } from 'lucide-react';
import { Palette, Sun, Moon, Monitor, Laptop, Check } from 'lucide-react';
import { useTheme } from '../hooks/useTheme';
import type { ThemeName, AccentColor } from '../contexts/ThemeContextDef';
import { useT } from '../hooks/useLocale';
@@ -7,6 +7,7 @@ import { useT } from '../hooks/useLocale';
import type { TranslationKey } from '../lib/i18n';
const themeOptions: { value: ThemeName; icon: typeof Sun; labelKey: TranslationKey }[] = [
{ value: 'system', icon: Laptop, labelKey: 'theme.system' },
{ value: 'dark', icon: Moon, labelKey: 'theme.dark' },
{ value: 'light', icon: Sun, labelKey: 'theme.light' },
{ value: 'oled', icon: Monitor, labelKey: 'theme.oled' },

View File

@@ -10,7 +10,8 @@ interface StoredTheme {
accent: AccentColor;
}
const themes: Record<ThemeName, Record<string, string>> = {
type ConcreteTheme = 'dark' | 'light' | 'oled';
const themes: Record<ConcreteTheme, Record<string, string>> = {
dark: {
'--pc-bg-base': '#1e1e24',
'--pc-bg-surface': '#232329',
@@ -131,12 +132,20 @@ function applyVars(vars: Record<string, string>) {
}
}
/** Resolve 'system' to the actual theme based on OS preference. */
function resolveTheme(name: ThemeName): 'dark' | 'light' | 'oled' {
if (name === 'system') {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
return name;
}
function loadStored(): StoredTheme {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed.theme in themes && parsed.accent in accents) return parsed;
if ((parsed.theme in themes || parsed.theme === 'system') && parsed.accent in accents) return parsed;
}
} catch { /* ignore invalid stored JSON */ }
return { theme: 'dark', accent: 'cyan' };
@@ -153,7 +162,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const setTheme = useCallback((t: ThemeName) => {
setThemeState(t);
applyVars(themes[t]);
applyVars(themes[resolveTheme(t)]);
persist(t, accent);
}, [accent, persist]);
@@ -165,9 +174,18 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// Apply on mount
useEffect(() => {
applyVars({ ...themes[theme], ...accents[accent] });
applyVars({ ...themes[resolveTheme(theme)], ...accents[accent] });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Listen to OS color scheme changes when theme is 'system'
useEffect(() => {
if (theme !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: light)');
const handler = () => applyVars(themes[mq.matches ? 'light' : 'dark']);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, accent, setTheme, setAccent }}>
{children}

View File

@@ -1,6 +1,6 @@
import { createContext } from 'react';
export type ThemeName = 'dark' | 'light' | 'oled';
export type ThemeName = 'dark' | 'light' | 'oled' | 'system';
export type AccentColor = 'cyan' | 'violet' | 'emerald' | 'amber' | 'rose' | 'blue';
export interface ThemeContextValue {

View File

@@ -113,6 +113,7 @@ const en = {
'theme.title': 'Theme',
'theme.mode': 'Mode',
'theme.accent': 'Accent',
'theme.system': 'System',
'theme.dark': 'Dark',
'theme.light': 'Light',
'theme.oled': 'OLED',
@@ -218,6 +219,7 @@ const fr: Record<keyof typeof en, string> = {
'theme.title': 'Thème',
'theme.mode': 'Mode',
'theme.accent': 'Accent',
'theme.system': 'Système',
'theme.dark': 'Sombre',
'theme.light': 'Clair',
'theme.oled': 'OLED',