Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
436 lines
12 KiB
JavaScript
436 lines
12 KiB
JavaScript
/**
|
|
* 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 };
|