Files
dss/admin-ui/js/core/token-validator.js
Digital Production Factory 276ed71f31 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
2025-12-09 18:45:48 -03:00

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();
};
}
}