Initial commit: Clean DSS implementation
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
This commit is contained in:
435
admin-ui/js/core/theme-loader.js
Normal file
435
admin-ui/js/core/theme-loader.js
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user