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
411 lines
13 KiB
JavaScript
411 lines
13 KiB
JavaScript
/**
|
|
* TokenValidator - Validates CSS and HTML for design token compliance
|
|
*
|
|
* Ensures all color, spacing, typography, and other design values use
|
|
* valid CSS custom properties instead of hardcoded values.
|
|
*
|
|
* Usage:
|
|
* const validator = new TokenValidator();
|
|
* const report = validator.validateCSS(cssText);
|
|
* console.log(report.violations); // Array of violations found
|
|
*/
|
|
|
|
class TokenValidator {
|
|
constructor() {
|
|
// Define all valid tokens from the design system
|
|
this.validTokens = {
|
|
colors: [
|
|
// Semantic colors
|
|
'--primary', '--primary-foreground',
|
|
'--secondary', '--secondary-foreground',
|
|
'--accent', '--accent-foreground',
|
|
'--destructive', '--destructive-foreground',
|
|
'--success', '--success-foreground',
|
|
'--warning', '--warning-foreground',
|
|
'--info', '--info-foreground',
|
|
// Functional colors
|
|
'--background', '--foreground',
|
|
'--card', '--card-foreground',
|
|
'--popover', '--popover-foreground',
|
|
'--muted', '--muted-foreground',
|
|
'--border', '--ring',
|
|
'--input'
|
|
],
|
|
spacing: [
|
|
'--space-0', '--space-1', '--space-2', '--space-3', '--space-4',
|
|
'--space-5', '--space-6', '--space-8', '--space-10', '--space-12',
|
|
'--space-16', '--space-20', '--space-24', '--space-32'
|
|
],
|
|
typography: [
|
|
// Font families
|
|
'--font-sans', '--font-mono',
|
|
// Font sizes
|
|
'--text-xs', '--text-sm', '--text-base', '--text-lg', '--text-xl',
|
|
'--text-2xl', '--text-3xl', '--text-4xl',
|
|
// Font weights
|
|
'--font-normal', '--font-medium', '--font-semibold', '--font-bold',
|
|
// Line heights
|
|
'--leading-tight', '--leading-normal', '--leading-relaxed',
|
|
// Letter spacing
|
|
'--tracking-tight', '--tracking-normal', '--tracking-wide'
|
|
],
|
|
radius: [
|
|
'--radius-none', '--radius-sm', '--radius', '--radius-md',
|
|
'--radius-lg', '--radius-xl', '--radius-full'
|
|
],
|
|
shadows: [
|
|
'--shadow-sm', '--shadow', '--shadow-md', '--shadow-lg', '--shadow-xl'
|
|
],
|
|
timing: [
|
|
'--duration-fast', '--duration-normal', '--duration-slow',
|
|
'--ease-default', '--ease-in', '--ease-out'
|
|
],
|
|
zIndex: [
|
|
'--z-base', '--z-dropdown', '--z-sticky', '--z-fixed',
|
|
'--z-modal-background', '--z-modal', '--z-popover', '--z-tooltip',
|
|
'--z-notification', '--z-toast'
|
|
]
|
|
};
|
|
|
|
// Flatten all valid tokens for quick lookup
|
|
this.allValidTokens = new Set();
|
|
Object.values(this.validTokens).forEach(group => {
|
|
group.forEach(token => this.allValidTokens.add(token));
|
|
});
|
|
|
|
// Patterns for detecting CSS variable usage
|
|
this.varPattern = /var\(\s*([a-z0-9\-_]+)/gi;
|
|
// Patterns for detecting hardcoded values
|
|
this.colorPattern = /#[0-9a-f]{3,6}|rgb\(|hsl\(|oklch\(/gi;
|
|
this.spacingPattern = /\b\d+(?:px|rem|em|ch|vw|vh)\b/g;
|
|
|
|
// Track violations
|
|
this.violations = [];
|
|
this.warnings = [];
|
|
this.stats = {
|
|
totalTokenReferences: 0,
|
|
validTokenReferences: 0,
|
|
invalidTokenReferences: 0,
|
|
hardcodedValues: 0,
|
|
files: {}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate CSS text for token compliance
|
|
* @param {string} cssText - CSS content to validate
|
|
* @param {string} [fileName] - Optional filename for reporting
|
|
* @returns {object} Report with violations, warnings, and stats
|
|
*/
|
|
validateCSS(cssText, fileName = 'inline') {
|
|
this.violations = [];
|
|
this.warnings = [];
|
|
|
|
if (!cssText || typeof cssText !== 'string') {
|
|
return { violations: [], warnings: [], stats: this.stats };
|
|
}
|
|
|
|
// Parse CSS and check for token compliance
|
|
this._validateTokenReferences(cssText, fileName);
|
|
this._validateHardcodedColors(cssText, fileName);
|
|
this._validateSpacingValues(cssText, fileName);
|
|
|
|
// Store stats for this file
|
|
this.stats.files[fileName] = {
|
|
violations: this.violations.length,
|
|
warnings: this.warnings.length
|
|
};
|
|
|
|
return {
|
|
violations: this.violations,
|
|
warnings: this.warnings,
|
|
stats: this.stats,
|
|
isCompliant: this.violations.length === 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate all CSS variable references
|
|
* @private
|
|
*/
|
|
_validateTokenReferences(cssText, fileName) {
|
|
let match;
|
|
const varPattern = /var\(\s*([a-z0-9\-_]+)/gi;
|
|
|
|
while ((match = varPattern.exec(cssText)) !== null) {
|
|
const tokenName = match[1];
|
|
this.stats.totalTokenReferences++;
|
|
|
|
if (this.allValidTokens.has(tokenName)) {
|
|
this.stats.validTokenReferences++;
|
|
} else {
|
|
this.stats.invalidTokenReferences++;
|
|
this.violations.push({
|
|
type: 'INVALID_TOKEN',
|
|
token: tokenName,
|
|
message: `Invalid token: '${tokenName}'`,
|
|
file: fileName,
|
|
suggestion: this._suggestToken(tokenName),
|
|
code: match[0]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect hardcoded colors (anti-pattern)
|
|
* @private
|
|
*/
|
|
_validateHardcodedColors(cssText, fileName) {
|
|
// Skip comments
|
|
const cleanCSS = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
|
|
// Find hardcoded hex colors
|
|
const hexPattern = /#[0-9a-f]{3,6}(?![a-z0-9\-])/gi;
|
|
let match;
|
|
|
|
while ((match = hexPattern.exec(cleanCSS)) !== null) {
|
|
// Check if this is a valid CSS variable fallback (e.g., var(--primary, #fff))
|
|
const context = cleanCSS.substring(Math.max(0, match.index - 20), match.index + 10);
|
|
if (!context.includes('var(')) {
|
|
this.stats.hardcodedValues++;
|
|
this.violations.push({
|
|
type: 'HARDCODED_COLOR',
|
|
value: match[0],
|
|
message: `Hardcoded color detected: '${match[0]}' - Use CSS tokens instead`,
|
|
file: fileName,
|
|
suggestion: `Use var(--primary) or similar color token`,
|
|
code: match[0],
|
|
severity: 'HIGH'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Find hardcoded rgb/hsl colors (but not as fallback)
|
|
const rgbPattern = /rgb\([^)]+\)|hsl\([^)]+\)|oklch\([^)]+\)(?![a-z])/gi;
|
|
while ((match = rgbPattern.exec(cleanCSS)) !== null) {
|
|
const context = cleanCSS.substring(Math.max(0, match.index - 20), match.index + 10);
|
|
if (!context.includes('var(')) {
|
|
this.stats.hardcodedValues++;
|
|
this.violations.push({
|
|
type: 'HARDCODED_COLOR_FUNCTION',
|
|
value: match[0],
|
|
message: `Hardcoded color function detected - Use CSS tokens instead`,
|
|
file: fileName,
|
|
suggestion: 'Use var(--color-name) instead of hardcoded rgb/hsl/oklch',
|
|
code: match[0],
|
|
severity: 'HIGH'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect hardcoded spacing values
|
|
* @private
|
|
*/
|
|
_validateSpacingValues(cssText, fileName) {
|
|
// Look for padding/margin with px values that could be tokens
|
|
const spacingProps = ['padding', 'margin', 'gap', 'width', 'height'];
|
|
const spacingPattern = /(?:padding|margin|gap|width|height):\s*(\d+)px\b/gi;
|
|
|
|
let match;
|
|
while ((match = spacingPattern.exec(cssText)) !== null) {
|
|
const value = parseInt(match[1]);
|
|
// Only warn if it's a multiple of 4 (our spacing base unit)
|
|
if (value % 4 === 0 && value <= 128) {
|
|
this.warnings.push({
|
|
type: 'HARDCODED_SPACING',
|
|
value: match[0],
|
|
message: `Hardcoded spacing value: '${match[1]}px' - Could use --space-* token`,
|
|
file: fileName,
|
|
suggestion: `Use var(--space-${Math.log2(value / 4)}) or appropriate token`,
|
|
code: match[0],
|
|
severity: 'MEDIUM'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Suggest the closest valid token based on fuzzy matching
|
|
* @private
|
|
*/
|
|
_suggestToken(invalidToken) {
|
|
const lower = invalidToken.toLowerCase();
|
|
let closest = null;
|
|
let closestDistance = Infinity;
|
|
|
|
this.allValidTokens.forEach(validToken => {
|
|
const distance = this._levenshteinDistance(lower, validToken.toLowerCase());
|
|
if (distance < closestDistance && distance < 5) {
|
|
closestDistance = distance;
|
|
closest = validToken;
|
|
}
|
|
});
|
|
|
|
return closest ? `Did you mean '${closest}'?` : 'Check valid tokens in tokens.css';
|
|
}
|
|
|
|
/**
|
|
* Calculate Levenshtein distance for fuzzy matching
|
|
* @private
|
|
*/
|
|
_levenshteinDistance(str1, str2) {
|
|
const matrix = [];
|
|
|
|
for (let i = 0; i <= str2.length; i++) {
|
|
matrix[i] = [i];
|
|
}
|
|
|
|
for (let j = 0; j <= str1.length; j++) {
|
|
matrix[0][j] = j;
|
|
}
|
|
|
|
for (let i = 1; i <= str2.length; i++) {
|
|
for (let j = 1; j <= str1.length; j++) {
|
|
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
|
|
matrix[i][j] = matrix[i - 1][j - 1];
|
|
} else {
|
|
matrix[i][j] = Math.min(
|
|
matrix[i - 1][j - 1] + 1,
|
|
matrix[i][j - 1] + 1,
|
|
matrix[i - 1][j] + 1
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return matrix[str2.length][str1.length];
|
|
}
|
|
|
|
/**
|
|
* Validate HTML for inline style violations
|
|
* @param {HTMLElement} element - Element to validate
|
|
* @param {string} [fileName] - Optional filename for reporting
|
|
* @returns {object} Report with violations
|
|
*/
|
|
validateHTML(element, fileName = 'html') {
|
|
this.violations = [];
|
|
this.warnings = [];
|
|
|
|
if (!element) return { violations: [], warnings: [] };
|
|
|
|
// Check style attributes
|
|
const allElements = element.querySelectorAll('[style]');
|
|
allElements.forEach(el => {
|
|
const styleAttr = el.getAttribute('style');
|
|
if (styleAttr) {
|
|
this.validateCSS(styleAttr, `${fileName}:${el.tagName}`);
|
|
}
|
|
});
|
|
|
|
// Check shadow DOM styles
|
|
const walkElements = (node) => {
|
|
if (node.shadowRoot) {
|
|
const styleElements = node.shadowRoot.querySelectorAll('style');
|
|
styleElements.forEach(style => {
|
|
this.validateCSS(style.textContent, `${fileName}:${node.tagName}(shadow)`);
|
|
});
|
|
}
|
|
|
|
for (let child of node.children) {
|
|
walkElements(child);
|
|
}
|
|
};
|
|
|
|
walkElements(element);
|
|
|
|
return {
|
|
violations: this.violations,
|
|
warnings: this.warnings,
|
|
stats: this.stats
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate a compliance report
|
|
* @param {boolean} [includeStats=true] - Include detailed statistics
|
|
* @returns {string} Formatted report
|
|
*/
|
|
generateReport(includeStats = true) {
|
|
let report = `
|
|
╔══════════════════════════════════════════════════════════════╗
|
|
║ Design Token Compliance Report ║
|
|
╚══════════════════════════════════════════════════════════════╝
|
|
|
|
`;
|
|
|
|
if (this.violations.length === 0) {
|
|
report += `✅ COMPLIANT - No token violations found\n`;
|
|
} else {
|
|
report += `❌ ${this.violations.length} VIOLATIONS FOUND:\n\n`;
|
|
this.violations.forEach((v, i) => {
|
|
report += `${i + 1}. ${v.type}: ${v.message}\n`;
|
|
report += ` File: ${v.file}\n`;
|
|
report += ` Code: ${v.code}\n`;
|
|
if (v.suggestion) report += ` Suggestion: ${v.suggestion}\n`;
|
|
report += `\n`;
|
|
});
|
|
}
|
|
|
|
if (this.warnings.length > 0) {
|
|
report += `⚠️ ${this.warnings.length} WARNINGS:\n\n`;
|
|
this.warnings.forEach((w, i) => {
|
|
report += `${i + 1}. ${w.type}: ${w.message}\n`;
|
|
if (w.suggestion) report += ` Suggestion: ${w.suggestion}\n`;
|
|
report += `\n`;
|
|
});
|
|
}
|
|
|
|
if (includeStats && this.stats.totalTokenReferences > 0) {
|
|
report += `\n📊 STATISTICS:\n`;
|
|
report += ` Total Token References: ${this.stats.totalTokenReferences}\n`;
|
|
report += ` Valid References: ${this.stats.validTokenReferences}\n`;
|
|
report += ` Invalid References: ${this.stats.invalidTokenReferences}\n`;
|
|
report += ` Hardcoded Values: ${this.stats.hardcodedValues}\n`;
|
|
report += ` Compliance Rate: ${((this.stats.validTokenReferences / this.stats.totalTokenReferences) * 100).toFixed(1)}%\n`;
|
|
}
|
|
|
|
return report;
|
|
}
|
|
|
|
/**
|
|
* Get list of all valid tokens
|
|
* @returns {object} Tokens organized by category
|
|
*/
|
|
getValidTokens() {
|
|
return this.validTokens;
|
|
}
|
|
|
|
/**
|
|
* Export validator instance for global access
|
|
*/
|
|
static getInstance() {
|
|
if (!window.__dssTokenValidator) {
|
|
window.__dssTokenValidator = new TokenValidator();
|
|
}
|
|
return window.__dssTokenValidator;
|
|
}
|
|
}
|
|
|
|
// Export for module systems
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = TokenValidator;
|
|
}
|
|
|
|
// Make available globally for console debugging
|
|
if (typeof window !== 'undefined') {
|
|
window.TokenValidator = TokenValidator;
|
|
window.__dssTokenValidator = new TokenValidator();
|
|
|
|
// Add to debug interface
|
|
if (window.__DSS_DEBUG) {
|
|
window.__DSS_DEBUG.validateTokens = () => {
|
|
const report = TokenValidator.getInstance().generateReport();
|
|
console.log(report);
|
|
return TokenValidator.getInstance();
|
|
};
|
|
}
|
|
}
|