import type { DesignSpecs } from './specs-extractor.js';
import type { ExtractedStyles, StyleEntry } from '../types/index.js';
export interface ValidationResult {
property: string;
category: 'spacing' | 'colors' | 'typography' | 'border' | 'layout' | 'dimensions';
figmaValue: string | number | null;
implementedValue: string | number | null;
status: 'match' | 'mismatch' | 'missing' | 'extra';
tolerance?: number;
}
export interface ValidationReport {
componentName: string;
summary: {
total: number;
matches: number;
mismatches: number;
missing: number;
extra: number;
score: number;
};
validations: ValidationResult[];
}
// Tailwind spacing scale (in pixels)
const TAILWIND_SPACING: Record<string, number> = {
'0': 0, '0.5': 2, '1': 4, '1.5': 6, '2': 8, '2.5': 10, '3': 12, '3.5': 14,
'4': 16, '5': 20, '6': 24, '7': 28, '8': 32, '9': 36, '10': 40, '11': 44,
'12': 48, '14': 56, '16': 64, '20': 80, '24': 96, '28': 112, '32': 128,
'36': 144, '40': 160, '44': 176, '48': 192, '52': 208, '56': 224, '60': 240,
'64': 256, '72': 288, '80': 320, '96': 384
};
// Tailwind font sizes (in pixels)
const TAILWIND_FONT_SIZES: Record<string, number> = {
'xs': 12, 'sm': 14, 'base': 16, 'lg': 18, 'xl': 20,
'2xl': 24, '3xl': 30, '4xl': 36, '5xl': 48, '6xl': 60, '7xl': 72, '8xl': 96, '9xl': 128
};
// Tailwind font weights
const TAILWIND_FONT_WEIGHTS: Record<string, number> = {
'thin': 100, 'extralight': 200, 'light': 300, 'normal': 400,
'medium': 500, 'semibold': 600, 'bold': 700, 'extrabold': 800, 'black': 900
};
// Tailwind colors (common ones)
const TAILWIND_COLORS: Record<string, string> = {
'white': '#FFFFFF', 'black': '#000000',
'gray-50': '#F9FAFB', 'gray-100': '#F3F4F6', 'gray-200': '#E5E7EB',
'gray-300': '#D1D5DB', 'gray-400': '#9CA3AF', 'gray-500': '#6B7280',
'gray-600': '#4B5563', 'gray-700': '#374151', 'gray-800': '#1F2937', 'gray-900': '#111827',
'red-500': '#EF4444', 'red-600': '#DC2626',
'green-500': '#22C55E', 'green-600': '#16A34A',
'blue-500': '#3B82F6', 'blue-600': '#2563EB',
'yellow-500': '#EAB308', 'yellow-600': '#CA8A04',
'purple-500': '#A855F7', 'purple-600': '#9333EA',
'pink-500': '#EC4899', 'pink-600': '#DB2777',
'indigo-500': '#6366F1', 'indigo-600': '#4F46E5'
};
/**
* Parse Tailwind class value to pixels
*/
function parseTailwindSpacing(value: string): number | null {
// Handle direct pixel values
if (value.endsWith('px')) {
return parseFloat(value);
}
// Handle Tailwind scale
if (TAILWIND_SPACING[value] !== undefined) {
return TAILWIND_SPACING[value];
}
// Handle arbitrary values like [16px]
const arbitraryMatch = value.match(/\[(\d+)px\]/);
if (arbitraryMatch) {
return parseInt(arbitraryMatch[1], 10);
}
return null;
}
/**
* Parse Tailwind font size to pixels
*/
function parseTailwindFontSize(value: string): number | null {
if (TAILWIND_FONT_SIZES[value] !== undefined) {
return TAILWIND_FONT_SIZES[value];
}
const arbitraryMatch = value.match(/\[(\d+)px\]/);
if (arbitraryMatch) {
return parseInt(arbitraryMatch[1], 10);
}
return null;
}
/**
* Parse Tailwind font weight
*/
function parseTailwindFontWeight(value: string): number | null {
if (TAILWIND_FONT_WEIGHTS[value] !== undefined) {
return TAILWIND_FONT_WEIGHTS[value];
}
const num = parseInt(value, 10);
if (!isNaN(num)) {
return num;
}
return null;
}
/**
* Parse Tailwind color to hex
*/
function parseTailwindColor(value: string): string | null {
// Direct color name
if (TAILWIND_COLORS[value]) {
return TAILWIND_COLORS[value];
}
// Handle hex values
if (value.startsWith('#')) {
return value.toUpperCase();
}
// Handle arbitrary values like [#FF0000]
const arbitraryMatch = value.match(/\[(#[0-9A-Fa-f]+)\]/);
if (arbitraryMatch) {
return arbitraryMatch[1].toUpperCase();
}
return null;
}
/**
* Extract implemented values from styles
*/
function extractImplementedValues(styles: ExtractedStyles): Map<string, { value: string; raw: string }> {
const values = new Map<string, { value: string; raw: string }>();
const processStyles = (entries: StyleEntry[], category: string) => {
for (const entry of entries) {
const key = `${category}:${entry.property}`;
values.set(key, { value: entry.value, raw: entry.raw || entry.value });
}
};
processStyles(styles.colors, 'colors');
processStyles(styles.spacing, 'spacing');
processStyles(styles.typography, 'typography');
processStyles(styles.other, 'other');
return values;
}
/**
* Compare Figma specs with implemented styles
*/
export function validateImplementation(
figmaSpecs: DesignSpecs,
implementedStyles: ExtractedStyles,
tolerance: number = 2
): ValidationReport {
const validations: ValidationResult[] = [];
const implemented = extractImplementedValues(implementedStyles);
// Validate spacing
if (figmaSpecs.spacing.paddingTop !== undefined) {
validations.push(validateSpacing('padding-top', figmaSpecs.spacing.paddingTop, implemented, tolerance));
}
if (figmaSpecs.spacing.paddingRight !== undefined) {
validations.push(validateSpacing('padding-right', figmaSpecs.spacing.paddingRight, implemented, tolerance));
}
if (figmaSpecs.spacing.paddingBottom !== undefined) {
validations.push(validateSpacing('padding-bottom', figmaSpecs.spacing.paddingBottom, implemented, tolerance));
}
if (figmaSpecs.spacing.paddingLeft !== undefined) {
validations.push(validateSpacing('padding-left', figmaSpecs.spacing.paddingLeft, implemented, tolerance));
}
if (figmaSpecs.spacing.gap !== undefined) {
validations.push(validateSpacing('gap', figmaSpecs.spacing.gap, implemented, tolerance));
}
// Validate colors
if (figmaSpecs.colors.background) {
validations.push(validateColor('background', figmaSpecs.colors.background, implemented));
}
if (figmaSpecs.colors.text) {
validations.push(validateColor('text-color', figmaSpecs.colors.text, implemented));
}
if (figmaSpecs.colors.border) {
validations.push(validateColor('border-color', figmaSpecs.colors.border, implemented));
}
// Validate typography
if (figmaSpecs.typography) {
if (figmaSpecs.typography.fontSize) {
validations.push(validateTypography('font-size', figmaSpecs.typography.fontSize, implemented, tolerance));
}
if (figmaSpecs.typography.fontWeight) {
validations.push(validateTypography('font-weight', figmaSpecs.typography.fontWeight, implemented, 0));
}
if (figmaSpecs.typography.lineHeight) {
validations.push(validateTypography('line-height', figmaSpecs.typography.lineHeight, implemented, tolerance));
}
}
// Validate border
if (figmaSpecs.border) {
if (figmaSpecs.border.radius) {
const radius = Array.isArray(figmaSpecs.border.radius)
? figmaSpecs.border.radius[0]
: figmaSpecs.border.radius;
validations.push(validateSpacing('border-radius', radius, implemented, tolerance));
}
}
// Calculate summary
const matches = validations.filter(v => v.status === 'match').length;
const mismatches = validations.filter(v => v.status === 'mismatch').length;
const missing = validations.filter(v => v.status === 'missing').length;
const extra = validations.filter(v => v.status === 'extra').length;
const total = validations.length;
const score = total > 0 ? Math.round((matches / total) * 100) : 100;
return {
componentName: figmaSpecs.name,
summary: { total, matches, mismatches, missing, extra, score },
validations
};
}
function validateSpacing(
property: string,
figmaValue: number,
implemented: Map<string, { value: string; raw: string }>,
tolerance: number
): ValidationResult {
// Find matching implementation
const paddingKey = `spacing:padding`;
const specificKey = `spacing:${property}`;
// Try to find the value in implemented styles
let implValue: number | null = null;
let implRaw = '';
for (const [key, val] of implemented) {
if (key.includes(property) || (property.startsWith('padding') && key.includes('padding'))) {
implValue = parseTailwindSpacing(val.value);
implRaw = val.raw;
break;
}
}
if (implValue === null) {
return {
property,
category: 'spacing',
figmaValue: `${figmaValue}px`,
implementedValue: null,
status: 'missing'
};
}
const diff = Math.abs(figmaValue - implValue);
return {
property,
category: 'spacing',
figmaValue: `${figmaValue}px`,
implementedValue: `${implValue}px (${implRaw})`,
status: diff <= tolerance ? 'match' : 'mismatch',
tolerance
};
}
function validateColor(
property: string,
figmaValue: string,
implemented: Map<string, { value: string; raw: string }>
): ValidationResult {
let implValue: string | null = null;
let implRaw = '';
for (const [key, val] of implemented) {
if (
(property === 'background' && (key.includes('background') || key.includes('bg'))) ||
(property === 'text-color' && key.includes('color')) ||
(property === 'border-color' && key.includes('border'))
) {
implValue = parseTailwindColor(val.value);
implRaw = val.raw;
break;
}
}
if (implValue === null) {
return {
property,
category: 'colors',
figmaValue,
implementedValue: null,
status: 'missing'
};
}
// Compare colors (case insensitive)
const match = figmaValue.toUpperCase() === implValue.toUpperCase();
return {
property,
category: 'colors',
figmaValue,
implementedValue: `${implValue} (${implRaw})`,
status: match ? 'match' : 'mismatch'
};
}
function validateTypography(
property: string,
figmaValue: number,
implemented: Map<string, { value: string; raw: string }>,
tolerance: number
): ValidationResult {
let implValue: number | null = null;
let implRaw = '';
for (const [key, val] of implemented) {
if (key.includes(property.replace('-', ''))) {
if (property === 'font-size') {
implValue = parseTailwindFontSize(val.value);
} else if (property === 'font-weight') {
implValue = parseTailwindFontWeight(val.value);
} else {
implValue = parseTailwindSpacing(val.value);
}
implRaw = val.raw;
break;
}
}
if (implValue === null) {
return {
property,
category: 'typography',
figmaValue: property === 'font-weight' ? figmaValue : `${figmaValue}px`,
implementedValue: null,
status: 'missing'
};
}
const diff = Math.abs(figmaValue - implValue);
return {
property,
category: 'typography',
figmaValue: property === 'font-weight' ? figmaValue : `${figmaValue}px`,
implementedValue: `${implValue}${property !== 'font-weight' ? 'px' : ''} (${implRaw})`,
status: diff <= tolerance ? 'match' : 'mismatch',
tolerance
};
}
/**
* Format validation report as markdown
*/
export function formatValidationReport(report: ValidationReport): string {
const lines: string[] = [];
lines.push(`# Design Validation Report: ${report.componentName}`);
lines.push('');
// Summary
const emoji = report.summary.score >= 90 ? '✅' : report.summary.score >= 70 ? '⚠️' : '❌';
lines.push(`## Summary ${emoji}`);
lines.push(`- **Score**: ${report.summary.score}%`);
lines.push(`- **Matches**: ${report.summary.matches}/${report.summary.total}`);
if (report.summary.mismatches > 0) {
lines.push(`- **Mismatches**: ${report.summary.mismatches}`);
}
if (report.summary.missing > 0) {
lines.push(`- **Missing**: ${report.summary.missing}`);
}
lines.push('');
// Detailed table
lines.push('## Validation Details');
lines.push('');
lines.push('| Property | Figma | Implementation | Status |');
lines.push('|----------|-------|----------------|--------|');
for (const v of report.validations) {
const statusIcon = v.status === 'match' ? '✅' : v.status === 'mismatch' ? '❌' : '⚠️';
const impl = v.implementedValue || '—';
lines.push(`| ${v.property} | ${v.figmaValue} | ${impl} | ${statusIcon} ${v.status} |`);
}
lines.push('');
// Issues
const issues = report.validations.filter(v => v.status !== 'match');
if (issues.length > 0) {
lines.push('## Issues to Fix');
lines.push('');
for (const issue of issues) {
if (issue.status === 'missing') {
lines.push(`- **${issue.property}**: Missing implementation. Figma specifies \`${issue.figmaValue}\``);
} else if (issue.status === 'mismatch') {
lines.push(`- **${issue.property}**: Value mismatch. Figma: \`${issue.figmaValue}\`, Implementation: \`${issue.implementedValue}\``);
}
}
}
return lines.join('\n');
}