/** * Fluent Grow - Unified Theme Manager * ------------------------------------ * Combines ThemeManager + ColorManager into a single, powerful theme system * with dark mode, persistence, transitions, and expanded tokens. */ import { ColorManager, type ColorThemeDefinition } from '../colors'; import { applyTokens } from './token-manager'; export interface ThemeTokens { mode?: 'light' | 'dark'; colors?: Record; typography?: { fontFamily?: Record; fontSize?: Record; fontWeight?: Record; lineHeight?: Record; letterSpacing?: Record; }; spacing?: Record; borders?: { radius?: Record; width?: Record; }; shadows?: Record; animations?: { duration?: Record; easing?: Record; }; // Allow other framework tokens [key: string]: any; } export interface UnifiedThemeDefinition { name: string; tokens: ThemeTokens; colorPalettes?: ColorThemeDefinition['palettes']; inheritsFrom?: string; } export interface ThemeManagerOptions { defaultTheme?: string; detectSystemPreference?: boolean; persist?: boolean; persistKey?: string; transitions?: boolean; transitionDuration?: string; } export type UnifiedThemeChangeListener = (theme: UnifiedThemeDefinition) => void; const STORAGE_KEY = 'fluent-theme-preference'; export class UnifiedThemeManager { private themes = new Map(); private colorManager = new ColorManager(); private activeTheme?: UnifiedThemeDefinition; private listeners = new Set(); private options: Required; private mediaQuery?: MediaQueryList; constructor(options: ThemeManagerOptions = {}) { this.options = { defaultTheme: options.defaultTheme ?? 'fluent-light', detectSystemPreference: options.detectSystemPreference ?? true, persist: options.persist ?? true, persistKey: options.persistKey ?? STORAGE_KEY, transitions: options.transitions ?? true, transitionDuration: options.transitionDuration ?? '300ms' }; } /** * Initialize the theme system */ init(): void { // If no themes are registered, we don't have enough information to apply one. // However, we can at least try to apply a system default if themes are registered later. const persistedTheme = this.loadPersistedTheme(); const themeToApply = persistedTheme ?? this.options.defaultTheme; // Setup system preference detection if (this.options.detectSystemPreference && typeof window !== 'undefined') { this.setupSystemPreferenceDetection(); } // Apply initial theme if available if (this.themes.has(themeToApply)) { this.apply(themeToApply); } else { // If the default theme isn't registered yet, we might want to register // minimal defaults or just log a warning. For the best DX, we'll wait // for themes to be registered and then apply the preference. const stopListening = this.onChange(() => { if (this.themes.has(themeToApply) && !this.activeTheme) { this.apply(themeToApply); stopListening(); } }); } } /** * Register a theme */ register(theme: UnifiedThemeDefinition): this { this.themes.set(theme.name, theme); // Register color palettes if provided if (theme.colorPalettes) { const colorTheme: ColorThemeDefinition = { name: theme.name, palettes: theme.colorPalettes }; this.colorManager.registerTheme(colorTheme); } return this; } /** * Apply a theme */ apply(name: string, options: { transition?: boolean; persist?: boolean } = {}): void { const resolved = this.resolveTheme(name); if (!resolved) { throw new Error(`Theme "${name}" is not registered.`); } const shouldTransition = options.transition ?? this.options.transitions; const shouldPersist = options.persist ?? this.options.persist; if (typeof document === 'undefined') { return; } const root = document.documentElement; // Apply transition if (shouldTransition) { this.applyTransition(root, () => this.applyThemeToDOM(resolved, root)); } else { this.applyThemeToDOM(resolved, root); } // Persist preference if (shouldPersist) { this.persistTheme(name); } this.activeTheme = resolved; this.notifyListeners(resolved); } /** * Apply theme scoped to a specific element */ applyScoped(name: string, element: HTMLElement | string): void { const resolved = this.resolveTheme(name); if (!resolved) { throw new Error(`Theme "${name}" is not registered.`); } if (typeof document === 'undefined') { return; } const root: HTMLElement | null = typeof element === 'string' ? document.querySelector(element) : element; if (!root) { throw new Error('Theme scope element not found.'); } root.setAttribute('data-theme', name); this.applyThemeToDOM(resolved, root); } /** * Toggle between light and dark mode */ toggleDarkMode(): void { const currentMode = this.activeTheme?.tokens.mode ?? 'light'; const newMode = currentMode === 'light' ? 'dark' : 'light'; const themeName = `fluent-${newMode}`; if (this.themes.has(themeName)) { this.apply(themeName); } } /** * Get current theme mode */ getMode(): 'light' | 'dark' { return this.activeTheme?.tokens.mode ?? 'light'; } /** * Get active theme name */ getActiveTheme(): string | undefined { return this.activeTheme?.name; } /** * Listen to theme changes */ onChange(listener: UnifiedThemeChangeListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } /** * Create a custom theme */ createTheme(config: { name: string; extends?: string; mode?: 'light' | 'dark'; colors?: Record; typography?: ThemeTokens['typography']; spacing?: ThemeTokens['spacing']; borders?: ThemeTokens['borders']; shadows?: ThemeTokens['shadows']; animations?: ThemeTokens['animations']; }): UnifiedThemeDefinition { const baseTheme = config.extends ? this.themes.get(config.extends) : undefined; const theme: UnifiedThemeDefinition = { name: config.name, tokens: { mode: config.mode ?? baseTheme?.tokens.mode ?? 'light', colors: { ...baseTheme?.tokens.colors, ...config.colors }, typography: { ...baseTheme?.tokens.typography, ...config.typography }, spacing: { ...baseTheme?.tokens.spacing, ...config.spacing }, borders: { ...baseTheme?.tokens.borders, ...config.borders }, shadows: { ...baseTheme?.tokens.shadows, ...config.shadows }, animations: { ...baseTheme?.tokens.animations, ...config.animations } }, inheritsFrom: config.extends } as UnifiedThemeDefinition; this.register(theme); return theme; } // Private methods private applyThemeToDOM(theme: UnifiedThemeDefinition, root: HTMLElement): void { // Apply color palettes if (theme.colorPalettes) { this.colorManager.applyTheme(theme.name, root); } // Apply other tokens if (theme.tokens) { applyTokens(theme.tokens, root); } // Set data attribute root.setAttribute('data-theme', theme.name); root.setAttribute('data-theme-mode', theme.tokens.mode ?? 'light'); } private applyTransition(root: HTMLElement, callback: () => void): void { // Use View Transition API if available if ('startViewTransition' in document && (document as any).startViewTransition) { (document as any).startViewTransition(() => callback()); } else { // Fallback to CSS transition root.style.transition = `all ${this.options.transitionDuration} ease`; callback(); setTimeout(() => { root.style.removeProperty('transition'); }, parseInt(this.options.transitionDuration)); } } private resolveTheme(name: string, visited = new Set()): UnifiedThemeDefinition | undefined { if (visited.has(name)) { throw new Error(`Theme inheritance cycle detected for "${name}".`); } const theme = this.themes.get(name); if (!theme) { return undefined; } if (!theme.inheritsFrom) { return theme; } visited.add(name); const parent = this.resolveTheme(theme.inheritsFrom, visited); if (!parent) { throw new Error(`Theme "${name}" inherits from unknown theme "${theme.inheritsFrom}".`); } // Merge tokens return { name: theme.name, tokens: { ...parent.tokens, ...theme.tokens, colors: { ...parent.tokens.colors, ...theme.tokens.colors }, typography: { ...parent.tokens.typography, ...theme.tokens.typography }, spacing: { ...parent.tokens.spacing, ...theme.tokens.spacing }, borders: { ...parent.tokens.borders, ...theme.tokens.borders }, shadows: { ...parent.tokens.shadows, ...theme.tokens.shadows }, animations: { ...parent.tokens.animations, ...theme.tokens.animations } }, colorPalettes: theme.colorPalettes ?? parent.colorPalettes, inheritsFrom: parent.name } as UnifiedThemeDefinition; } private setupSystemPreferenceDetection(): void { this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const updateTheme = () => { // Only auto-switch if no persisted preference if (!this.loadPersistedTheme()) { const prefersDark = this.mediaQuery?.matches ?? false; const themeName = prefersDark ? 'fluent-dark' : 'fluent-light'; if (this.themes.has(themeName)) { this.apply(themeName, { persist: false }); } } }; this.mediaQuery.addEventListener('change', updateTheme); } private persistTheme(name: string): void { if (typeof localStorage === 'undefined') { return; } try { localStorage.setItem(this.options.persistKey, name); } catch (error) { console.warn('[UnifiedThemeManager] Failed to persist theme', error); } } private loadPersistedTheme(): string | null { if (typeof localStorage === 'undefined') { return null; } try { return localStorage.getItem(this.options.persistKey); } catch (error) { console.warn('[UnifiedThemeManager] Failed to load persisted theme', error); return null; } } private notifyListeners(theme: UnifiedThemeDefinition): void { this.listeners.forEach((listener) => { try { listener(theme); } catch (error) { console.error('[UnifiedThemeManager] Theme change listener failed', error); } }); } }