feat(theme): extend ThemeProvider with contrast, density, accent, fontSize

This commit is contained in:
senke 2026-02-25 09:52:32 +01:00
parent e32ff181f5
commit 50482a01fd
3 changed files with 132 additions and 26 deletions

View file

@ -1,6 +1,16 @@
import { createContext, useContext, useEffect, useState } from 'react';
export type Theme = 'dark' | 'light' | 'system';
export type Contrast = 'normal' | 'high';
export type Density = 'comfortable' | 'compact' | 'cozy';
const STORAGE_KEYS = {
theme: 'vite-ui-theme',
contrast: 'veza-contrast',
density: 'veza-density',
accentHue: 'veza-accent-hue',
fontSize: 'veza-font-size',
} as const;
type ThemeProviderProps = {
children: React.ReactNode;
@ -11,76 +21,150 @@ type ThemeProviderProps = {
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
contrast: Contrast;
setContrast: (contrast: Contrast) => void;
density: Density;
setDensity: (density: Density) => void;
accentHue: number;
setAccentHue: (hue: number) => void;
fontSize: number;
setFontSize: (size: number) => void;
};
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
contrast: 'normal',
setContrast: () => null,
density: 'comfortable',
setDensity: () => null,
accentHue: 220,
setAccentHue: () => null,
fontSize: 16,
setFontSize: () => null,
};
const ThemeContext = createContext<ThemeProviderState>(initialState);
function loadStored<T>(key: string, defaultValue: T, parse: (s: string) => T): T {
try {
const stored = localStorage.getItem(key);
if (stored != null) return parse(stored);
} catch {
/* ignore */
}
return defaultValue;
}
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'vite-ui-theme',
storageKey = STORAGE_KEYS.theme,
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
const [theme, setThemeState] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
const [contrast, setContrastState] = useState<Contrast>(
() => loadStored(STORAGE_KEYS.contrast, 'normal', (s) => (s === 'high' ? 'high' : 'normal'))
);
const [density, setDensityState] = useState<Density>(
() =>
loadStored(STORAGE_KEYS.density, 'comfortable', (s) =>
s === 'compact' || s === 'cozy' ? s : 'comfortable'
)
);
const [accentHue, setAccentHueState] = useState<number>(
() => loadStored(STORAGE_KEYS.accentHue, 220, (s) => Math.min(360, Math.max(0, parseInt(s, 10) || 220)))
);
const [fontSize, setFontSizeState] = useState<number>(
() => loadStored(STORAGE_KEYS.fontSize, 16, (s) => Math.min(20, Math.max(14, parseInt(s, 10) || 16)))
);
useEffect(() => {
const root = window.document.documentElement;
// SUMI: Use data-theme attribute instead of class toggle
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
root.setAttribute('data-theme', systemTheme);
return;
}
root.setAttribute('data-theme', theme);
}, [theme]);
// Listen for system changes when theme is 'system'
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
const root = window.document.documentElement;
root.setAttribute('data-theme', mediaQuery.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
const value = {
useEffect(() => {
const root = window.document.documentElement;
root.setAttribute('data-contrast', contrast);
}, [contrast]);
useEffect(() => {
const root = window.document.documentElement;
root.setAttribute('data-density', density);
}, [density]);
useEffect(() => {
const root = window.document.documentElement;
root.style.setProperty('--sumi-accent', `hsl(${accentHue}, 55%, 55%)`);
root.style.setProperty('--sumi-accent-hover', `hsl(${accentHue}, 55%, 62%)`);
root.style.setProperty('--sumi-accent-active', `hsl(${accentHue}, 55%, 48%)`);
root.style.setProperty('--sumi-accent-muted', `hsl(${accentHue}, 55%, 55%, 0.2)`);
root.style.setProperty('--sumi-accent-subtle', `hsl(${accentHue}, 55%, 55%, 0.12)`);
root.style.setProperty('--sumi-accent-emphasis', `hsl(${accentHue}, 55%, 45%)`);
root.style.setProperty('--primary', `hsl(${accentHue}, 55%, 55%)`);
root.style.setProperty('--sumi-border-focus', `hsl(${accentHue}, 55%, 55%, 0.5)`);
root.style.setProperty('--sumi-border-accent', `hsl(${accentHue}, 55%, 55%, 0.3)`);
}, [accentHue]);
useEffect(() => {
const root = window.document.documentElement;
root.style.setProperty('--sumi-font-size-base', `${fontSize}px`);
}, [fontSize]);
const value: ThemeProviderState = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
setTheme: (t) => {
localStorage.setItem(storageKey, t);
setThemeState(t);
},
contrast,
setContrast: (c) => {
localStorage.setItem(STORAGE_KEYS.contrast, c);
setContrastState(c);
},
density,
setDensity: (d) => {
localStorage.setItem(STORAGE_KEYS.density, d);
setDensityState(d);
},
accentHue,
setAccentHue: (h) => {
const clamped = Math.min(360, Math.max(0, h));
localStorage.setItem(STORAGE_KEYS.accentHue, String(clamped));
setAccentHueState(clamped);
},
fontSize,
setFontSize: (s) => {
const clamped = Math.min(20, Math.max(14, s));
localStorage.setItem(STORAGE_KEYS.fontSize, String(clamped));
setFontSizeState(clamped);
},
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider');
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
return context;
};

View file

@ -25,6 +25,7 @@ export { useCopyToClipboard } from './useCopyToClipboard';
export { useUnsavedChanges, useFormDirtyState } from './useUnsavedChanges';
export { useAnimatedCounter } from './useAnimatedCounter';
export { useSidebarNavigation } from './useSidebarNavigation';
export { useReducedMotion } from './useReducedMotion';
// Hook types
export type {

View file

@ -0,0 +1,21 @@
/**
* Hook for prefers-reduced-motion media query
* Returns true when the user prefers reduced motion (OS/browser setting)
*/
import { useState, useEffect } from 'react';
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
});
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return reduced;
}