/** * Canonical Style Comparison Engine * * Pure comparison logic for design-code drift detection. * No React, no filesystem, no browser APIs, no Figma client, no Cloud types. * * This module is the single source of truth for style comparison. * Both @fragments-sdk/viewer and @fragments-sdk/cli consume these exports. */ import type { EnhancedStyleDiffItem, TokenCategory, TokenUsageSummary, } from "./token-types.js"; import { parseColor } from "./tokens/color.js"; import type { TokenLookup } from "./tokens/lookup.js"; export { parseColor } from "./tokens/color.js"; export type { TokenLookup } from "./tokens/lookup.js"; // ─── Canonical Contract Types ──────────────────────────────────────────────── /** * Source-agnostic normalized token for comparison. * * This is the canonical shape that all token sources (CSS vars, Tailwind, * DTCG, Figma) must produce before entering the comparison engine. * A `DesignToken` (CSS-custom-property-specific) can be converted to this * by dropping source-specific fields. */ export interface NormalizedToken { /** Canonical token name (e.g., "--color-primary", "colors.primary") */ name: string; /** Normalized resolved value suitable for comparison (e.g., "#0051c2", "16px") */ value: string; /** Token category for grouping */ category: TokenCategory; /** Source system identifier */ source: "css-var" | "tailwind" | "dtcg" | "figma" | (string & {}); /** Original token name as declared in the source system */ originalName: string; /** Theme or mode (e.g., "default", "dark", "light") */ theme: string; } /** * Normalized style map: a record of CSS property names to their string values. * Used as the canonical input shape for both design-side and code-side styles. */ export type NormalizedStyleMap = Record; /** * Options for configuring style comparison behavior. */ export interface StyleComparisonOptions { /** Color comparison tolerance in RGB channel delta (default: 5) */ colorTolerance?: number; /** Numeric comparison tolerance in pixels (default: 1) */ numericTolerance?: number; /** Alpha channel comparison tolerance (default: 0.05) */ alphaTolerance?: number; /** CSS properties to compare. If omitted, uses DEFAULT_STYLE_PROPERTIES */ properties?: string[]; /** Theme to use for token lookup (default: "default") */ theme?: string; } // ─── Comparison Types ──────────────────────────────────────────────────────── /** * Style diff result for a single CSS property. */ export interface StyleDiffItem { /** CSS property name */ property: string; /** Expected value from design source */ figma: string; /** Actual value from rendered component */ rendered: string; /** Whether values match (within tolerance) */ match: boolean; } /** * Result of comparing styles between design and code. */ export interface StyleComparisonResult { /** Whether all styles match */ match: boolean; /** Individual property comparisons */ properties: StyleDiffItem[]; /** CSS properties from design source */ figmaStyles: NormalizedStyleMap; /** Computed CSS properties from rendered component */ renderedStyles: NormalizedStyleMap; } /** * Enhanced style comparison result with token information. */ export interface EnhancedStyleComparisonResult extends StyleComparisonResult { /** Individual property comparisons with token info */ properties: EnhancedStyleDiffItem[]; /** Token usage summary */ tokenSummary?: TokenUsageSummary; } // ─── Constants ─────────────────────────────────────────────────────────────── /** Properties that use color comparison with tolerance */ const COLOR_PROPERTIES = new Set(["backgroundColor", "borderColor", "color"]); /** Properties that use numeric comparison with tolerance */ const NUMERIC_PROPERTIES = new Set([ "borderWidth", "borderRadius", "fontSize", "padding", "gap", ]); /** Default CSS properties compared by compareStyles() */ export const DEFAULT_STYLE_PROPERTIES = [ "backgroundColor", "borderColor", "borderWidth", "borderRadius", "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "textAlign", "boxShadow", "padding", "gap", "opacity", ] as const; /** Default CSS properties compared by compareStylesWithTokens() (includes "color") */ export const DEFAULT_ENHANCED_STYLE_PROPERTIES = [ ...DEFAULT_STYLE_PROPERTIES, "color", ] as const; // ─── Pure Comparison Functions ─────────────────────────────────────────────── /** * Normalize a style value for comparison. */ export function normalizeStyleValue(prop: string, value: string): string { let normalized = value.trim().replace(/\s+/g, " "); // Normalize "none" shadow to empty if (prop === "boxShadow" && normalized === "none") { normalized = ""; } // Normalize rgba(0, 0, 0, 0) to "transparent" if (normalized.match(/rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)/)) { normalized = "transparent"; } return normalized; } /** * Compare two color values with tolerance. */ export function compareColors( color1: string, color2: string, tolerance: number, alphaTolerance = 0.05 ): boolean { const rgb1 = parseColor(color1); const rgb2 = parseColor(color2); if (!rgb1 || !rgb2) { return color1 === color2; } return ( Math.abs(rgb1.r - rgb2.r) <= tolerance && Math.abs(rgb1.g - rgb2.g) <= tolerance && Math.abs(rgb1.b - rgb2.b) <= tolerance && Math.abs((rgb1.a ?? 1) - (rgb2.a ?? 1)) <= alphaTolerance ); } /** * Compare numeric values (e.g., "10px" vs "11px") with tolerance. */ export function compareNumericValues( value1: string, value2: string, tolerance: number ): boolean { const num1 = parseFloat(value1); const num2 = parseFloat(value2); if (isNaN(num1) || isNaN(num2)) { return value1 === value2; } return Math.abs(num1 - num2) <= tolerance; } /** * Compare a single style value with tolerance for color and numeric differences. */ export function compareStyleValue( prop: string, figma: string, rendered: string ): boolean { const normalizedFigma = normalizeStyleValue(prop, figma); const normalizedRendered = normalizeStyleValue(prop, rendered); // Direct match if (normalizedFigma === normalizedRendered) { return true; } // Color comparison with tolerance if (COLOR_PROPERTIES.has(prop)) { return compareColors(normalizedFigma, normalizedRendered, 5); } // Numeric comparison with tolerance (for pixels) if (NUMERIC_PROPERTIES.has(prop)) { return compareNumericValues(normalizedFigma, normalizedRendered, 1); } return false; } /** * Compare design CSS properties with rendered computed styles. */ export function compareStyles( figmaStyles: Record, renderedStyles: NormalizedStyleMap ): StyleComparisonResult { const properties: StyleDiffItem[] = []; const cleanFigmaStyles: NormalizedStyleMap = {}; const propsToCompare = DEFAULT_STYLE_PROPERTIES; for (const prop of propsToCompare) { const figmaValue = figmaStyles[prop]; const renderedValue = renderedStyles[prop]; if (figmaValue !== undefined) { cleanFigmaStyles[prop] = figmaValue; const match = compareStyleValue(prop, figmaValue, renderedValue || ""); properties.push({ property: prop, figma: figmaValue, rendered: renderedValue || "(not set)", match, }); } } const allMatch = properties.every((p) => p.match); return { match: allMatch, properties, figmaStyles: cleanFigmaStyles, renderedStyles, }; } // ─── Token-Aware Comparison ────────────────────────────────────────────────── /** * Compare styles with token awareness. * * This enhanced version: * 1. Performs normal style comparison * 2. Identifies which values match design tokens * 3. Flags hardcoded values that should use tokens * 4. Generates fix suggestions */ export function compareStylesWithTokens( figmaStyles: Record, renderedStyles: NormalizedStyleMap, tokenLookup?: TokenLookup, theme = "default" ): EnhancedStyleComparisonResult { const properties: EnhancedStyleDiffItem[] = []; const cleanFigmaStyles: NormalizedStyleMap = {}; const propsToCompare = DEFAULT_ENHANCED_STYLE_PROPERTIES; for (const prop of propsToCompare) { const figmaValue = figmaStyles[prop]; const renderedValue = renderedStyles[prop]; if (figmaValue !== undefined) { cleanFigmaStyles[prop] = figmaValue; const match = compareStyleValue(prop, figmaValue, renderedValue || ""); const item: EnhancedStyleDiffItem = { property: prop, figma: figmaValue, rendered: renderedValue || "(not set)", match, isHardcoded: false, }; if (tokenLookup) { const figmaTokens = tokenLookup.findByValue(figmaValue, theme); const renderedTokens = renderedValue ? tokenLookup.findByValue(renderedValue, theme) : []; if (figmaTokens.length > 0) { item.figmaToken = figmaTokens[0]; } if (renderedTokens.length > 0) { item.renderedToken = renderedTokens[0]; } // Hardcoded = Figma matches a token, but rendered doesn't use a token item.isHardcoded = !!item.figmaToken && !item.renderedToken; if (item.isHardcoded && item.figmaToken) { const token = tokenLookup.getToken(item.figmaToken); if (token) { const cssProperty = toCssProperty(prop); item.suggestedFix = { tokenName: item.figmaToken, tokenValue: token.resolvedValue, codeFix: `${cssProperty}: var(${item.figmaToken});`, confidence: 0.9, reason: `Figma uses token ${item.figmaToken} (${token.resolvedValue}). Replace hardcoded value with token for consistency.`, }; } } } properties.push(item); } } const allMatch = properties.every((p) => p.match); let tokenSummary: TokenUsageSummary | undefined; if (tokenLookup) { tokenSummary = tokenLookup.calculateUsageSummary( properties.map((p) => ({ property: p.property, figma: p.figma, rendered: p.rendered, match: p.match, })), theme ); } return { match: allMatch, properties, figmaStyles: cleanFigmaStyles, renderedStyles, tokenSummary, }; } // ─── Formatting Helpers ────────────────────────────────────────────────────── /** * Convert camelCase CSS property name to kebab-case. */ function toCssProperty(prop: string): string { return prop.replace(/([A-Z])/g, "-$1").toLowerCase(); } /** * Format token usage summary for display. */ export function formatTokenSummary(summary: TokenUsageSummary): string { const lines: string[] = []; lines.push(`Token Compliance: ${summary.compliancePercent}%`); lines.push( `${summary.usingTokens}/${summary.totalProperties} properties using tokens` ); if (summary.hardcoded > 0) { lines.push(`${summary.hardcoded} hardcoded value(s) detected`); } if (summary.implicitMatches > 0) { lines.push(`${summary.implicitMatches} implicit match(es)`); } return lines.join("\n"); } /** * Get status badge for token compliance level. */ export function getComplianceBadge( compliancePercent: number ): { label: string; color: string } { if (compliancePercent >= 100) { return { label: "Excellent", color: "green" }; } else if (compliancePercent >= 80) { return { label: "Good", color: "blue" }; } else if (compliancePercent >= 50) { return { label: "Fair", color: "yellow" }; } else { return { label: "Poor", color: "red" }; } }