import type { ThemeColors, ThemeVariant, EffectiveMode, HSLValue, A11ySettings } from './types'; import { THEME_DEFINITIONS, CSS_VAR_PREFIX } from './constants'; import { applyA11yTransformations, applyA11yAttributes } from './a11y-utils'; /** * Check if code is running in browser */ export function isBrowser(): boolean { return typeof window !== 'undefined' && typeof document !== 'undefined'; } /** * Get system color scheme preference */ export function getSystemPreference(): EffectiveMode { if (!isBrowser()) return 'light'; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } /** * Create a media query listener for system preference changes */ export function createSystemPreferenceListener(callback: (isDark: boolean) => void): () => void { if (!isBrowser()) return () => {}; const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handler = (e: MediaQueryListEvent) => callback(e.matches); // Modern browsers if (mediaQuery.addEventListener) { mediaQuery.addEventListener('change', handler); return () => mediaQuery.removeEventListener('change', handler); } // Legacy browsers mediaQuery.addListener(handler); return () => mediaQuery.removeListener(handler); } /** * Get colors for a specific variant and mode */ export function getThemeColors( variant: ThemeVariant, mode: EffectiveMode, primaryOverride?: { light: HSLValue; dark: HSLValue } ): ThemeColors { const definition = THEME_DEFINITIONS[variant]; const colors = mode === 'dark' ? { ...definition.dark } : { ...definition.light }; // Apply app-specific primary color override if (primaryOverride) { const overrideColor = mode === 'dark' ? primaryOverride.dark : primaryOverride.light; colors.primary = overrideColor; colors.ring = overrideColor; } return colors; } /** * Convert ThemeColors to CSS variables object */ export function colorsToCssVars(colors: ThemeColors): Record { return { [`${CSS_VAR_PREFIX}-primary`]: colors.primary, [`${CSS_VAR_PREFIX}-primary-foreground`]: colors.primaryForeground, [`${CSS_VAR_PREFIX}-secondary`]: colors.secondary, [`${CSS_VAR_PREFIX}-secondary-foreground`]: colors.secondaryForeground, [`${CSS_VAR_PREFIX}-background`]: colors.background, [`${CSS_VAR_PREFIX}-foreground`]: colors.foreground, [`${CSS_VAR_PREFIX}-surface`]: colors.surface, [`${CSS_VAR_PREFIX}-surface-hover`]: colors.surfaceHover, [`${CSS_VAR_PREFIX}-surface-elevated`]: colors.surfaceElevated, [`${CSS_VAR_PREFIX}-muted`]: colors.muted, [`${CSS_VAR_PREFIX}-muted-foreground`]: colors.mutedForeground, [`${CSS_VAR_PREFIX}-border`]: colors.border, [`${CSS_VAR_PREFIX}-border-strong`]: colors.borderStrong, [`${CSS_VAR_PREFIX}-error`]: colors.error, [`${CSS_VAR_PREFIX}-success`]: colors.success, [`${CSS_VAR_PREFIX}-warning`]: colors.warning, [`${CSS_VAR_PREFIX}-input`]: colors.input, [`${CSS_VAR_PREFIX}-ring`]: colors.ring, }; } /** * Apply theme to document */ export function applyThemeToDocument( variant: ThemeVariant, effectiveMode: EffectiveMode, primaryOverride?: { light: HSLValue; dark: HSLValue }, a11ySettings?: A11ySettings ): void { if (!isBrowser()) return; const root = document.documentElement; let colors = getThemeColors(variant, effectiveMode, primaryOverride); // Apply A11y transformations if provided if (a11ySettings) { colors = applyA11yTransformations(colors, effectiveMode, a11ySettings); applyA11yAttributes(a11ySettings); } const cssVars = colorsToCssVars(colors); // Set CSS variables Object.entries(cssVars).forEach(([key, value]) => { root.style.setProperty(key, value); }); // Set per-theme paper-grain CSS variables (consumed by PageShell). // Dark mode auto-switches multiply → overlay because multiply on a // dark backdrop is practically invisible (dark × dark ≈ dark). Other // blend modes (soft-light/screen/…) are respected as-is. const paper = THEME_DEFINITIONS[variant]?.paper; if (paper) { const configuredBlend = paper.blendMode ?? 'multiply'; const effectiveBlend = effectiveMode === 'dark' && configuredBlend === 'multiply' ? 'overlay' : configuredBlend; root.style.setProperty('--paper-texture', `url("${paper.url}")`); root.style.setProperty('--paper-blend-mode', effectiveBlend); root.style.setProperty('--paper-size', paper.size ?? '240px 240px'); } else { root.style.removeProperty('--paper-texture'); root.style.removeProperty('--paper-blend-mode'); root.style.removeProperty('--paper-size'); } // Set data-theme attribute root.setAttribute('data-theme', variant); // Set dark class if (effectiveMode === 'dark') { root.classList.add('dark'); } else { root.classList.remove('dark'); } // Update color-scheme for native elements root.style.colorScheme = effectiveMode; } /** * Load theme from localStorage */ export function loadThemeFromStorage( storageKey: string ): { mode?: string; variant?: string } | null { if (!isBrowser()) return null; try { const stored = localStorage.getItem(storageKey); if (stored) { return JSON.parse(stored); } } catch (e) { console.warn('Failed to load theme from storage:', e); } return null; } /** * Save theme to localStorage */ export function saveThemeToStorage(storageKey: string, mode: string, variant: string): void { if (!isBrowser()) return; try { localStorage.setItem(storageKey, JSON.stringify({ mode, variant })); } catch (e) { console.warn('Failed to save theme to storage:', e); } } /** * Parse HSL string to components * Input: "47 95% 58%" -> { h: 47, s: 95, l: 58 } */ export function parseHSL(hsl: HSLValue): { h: number; s: number; l: number } { const parts = hsl.split(' '); return { h: parseFloat(parts[0]), s: parseFloat(parts[1]), l: parseFloat(parts[2]), }; } /** * Create HSL string from components */ export function createHSL(h: number, s: number, l: number): HSLValue { return `${h} ${s}% ${l}%`; } /** * Adjust lightness of an HSL color */ export function adjustLightness(hsl: HSLValue, amount: number): HSLValue { const { h, s, l } = parseHSL(hsl); const newL = Math.max(0, Math.min(100, l + amount)); return createHSL(h, s, newL); } /** * Adjust saturation of an HSL color */ export function adjustSaturation(hsl: HSLValue, amount: number): HSLValue { const { h, s, l } = parseHSL(hsl); const newS = Math.max(0, Math.min(100, s + amount)); return createHSL(h, newS, l); } /** * Get contrasting text color (black or white) for a background */ export function getContrastColor(backgroundHSL: HSLValue): HSLValue { const { l } = parseHSL(backgroundHSL); // Use white text for dark backgrounds, black for light return l > 55 ? '0 0% 0%' : '0 0% 100%'; } /** * Generate CSS string for all theme variants * Useful for generating static CSS files */ export function generateThemeCSS( primaryOverrides?: Record ): string { let css = ''; // Root (default Lume light) const defaultColors = getThemeColors('lume', 'light', primaryOverrides?.['lume']); const defaultVars = colorsToCssVars(defaultColors); css += ':root {\n'; Object.entries(defaultVars).forEach(([key, value]) => { css += ` ${key}: ${value};\n`; }); css += '}\n\n'; // Each variant for (const [variantName] of Object.entries(THEME_DEFINITIONS)) { const variant = variantName as ThemeVariant; const override = primaryOverrides?.[variant]; // Light mode const lightColors = getThemeColors(variant, 'light', override); const lightVars = colorsToCssVars(lightColors); css += `[data-theme="${variant}"] {\n`; Object.entries(lightVars).forEach(([key, value]) => { css += ` ${key}: ${value};\n`; }); css += '}\n\n'; // Dark mode const darkColors = getThemeColors(variant, 'dark', override); const darkVars = colorsToCssVars(darkColors); css += `[data-theme="${variant}"].dark,\n.dark[data-theme="${variant}"] {\n`; Object.entries(darkVars).forEach(([key, value]) => { css += ` ${key}: ${value};\n`; }); css += '}\n\n'; } return css; }