/** * DSS Theme Loader Service * * Manages the loading and hot-reloading of DSS CSS layers. * Handles the "Bootstrapping Paradox" by providing fallback mechanisms * when token files are missing or corrupted. * * CSS Layer Order: * 1. dss-core.css (structural) - REQUIRED, always loaded first * 2. dss-tokens.css (design tokens) - Can be regenerated from Figma * 3. dss-theme.css (semantic mapping) - Maps tokens to purposes * 4. dss-components.css (styled components) - Uses semantic tokens */ import logger from './logger.js'; import { notifySuccess, notifyError, notifyInfo, ErrorCode } from './messaging.js'; // CSS Layer definitions with fallback behavior const CSS_LAYERS = [ { id: 'dss-core', path: '/admin-ui/css/dss-core.css', name: 'Core/Structural', required: true, fallback: null // No fallback - this is the baseline }, { id: 'dss-tokens', path: '/admin-ui/css/dss-tokens.css', name: 'Design Tokens', required: false, fallback: '/admin-ui/css/dss-tokens-fallback.css' }, { id: 'dss-theme', path: '/admin-ui/css/dss-theme.css', name: 'Semantic Theme', required: false, fallback: null }, { id: 'dss-components', path: '/admin-ui/css/dss-components.css', name: 'Component Styles', required: false, fallback: null } ]; class ThemeLoaderService { constructor() { this.layers = new Map(); this.isInitialized = false; this.healthCheckInterval = null; this.listeners = new Set(); } /** * Initialize the theme loader * Validates all CSS layers are loaded and functional */ async init() { logger.info('ThemeLoader', 'Initializing DSS Theme Loader...'); try { // Perform health check on all layers const healthStatus = await this.healthCheck(); if (healthStatus.allHealthy) { logger.info('ThemeLoader', 'All CSS layers loaded successfully'); this.isInitialized = true; notifySuccess('Design system styles loaded successfully'); } else { logger.warn('ThemeLoader', 'Some CSS layers failed to load', { failed: healthStatus.failed }); notifyInfo(`Design system loaded with ${healthStatus.failed.length} layer(s) using fallbacks`); } // Start periodic health checks (every 30 seconds) this.startHealthCheckInterval(); return healthStatus; } catch (error) { logger.error('ThemeLoader', 'Failed to initialize theme loader', { error: error.message }); notifyError('Failed to load design system styles', ErrorCode.SYSTEM_STARTUP_FAILED); throw error; } } /** * Check health of all CSS layers * Returns status of each layer and overall health */ async healthCheck() { const results = { allHealthy: true, layers: [], failed: [], timestamp: new Date().toISOString() }; for (const layer of CSS_LAYERS) { const linkElement = document.querySelector(`link[href*="${layer.id}"]`); const status = { id: layer.id, name: layer.name, loaded: false, path: layer.path, error: null }; if (linkElement) { // Check if stylesheet is loaded and accessible try { const response = await fetch(layer.path, { method: 'HEAD' }); status.loaded = response.ok; if (!response.ok) { status.error = `HTTP ${response.status}`; } } catch (error) { status.error = error.message; } } else { status.error = 'Link element not found in DOM'; } if (!status.loaded && layer.required) { results.allHealthy = false; results.failed.push(layer.id); } else if (!status.loaded && !layer.required && layer.fallback) { // Try to load fallback await this.loadFallback(layer); } results.layers.push(status); this.layers.set(layer.id, status); } // Notify listeners of health check results this.notifyListeners('healthCheck', results); return results; } /** * Load a fallback CSS file for a failed layer */ async loadFallback(layer) { if (!layer.fallback) return false; try { const response = await fetch(layer.fallback, { method: 'HEAD' }); if (response.ok) { const linkElement = document.querySelector(`link[href*="${layer.id}"]`); if (linkElement) { linkElement.href = layer.fallback; logger.info('ThemeLoader', `Loaded fallback for ${layer.name}`, { fallback: layer.fallback }); return true; } } } catch (error) { logger.warn('ThemeLoader', `Failed to load fallback for ${layer.name}`, { error: error.message }); } return false; } /** * Reload a specific CSS layer * Used when tokens are regenerated from Figma */ async reloadLayer(layerId) { const layer = CSS_LAYERS.find(l => l.id === layerId); if (!layer) { logger.warn('ThemeLoader', `Unknown layer: ${layerId}`); return false; } logger.info('ThemeLoader', `Reloading layer: ${layer.name}`); try { const linkElement = document.querySelector(`link[href*="${layer.id}"]`); if (!linkElement) { logger.error('ThemeLoader', `Link element not found for ${layer.id}`); return false; } // Force reload by adding cache-busting timestamp const timestamp = Date.now(); const newHref = `${layer.path}?t=${timestamp}`; // Create a promise that resolves when the new stylesheet loads return new Promise((resolve, reject) => { const tempLink = document.createElement('link'); tempLink.rel = 'stylesheet'; tempLink.href = newHref; tempLink.onload = () => { // Replace old link with new one linkElement.href = newHref; tempLink.remove(); logger.info('ThemeLoader', `Successfully reloaded ${layer.name}`); this.notifyListeners('layerReloaded', { layerId, timestamp }); resolve(true); }; tempLink.onerror = () => { tempLink.remove(); logger.error('ThemeLoader', `Failed to reload ${layer.name}`); reject(new Error(`Failed to reload ${layer.name}`)); }; // Add temp link to head to trigger load document.head.appendChild(tempLink); // Timeout after 5 seconds setTimeout(() => { if (document.head.contains(tempLink)) { tempLink.remove(); reject(new Error(`Timeout loading ${layer.name}`)); } }, 5000); }); } catch (error) { logger.error('ThemeLoader', `Error reloading ${layer.name}`, { error: error.message }); return false; } } /** * Reload all CSS layers (hot reload) */ async reloadAllLayers() { logger.info('ThemeLoader', 'Reloading all CSS layers...'); notifyInfo('Reloading design system styles...'); const results = []; for (const layer of CSS_LAYERS) { const success = await this.reloadLayer(layer.id); results.push({ id: layer.id, success }); } const failed = results.filter(r => !r.success); if (failed.length === 0) { notifySuccess('All styles reloaded successfully'); } else { notifyError(`Failed to reload ${failed.length} layer(s)`); } return results; } /** * Reload only the tokens layer * Used after Figma sync or token generation */ async reloadTokens() { logger.info('ThemeLoader', 'Reloading design tokens...'); notifyInfo('Applying new design tokens...'); try { await this.reloadLayer('dss-tokens'); // Also reload theme since it depends on tokens await this.reloadLayer('dss-theme'); notifySuccess('Design tokens applied successfully'); return true; } catch (error) { notifyError('Failed to apply design tokens'); return false; } } /** * Start periodic health check interval */ startHealthCheckInterval(intervalMs = 30000) { if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); } this.healthCheckInterval = setInterval(async () => { const status = await this.healthCheck(); if (!status.allHealthy) { logger.warn('ThemeLoader', 'Health check detected issues', { failed: status.failed }); } }, intervalMs); } /** * Stop periodic health checks */ stopHealthCheckInterval() { if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); this.healthCheckInterval = null; } } /** * Get current theme status */ getStatus() { return { initialized: this.isInitialized, layers: Array.from(this.layers.values()), layerCount: CSS_LAYERS.length }; } /** * Subscribe to theme loader events */ subscribe(callback) { this.listeners.add(callback); return () => this.listeners.delete(callback); } /** * Notify all listeners of an event */ notifyListeners(event, data) { this.listeners.forEach(callback => { try { callback(event, data); } catch (error) { logger.error('ThemeLoader', 'Listener error', { error: error.message }); } }); } /** * Generate CSS token file from extracted tokens * This is called after Figma sync to create dss-tokens.css */ async generateTokensFile(tokens) { logger.info('ThemeLoader', 'Generating tokens CSS file...'); // Convert tokens object to CSS custom properties const cssContent = this.tokensToCSS(tokens); // Send to backend to save file try { const response = await fetch('/api/dss/save-tokens', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: cssContent, path: '/admin-ui/css/dss-tokens.css' }) }); if (response.ok) { logger.info('ThemeLoader', 'Tokens file generated successfully'); await this.reloadTokens(); return true; } else { throw new Error(`Server returned ${response.status}`); } } catch (error) { logger.error('ThemeLoader', 'Failed to generate tokens file', { error: error.message }); notifyError('Failed to save design tokens'); return false; } } /** * Convert tokens object to CSS custom properties */ tokensToCSS(tokens) { let css = `/** * DSS Design Tokens - Generated ${new Date().toISOString()} * Source: Figma extraction */ :root { `; // Recursively flatten tokens and create CSS variables const flattenTokens = (obj, prefix = '--ds') => { let result = ''; for (const [key, value] of Object.entries(obj)) { const varName = `${prefix}-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`; if (typeof value === 'object' && value !== null && !value.$value) { result += flattenTokens(value, varName); } else { const cssValue = value.$value || value; result += ` ${varName}: ${cssValue};\n`; } } return result; }; css += flattenTokens(tokens); css += '}\n'; return css; } /** * Export current tokens as JSON */ async exportTokens() { const computedStyle = getComputedStyle(document.documentElement); const tokens = {}; // Extract all --ds-* variables const styleSheets = document.styleSheets; for (const sheet of styleSheets) { try { for (const rule of sheet.cssRules) { if (rule.selectorText === ':root') { const text = rule.cssText; const matches = text.matchAll(/--ds-([^:]+):\s*([^;]+);/g); for (const match of matches) { const [, name, value] = match; tokens[name] = value.trim(); } } } } catch (e) { // CORS restrictions on external stylesheets } } return tokens; } } // Export singleton instance const themeLoader = new ThemeLoaderService(); export default themeLoader; export { ThemeLoaderService, CSS_LAYERS };