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:
410
admin-ui/js/core/token-validator.js
Normal file
410
admin-ui/js/core/token-validator.js
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* 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();
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user